From e9f83a73ec1b9a6d70aca51107b382939eef18dc Mon Sep 17 00:00:00 2001 From: Nathan Zender Date: Fri, 24 Jul 2026 22:50:22 -0400 Subject: [PATCH 01/16] feat(frontend): bump @lab-sync/nuxt-common to ^0.2.3 The 0.2.x line adds the ShellRail and PageTitle shell components plus the shellRailTestIds helper, which the circuit-python device driver frontend template uses for the shared Lab Sync theming. 0.2.3 declares `@nuxt/ui >=4.10.0` and a non-optional `openapi-types >=12.1` peer dependency, so bump @nuxt/ui and add openapi-types as a dev dependency for driver projects. --- extensions/context.py | 5 +++-- template/frontend/package.json.jinja | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/extensions/context.py b/extensions/context.py index 63226f1dd..cd4489cb2 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -66,7 +66,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["pyrefly_version"] = ">=1.1.1" context["default_node_version"] = "24.11.1" - context["nuxt_ui_version"] = "^4.8.1" + context["nuxt_ui_version"] = "^4.10.0" context["nuxt_version"] = "^4.4.6" context["nuxt_icon_version"] = "^2.2.1" context["typescript_version"] = "^6.0.2" @@ -96,9 +96,10 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["vue_eslint_parser_version"] = "^10.4.0" context["happy_dom_version"] = "^20.10.1" context["node_kiota_bundle_version"] = "1.0.0-preview.103" - context["labsync_nuxt_common_version"] = "^0.1.4" + context["labsync_nuxt_common_version"] = "^0.2.3" context["tanstack_vue_table_version"] = "^8.21.3" context["unplugin_auto_import_version"] = "^21.0.0" + context["openapi_types_version"] = "^12.1.3" context["gha_checkout"] = "v6.0.2" context["gha_setup_python"] = "v6.2.0" diff --git a/template/frontend/package.json.jinja b/template/frontend/package.json.jinja index e4d4ec7a2..37463b1fd 100644 --- a/template/frontend/package.json.jinja +++ b/template/frontend/package.json.jinja @@ -71,7 +71,8 @@ "graphql": "^16.14.0", "graphql-tag": "^2.12.6",{% endraw %}{% endif %}{% raw %} "happy-dom": "{% endraw %}{{ happy_dom_version }}{% raw %}",{% endraw %}{% if frontend_uses_graphql %}{% raw %} - "mock-apollo-client": "^1.4.0",{% endraw %}{% endif %}{% raw %} + "mock-apollo-client": "^1.4.0",{% endraw %}{% endif %}{% if is_circuit_python_driver %}{% raw %} + "openapi-types": "{% endraw %}{{ openapi_types_version }}{% raw %}",{% endraw %}{% endif %}{% raw %} "playwright": "{% endraw %}{{ playwright_version }}{% raw %}", "playwright-core": "{% endraw %}{{ playwright_version }}{% raw %}", "postcss": "^8.5.14", From 919e1c1aae1db3617e0437206ea0c8a8cc2e0b5a Mon Sep 17 00:00:00 2001 From: Nathan Zender Date: Sat, 25 Jul 2026 12:55:26 -0400 Subject: [PATCH 02/16] feat(vacuum): replace ref-blind description/example rules with deref-aware ones The built-in operation-description and oas3-missing-example rules inspect requestBody/response nodes directly and never follow $ref, so a pydantic model's docstring and its field-level examples are invisible to them. That forced every route to hand-type a description and example duplicating what the payload model already declares. Disable both and replace them with request/response-body-description and request/response-body-example, backed by two new custom functions that run against the schema vacuum has already dereferenced. Also raise every self-defined rule to error severity, and move the graphql 200-response exemption over to the new response-body-example rule. Backported from lab-sync/git-sherpa#241. --- .../vacuum-functions/bodyDescription.js | 63 +++++++++++++ .../.config/vacuum-functions/bodyExample.js | 92 +++++++++++++++++++ template/.config/vacuum-ignore.yaml.jinja | 17 ++-- template/.config/vacuum-ruleset.yaml | 69 ++++++++++++-- 4 files changed, 223 insertions(+), 18 deletions(-) create mode 100644 template/.config/vacuum-functions/bodyDescription.js create mode 100644 template/.config/vacuum-functions/bodyExample.js diff --git a/template/.config/vacuum-functions/bodyDescription.js b/template/.config/vacuum-functions/bodyDescription.js new file mode 100644 index 000000000..a62e76b13 --- /dev/null +++ b/template/.config/vacuum-functions/bodyDescription.js @@ -0,0 +1,63 @@ +// Vacuum custom JS function (goja runtime: no npm, no require/import — plain self-contained JS). +// +// Replaces the built-in `operation-description` rule's requestBody/response checks. That rule only looks +// at requestBody.description / response.description directly and does not resolve $ref, so it can't see +// a description that comes from the referenced schema's docstring. That forced every route to carry a +// hand-typed description duplicating the payload/response model's own docstring. +// +// Vacuum resolves $ref before handing the node to a custom function, so `input.content[mediaType].schema` +// here is already the dereferenced schema — its `description` (from the pydantic model's docstring) is +// visible. This function passes if there's a description directly on the body object OR on any of its +// content schemas, and only flags a body with no description anywhere. +// +// An Optional payload (`payload: Model | None = None`) produces an `anyOf: [, {type: null}]` +// wrapper instead of the model schema directly, so the description lives one level deeper — branches of +// anyOf/oneOf are checked too. + +function getSchema() { + return { name: "bodyDescription" }; +} + +function hasOwnDescription(node) { + return !!node && typeof node.description === "string" && node.description.length > 0; +} + +// An Optional[Model] field (e.g. `payload: Model | None = None`) resolves to +// {"anyOf": [{...model schema, with its docstring as description...}, {"type": "null"}]} — the +// description lives on a branch, not on the anyOf node itself, so branches must be checked too. +function schemaHasDescription(schema) { + if (!schema || typeof schema !== "object") { + return false; + } + if (hasOwnDescription(schema)) { + return true; + } + var branches = schema.anyOf || schema.oneOf; + if (Array.isArray(branches)) { + for (var i = 0; i < branches.length; i++) { + if (schemaHasDescription(branches[i])) { + return true; + } + } + } + return false; +} + +function runRule(input) { + if (!input || typeof input !== "object") { + return []; + } + if (hasOwnDescription(input)) { + return []; + } + var content = input.content; + if (content && typeof content === "object") { + for (var mediaType in content) { + var schema = content[mediaType] && content[mediaType].schema; + if (schemaHasDescription(schema)) { + return []; + } + } + } + return [{ message: "body is missing a description (own, or via its schema's docstring)" }]; +} diff --git a/template/.config/vacuum-functions/bodyExample.js b/template/.config/vacuum-functions/bodyExample.js new file mode 100644 index 000000000..f66b5ec97 --- /dev/null +++ b/template/.config/vacuum-functions/bodyExample.js @@ -0,0 +1,92 @@ +// Vacuum custom JS function (goja runtime: no npm, no require/import — plain self-contained JS). +// +// Replaces the built-in `oas3-missing-example` rule's requestBody/response checks. That rule only looks +// for an `example`/`examples` key on the media type object itself (or, at the schema-property level, on +// that property's own node) and does not drill into a `$ref`'d schema's own fields. Every field-level +// `Field(examples=[...])` on a payload/response model is therefore invisible to it, which forced routes +// to carry a hand-typed body example duplicating examples that already exist on the model. +// +// Vacuum resolves $ref before handing the node to a custom function, so `input.content[mediaType].schema` +// here is already the dereferenced schema. This function passes if there's an example directly on the +// body/media object, or anywhere within its (dereferenced) schema: on the schema itself, on any of its +// properties (recursively), on array items, or on an anyOf/oneOf branch (the shape of an `Optional[Model]` +// field). It only flags a body with no example reachable anywhere in that tree. + +function getSchema() { + return { name: "bodyExample" }; +} + +function hasOwnExample(node) { + if (!node || typeof node !== "object") { + return false; + } + if (Array.isArray(node.examples) && node.examples.length > 0) { + return true; + } + return "example" in node && node.example !== undefined; +} + +// Walks a resolved schema looking for an example anywhere within it: on itself, on a property +// (recursively, since a nested model's own field examples count), inside array items, or on an +// anyOf/oneOf branch (how an `Optional[Model]` field is represented). `seen` guards against infinite +// recursion on a self-referencing schema. +function schemaHasExample(schema, seen) { + if (!schema || typeof schema !== "object") { + return false; + } + if (seen.indexOf(schema) !== -1) { + return false; + } + seen.push(schema); + + if (hasOwnExample(schema)) { + return true; + } + + var branches = schema.anyOf || schema.oneOf || schema.allOf; + if (Array.isArray(branches)) { + for (var i = 0; i < branches.length; i++) { + if (schemaHasExample(branches[i], seen)) { + return true; + } + } + } + + if (schema.items && schemaHasExample(schema.items, seen)) { + return true; + } + + var properties = schema.properties; + if (properties && typeof properties === "object") { + for (var key in properties) { + if (schemaHasExample(properties[key], seen)) { + return true; + } + } + } + + return false; +} + +function runRule(input) { + if (!input || typeof input !== "object") { + return []; + } + var content = input.content; + if (!content || typeof content !== "object") { + return []; + } + + var results = []; + for (var mediaType in content) { + var mediaObject = content[mediaType]; + if (hasOwnExample(mediaObject)) { + continue; + } + if (schemaHasExample(mediaObject && mediaObject.schema, [])) { + continue; + } + results.push({ message: "body (" + mediaType + ") is missing an example, own or via its schema's fields" }); + } + return results; +} diff --git a/template/.config/vacuum-ignore.yaml.jinja b/template/.config/vacuum-ignore.yaml.jinja index d68256e7e..408adce98 100644 --- a/template/.config/vacuum-ignore.yaml.jinja +++ b/template/.config/vacuum-ignore.yaml.jinja @@ -7,17 +7,14 @@ 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 %} +{% 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. +response-body-example: + - "$.paths['/api/graphql'].get.responses['200']" + - "$.paths['/api/graphql'].post.responses['200']" +{% endraw %}{% endif %}{% raw %}# 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. owasp-integer-format: - "$.components.schemas['ValidationError'].properties['loc'].items.anyOf[1]" owasp-integer-limit: diff --git a/template/.config/vacuum-ruleset.yaml b/template/.config/vacuum-ruleset.yaml index af27f2b29..e61404997 100644 --- a/template/.config/vacuum-ruleset.yaml +++ b/template/.config/vacuum-ruleset.yaml @@ -25,6 +25,21 @@ rules: # `Any`-typed fields, which are rare and usually unavoidable (e.g. the generated ValidationError.input). oas-missing-type: "off" + # DISABLED: same false-positive family as `oas-missing-type` above — it checks requestBody.description + # / response.description directly and does not follow `$ref`, so it can't see that the payload/response + # model's own docstring already documents the body. That forced every route to hand-type a description + # duplicating the model docstring almost verbatim. Replaced below by `request-body-description` / + # `response-body-description`, which resolve the schema first and pass if either the body itself or its + # (dereferenced) schema has a description. + operation-description: "off" + + # DISABLED: same false-positive family as `operation-description` above — it only checks for an + # `example`/`examples` key on the media type object (or, at the property level, on that property's own + # node) and does not resolve `$ref`, so it can't see a body/response model's own field-level examples. + # Replaced below by `request-body-example` / `response-body-example`, which resolve the schema first and + # pass if an example exists anywhere within it. + oas3-missing-example: "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`). @@ -81,7 +96,7 @@ rules: path-param-snake-case: description: Path parameters must be snake_case. given: $.paths[*][*].parameters[?(@.in=='path')].name - severity: warn + severity: error then: function: casing functionOptions: @@ -92,7 +107,7 @@ rules: query-param-camel-case: description: Query parameters must be camelCase. given: $.paths[*][*].parameters[?(@.in=='query')].name - severity: warn + severity: error then: function: casing functionOptions: @@ -104,7 +119,7 @@ rules: response-media-type-json: description: Response bodies must be application/json (or application/problem+json for errors). given: $.paths[*][*].responses[*].content[*]~ - severity: warn + severity: error then: function: pattern functionOptions: @@ -115,7 +130,7 @@ rules: request-media-type-json: description: Request bodies must be application/json. given: $.paths[*][*].requestBody.content[*]~ - severity: warn + severity: error then: function: pattern functionOptions: @@ -130,7 +145,7 @@ rules: description: POST request bodies must not require an id (backend-generated). given: $.paths[*].post.requestBody.content.*.schema.required[*] resolved: true - severity: warn + severity: error then: function: pattern functionOptions: @@ -142,7 +157,7 @@ rules: no-action-verbs-in-path: description: Path segments must be nouns, not action verbs. given: $.paths[*]~ - severity: warn + severity: error then: function: pattern functionOptions: @@ -157,7 +172,7 @@ rules: location-header-on-201: description: 201 Created responses must declare a Location header. given: $.paths[*].post.responses['201'] - severity: warn + severity: error then: field: headers.Location function: truthy @@ -171,6 +186,44 @@ rules: resources-plural: description: Path resource segments should be plural nouns. given: $.paths[*]~ - severity: warn + severity: error then: function: resourcesPlural + + # ENABLED: replaces the requestBody half of the built-in `operation-description` (disabled above). + # Uses .config/vacuum-functions/bodyDescription.js, which resolves the requestBody's schema and passes + # if either the requestBody itself or its (dereferenced) schema has a description — so a payload + # model's docstring is enough, no hand-typed duplicate needed. + request-body-description: + description: Request bodies must have a description, either their own or via their schema's docstring. + given: $.paths[*][*].requestBody + severity: error + then: + function: bodyDescription + + # ENABLED: same replacement as `request-body-description` above, for response bodies. + response-body-description: + description: Response bodies must have a description, either their own or via their schema's docstring. + given: $.paths[*][*].responses[*] + severity: error + then: + function: bodyDescription + + # ENABLED: replaces the requestBody half of the built-in `oas3-missing-example` (disabled above). Uses + # .config/vacuum-functions/bodyExample.js, which resolves the requestBody's schema and passes if an + # example exists on the body itself or anywhere within its (dereferenced) schema — so field-level + # `Field(examples=[...])` on the payload model is enough, no hand-typed duplicate needed. + request-body-example: + description: Request bodies must have an example, either their own or via their schema's fields. + given: $.paths[*][*].requestBody + severity: error + then: + function: bodyExample + + # ENABLED: same replacement as `request-body-example` above, for response bodies. + response-body-example: + description: Response bodies must have an example, either their own or via their schema's fields. + given: $.paths[*][*].responses[*] + severity: error + then: + function: bodyExample From 271a807f20c065fa7c3a6e76cf60502caefb942d Mon Sep 17 00:00:00 2001 From: Nathan Zender Date: Sat, 25 Jul 2026 13:00:17 -0400 Subject: [PATCH 03/16] fix(vacuum): pin the vacuum version and run the hook before the slow linters The hook interpolated vacuum_openapi_version, which was never defined in copier.yml or the context extension, so it rendered empty and downstream repos installed an unpinned '@quobix/vacuum@'. Define it alongside the other tool pins. Also move the hook out of the tail of the has_backend block, where it sat behind pyright and pyrefly, to just after the security hooks. It is cheap and its failures are cheap to act on, so it should fail before the slow type-checkers run. --- extensions/context.py | 1 + template/.gitignore | 3 ++ template/.pre-commit-config.yaml.jinja | 42 +++++++++++++------------- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/extensions/context.py b/extensions/context.py index 63226f1dd..615e50058 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -24,6 +24,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["npm_version"] = "11.13.0" context["nvm_version"] = "0.40.5" context["pre_commit_version"] = "4.5.1" + context["vacuum_openapi_version"] = "0.30.0" context["pyright_version"] = ">=1.1.411" context["pytest_version"] = ">=9.1.1" context["pytest_randomly_version"] = ">=4.1.0" diff --git a/template/.gitignore b/template/.gitignore index 3230070b3..7eb54ca2c 100644 --- a/template/.gitignore +++ b/template/.gitignore @@ -109,6 +109,9 @@ node_modules/ # Nuxt generate creates a symlink to the dist folder, which can't have trailing slash dist +# vacuum OpenAPI lint reports (generated ad-hoc; the linter runs against the committed snapshot) +vacuum-report-*.json + # Infrastructure as Code .terraform/ diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index 50dcefc4d..b35abb66a 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -212,6 +212,27 @@ repos: # rev: b933184438555436e38621f46ceb0c417cbed400 # frozen: v1.13.0 # hooks: # - id: zizmor +{% endraw %}{% if has_backend %}{% raw %} + - 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 --min-score 100 -d --no-message --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 %} # Linting @@ -339,27 +360,6 @@ 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 --min-score 100 -d --no-message --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 From 3feda3194463a1e91b335d9bb877cb95f73be0ca Mon Sep 17 00:00:00 2001 From: Nathan Zender Date: Sat, 25 Jul 2026 13:00:26 -0400 Subject: [PATCH 04/16] feat(backend): add CamelCaseModel and give the schema real examples Every downstream app needs the same camelCase-over-the-wire base model and was reinventing it, so ship it in the template. HealthcheckResponse, HealthcheckQuery, ShutdownResponse and ProblemDetails all base on it now. ProblemDetails carried a hand-written alias="errorType" to camelCase one field; the alias generator covers that now. Dropping the explicit alias also lets field_title_generator apply, so its JSON-schema title becomes "Error Type" instead of the "Errortype" pydantic derives from an alias. The wire format is unchanged. The healthcheck query parameter moves from a bare Query(alias="prependV") to a HealthcheckQuery model, which drops a pyright suppression and gets the alias from the generator. Response fields gain examples so the new request/response-body-example rules pass on the model rather than on a hand-typed duplicate. Snapshot updated to match, and verified against the schema the rendered app actually serves. Backported from lab-sync/git-sherpa#241. --- .../src/backend_api/app_def.py.jinja | 21 ++++++++------- .../src/backend_api/camel_case_model.py | 26 +++++++++++++++++++ .../fast_api_exception_handlers.py | 7 +++-- .../test_openapi_schema.json.jinja | 16 ++++++++---- .../tests/unit/test_camel_case_model.py | 20 ++++++++++++++ .../unit/test_fast_api_exception_handlers.py | 2 ++ 6 files changed, 74 insertions(+), 18 deletions(-) create mode 100644 template/{% if has_backend %}backend{% endif %}/src/backend_api/camel_case_model.py create mode 100644 template/{% if has_backend %}backend{% endif %}/tests/unit/test_camel_case_model.py 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 cea5b414c..b10fb5611 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 @@ -4,6 +4,7 @@ import threading import time{% endraw %}{% if has_circuit_python_backend_template_been_instantiated %}{% raw %} from contextlib import asynccontextmanager{% endraw %}{% endif %}{% raw %} from pathlib import Path +from typing import Annotated from typing import Any from typing import override {% endraw %}{% if has_circuit_python_backend_template_been_instantiated %}{% raw %} @@ -13,7 +14,6 @@ from fastapi import Query from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi_offline import FastAPIOffline -from pydantic import BaseModel from pydantic import Field{% endraw %}{% if has_circuit_python_backend_template_been_instantiated %}{% raw %} from zeroconf.asyncio import AsyncZeroconf{% endraw %}{% endif %}{% raw %} {% endraw %}{% if has_circuit_python_backend_template_been_instantiated %}{% raw %} @@ -23,6 +23,7 @@ from .common.mdns import register_driver from .common.mdns import router as mdns_router from .common.rfc_servers_jinja import get_servers_container from .driver_routes import router as driver_router{% endraw %}{% endif %}{% raw %} +from .camel_case_model import CamelCaseModel from .entrypoint.parser import get_version from .fast_api_exception_handlers import register_exception_handlers{% endraw %}{% if backend_uses_graphql %}{% raw %} from .graphql.schema import schema{% endraw %}{% endif %}{% raw %} @@ -108,17 +109,21 @@ except ( # pragma: no cover # This is just logging unexpected errors, and it's raise -class HealthcheckResponse(BaseModel): +class HealthcheckResponse(CamelCaseModel): """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") + version: str = Field(description="Version of the application", default="1.0.0", examples=["1.0.0"]) -class ShutdownResponse(BaseModel): +class HealthcheckQuery(CamelCaseModel): + prepend_v: bool = Field(default=False, description="Include a 'v' before the version number") + + +class ShutdownResponse(CamelCaseModel): """Acknowledgement of a server shutdown request. Returned immediately when a shutdown is requested; the server process exits shortly after this response @@ -128,6 +133,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."], ) @@ -147,12 +153,9 @@ class NoCacheStaticFiles(StaticFiles): @app.get("/api/healthcheck", summary="Check API health", tags=["system"]) def healthcheck( - *, - prepend_v: bool = Query( # pyright: ignore[reportCallInDefaultInitializer] # This seems to be an accepted FastAPI pattern - default=False, alias="prependV", description="Include a 'v' before the version number" - ), + query: Annotated[HealthcheckQuery, Query()], ) -> HealthcheckResponse: - return HealthcheckResponse(version=get_version(prepend_v=prepend_v)) + return HealthcheckResponse(version=get_version(prepend_v=query.prepend_v)) @app.get("/api/shutdown", summary="Shut down the server", tags=["system"]) diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/camel_case_model.py b/template/{% if has_backend %}backend{% endif %}/src/backend_api/camel_case_model.py new file mode 100644 index 000000000..53ac9ba39 --- /dev/null +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/camel_case_model.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic.alias_generators import to_camel +from pydantic.fields import ComputedFieldInfo +from pydantic.fields import FieldInfo + + +def _title_from_field_name(field_name: str, _info: FieldInfo | ComputedFieldInfo) -> str: # noqa: ARG001 # signature fixed by pydantic field_title_generator + """Generate the JSON-schema ``title`` from the snake_case field name. + + Without this, pydantic derives ``title`` from the camelCase alias (``remoteUrl`` -> "Remoteurl"). + Titleizing the python field name restores the pre-camelize output ("Remote Url"), which the + frontend code generator relies on. + """ + return field_name.replace("_", " ").title() + + +class CamelCaseModel(BaseModel): + """Base for schema models that serialize field names as camelCase over the wire.""" + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + serialize_by_alias=True, + field_title_generator=_title_from_field_name, + ) 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 a5a06c14a..5d8479532 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 @@ -8,12 +8,12 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi from fastapi.responses import JSONResponse -from pydantic import BaseModel from pydantic import Field from pydantic import JsonValue from starlette.exceptions import HTTPException from uuid_utils import uuid7 +from .camel_case_model import CamelCaseModel from .openapi_schema_simplifier import collapse_nullable_anyof logger = logging.getLogger(__name__) @@ -22,7 +22,7 @@ _HTTP_STATUS_SCHEMA: dict[str, JsonValue] = {"format": "int32", "minimum": 100, "maximum": 599} -class ProblemDetails(BaseModel): +class ProblemDetails(CamelCaseModel): """RFC 9457 problem details describing an error response. Returned (as application/problem+json) for error responses so clients and codegen tools such as Kiota @@ -48,7 +48,6 @@ class ProblemDetails(BaseModel): 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.", ) @@ -76,7 +75,7 @@ def _problem_dict( status=status, detail=detail, instance=f"urn:uuid:{trace_id}", - errorType=exc_type, + error_type=exc_type, ).model_dump(by_alias=True, mode="json") 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 index e659ed325..03be1b24d 100644 --- 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 @@ -20,9 +20,9 @@ "required": false, "schema": { "type": "boolean", + "title": "Prepend V", "description": "Include a 'v' before the version number", - "default": false, - "title": "Prependv" + "default": false }, "description": "Include a 'v' before the version number" } @@ -228,7 +228,10 @@ "type": "string", "title": "Version", "description": "Version of the application", - "default": "1.0.0" + "default": "1.0.0", + "examples": [ + "1.0.0" + ] } }, "type": "object", @@ -241,7 +244,10 @@ "type": "string", "title": "Message", "description": "Message indicating the shutdown request was received", - "default": "Shutdown request received. Server will exit shortly." + "default": "Shutdown request received. Server will exit shortly.", + "examples": [ + "Shutdown request received. Server will exit shortly." + ] } }, "type": "object", @@ -353,7 +359,7 @@ "examples": [ "ValueError" ], - "title": "Errortype", + "title": "Error Type", "type": [ "string", "null" diff --git a/template/{% if has_backend %}backend{% endif %}/tests/unit/test_camel_case_model.py b/template/{% if has_backend %}backend{% endif %}/tests/unit/test_camel_case_model.py new file mode 100644 index 000000000..4a1bb0761 --- /dev/null +++ b/template/{% if has_backend %}backend{% endif %}/tests/unit/test_camel_case_model.py @@ -0,0 +1,20 @@ +from backend_api.camel_case_model import CamelCaseModel +from pydantic import Field + + +class _Sample(CamelCaseModel): + remote_url: str = Field(description="x") + sparse_checkout_patterns: list[str] | None = None + + +def test_field_names_serialize_as_camel_case() -> None: + schema = _Sample.model_json_schema() + assert set(schema["properties"]) == {"remoteUrl", "sparseCheckoutPatterns"} + + +def test_titles_derive_from_snake_case_field_name_not_camel_alias() -> None: + # Regression: with alias_generator=to_camel, pydantic derives `title` from the alias, + # producing "Remoteurl" / "Sparsecheckoutpatterns" which breaks frontend code generation. + props = _Sample.model_json_schema()["properties"] + assert props["remoteUrl"]["title"] == "Remote Url" + assert props["sparseCheckoutPatterns"]["title"] == "Sparse Checkout Patterns" 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 6b1a8ccc7..31c01a40b 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 @@ -140,6 +140,8 @@ def test_Given_route_mocked_to_error_and_error_details_should_not_be_displayed__ expected_uuid = str(self.spied_uuid_generator.spy_return) assert response.status_code == codes.INTERNAL_SERVER_ERROR assert response.headers["Content-Type"] == "application/problem+json" + assert "Access-Control-Allow-Origin" in response.headers + assert "Access-Control-Allow-Credentials" in response.headers response_json = response.json() assert response_json["type"] == "about:blank" assert response_json["title"] == "Internal Server Error" From 3d93c8e4eee3265b4440beff7b8594a479bcf0cf Mon Sep 17 00:00:00 2001 From: Nathan Zender Date: Sat, 25 Jul 2026 17:53:47 -0400 Subject: [PATCH 05/16] feat(backend): add a problem_response helper for documenting error responses Documenting an HTTPException in a route decorator meant hand-writing the same application/problem+json media type and ProblemDetails schema ref every time. problem_response holds that shared part so a route passes only what differs: status code and description. It also synthesizes an example from those two, so the docs show a body matching that specific response instead of the generic field-level example a bare schema ref renders. Backported from lab-sync/git-sherpa#241, with tests added. --- .../backend_api/openapi_problem_responses.py | 34 +++++++++++++++++ .../unit/test_openapi_problem_responses.py | 37 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 template/{% if has_backend %}backend{% endif %}/src/backend_api/openapi_problem_responses.py create mode 100644 template/{% if has_backend %}backend{% endif %}/tests/unit/test_openapi_problem_responses.py diff --git a/template/{% if has_backend %}backend{% endif %}/src/backend_api/openapi_problem_responses.py b/template/{% if has_backend %}backend{% endif %}/src/backend_api/openapi_problem_responses.py new file mode 100644 index 000000000..5733cd72d --- /dev/null +++ b/template/{% if has_backend %}backend{% endif %}/src/backend_api/openapi_problem_responses.py @@ -0,0 +1,34 @@ +from http import HTTPStatus +from typing import Any + +from .fast_api_exception_handlers import ProblemDetails + +PROBLEM_DETAILS_REF = "#/components/schemas/ProblemDetails" +PROBLEM_JSON_MEDIA_TYPE = "application/problem+json" + + +def problem_response( + status_code: int, + description: str, + *, + instance: str = "about:blank", +) -> dict[int | str, dict[str, Any]]: + """Build an OpenAPI ``responses`` entry documenting a ``ProblemDetails`` error. + + Holds the ~80% that every error response shares (the ``application/problem+json`` media type and the + ``ProblemDetails`` schema ref) so route decorators only pass what differs: status and description. + Merge several with dict unpacking: ``{**problem_response(...), **problem_response(...)}``. + + ``example`` is synthesized from ``status_code`` and ``description`` so the docs + show a body that matches this response (e.g. a 400 titled "Bad Request") instead of the generic + field-level example rendered from a bare ``ProblemDetails`` schema ref. Pass ``instance`` to point the + example at the route's real path. + """ + example = ProblemDetails( + title=HTTPStatus(status_code).phrase, status=status_code, detail=description, instance=instance + ) + content: dict[str, Any] = { + "schema": {"$ref": PROBLEM_DETAILS_REF}, + "example": example.model_dump(mode="json", by_alias=True), + } + return {status_code: {"description": description, "content": {PROBLEM_JSON_MEDIA_TYPE: content}}} diff --git a/template/{% if has_backend %}backend{% endif %}/tests/unit/test_openapi_problem_responses.py b/template/{% if has_backend %}backend{% endif %}/tests/unit/test_openapi_problem_responses.py new file mode 100644 index 000000000..a6752c910 --- /dev/null +++ b/template/{% if has_backend %}backend{% endif %}/tests/unit/test_openapi_problem_responses.py @@ -0,0 +1,37 @@ +from backend_api.openapi_problem_responses import PROBLEM_DETAILS_REF +from backend_api.openapi_problem_responses import PROBLEM_JSON_MEDIA_TYPE +from backend_api.openapi_problem_responses import problem_response +from httpx import codes + + +def test_Given_status_and_description__Then_responses_entry_is_keyed_by_status_code() -> None: + responses = problem_response(codes.NOT_FOUND, "Widget not found") + + assert set(responses) == {codes.NOT_FOUND} + assert responses[codes.NOT_FOUND]["description"] == "Widget not found" + + +def test_Given_status_and_description__Then_content_uses_problem_json_and_problem_details_ref() -> None: + content = problem_response(codes.NOT_FOUND, "Widget not found")[codes.NOT_FOUND]["content"] + + assert set(content) == {PROBLEM_JSON_MEDIA_TYPE} + assert content[PROBLEM_JSON_MEDIA_TYPE]["schema"] == {"$ref": PROBLEM_DETAILS_REF} + + +def test_Given_status_and_description__Then_example_is_synthesized_from_them() -> None: + example = problem_response(codes.NOT_FOUND, "Widget not found")[codes.NOT_FOUND]["content"][ + PROBLEM_JSON_MEDIA_TYPE + ]["example"] + + assert example["title"] == "Not Found" + assert example["status"] == codes.NOT_FOUND + assert example["detail"] == "Widget not found" + assert example["instance"] == "about:blank" + + +def test_Given_explicit_instance__Then_example_points_at_it() -> None: + example = problem_response(codes.NOT_FOUND, "Widget not found", instance="/api/widgets/7")[codes.NOT_FOUND][ + "content" + ][PROBLEM_JSON_MEDIA_TYPE]["example"] + + assert example["instance"] == "/api/widgets/7" From 841752800edee3ef6d03100c80d8d8674070e8d6 Mon Sep 17 00:00:00 2001 From: Nathan Zender Date: Sat, 25 Jul 2026 17:53:54 -0400 Subject: [PATCH 06/16] feat(vrt): accept masks on every screenshot helper, and add expectElementScreenshot expectContentPaneScreenshot assumed the main area held no dynamic chrome, so it took no masks. That does not hold once a page renders non-deterministic values (generated ids and the like), so accept a mask list like the full-page helpers already do. expectElementScreenshot screenshots a single locator rather than the page or the content pane, keeping a widget's baseline insensitive to unrelated layout churn elsewhere. Use it for a self-contained section that a broader page-level VRT already covers for overall layout. It takes masks too -- LocatorAssertions.toHaveScreenshot supports the same option -- so all four helpers now expose it consistently. Backported from lab-sync/git-sherpa#241 and lab-sync/sensor-monitor#148. --- template/frontend/tests/e2e/helpers/vrt.ts | 23 +++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/template/frontend/tests/e2e/helpers/vrt.ts b/template/frontend/tests/e2e/helpers/vrt.ts index d9af6e34e..b6a9b7aa6 100644 --- a/template/frontend/tests/e2e/helpers/vrt.ts +++ b/template/frontend/tests/e2e/helpers/vrt.ts @@ -38,16 +38,33 @@ export async function expectFullPageScreenshot( // Main-content-only visual snapshot (no navbar/header/footer) taken in both light and dark mode by // default. Uses `#__nuxt > div > * > main` — the wildcard third segment avoids coupling to the -// layout wrapper's utility classes. No masks needed; the main area contains no dynamic chrome. +// layout wrapper's utility classes. Pass `mask` to cover any dynamic regions inside the content area +// (e.g. non-deterministic ids); masks are z-unaware and paint a flat rect over the element's box. export async function expectContentPaneScreenshot( page: Page, name: string, - { colorSchemes = ["light", "dark"] }: { colorSchemes?: ColorScheme[] } = {}, + { mask = [], colorSchemes = ["light", "dark"] }: { mask?: Locator[]; colorSchemes?: ColorScheme[] } = {}, ): Promise { for (const colorScheme of colorSchemes) { await page.emulateMedia({ colorScheme }); const main = page.locator("#__nuxt > div > * > main"); - await expect(main).toHaveScreenshot(withColorSchemeSuffix(name, colorScheme)); + await expect(main).toHaveScreenshot(withColorSchemeSuffix(name, colorScheme), { mask }); + } +} + +// Element-scoped visual snapshot taken in both light and dark mode by default. Screenshots just the +// given locator rather than the whole page or content pane, so the baseline is insensitive to +// unrelated layout changes elsewhere on the page. Use this for a self-contained widget/section that +// already has a broader page-level VRT covering overall layout. Pass `mask` to cover dynamic regions +// within the element; masks are z-unaware and paint a flat rect over the element's box. +export async function expectElementScreenshot( + locator: Locator, + name: string, + { mask = [], colorSchemes = ["light", "dark"] }: { mask?: Locator[]; colorSchemes?: ColorScheme[] } = {}, +): Promise { + for (const colorScheme of colorSchemes) { + await locator.page().emulateMedia({ colorScheme }); + await expect(locator).toHaveScreenshot(withColorSchemeSuffix(name, colorScheme), { mask }); } } From adefaddcaa4de594404a2e40aa12467d00824006 Mon Sep 17 00:00:00 2001 From: Nathan Zender Date: Sat, 25 Jul 2026 18:03:19 -0400 Subject: [PATCH 07/16] fix: backport encoding fix from sherpa --- .claude/skills/address-pr-comments/fetch-pr-comments.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.claude/skills/address-pr-comments/fetch-pr-comments.py b/.claude/skills/address-pr-comments/fetch-pr-comments.py index c88b27aff..283a46a41 100755 --- a/.claude/skills/address-pr-comments/fetch-pr-comments.py +++ b/.claude/skills/address-pr-comments/fetch-pr-comments.py @@ -70,6 +70,7 @@ def gh_rest(*args: str) -> Any: # noqa: ANN401 — return type is genuinely unk ["gh", *call_args], # noqa: S607 — gh is expected on PATH capture_output=True, text=True, + encoding="utf-8", check=True, timeout=GH_TIMEOUT_SECONDS, ) @@ -91,6 +92,7 @@ def gh_graphql(query: str, variables: dict[str, str | int]) -> Any: # noqa: ANN args, capture_output=True, text=True, + encoding="utf-8", check=True, timeout=GH_TIMEOUT_SECONDS, ) @@ -105,6 +107,7 @@ def fetch_current_user_login() -> str: ["gh", "api", "user", "--jq", ".login"], # noqa: S607 — gh is expected on PATH capture_output=True, text=True, + encoding="utf-8", check=True, timeout=GH_TIMEOUT_SECONDS, ) From f4d3c085772c5bc6256526d9e1a9f421201b1eea Mon Sep 17 00:00:00 2001 From: Nathan Zender Date: Sat, 25 Jul 2026 18:49:48 -0400 Subject: [PATCH 08/16] chore: backport expectNavigationRailScreenshot --- template/frontend/tests/e2e/helpers/vrt.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/template/frontend/tests/e2e/helpers/vrt.ts b/template/frontend/tests/e2e/helpers/vrt.ts index b6a9b7aa6..ef5883b09 100644 --- a/template/frontend/tests/e2e/helpers/vrt.ts +++ b/template/frontend/tests/e2e/helpers/vrt.ts @@ -92,3 +92,19 @@ export async function expectFullPageScreenshotInCurrentColorMode( mask: [...defaultMasks, ...mask], }); } + +// Navigation-rail-only visual snapshot (the ShellRail `