Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
56684df
Integrate vacuum for open api spec validation
zendern Jul 11, 2026
57737d3
Handle open api tags
zendern Jul 11, 2026
f0bd485
Add question to get rest api description
zendern Jul 11, 2026
c1139fb
Need the new param
zendern Jul 11, 2026
e340c76
FOrmatting
zendern Jul 11, 2026
526e944
Updates to exception handler
zendern Jul 11, 2026
152bc7d
Dont need it in devcontainers put it in pre-commit
zendern Jul 11, 2026
37e9cc6
If has_backend aroudn pre-commit
zendern Jul 11, 2026
7787b44
Correct commit
zendern Jul 11, 2026
fe149e8
dont need explicit definitions as we are just doing the defaults
zendern Jul 11, 2026
e0ae863
Cleanup
zendern Jul 11, 2026
b8bab3b
Drop report.html since its super generic
zendern Jul 11, 2026
0d0861f
Remove kiota comment
zendern Jul 11, 2026
a6d5549
Bring back linting of template
zendern Jul 11, 2026
827a518
fix(pre-commit): correct malformed raw tag in vacuum hook template
zendern Jul 11, 2026
5855fcf
fix(backend): avoid constant redefinition in openapi tags setup
zendern Jul 11, 2026
1acb235
fix(backend): generate openapi snapshot from real test instead of han…
zendern Jul 11, 2026
707993b
fix(backend): add response model docstrings for vacuum component-desc…
zendern Jul 11, 2026
955887a
fix(backend): replace placeholder graphql query with real system.vers…
zendern Jul 11, 2026
f5b26b2
Remove comment
zendern Jul 11, 2026
07be353
chore(#195): add debug step to investigate pyright spawn failure
zendern Jul 11, 2026
72d32ae
fix(#195): remove stale backend .venv after moving template out of ne…
zendern Jul 11, 2026
d0fb371
Remove DEBUG output
zendern Jul 11, 2026
bc16b3b
fix(backend): satisfy vacuum operation-tags and oas3-missing-example …
zendern Jul 11, 2026
8f4e1a8
fix(backend): satisfy vacuum operation-tag-defined and oas3-missing-e…
zendern Jul 11, 2026
87e0dc4
fix(backend): gate graphql-only vacuum ignore entries on backend_uses…
zendern Jul 11, 2026
a0e9f1f
Simplify description
zendern Jul 11, 2026
2b636e9
Driver specific tags
zendern Jul 11, 2026
f92a309
feat(openapi): add API naming-convention vacuum rules
zendern Jul 12, 2026
2c23abe
(feature) check for plural in paths
zendern Jul 12, 2026
59cc3fa
fix(openapi): allowlist graphql path segment in plural check
zendern Jul 14, 2026
a0f290a
chore(pre-commit): show full violation details for vacuum lint
zendern Jul 14, 2026
236deab
to json so quoting doesn't break things
zendern Jul 14, 2026
131516e
fix(vacuum): enforce camelCase query params and repair casing checks
zendern Jul 16, 2026
6405b2d
fix(backend): alias healthcheck prepend_v query param as prependV
zendern Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,10 @@ jobs:
# delete the subfolder
rm -frd new-template
ls -la

# any venv created by copier _tasks while the template still lived under new-template/
# has console-script shebangs baked with that now-stale absolute path. Remove it so it
# gets rebuilt from scratch at the correct final path.
rm -rf backend/.venv
Comment on lines +174 to +177

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toss it into the "Backport" pile next time you're doing copier-base


- name: install new dependencies
env:
Expand Down
12 changes: 12 additions & 0 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ human_friendly_app_name:
help: What's the human-friendly name of this application? (e.g. for documentation)
default: "{{ repo_name.replace('-', ' ').replace('_', ' ') }}"

backend_rest_api_description:
type: str
help: What's the description of the backend REST API? (e.g. for documentation)
when: "{{ has_backend }}"

backend_makes_api_calls:
type: bool
help: Will the backend make calls to an external API?
Expand Down Expand Up @@ -402,6 +407,13 @@ _tasks:
'{{ _copier_conf.src_path }}/template' \
'.' \
--template-src '{{ _src_path }}'
- command: |
snapshot_file=backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json
if [ ! -f "$snapshot_file" ]; then
echo "Generating $snapshot_file from the actual OpenAPI schema..."
uv --directory=backend run pytest tests/unit/test_basic_server_functionality.py::test_openapi_schema --snapshot-update --cov-fail-under=0
fi
when: "{{ has_backend }}"

# Additional Settings
_min_copier_version: "9.8"
Expand Down
1 change: 1 addition & 0 deletions extensions/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just
context["python_faker_version"] = ">=40.28.1"
context["mutmut_version"] = ">=3.6.0"
context["pyrefly_version"] = ">=1.1.1"
context["vacuum_openapi_version"] = "0.29.9"
Comment thread
zendern marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kill it


context["default_node_version"] = "24.11.1"
context["nuxt_ui_version"] = "^4.8.1"
Expand Down
68 changes: 68 additions & 0 deletions template/.config/vacuum-functions/resourcesPlural.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Vacuum custom JS function (goja runtime: no npm, no require/import — plain self-contained JS).
//
// Flags static path segments that look like singular resource nouns; REST collections should be plural
// (gov.au naming). `given: $.paths[*]~` passes each path key (e.g. "/api/data/devices/{device_id}") as
// the single `input` argument. We split on "/", ignore "{param}" segments, and check each static segment.
//
// Pluralization is heuristic. To keep false positives down we DO NOT try to pluralize every word; instead
// we flag a segment only when it is clearly singular (does not end in "s") AND is not in the ALLOWLIST of
// non-collection segments (namespaces, versions, actions, status/singleton sub-resources, uncountable
// nouns). Projects can edit ALLOWLIST below — this file is copier-managed but locally customizable.

// Segments that are legitimately not plural collections.
var ALLOWLIST = [
// namespace / versioning
"api", "v1", "v2", "v3",
// uncountable / mass nouns
"data", "series", "telemetry", "equipment", "info", "metadata", "media", "config", "configuration",
// health / status / lifecycle singletons
"health", "healthcheck", "healthz", "readyz", "livez", "status", "shutdown", "ping", "version",
// common actions, filters and singleton sub-resources
"me", "self", "search", "online", "offline", "push", "polling", "dashboard", "summary", "latest", "current",
// non-REST endpoints (not resource collections)
"graphql"
];

function getSchema() {
return { name: "resourcesPlural" };
}

function isAllowed(seg) {
var lower = seg.toLowerCase();
for (var i = 0; i < ALLOWLIST.length; i++) {
if (ALLOWLIST[i] === lower) {
return true;
}
}
return false;
}

// Lenient: anything ending in "s" is treated as already plural. This intentionally under-flags words like
// "status"/"analysis" (safe: those are allowlisted or uncountable) so we never wrongly flag a plural.
function looksPlural(seg) {
return seg.length > 1 && seg.charAt(seg.length - 1) === "s";
}

function runRule(input) {
if (typeof input !== "string") {
return [];
}
var results = [];
var segments = input.split("/");
for (var i = 0; i < segments.length; i++) {
var seg = segments[i];
if (seg === "") {
continue; // leading slash / empty
}
if (seg.charAt(0) === "{") {
continue; // path parameter, not a resource segment
}
if (isAllowed(seg) || looksPlural(seg)) {
continue;
}
results.push({
message: "path segment '" + seg + "' should be a plural resource noun (or added to the allowlist if it is not a collection)"
});
}
return results;
}
24 changes: 24 additions & 0 deletions template/.config/vacuum-ignore.yaml.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{% raw %}# Ignored vacuum findings, with justification.
#
# owasp-define-error-validation requires every operation to document a 400/422/4XX validation-error
# response. These five endpoints take no path/query/body parameters, so there is no honest client-error
# to document. The rule stays enabled so it enforces error documentation on all *future* endpoints; only
# these input-less operations are exempted.
owasp-define-error-validation:
- "$.paths['/api/shutdown'].get.responses"

# ValidationError is generated by FastAPI (the 422 body); its `loc` union contains an int we cannot
# annotate with a format/bounds without owning the model, so exempt that one generated node.
# HTTPValidationError is generated by FastAPI; its `detail` property is exempt because we cannot attach a
# field-level example to a generated model (a schema-level example is injected in custom_openapi instead).{% endraw %}{% if backend_uses_graphql %}{% raw %}
# /api/graphql's 200 response body is arbitrary JSON shaped by the caller's GraphQL query/GraphiQL page;
# there is no single representative example to attach without misleadingly implying a fixed response shape.{% endraw %}{% endif %}{% raw %}
oas3-missing-example:
- "$.components.schemas['HTTPValidationError'].properties['detail']"{% endraw %}{% if backend_uses_graphql %}{% raw %}
- "$.paths['/api/graphql'].get.responses['200'].content['application/json']"
- "$.paths['/api/graphql'].post.responses['200'].content['application/json']"{% endraw %}{% endif %}{% raw %}

owasp-integer-format:
- "$.components.schemas['ValidationError'].properties['loc'].items.anyOf[1]"
owasp-integer-limit:
- "$.components.schemas['ValidationError'].properties['loc'].items.anyOf[1]"{% endraw %}
183 changes: 183 additions & 0 deletions template/.config/vacuum-ruleset.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Curated vacuum ruleset for the backend OpenAPI schema.
#
# Extends vacuum's built-in OpenAPI "recommended" set, then disables two rules that are noise/false
# positives for a FastAPI-generated spec and enables a handful of opt-in best-practice rules plus three
# OWASP data-hardening rules. Every non-default rule carries a comment explaining WHY.
#
# The target document is the OpenAPI 3.1.0 snapshot the backend emits (also the canonical spec fed to
# Kiota codegen):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to comment about kiota in here?

# backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json
#
# `formats` is intentionally omitted from every custom rule below: this repo only ever lints one 3.1.0
# document, so scoping by format would be dead configuration.
extends:
- - vacuum:oas
- recommended
rules:
# DISABLED: flags identical description strings, but ours repeat legitimately — the same field
# (e.g. SensorInfo.id) is inherited across many response models, and the same 404 text is reused
# across endpoints. Forcing unique wording would make the docs less consistent, not better.
description-duplication: "off"

# DISABLED: `servers` is meaningless here — single-server, air-gapped device, no multi-environment
# URLs to document. FastAPI's default "/ - This server" relative entry is all Swagger UI needs.
oas3-api-servers: "off"

# DISABLED: false positive — it does not follow `$ref`, so enum/model-typed properties
# (severity, direction, status, measurementType, etc.) that reference a fully-typed component are wrongly
# flagged as "missing type". Every code-level workaround trips a different recommended rule
# (allOf -> no-unnecessary-combinator; bare $ref -> oas3-missing-example and loses the field
# description), so disabling this single shallow rule is the cleanest option. The fields keep their
# normal Field(description=, examples=). A FastAPI-generated spec can otherwise only trigger this on
# `Any`-typed fields, which are rare and usually unavoidable (e.g. the generated ValidationError.input).
oas-missing-type: "off"

# ENABLED: guarantees every tag used by an operation is declared at the top level with real metadata —
# this drives navigation/grouping in docs and Kiota-generated client namespaces.
# `true` pulls vacuum's built-in definition verbatim (it's an opt-in rule, absent from `recommended`).
openapi-tags: true

# ENABLED: a tag with no description is useless to a consumer reading the docs — enforce a
# human-readable purpose for each of system/debug/devices/data/alarms.
tag-description: true

# ENABLED: keeps each operation to exactly one tag so it appears once in generated navigation/clients
# (multi-tagging duplicates operations across groups and confuses codegen). We already tag singly.
operation-singular-tag: true

# ENABLED: a POST with no documented success response is an incomplete contract; ensures every
# create/action endpoint declares its 2xx/3xx (we already return 201) so clients know the success shape.
post-response-success: true

# ENABLED: correctness guard — every entry in a schema's `required` list must actually exist in
# `properties`. Catches real bugs (typo'd/removed field left in required) that break client codegen.
required-fields-defined: true

# ENABLED (OWASP): three data-hardening rules worth keeping even on an air-gapped/no-auth API. The
# rest of the OWASP pack (auth/HTTPS/rate-limit/protection-*/string-restricted) is intentionally NOT
# enabled for this trusted air-gapped, no-auth deployment; revisit if the API is ever exposed/authed.
owasp-define-error-validation: true

# Integers must declare int32/int64 `format` so codegen picks the right numeric type.
# (Config, not `true`: OWASP rules aren't in the extended set, so `true` would be dropped.)
owasp-integer-format: true

# Integers must declare min/max bounds (input validation / resource-exhaustion guard).
owasp-integer-limit: true

# ENABLED: path id parameters must be explicit and resource-qualified, never a bare generic
# identifier. Bare `{id}` is ambiguous in a multi-resource spec and produces collision-prone Kiota
# client method names (every resource collapses to `by_id`). The denylist is Tier 1 only
# (unambiguously generic tokens). Tier 2 natural-key tokens (code|slug|name|type) are intentionally
# excluded because they are often legitimate (e.g. /countries/{code}); add them only if wanted.
path-no-generic-id:
description: Path id parameters must be explicit, not a bare generic identifier.
given: $.paths[*]~
severity: error
then:
function: pattern
functionOptions:
notMatch: "\\{(id|pk|fk|uid|guid|uuid|oid|key|ref|hash|idx|index|num|no|identifier|value|item|object|entity|record|data|thing)\\}"

# ENABLED: path PARAMETERS must be snake_case. The kebab-case + lowercase half of our hybrid casing
# decision (kebab static segments, snake_case params) is ALREADY enforced by the recommended set's
# `paths-kebab-case` rule, which flags non-kebab STATIC segments and ignores `{param}` segments
# entirely. This rule adds the parameter half that `paths-kebab-case` does not cover. FastAPI binds a
# path parameter's name to a Python function argument (which cannot contain hyphens), so snake_case is
# the only practical casing for parameters.
path-param-snake-case:
description: Path parameters must be snake_case.
given: $.paths[*][*].parameters[?(@.in=='path')].name
severity: warn
then:
function: casing
functionOptions:
type: snake

# ENABLED: query parameters must be camelCase — body params are already camelCase, so matching that
# for query params keeps frontend clients consistent instead of converting between casings per field.
query-param-camel-case:
description: Query parameters must be camelCase.
given: $.paths[*][*].parameters[?(@.in=='query')].name
severity: warn
then:
function: casing
functionOptions:
type: camel

# ENABLED (gov.au API Response): response bodies must be application/json. `application/problem+json`
# is also allowed because custom_openapi emits RFC 7807 ProblemDetails error bodies with that media
# type. Fires zero violations against the current snapshot.
response-media-type-json:
description: Response bodies must be application/json (or application/problem+json for errors).
given: $.paths[*][*].responses[*].content[*]~
severity: warn
then:
function: pattern
functionOptions:
match: "^application/(json|problem\\+json)$"

# ENABLED (gov.au API Request): request bodies must be application/json. Fires zero violations against
# the current snapshot.
request-media-type-json:
description: Request bodies must be application/json.
given: $.paths[*][*].requestBody.content[*]~
severity: warn
then:
function: pattern
functionOptions:
match: "^application/json$"

# ENABLED: POST request bodies must not REQUIRE an id — the backend generates it (a client may still
# supply an optional one). This targets each entry of the resolved request-body schema's `required`
# array and asserts none is literally "id"; if `required` is absent, no nodes match and the rule
# passes. (A schema-of-schema `not/contains` approach was tried first but vacuum reported it as a
# false positive on every POST body, so this pattern-based form is used instead.)
create-body-no-required-id:
description: POST request bodies must not require an id (backend-generated).
given: $.paths[*].post.requestBody.content.*.schema.required[*]
resolved: true
severity: warn
then:
function: pattern
functionOptions:
notMatch: "^id$"

# ENABLED (gov.au Naming: nouns not verbs). The recommended set's `no-http-verbs-in-path` already
# catches HTTP-method names used as path segments; this adds the non-HTTP action verbs it does not
# cover. Fires zero violations against the current snapshot.
no-action-verbs-in-path:
description: Path segments must be nouns, not action verbs.
given: $.paths[*]~
severity: warn
then:
function: pattern
functionOptions:
notMatch: "/(list|create|add|update|edit|remove|fetch|retrieve|make)(-|/|$)"

# ENABLED (gov.au API Response): a 201 Created must return the new resource's Location. This fires on
# POST operations that declare a 201 response, so genuine resource creates (add device, create
# threshold, create view) declare + emit a Location header. Non-create POST actions should return a
# non-201 success (e.g. push measurements returns 202 Accepted) so this rule does not apply to them.
# (A `function: schema` form with a nested `required: [Location]` was tried first but vacuum reported
# it as a false positive even when the header was present, so this truthy field check is used instead.)
location-header-on-201:
description: 201 Created responses must declare a Location header.
given: $.paths[*].post.responses['201']
severity: warn
then:
field: headers.Location
function: truthy

# ENABLED (gov.au Naming: plural resources). Static path segments should be plural resource nouns.
# Declarative casing/pattern rules cannot pluralize, so this uses a custom JavaScript function
# (.config/vacuum-functions/resourcesPlural.js) run via `vacuum lint -f .config/vacuum-functions`. The
# check is heuristic: it flags a static segment only when it is clearly singular (does not end in "s")
# and is not in the function's ALLOWLIST of non-collection segments (namespaces, actions, uncountable
# nouns). Tune that allowlist in the JS file rather than here.
resources-plural:
description: Path resource segments should be plural nouns.
given: $.paths[*]~
severity: warn
then:
function: resourcesPlural
3 changes: 3 additions & 0 deletions template/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,7 @@ dist
# macOS dev cleanliness
**/.DS_Store

# vacuum OpenAPI lint reports (generated ad-hoc; the linter runs against the committed snapshot)
vacuum-report-*.json

# Ignores specific to this repository
21 changes: 21 additions & 0 deletions template/.pre-commit-config.yaml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,27 @@ repos:
# use require_serial so that script is only called once per commit
require_serial: true
# print the number of files as a sanity-check
verbose: true

- repo: local
hooks:
- id: vacuum-openapi
name: vacuum OpenAPI schema lint
# Lints the generated OpenAPI snapshot against the curated ruleset.
# `language: node` with additional_dependencies lets pre-commit install vacuum in an
# isolated env, so this runs in CI without any global/devcontainer install. Fails on warn-or-worse.
entry: vacuum lint --no-banner --no-style --fail-severity warn -d --no-clip -f .config/vacuum-functions -r .config/vacuum-ruleset.yaml --ignore-file .config/vacuum-ignore.yaml backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json
language: node
additional_dependencies: ['@quobix/vacuum@{% endraw %}{{ vacuum_openapi_version }}{% raw %}']
files: |
(?x)^(
backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema\.json|
.config/vacuum-ruleset\.yaml|
.config/vacuum-ignore\.yaml|
.config/vacuum-functions/.*\.js
)$
pass_filenames: false
require_serial: true
verbose: true{% endraw %}{% endif %}{% raw %}

# Updating repo config/tooling files
Expand Down
Loading