From 56684dfde22d96ef1052f5eee73450c43187a7f8 Mon Sep 17 00:00:00 2001 From: zender Date: Fri, 10 Jul 2026 21:56:39 -0400 Subject: [PATCH 01/35] Integrate vacuum for open api spec validation --- .github/workflows/ci.yaml | 3 +- extensions/context.py | 1 + template/.config/vacuum-ignore.yaml | 20 +++ template/.config/vacuum-ruleset.yaml | 120 ++++++++++++++++++ .../.devcontainer/install-ci-tooling.py.jinja | 16 +++ template/.gitignore | 4 + template/.pre-commit-config.yaml.jinja | 20 +++ 7 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 template/.config/vacuum-ignore.yaml create mode 100644 template/.config/vacuum-ruleset.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6e1add0ed..cbec7d02f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -206,7 +206,8 @@ jobs: - name: Run pre-commit run: | # skip devcontainer context hash because the template instantiation may make it different every time - SKIP=git-dirty,compute-devcontainer-context-hash pre-commit run -a || PRE_COMMIT_EXIT_CODE=$? + # skip vacuum-openapi because the new template will not have an OpenAPI spec yet, so it will always fail + SKIP=git-dirty,compute-devcontainer-context-hash,vacuum-openapi pre-commit run -a || PRE_COMMIT_EXIT_CODE=$? if [ -n "$PRE_COMMIT_EXIT_CODE" ]; then echo "Pre-commit failed with exit code $PRE_COMMIT_EXIT_CODE" echo "Showing git diff:" diff --git a/extensions/context.py b/extensions/context.py index 6977779fc..9f9186fe1 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -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" context["default_node_version"] = "24.11.1" context["nuxt_ui_version"] = "^4.8.1" diff --git a/template/.config/vacuum-ignore.yaml b/template/.config/vacuum-ignore.yaml new file mode 100644 index 000000000..21affe9c9 --- /dev/null +++ b/template/.config/vacuum-ignore.yaml @@ -0,0 +1,20 @@ +# 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). +oas3-missing-example: + - "$.components.schemas['HTTPValidationError'].properties['detail']" + +owasp-integer-format: + - "$.components.schemas['ValidationError'].properties['loc'].items.anyOf[1]" +owasp-integer-limit: + - "$.components.schemas['ValidationError'].properties['loc'].items.anyOf[1]" diff --git a/template/.config/vacuum-ruleset.yaml b/template/.config/vacuum-ruleset.yaml new file mode 100644 index 000000000..2bd385fb3 --- /dev/null +++ b/template/.config/vacuum-ruleset.yaml @@ -0,0 +1,120 @@ +# 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): +# 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. + openapi-tags: + given: $ + severity: warn + resolved: true + then: + field: tags + function: schema + functionOptions: + forceValidation: true + schema: + type: array + uniqueItems: true + items: + type: object + minItems: 1 + + # 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: + given: $ + severity: warn + resolved: true + then: + function: tagDescription + + # 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: + given: $ + severity: warn + resolved: true + then: + function: oasOpSingleTag + + # ENABLED: a POST with no documented success response is an incomplete contract; ensures every + # create/action endpoint declares its 2xx (we already return 201) so clients know the success shape. + post-response-success: + given: $.paths.*.post.responses + severity: warn + resolved: true + then: + function: postResponseSuccess + functionOptions: + properties: + - 200 + - 201 + - 202 + - 204 + + # 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: + given: $ + severity: warn + resolved: true + then: + function: requiredFieldsDefined + + # 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. + # + # Documented validation-error response (400/422/4XX) on every operation. + owasp-define-error-validation: + given: $ + severity: warn + then: + function: owaspDefineErrorDefinition + functionOptions: + codes: ["400", "422", "4XX"] + + # Integers must declare int32/int64 `format` so codegen picks the right numeric type. + owasp-integer-format: + given: $ + severity: error + then: + function: owaspIntegerFormat + + # Integers must declare min/max bounds (input validation / resource-exhaustion guard). + owasp-integer-limit: + given: $ + severity: error + then: + function: owaspIntegerLimit diff --git a/template/.devcontainer/install-ci-tooling.py.jinja b/template/.devcontainer/install-ci-tooling.py.jinja index 24ef5bd0b..e26a409f9 100644 --- a/template/.devcontainer/install-ci-tooling.py.jinja +++ b/template/.devcontainer/install-ci-tooling.py.jinja @@ -12,6 +12,7 @@ PNPM_VERSION = "{% endraw %}{{ pnpm_version }}{% raw %}" COPIER_VERSION = "{% endraw %}{{ copier_version }}{% raw %}" COPIER_TEMPLATE_EXTENSIONS_VERSION = "{% endraw %}{{ copier_template_extensions_version }}{% raw %}" PRE_COMMIT_VERSION = "{% endraw %}{{ pre_commit_version }}{% raw %}" +VACUUM_OPENAPI_VERSION = "{% endraw %}{{ vacuum_openapi_version }}{% raw %}" GITHUB_WINDOWS_RUNNER_BIN_PATH = r"C:\Users\runneradmin\.local\bin" INSTALL_SSM_PLUGIN_BY_DEFAULT = {% endraw %}{% if is_child_of_copier_base_template is not defined and install_aws_ssm_port_forwarding_plugin is defined and install_aws_ssm_port_forwarding_plugin is sameas(true) %}True{% else %}False{% endif %}{% raw %} parser = argparse.ArgumentParser(description="Install CI tooling for the repo") @@ -117,6 +118,21 @@ def main(): else [cmd] ) _ = subprocess.run(run_cmd, shell=True, check=True) # noqa: S602 # we need shell=True for npm commands, and this is all our own input + + vacuum_install_sequence = ["npm -v", f"npm install -g @quobix/vacuum@{VACUUM_OPENAPI_VERSION}", "vacuum version"] + for cmd in vacuum_install_sequence: + run_cmd = ( + [ + pwsh, # type: ignore[reportPossiblyUnboundVariable] # this matches the conditional above that defines pwsh + "-NoProfile", + "-NonInteractive", + "-Command", + cmd, + ] + if is_windows + else [cmd] + ) + _ = subprocess.run(run_cmd, shell=True, check=True) # noqa: S602 # we need shell=True for npm commands, and this is all our own input if INSTALL_SSM_PLUGIN_BY_DEFAULT and not args.skip_installing_ssm_plugin: with tempfile.TemporaryDirectory() as tmp_dir: if is_windows: diff --git a/template/.gitignore b/template/.gitignore index cc7c8b5be..189ef2fef 100644 --- a/template/.gitignore +++ b/template/.gitignore @@ -123,4 +123,8 @@ dist # macOS dev cleanliness **/.DS_Store +# vacuum OpenAPI lint reports (generated ad-hoc; the linter runs against the committed snapshot) +vacuum-report-*.json +/report.html + # Ignores specific to this repository diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index c9a65001f..fff1427b6 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -341,6 +341,26 @@ repos: # print the number of files as a sanity-check verbose: true{% endraw %}{% endif %}{% raw %} + - repo: local + hooks: + - id: vacuum-openapi + name: vacuum OpenAPI schema lint + # Lints the generated OpenAPI snapshot (the canonical spec fed to Kiota) 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 -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@0.27.0'] + files: | + (?x)^( + backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema\.json| + .config/vacuum-ruleset\.yaml| + .config/vacuum-ignore\.yaml + )$ + pass_filenames: false + require_serial: true + verbose: true + # Updating repo config/tooling files - repo: local hooks: From 57737d39ced00e7304411e0b07e9e16b032c660f Mon Sep 17 00:00:00 2001 From: zender Date: Fri, 10 Jul 2026 22:06:32 -0400 Subject: [PATCH 02/35] Handle open api tags --- .../src/backend_api/app_def.py.jinja | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja index 4b8ce98e0..13a424c15 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja @@ -74,9 +74,22 @@ async def lifespan(app: FastAPI): logger.info("Shutting down FastAPI application") {% endraw %}{% endif %}{% raw %} + +API_DESCRIPTION = "Metrics Monitor REST API that discovers local network sensors via mDNS, collects and stores measurements on a configurable polling schedule, and provides dashboard/chart management plus alarm threshold configuration." + +OPENAPI_TAGS = [ + {"name": "system", "description": "Server health and lifecycle operations."} +] +OPENAPI_APP_SPECIFIC_TAGS = [ + // Insert app specific openapi tags here +] +OPENAPI_TAGS += OPENAPI_APP_SPECIFIC_TAGS + try: app = FastAPIOffline( title=HUMAN_FRIENDLY_APP_NAME, + description=API_DESCRIPTION, + openapi_tags=OPENAPI_TAGS, favicon_url="favicon.ico", version=get_version(), docs_url="/api-docs", From f0bd4857ecca3fbf60ff4e74f77f684281b9ac97 Mon Sep 17 00:00:00 2001 From: zender Date: Fri, 10 Jul 2026 22:09:37 -0400 Subject: [PATCH 03/35] Add question to get rest api description --- copier.yml | 5 +++++ .../src/backend_api/app_def.py.jinja | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/copier.yml b/copier.yml index b2dec3503..01458c4f1 100644 --- a/copier.yml +++ b/copier.yml @@ -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? diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja index 13a424c15..b06c94653 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja @@ -75,7 +75,7 @@ async def lifespan(app: FastAPI): {% endraw %}{% endif %}{% raw %} -API_DESCRIPTION = "Metrics Monitor REST API that discovers local network sensors via mDNS, collects and stores measurements on a configurable polling schedule, and provides dashboard/chart management plus alarm threshold configuration." +API_DESCRIPTION = "{% endraw %}{{ backend_rest_api_description }}{% raw %}" OPENAPI_TAGS = [ {"name": "system", "description": "Server health and lifecycle operations."} From c1139fb95037e4b7d249fd82e2f661363e6f9e54 Mon Sep 17 00:00:00 2001 From: zender Date: Fri, 10 Jul 2026 22:13:09 -0400 Subject: [PATCH 04/35] Need the new param --- tests/copier_data/data1.yaml | 1 + tests/copier_data/data3.yaml | 1 + tests/copier_data/data4.yaml | 1 + tests/copier_data/data5.yaml | 1 + 4 files changed, 4 insertions(+) diff --git a/tests/copier_data/data1.yaml b/tests/copier_data/data1.yaml index b5d241f3a..a4b91df31 100644 --- a/tests/copier_data/data1.yaml +++ b/tests/copier_data/data1.yaml @@ -33,6 +33,7 @@ aws_region_for_stack: us-east-2 # Data added based on the specifics of this template human_friendly_app_name: Doing Great Thing has_backend: true +backend_rest_api_description: Doing Great Thing REST API backend_makes_api_calls: true backend_source_uses_kiota: true backend_deployed_port_number: 8080 diff --git a/tests/copier_data/data3.yaml b/tests/copier_data/data3.yaml index a16976470..212baabea 100644 --- a/tests/copier_data/data3.yaml +++ b/tests/copier_data/data3.yaml @@ -25,6 +25,7 @@ aws_region_for_stack: us-east-2 # Data added based on the specifics of this template human_friendly_app_name: Sky Net has_backend: true +backend_rest_api_description: Sky Net REST API backend_makes_api_calls: false backend_source_uses_kiota: false backend_deployed_port_number: 4000 diff --git a/tests/copier_data/data4.yaml b/tests/copier_data/data4.yaml index c04f96b6f..9d23e186b 100644 --- a/tests/copier_data/data4.yaml +++ b/tests/copier_data/data4.yaml @@ -25,6 +25,7 @@ aws_region_for_stack: us-east-2 # Data added based on the specifics of this template human_friendly_app_name: Destroyer of Worlds has_backend: true +backend_rest_api_description: Destroyer of Worlds REST API backend_makes_api_calls: true backend_source_uses_kiota: false backend_deployed_port_number: 4020 diff --git a/tests/copier_data/data5.yaml b/tests/copier_data/data5.yaml index 490bc61f2..dc4fde958 100644 --- a/tests/copier_data/data5.yaml +++ b/tests/copier_data/data5.yaml @@ -25,6 +25,7 @@ aws_region_for_stack: eu-west-1 # Data added based on the specifics of this template human_friendly_app_name: Tiny Exe has_backend: true +backend_rest_api_description: Tiny Exe REST API backend_makes_api_calls: false backend_source_uses_kiota: false backend_deployed_port_number: 5050 From e340c7600f750d0fc8f671e7832c18c7197beb11 Mon Sep 17 00:00:00 2001 From: zender Date: Fri, 10 Jul 2026 22:45:20 -0400 Subject: [PATCH 05/35] FOrmatting --- template/.devcontainer/install-ci-tooling.py.jinja | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/template/.devcontainer/install-ci-tooling.py.jinja b/template/.devcontainer/install-ci-tooling.py.jinja index e26a409f9..67f0f56c0 100644 --- a/template/.devcontainer/install-ci-tooling.py.jinja +++ b/template/.devcontainer/install-ci-tooling.py.jinja @@ -119,7 +119,11 @@ def main(): ) _ = subprocess.run(run_cmd, shell=True, check=True) # noqa: S602 # we need shell=True for npm commands, and this is all our own input - vacuum_install_sequence = ["npm -v", f"npm install -g @quobix/vacuum@{VACUUM_OPENAPI_VERSION}", "vacuum version"] + vacuum_install_sequence = [ + "npm -v", + f"npm install -g @quobix/vacuum@{VACUUM_OPENAPI_VERSION}", + "vacuum version", + ] for cmd in vacuum_install_sequence: run_cmd = ( [ From 526e944c2a3219a10afa83e1709e18ae8f095558 Mon Sep 17 00:00:00 2001 From: zender Date: Fri, 10 Jul 2026 22:46:25 -0400 Subject: [PATCH 06/35] Updates to exception handler --- .../fast_api_exception_handlers.py | 69 +++++++++++++++++-- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/fast_api_exception_handlers.py b/template/{% if has_backend %}backend{% endif %}/src/backend_api/fast_api_exception_handlers.py index c049ec092..a5a06c14a 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/fast_api_exception_handlers.py +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/fast_api_exception_handlers.py @@ -19,14 +19,39 @@ logger = logging.getLogger(__name__) +_HTTP_STATUS_SCHEMA: dict[str, JsonValue] = {"format": "int32", "minimum": 100, "maximum": 599} + + class ProblemDetails(BaseModel): - type: str = "about:blank" - title: str - status: int + """RFC 9457 problem details describing an error response. + + Returned (as application/problem+json) for error responses so clients and codegen tools such as Kiota + have a consistent, machine-readable error shape. + """ + + type: str = Field( + default="about:blank", examples=["about:blank"], description="A URI reference identifying the problem type." + ) + title: str = Field( + examples=["Internal Server Error"], description="A short, human-readable summary of the problem." + ) + status: int = Field( + json_schema_extra=_HTTP_STATUS_SCHEMA, examples=[500], description="The HTTP status code for this problem." + ) # The vendor extension lets Kiota use this as the exception message. - detail: str = Field(..., json_schema_extra={"x-ms-primary-error-message": True}) - instance: str - error_type: str | None = Field(default=None, alias="errorType") + detail: str = Field( + ..., + json_schema_extra={"x-ms-primary-error-message": True}, + examples=["An unexpected error occurred."], + description="A human-readable explanation specific to this occurrence of the problem.", + ) + instance: str = Field(examples=["/api/healthcheck"], description="A URI reference identifying this occurrence.") + error_type: str | None = Field( + default=None, + alias="errorType", + examples=["ValueError"], + description="The internal exception class name, when available.", + ) def should_show_error_details() -> bool: @@ -144,7 +169,14 @@ def custom_openapi(app: FastAPI) -> dict[str, Any]: """ if app.openapi_schema: # don't regenerate the schema on subsequent API calls if it's already been done return app.openapi_schema - oas = get_openapi(title=app.title, version=app.version, routes=app.routes) + oas = get_openapi( + title=app.title, + version=app.version, + routes=app.routes, + description=app.description, + tags=app.openapi_tags, + servers=app.servers, + ) # Ensure ProblemDetails schema exists comps = oas.setdefault("components", {}).setdefault("schemas", {}) @@ -153,6 +185,29 @@ def custom_openapi(app: FastAPI) -> dict[str, Any]: ProblemDetails.model_json_schema(ref_template="#/components/schemas/{model}"), ) + # FastAPI generates the validation-error schemas without descriptions or examples; we cannot annotate + # them at the model level, so document and exemplify them here. This also lets every media type that + # references them (the 422 responses) satisfy the "missing example" lint rule. + validation_error_example: JsonValue = { + "loc": ["body", "field_name"], + "msg": "field required", + "type": "missing", + } + generated_schema_metadata: dict[str, dict[str, JsonValue]] = { + "HTTPValidationError": { + "description": "Error response returned when request validation fails.", + "examples": [{"detail": [validation_error_example]}], + }, + "ValidationError": { + "description": "Details of a single request validation failure.", + "examples": [validation_error_example], + }, + } + for schema_name, schema_metadata in generated_schema_metadata.items(): + if schema_name in comps: + for key, value in schema_metadata.items(): + _ = comps[schema_name].setdefault(key, value) + def problem_ref() -> dict[str, JsonValue]: return {"$ref": "#/components/schemas/ProblemDetails"} From 152bc7dc35f1eb102dfc3ac4c662bded6acbba73 Mon Sep 17 00:00:00 2001 From: zender Date: Fri, 10 Jul 2026 22:51:06 -0400 Subject: [PATCH 07/35] Dont need it in devcontainers put it in pre-commit --- .../.devcontainer/install-ci-tooling.py.jinja | 19 ------------------- template/.pre-commit-config.yaml.jinja | 2 +- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/template/.devcontainer/install-ci-tooling.py.jinja b/template/.devcontainer/install-ci-tooling.py.jinja index 67f0f56c0..10105de3c 100644 --- a/template/.devcontainer/install-ci-tooling.py.jinja +++ b/template/.devcontainer/install-ci-tooling.py.jinja @@ -118,25 +118,6 @@ def main(): else [cmd] ) _ = subprocess.run(run_cmd, shell=True, check=True) # noqa: S602 # we need shell=True for npm commands, and this is all our own input - - vacuum_install_sequence = [ - "npm -v", - f"npm install -g @quobix/vacuum@{VACUUM_OPENAPI_VERSION}", - "vacuum version", - ] - for cmd in vacuum_install_sequence: - run_cmd = ( - [ - pwsh, # type: ignore[reportPossiblyUnboundVariable] # this matches the conditional above that defines pwsh - "-NoProfile", - "-NonInteractive", - "-Command", - cmd, - ] - if is_windows - else [cmd] - ) - _ = subprocess.run(run_cmd, shell=True, check=True) # noqa: S602 # we need shell=True for npm commands, and this is all our own input if INSTALL_SSM_PLUGIN_BY_DEFAULT and not args.skip_installing_ssm_plugin: with tempfile.TemporaryDirectory() as tmp_dir: if is_windows: diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index fff1427b6..f1cdd7233 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -350,7 +350,7 @@ repos: # 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 -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@0.27.0'] + additional_dependencies: ['@quobix/vacuum@{% endraw %}{{ vacuum_openapi_version }}{ %raw %}'] files: | (?x)^( backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema\.json| From 37e9cc6ced94b4bcc429dec4aa5da1fe5052fc29 Mon Sep 17 00:00:00 2001 From: zender Date: Fri, 10 Jul 2026 22:53:13 -0400 Subject: [PATCH 08/35] If has_backend aroudn pre-commit --- template/.pre-commit-config.yaml.jinja | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index f1cdd7233..7d94fbff2 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -339,7 +339,7 @@ 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{% endraw %}{% endif %}{% raw %} + verbose: true - repo: local hooks: @@ -359,7 +359,7 @@ repos: )$ pass_filenames: false require_serial: true - verbose: true + verbose: true{% endraw %}{% endif %}{% raw %} # Updating repo config/tooling files - repo: local From 7787b448b63eabf6f460fc864077ef4a68ba9680 Mon Sep 17 00:00:00 2001 From: Nathan Zender Date: Fri, 10 Jul 2026 22:54:02 -0400 Subject: [PATCH 09/35] Correct commit Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../src/backend_api/app_def.py.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja index b06c94653..9f354ee89 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja @@ -81,7 +81,7 @@ OPENAPI_TAGS = [ {"name": "system", "description": "Server health and lifecycle operations."} ] OPENAPI_APP_SPECIFIC_TAGS = [ - // Insert app specific openapi tags here + # Insert app specific openapi tags here ] OPENAPI_TAGS += OPENAPI_APP_SPECIFIC_TAGS From fe149e8cc86ad4db7e4fe087dfb8e1637f994dbb Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 09:57:22 -0400 Subject: [PATCH 10/35] dont need explicit definitions as we are just doing the defaults --- template/.config/vacuum-ruleset.yaml | 76 ++++------------------------ 1 file changed, 11 insertions(+), 65 deletions(-) diff --git a/template/.config/vacuum-ruleset.yaml b/template/.config/vacuum-ruleset.yaml index 2bd385fb3..2ef0f84c6 100644 --- a/template/.config/vacuum-ruleset.yaml +++ b/template/.config/vacuum-ruleset.yaml @@ -34,87 +34,33 @@ rules: # 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. - openapi-tags: - given: $ - severity: warn - resolved: true - then: - field: tags - function: schema - functionOptions: - forceValidation: true - schema: - type: array - uniqueItems: true - items: - type: object - minItems: 1 + # `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: - given: $ - severity: warn - resolved: true - then: - function: tagDescription + 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: - given: $ - severity: warn - resolved: true - then: - function: oasOpSingleTag + operation-singular-tag: true # ENABLED: a POST with no documented success response is an incomplete contract; ensures every - # create/action endpoint declares its 2xx (we already return 201) so clients know the success shape. - post-response-success: - given: $.paths.*.post.responses - severity: warn - resolved: true - then: - function: postResponseSuccess - functionOptions: - properties: - - 200 - - 201 - - 202 - - 204 + # 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: - given: $ - severity: warn - resolved: true - then: - function: requiredFieldsDefined + 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. - # - # Documented validation-error response (400/422/4XX) on every operation. - owasp-define-error-validation: - given: $ - severity: warn - then: - function: owaspDefineErrorDefinition - functionOptions: - codes: ["400", "422", "4XX"] + owasp-define-error-validation: true # Integers must declare int32/int64 `format` so codegen picks the right numeric type. - owasp-integer-format: - given: $ - severity: error - then: - function: owaspIntegerFormat + # (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: - given: $ - severity: error - then: - function: owaspIntegerLimit + owasp-integer-limit: true From e0ae863d144ef885b7606fcc3db87720704df77f Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 09:59:50 -0400 Subject: [PATCH 11/35] Cleanup --- template/.devcontainer/install-ci-tooling.py.jinja | 1 - 1 file changed, 1 deletion(-) diff --git a/template/.devcontainer/install-ci-tooling.py.jinja b/template/.devcontainer/install-ci-tooling.py.jinja index 10105de3c..24ef5bd0b 100644 --- a/template/.devcontainer/install-ci-tooling.py.jinja +++ b/template/.devcontainer/install-ci-tooling.py.jinja @@ -12,7 +12,6 @@ PNPM_VERSION = "{% endraw %}{{ pnpm_version }}{% raw %}" COPIER_VERSION = "{% endraw %}{{ copier_version }}{% raw %}" COPIER_TEMPLATE_EXTENSIONS_VERSION = "{% endraw %}{{ copier_template_extensions_version }}{% raw %}" PRE_COMMIT_VERSION = "{% endraw %}{{ pre_commit_version }}{% raw %}" -VACUUM_OPENAPI_VERSION = "{% endraw %}{{ vacuum_openapi_version }}{% raw %}" GITHUB_WINDOWS_RUNNER_BIN_PATH = r"C:\Users\runneradmin\.local\bin" INSTALL_SSM_PLUGIN_BY_DEFAULT = {% endraw %}{% if is_child_of_copier_base_template is not defined and install_aws_ssm_port_forwarding_plugin is defined and install_aws_ssm_port_forwarding_plugin is sameas(true) %}True{% else %}False{% endif %}{% raw %} parser = argparse.ArgumentParser(description="Install CI tooling for the repo") From b8bab3b37265db7aa1c844db429067eff406a8f8 Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 10:03:15 -0400 Subject: [PATCH 12/35] Drop report.html since its super generic --- template/.gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/template/.gitignore b/template/.gitignore index 189ef2fef..3230070b3 100644 --- a/template/.gitignore +++ b/template/.gitignore @@ -125,6 +125,5 @@ dist # vacuum OpenAPI lint reports (generated ad-hoc; the linter runs against the committed snapshot) vacuum-report-*.json -/report.html # Ignores specific to this repository From 0d0861fc6142e23fd832ed7cbaa8a67833bb67d6 Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 10:14:29 -0400 Subject: [PATCH 13/35] Remove kiota comment --- template/.pre-commit-config.yaml.jinja | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index 7d94fbff2..664e9816a 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -345,8 +345,8 @@ repos: hooks: - id: vacuum-openapi name: vacuum OpenAPI schema lint - # Lints the generated OpenAPI snapshot (the canonical spec fed to Kiota) against the curated - # ruleset. `language: node` with additional_dependencies lets pre-commit install vacuum in an + # 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 -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 From a6d55497042f12aefb81074a6d3e3588e139a27b Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 10:15:46 -0400 Subject: [PATCH 14/35] Bring back linting of template --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cbec7d02f..83eece29a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -207,7 +207,7 @@ jobs: run: | # skip devcontainer context hash because the template instantiation may make it different every time # skip vacuum-openapi because the new template will not have an OpenAPI spec yet, so it will always fail - SKIP=git-dirty,compute-devcontainer-context-hash,vacuum-openapi pre-commit run -a || PRE_COMMIT_EXIT_CODE=$? + SKIP=git-dirty,compute-devcontainer-context-hash pre-commit run -a || PRE_COMMIT_EXIT_CODE=$? if [ -n "$PRE_COMMIT_EXIT_CODE" ]; then echo "Pre-commit failed with exit code $PRE_COMMIT_EXIT_CODE" echo "Showing git diff:" From 827a518a86f8da26c418175a98bb6a5f1dd5065e Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 10:52:38 -0400 Subject: [PATCH 15/35] fix(pre-commit): correct malformed raw tag in vacuum hook template Space was misplaced in '{ %raw %}', making it invalid jinja syntax. Left the raw block unopened, so the later endraw tag had nothing to close, breaking template rendering for downstream copier consumers. --- template/.pre-commit-config.yaml.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index 664e9816a..d52017dde 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -350,7 +350,7 @@ repos: # 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 -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 %}'] + additional_dependencies: ['@quobix/vacuum@{% endraw %}{{ vacuum_openapi_version }}{% raw %}'] files: | (?x)^( backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema\.json| From 5855fcf9522fa66b06d5eb1c81d114b89ea1fbf2 Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 11:16:38 -0400 Subject: [PATCH 16/35] fix(backend): avoid constant redefinition in openapi tags setup Combining OPENAPI_TAGS via += reassignment tripped pyright's reportConstantRedefinition, the untyped empty OPENAPI_APP_SPECIFIC_TAGS list tripped pyrefly's implicit-any-empty-container check, and the multi-line dict literal got collapsed by ruff-format since it fits under the 120-char line length. Build the list in one assignment with an explicit type annotation, pre-formatted to match ruff's output, so freshly generated projects pass pre-commit without needing a reformat pass. --- .../src/backend_api/app_def.py.jinja | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja index 9f354ee89..de1dc36c9 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja @@ -77,13 +77,13 @@ async def lifespan(app: FastAPI): API_DESCRIPTION = "{% endraw %}{{ backend_rest_api_description }}{% raw %}" -OPENAPI_TAGS = [ - {"name": "system", "description": "Server health and lifecycle operations."} -] -OPENAPI_APP_SPECIFIC_TAGS = [ +OPENAPI_APP_SPECIFIC_TAGS: list[dict[str, str]] = [ # Insert app specific openapi tags here ] -OPENAPI_TAGS += OPENAPI_APP_SPECIFIC_TAGS + +OPENAPI_TAGS = [ + {"name": "system", "description": "Server health and lifecycle operations."} +] + OPENAPI_APP_SPECIFIC_TAGS try: app = FastAPIOffline( From 1acb23552fd0e6a53ce31c693eaf17600d9fcb6d Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 11:35:13 -0400 Subject: [PATCH 17/35] fix(backend): generate openapi snapshot from real test instead of hand-authoring it The OpenAPI schema snapshot was hand-authored as a jinja template, which had to be manually kept in sync with the actual schema for every combination of template flags (graphql, circuit python, etc). It was already drifting on fresh generations, requiring a manual --snapshot-update before pre-commit would pass. Drop the templated snapshot and add a copier task that runs the actual test_openapi_schema test with --snapshot-update right after generation, guarded on the snapshot file not already existing (so `copier update` never clobbers a snapshot the repo has since evolved, consistent with the existing _skip_if_exists entry for this file). Coverage enforcement is disabled for this one-off run since it only exercises one test. Also fix RUF005: build OPENAPI_TAGS with iterable unpacking instead of list concatenation, which ruff flags as non-idiomatic. --- copier.yml | 7 + .../src/backend_api/app_def.py.jinja | 5 +- .../test_openapi_schema.json.jinja | 242 ------------------ 3 files changed, 10 insertions(+), 244 deletions(-) delete mode 100644 template/{% if has_backend %}backend{% endif %}/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json.jinja diff --git a/copier.yml b/copier.yml index 01458c4f1..1fc7e020f 100644 --- a/copier.yml +++ b/copier.yml @@ -407,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" diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja index de1dc36c9..50947f461 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja @@ -82,8 +82,9 @@ OPENAPI_APP_SPECIFIC_TAGS: list[dict[str, str]] = [ ] OPENAPI_TAGS = [ - {"name": "system", "description": "Server health and lifecycle operations."} -] + OPENAPI_APP_SPECIFIC_TAGS + {"name": "system", "description": "Server health and lifecycle operations."}, + *OPENAPI_APP_SPECIFIC_TAGS, +] try: app = FastAPIOffline( diff --git a/template/{% if has_backend %}backend{% endif %}/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json.jinja b/template/{% if has_backend %}backend{% endif %}/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json.jinja deleted file mode 100644 index 8f81ab8dd..000000000 --- a/template/{% if has_backend %}backend{% endif %}/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json.jinja +++ /dev/null @@ -1,242 +0,0 @@ -{% raw %}{ - "openapi": "3.1.0", - "info": { - "title": "{% endraw %}{{ human_friendly_app_name }}{% raw %}", - "version": "0.1.0" - }, - "paths": { - "/api/healthcheck": { - "get": { - "tags": [ - "system" - ], - "summary": "Check API health", - "operationId": "healthcheck_api_healthcheck_get", - "parameters": [ - { - "name": "prepend_v", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "description": "Include a 'v' before the version number", - "default": false, - "title": "Prepend V" - }, - "description": "Include a 'v' before the version number" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthcheckResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "default": { - "description": "Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/shutdown": { - "get": { - "tags": [ - "system" - ], - "summary": "Shut down the server", - "operationId": "shutdown_api_shutdown_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShutdownResponse" - } - } - } - }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "default": { - "description": "Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": { - "$ref": "#/components/schemas/ValidationError" - }, - "type": "array", - "title": "Detail" - } - }, - "type": "object", - "title": "HTTPValidationError" - }, - "HealthcheckResponse": { - "properties": { - "version": { - "type": "string", - "title": "Version", - "description": "Version of the application", - "default": "1.0.0" - } - }, - "type": "object", - "title": "HealthcheckResponse" - }, - "ShutdownResponse": { - "properties": { - "message": { - "type": "string", - "title": "Message", - "description": "Message indicating the shutdown request was received", - "default": "Shutdown request received. Server will exit shortly." - } - }, - "type": "object", - "title": "ShutdownResponse" - }, - "ValidationError": { - "properties": { - "loc": { - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] - }, - "type": "array", - "title": "Location" - }, - "msg": { - "type": "string", - "title": "Message" - }, - "type": { - "type": "string", - "title": "Error Type" - }, - "input": { - "title": "Input" - }, - "ctx": { - "type": "object", - "title": "Context" - } - }, - "type": "object", - "required": [ - "loc", - "msg", - "type" - ], - "title": "ValidationError" - }, - "ProblemDetails": { - "properties": { - "type": { - "default": "about:blank", - "title": "Type", - "type": "string" - }, - "title": { - "title": "Title", - "type": "string" - }, - "status": { - "title": "Status", - "type": "integer" - }, - "detail": { - "title": "Detail", - "type": "string", - "x-ms-primary-error-message": true - }, - "instance": { - "title": "Instance", - "type": "string" - }, - "errorType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Errortype" - } - }, - "required": [ - "title", - "status", - "detail", - "instance" - ], - "title": "ProblemDetails", - "type": "object" - } - } - } -}{% endraw %} From 707993b0fdabfa804b73ba6c289e9e0dd95ff9da Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 11:43:57 -0400 Subject: [PATCH 18/35] fix(backend): add response model docstrings for vacuum component-description HealthcheckResponse and ShutdownResponse had no docstring, so FastAPI emitted their component schemas without a description, tripping vacuum's component-description rule on every fresh generation. --- .../src/backend_api/app_def.py.jinja | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja index 50947f461..bbe1c3709 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja @@ -106,10 +106,22 @@ except ( # pragma: no cover # This is just logging unexpected errors, and it's class HealthcheckResponse(BaseModel): + """Result of an API health check. + + Reports the running application version so a caller can confirm the server is up and identify which + build is currently deployed. + """ + version: str = Field(description="Version of the application", default="1.0.0") class ShutdownResponse(BaseModel): + """Acknowledgement of a server shutdown request. + + Returned immediately when a shutdown is requested; the server process exits shortly after this response + is sent. + """ + message: str = Field( default="Shutdown request received. Server will exit shortly.", description="Message indicating the shutdown request was received", From 955887a67ee1f68aedec2a70218e32c1373d3f21 Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 13:10:44 -0400 Subject: [PATCH 19/35] fix(backend): replace placeholder graphql query with real system.version field The graphql schema used query=int as a placeholder Query type, which crashed strawberry.Schema() at import time. This was previously masked in CI because the hand-authored openapi snapshot skipped the codepath that actually imports the schema; removing that snapshot in a prior commit exposed the crash. --- .../schema.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/{% if backend_uses_graphql %}graphql{% endif %}/schema.py b/template/{% if has_backend %}backend{% endif %}/src/backend_api/{% if backend_uses_graphql %}graphql{% endif %}/schema.py index 9ad226de2..051fa7a8f 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/{% if backend_uses_graphql %}graphql{% endif %}/schema.py +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/{% if backend_uses_graphql %}graphql{% endif %}/schema.py @@ -2,11 +2,25 @@ from pathlib import Path import strawberry +from backend_api.entrypoint.parser import get_version from backend_api.strawberry_router import AddErrorTrace logger = logging.getLogger(__name__) -schema = strawberry.Schema(query=int, extensions=[AddErrorTrace]) + +@strawberry.type +class SystemType: + version: str + + +@strawberry.type +class Query: + @strawberry.field + def system(self) -> SystemType: + return SystemType(version=get_version()) + + +schema = strawberry.Schema(query=Query, extensions=[AddErrorTrace]) sdl_path = Path(__file__).parent / "schema.graphql" schema_lines = [line.rstrip() for line in schema.as_str().splitlines()] _ = sdl_path.write_text("\n".join(schema_lines) + "\n") From f5b26b218de00201db967a66adf94811db26dc9a Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 13:15:39 -0400 Subject: [PATCH 20/35] Remove comment --- .github/workflows/ci.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 83eece29a..6e1add0ed 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -206,7 +206,6 @@ jobs: - name: Run pre-commit run: | # skip devcontainer context hash because the template instantiation may make it different every time - # skip vacuum-openapi because the new template will not have an OpenAPI spec yet, so it will always fail SKIP=git-dirty,compute-devcontainer-context-hash pre-commit run -a || PRE_COMMIT_EXIT_CODE=$? if [ -n "$PRE_COMMIT_EXIT_CODE" ]; then echo "Pre-commit failed with exit code $PRE_COMMIT_EXIT_CODE" From 07be3532c657f6496ed4744e682f23bd44e85586 Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 13:34:58 -0400 Subject: [PATCH 21/35] chore(#195): add debug step to investigate pyright spawn failure --- .github/workflows/ci.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6e1add0ed..4ea9bd864 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -203,6 +203,18 @@ jobs: restore-keys: | ${{ matrix.os }}-${{ matrix.python-version }}-build-${{ env.cache-name }}- + - name: DEBUG pyright investigation + if: hashFiles('backend/pyproject.toml') != '' + run: | + echo "--- backend/.venv/bin (pyright*) ---" + ls -la backend/.venv/bin/ | grep -i pyright || echo "NO PYRIGHT BINARY FOUND" + echo "--- uv --directory=backend run pyright --version ---" + PYRIGHT_PYTHON_DEBUG=1 uv --directory=backend run pyright --version || echo "EXIT CODE $?" + echo "--- uv --directory=backend pip show pyright ---" + uv --directory=backend pip show pyright || echo "EXIT CODE $?" + echo "--- ~/.cache/pyright ---" + ls -la ~/.cache/pyright 2>&1 || echo "NO CACHE DIR" + - name: Run pre-commit run: | # skip devcontainer context hash because the template instantiation may make it different every time From 72d32ae5ebec8c769de7c7e3d2b53d55e06653c3 Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 13:39:35 -0400 Subject: [PATCH 22/35] fix(#195): remove stale backend .venv after moving template out of new-template subfolder Copier _tasks runs uv sync/pytest while the template still lives under new-template/backend, baking console-script shebangs with that path. After the move to repo root, those shebangs point nowhere, breaking any python-wrapper console script (pyright) with ENOENT on exec. --- .github/workflows/ci.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4ea9bd864..20f997fe8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 - name: install new dependencies env: From d0fb371706a6d254af3764fb43b2e45f9b6df3d3 Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 14:05:08 -0400 Subject: [PATCH 23/35] Remove DEBUG output --- .github/workflows/ci.yaml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 20f997fe8..893dabe6d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -206,18 +206,6 @@ jobs: restore-keys: | ${{ matrix.os }}-${{ matrix.python-version }}-build-${{ env.cache-name }}- - - name: DEBUG pyright investigation - if: hashFiles('backend/pyproject.toml') != '' - run: | - echo "--- backend/.venv/bin (pyright*) ---" - ls -la backend/.venv/bin/ | grep -i pyright || echo "NO PYRIGHT BINARY FOUND" - echo "--- uv --directory=backend run pyright --version ---" - PYRIGHT_PYTHON_DEBUG=1 uv --directory=backend run pyright --version || echo "EXIT CODE $?" - echo "--- uv --directory=backend pip show pyright ---" - uv --directory=backend pip show pyright || echo "EXIT CODE $?" - echo "--- ~/.cache/pyright ---" - ls -la ~/.cache/pyright 2>&1 || echo "NO CACHE DIR" - - name: Run pre-commit run: | # skip devcontainer context hash because the template instantiation may make it different every time From bc16b3b13c9f8f4063b9095a598fb9da4b92bd43 Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 14:11:25 -0400 Subject: [PATCH 24/35] fix(backend): satisfy vacuum operation-tags and oas3-missing-example rules - tag the graphql router so /api/graphql operations aren't untagged - add examples to HealthcheckResponse.version and ShutdownResponse.message --- .../src/backend_api/app_def.py.jinja | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja index bbe1c3709..5da30495e 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja @@ -112,7 +112,7 @@ class HealthcheckResponse(BaseModel): build is currently deployed. """ - version: str = Field(description="Version of the application", default="1.0.0") + version: str = Field(description="Version of the application", default="1.0.0", examples=["1.0.0"]) class ShutdownResponse(BaseModel): @@ -125,6 +125,7 @@ class ShutdownResponse(BaseModel): message: str = Field( default="Shutdown request received. Server will exit shortly.", description="Message indicating the shutdown request was received", + examples=["Shutdown request received. Server will exit shortly."], ) @@ -177,7 +178,7 @@ try: ){% endraw %}{% if backend_uses_graphql %}{% raw %} graphql_app = OfflineGraphQLRouter(schema) - app.include_router(graphql_app, prefix="/api/graphql"){% endraw %}{% endif %}{% raw %}{% endraw %}{% if has_circuit_python_backend_template_been_instantiated %}{% raw %} + app.include_router(graphql_app, prefix="/api/graphql", tags=["graphql"]){% endraw %}{% endif %}{% raw %}{% endraw %}{% if has_circuit_python_backend_template_been_instantiated %}{% raw %} app.include_router(driver_router, prefix="/api/driver") app.include_router(bridges_router, prefix="/api/bridges", tags=["debug"]) app.include_router(mdns_router, prefix="/api", tags=["mDNS"]){% endraw %}{% endif %}{% raw %} From 8f4e1a810f9b499bbe48ee88854c83ac2808fbce Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 14:14:10 -0400 Subject: [PATCH 25/35] fix(backend): satisfy vacuum operation-tag-defined and oas3-missing-example for graphql - register the "graphql" tag in OPENAPI_TAGS so the tagged /api/graphql operations resolve against a defined tag - ignore oas3-missing-example on /api/graphql's 200 responses: the body is arbitrary JSON shaped by the caller's query/GraphiQL page, so there is no honest single example to attach --- template/.config/vacuum-ignore.yaml | 4 ++++ .../src/backend_api/app_def.py.jinja | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/template/.config/vacuum-ignore.yaml b/template/.config/vacuum-ignore.yaml index 21affe9c9..008c80330 100644 --- a/template/.config/vacuum-ignore.yaml +++ b/template/.config/vacuum-ignore.yaml @@ -11,8 +11,12 @@ owasp-define-error-validation: # 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). +# /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. oas3-missing-example: - "$.components.schemas['HTTPValidationError'].properties['detail']" + - "$.paths['/api/graphql'].get.responses['200'].content['application/json']" + - "$.paths['/api/graphql'].post.responses['200'].content['application/json']" owasp-integer-format: - "$.components.schemas['ValidationError'].properties['loc'].items.anyOf[1]" diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja index 5da30495e..4caed2776 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja @@ -82,7 +82,8 @@ OPENAPI_APP_SPECIFIC_TAGS: list[dict[str, str]] = [ ] OPENAPI_TAGS = [ - {"name": "system", "description": "Server health and lifecycle operations."}, + {"name": "system", "description": "Server health and lifecycle operations."},{% endraw %}{% if backend_uses_graphql %}{% raw %} + {"name": "graphql", "description": "GraphQL endpoint (GraphiQL IDE on GET, queries/mutations on POST)."},{% endraw %}{% endif %}{% raw %} *OPENAPI_APP_SPECIFIC_TAGS, ] @@ -112,7 +113,7 @@ class HealthcheckResponse(BaseModel): build is currently deployed. """ - version: str = Field(description="Version of the application", default="1.0.0", examples=["1.0.0"]) + version: str = Field(description="Version of the application", default="1.0.0") class ShutdownResponse(BaseModel): @@ -125,7 +126,6 @@ class ShutdownResponse(BaseModel): message: str = Field( default="Shutdown request received. Server will exit shortly.", description="Message indicating the shutdown request was received", - examples=["Shutdown request received. Server will exit shortly."], ) From 87e0dc491bbdc1f20c6338f6fd6418da9ae85154 Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 14:16:57 -0400 Subject: [PATCH 26/35] fix(backend): gate graphql-only vacuum ignore entries on backend_uses_graphql vacuum-ignore.yaml referenced /api/graphql paths unconditionally even though that endpoint only exists when backend_uses_graphql is true. Convert to a .jinja template and wrap those entries in the same {% if backend_uses_graphql %} check used in app_def.py.jinja. --- .../{vacuum-ignore.yaml => vacuum-ignore.yaml.jinja} | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) rename template/.config/{vacuum-ignore.yaml => vacuum-ignore.yaml.jinja} (79%) diff --git a/template/.config/vacuum-ignore.yaml b/template/.config/vacuum-ignore.yaml.jinja similarity index 79% rename from template/.config/vacuum-ignore.yaml rename to template/.config/vacuum-ignore.yaml.jinja index 008c80330..d68256e7e 100644 --- a/template/.config/vacuum-ignore.yaml +++ b/template/.config/vacuum-ignore.yaml.jinja @@ -1,4 +1,4 @@ -# Ignored vacuum findings, with justification. +{% 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 @@ -10,15 +10,15 @@ owasp-define-error-validation: # 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). +# 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. +# 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']" + - "$.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']" + - "$.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]" + - "$.components.schemas['ValidationError'].properties['loc'].items.anyOf[1]"{% endraw %} From a0e9f1fe7eeb8702454ded0d3acc90fb9c7bbda8 Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 14:45:30 -0400 Subject: [PATCH 27/35] Simplify description --- .../src/backend_api/app_def.py.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja index 4caed2776..a78aeb3e2 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja @@ -83,7 +83,7 @@ OPENAPI_APP_SPECIFIC_TAGS: list[dict[str, str]] = [ OPENAPI_TAGS = [ {"name": "system", "description": "Server health and lifecycle operations."},{% endraw %}{% if backend_uses_graphql %}{% raw %} - {"name": "graphql", "description": "GraphQL endpoint (GraphiQL IDE on GET, queries/mutations on POST)."},{% endraw %}{% endif %}{% raw %} + {"name": "graphql", "description": "GraphQL endpoint"},{% endraw %}{% endif %}{% raw %} *OPENAPI_APP_SPECIFIC_TAGS, ] From 2b636e91c93410d1ee5f45f8361d561de4197904 Mon Sep 17 00:00:00 2001 From: zender Date: Sat, 11 Jul 2026 14:47:32 -0400 Subject: [PATCH 28/35] Driver specific tags --- .../src/backend_api/app_def.py.jinja | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja index a78aeb3e2..3a472fa16 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja @@ -83,7 +83,9 @@ OPENAPI_APP_SPECIFIC_TAGS: list[dict[str, str]] = [ OPENAPI_TAGS = [ {"name": "system", "description": "Server health and lifecycle operations."},{% endraw %}{% if backend_uses_graphql %}{% raw %} - {"name": "graphql", "description": "GraphQL endpoint"},{% endraw %}{% endif %}{% raw %} + {"name": "graphql", "description": "GraphQL endpoint"},{% endraw %}{% endif %}{% if has_circuit_python_backend_template_been_instantiated %}{% raw %} + {"name": "debug", "description": "Debug and diagnostic operations."}, + {"name": "mDNS", "description": "mDNS service discovery operations."},{% endraw %}{% endif %}{% raw %} *OPENAPI_APP_SPECIFIC_TAGS, ] From f92a309f32bba27e2f377670ad3215c80302e5d5 Mon Sep 17 00:00:00 2001 From: zender Date: Sun, 12 Jul 2026 15:18:40 -0400 Subject: [PATCH 29/35] feat(openapi): add API naming-convention vacuum rules Backport the six OpenAPI lint rules developed in sensor-monitor so every generated project enforces them: - path-no-generic-id: path id params must be explicit, not a bare {id} - path-param-snake-case / query-param-snake-case - response-media-type-json / request-media-type-json - create-body-no-required-id: POST bodies must not require id - no-action-verbs-in-path - location-header-on-201 Ruleset-only change. The template backend skeleton (healthcheck + shutdown) does not exercise any of them, so the generated project stays green. Rule blocks are byte-identical to the sensor-monitor ruleset (which is this file plus the copier-managed warning header). --- template/.config/vacuum-ruleset.yaml | 106 +++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/template/.config/vacuum-ruleset.yaml b/template/.config/vacuum-ruleset.yaml index 2ef0f84c6..09247e77e 100644 --- a/template/.config/vacuum-ruleset.yaml +++ b/template/.config/vacuum-ruleset.yaml @@ -64,3 +64,109 @@ rules: # 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')] + severity: warn + then: + field: name + function: casing + functionOptions: + type: snake + + # ENABLED: query parameters must be snake_case — consistent with the existing code and with gov.au + # (which puts hyphens in path segments, not query strings). + query-param-snake-case: + description: Query parameters must be snake_case. + given: $.paths[*][*].parameters[?(@.in=='query')] + severity: warn + then: + field: name + function: casing + functionOptions: + type: snake + + # 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 From 2c23abed9f553e5cd4bc158038ba9a85a4e6c085 Mon Sep 17 00:00:00 2001 From: zender Date: Sun, 12 Jul 2026 16:24:20 -0400 Subject: [PATCH 30/35] (feature) check for plural in paths Cant use lirbaries so add simplistic plural check with some allow list words that are likely to be used. --- .../vacuum-functions/resourcesPlural.js | 66 +++++++++++++++++++ template/.config/vacuum-ruleset.yaml | 13 ++++ template/.pre-commit-config.yaml.jinja | 5 +- 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 template/.config/vacuum-functions/resourcesPlural.js diff --git a/template/.config/vacuum-functions/resourcesPlural.js b/template/.config/vacuum-functions/resourcesPlural.js new file mode 100644 index 000000000..3292ee0c8 --- /dev/null +++ b/template/.config/vacuum-functions/resourcesPlural.js @@ -0,0 +1,66 @@ +// 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" +]; + +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; +} diff --git a/template/.config/vacuum-ruleset.yaml b/template/.config/vacuum-ruleset.yaml index 09247e77e..e67fcae1e 100644 --- a/template/.config/vacuum-ruleset.yaml +++ b/template/.config/vacuum-ruleset.yaml @@ -170,3 +170,16 @@ rules: 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 diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index d52017dde..2dc048abb 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -348,14 +348,15 @@ repos: # 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 -r .config/vacuum-ruleset.yaml --ignore-file .config/vacuum-ignore.yaml backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json + entry: vacuum lint --no-banner --no-style --fail-severity warn -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-ignore\.yaml| + .config/vacuum-functions/.*\.js )$ pass_filenames: false require_serial: true From 59cc3fa5eef58df46d917767e38740408e2684f4 Mon Sep 17 00:00:00 2001 From: zender Date: Tue, 14 Jul 2026 11:34:48 -0400 Subject: [PATCH 31/35] fix(openapi): allowlist graphql path segment in plural check graphql is a singleton endpoint, not a REST collection, so it should never be flagged by resources-plural. Backported from lab-sync/sensor-monitor. --- template/.config/vacuum-functions/resourcesPlural.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/template/.config/vacuum-functions/resourcesPlural.js b/template/.config/vacuum-functions/resourcesPlural.js index 3292ee0c8..cc652b88d 100644 --- a/template/.config/vacuum-functions/resourcesPlural.js +++ b/template/.config/vacuum-functions/resourcesPlural.js @@ -18,7 +18,9 @@ var ALLOWLIST = [ // 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" + "me", "self", "search", "online", "offline", "push", "polling", "dashboard", "summary", "latest", "current", + // non-REST endpoints (not resource collections) + "graphql" ]; function getSchema() { From a0f290a526cae13671729fa3f0c1d5fd3ca21660 Mon Sep 17 00:00:00 2001 From: zender Date: Tue, 14 Jul 2026 11:35:05 -0400 Subject: [PATCH 32/35] chore(pre-commit): show full violation details for vacuum lint Bare summary table hides which path/rule failed. Add -d --no-clip so failures print file:line, message, and rule name per violation. Backported from lab-sync/sensor-monitor. --- template/.pre-commit-config.yaml.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index 2dc048abb..2d513d510 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -348,7 +348,7 @@ repos: # 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 -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 + 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: | From 236deabcf5d8e0126a6da662c9dd8e3e39ecbd77 Mon Sep 17 00:00:00 2001 From: Nathan Zender Date: Tue, 14 Jul 2026 13:39:37 -0400 Subject: [PATCH 33/35] to json so quoting doesn't break things Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../src/backend_api/app_def.py.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja index 3a472fa16..86e5f39f3 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja @@ -75,7 +75,7 @@ async def lifespan(app: FastAPI): {% endraw %}{% endif %}{% raw %} -API_DESCRIPTION = "{% endraw %}{{ backend_rest_api_description }}{% raw %}" +API_DESCRIPTION = {% endraw %}{{ backend_rest_api_description | tojson }}{% raw %} OPENAPI_APP_SPECIFIC_TAGS: list[dict[str, str]] = [ # Insert app specific openapi tags here From 131516e626da221be4218651ce374dd3c94bc6d7 Mon Sep 17 00:00:00 2001 From: zender Date: Thu, 16 Jul 2026 16:39:05 -0400 Subject: [PATCH 34/35] fix(vacuum): enforce camelCase query params and repair casing checks Body params are already camelCase, so require query params to match instead of snake_case, keeping frontend clients consistent. Also fix both path-param-snake-case and query-param-camel-case: given previously matched the whole parameter object with then.field: name, but vacuum's casing function validates the matched node's own map keys, not the referenced field's value, making both rules silent no-ops. given now points directly at .name so casing is actually checked. --- template/.config/vacuum-ruleset.yaml | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/template/.config/vacuum-ruleset.yaml b/template/.config/vacuum-ruleset.yaml index e67fcae1e..3425accf8 100644 --- a/template/.config/vacuum-ruleset.yaml +++ b/template/.config/vacuum-ruleset.yaml @@ -87,25 +87,23 @@ rules: # the only practical casing for parameters. path-param-snake-case: description: Path parameters must be snake_case. - given: $.paths[*][*].parameters[?(@.in=='path')] + given: $.paths[*][*].parameters[?(@.in=='path')].name severity: warn then: - field: name function: casing functionOptions: type: snake - # ENABLED: query parameters must be snake_case — consistent with the existing code and with gov.au - # (which puts hyphens in path segments, not query strings). - query-param-snake-case: - description: Query parameters must be snake_case. - given: $.paths[*][*].parameters[?(@.in=='query')] + # 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: - field: name function: casing functionOptions: - type: snake + 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 From 6405b2d6be883c433d2f740c0df2bb4672a82797 Mon Sep 17 00:00:00 2001 From: zender Date: Thu, 16 Jul 2026 21:22:41 -0400 Subject: [PATCH 35/35] fix(backend): alias healthcheck prepend_v query param as prependV The new query-param-camel-case vacuum rule now actually fires (previously a no-op, see prior commit) and flagged the template's own healthcheck endpoint. Keep the Python parameter snake_case but expose it as camelCase over the wire via FastAPI's Query alias, matching the query-param-camel-case rule. --- .../src/backend_api/app_def.py.jinja | 2 +- .../tests/unit/test_basic_server_functionality.py | 2 +- .../tests/unit/test_fast_api_exception_handlers.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja index 86e5f39f3..cea5b414c 100644 --- a/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/app_def.py.jinja @@ -149,7 +149,7 @@ class NoCacheStaticFiles(StaticFiles): def healthcheck( *, prepend_v: bool = Query( # pyright: ignore[reportCallInDefaultInitializer] # This seems to be an accepted FastAPI pattern - default=False, description="Include a 'v' before the version number" + default=False, alias="prependV", description="Include a 'v' before the version number" ), ) -> HealthcheckResponse: return HealthcheckResponse(version=get_version(prepend_v=prepend_v)) diff --git a/template/{% if has_backend %}backend{% endif %}/tests/unit/test_basic_server_functionality.py b/template/{% if has_backend %}backend{% endif %}/tests/unit/test_basic_server_functionality.py index 9e6689286..b8790745b 100644 --- a/template/{% if has_backend %}backend{% endif %}/tests/unit/test_basic_server_functionality.py +++ b/template/{% if has_backend %}backend{% endif %}/tests/unit/test_basic_server_functionality.py @@ -27,7 +27,7 @@ def test_Given_healthy__When_healthcheck__Then_version_in_response(): def test_Given_healthy__When_healthcheck_with_prepend_v__Then_version_in_response(): client = TestClient(app) - response = client.get("/api/healthcheck?prepend_v=true") + response = client.get("/api/healthcheck?prependV=true") assert response.status_code == codes.OK response_json = response.json() diff --git a/template/{% if has_backend %}backend{% endif %}/tests/unit/test_fast_api_exception_handlers.py b/template/{% if has_backend %}backend{% endif %}/tests/unit/test_fast_api_exception_handlers.py index ba3b463fd..6b1a8ccc7 100644 --- a/template/{% if has_backend %}backend{% endif %}/tests/unit/test_fast_api_exception_handlers.py +++ b/template/{% if has_backend %}backend{% endif %}/tests/unit/test_fast_api_exception_handlers.py @@ -24,7 +24,7 @@ def _setup(self, mocker: MockerFixture): def test_Given_malformed_input_to_api_route__Then_uuid_in_log_and_response_and_response_contains_details_and_cors_headers( self, ): - response = self.client.get("/api/healthcheck?prepend_v=not_a_bool") + response = self.client.get("/api/healthcheck?prependV=not_a_bool") self.spied_uuid_generator.assert_called_once() expected_uuid = str(self.spied_uuid_generator.spy_return) @@ -37,7 +37,7 @@ def test_Given_malformed_input_to_api_route__Then_uuid_in_log_and_response_and_r assert response_json["type"] == "about:blank" assert response_json["title"] == "Validation Error" assert response_json["status"] == codes.UNPROCESSABLE_ENTITY - assert "prepend_v" in response_json["detail"] + assert "prependV" in response_json["detail"] assert "valid boolean" in response_json["detail"] assert response_json["instance"] == f"urn:uuid:{expected_uuid}" self.spied_logger_warning.assert_called_once()