diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6e1add0ed..893dabe6d 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: diff --git a/copier.yml b/copier.yml index b2dec3503..1fc7e020f 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? @@ -402,6 +407,13 @@ _tasks: '{{ _copier_conf.src_path }}/template' \ '.' \ --template-src '{{ _src_path }}' + - command: | + snapshot_file=backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json + if [ ! -f "$snapshot_file" ]; then + echo "Generating $snapshot_file from the actual OpenAPI schema..." + uv --directory=backend run pytest tests/unit/test_basic_server_functionality.py::test_openapi_schema --snapshot-update --cov-fail-under=0 + fi + when: "{{ has_backend }}" # Additional Settings _min_copier_version: "9.8" 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-functions/resourcesPlural.js b/template/.config/vacuum-functions/resourcesPlural.js new file mode 100644 index 000000000..cc652b88d --- /dev/null +++ b/template/.config/vacuum-functions/resourcesPlural.js @@ -0,0 +1,68 @@ +// Vacuum custom JS function (goja runtime: no npm, no require/import — plain self-contained JS). +// +// Flags static path segments that look like singular resource nouns; REST collections should be plural +// (gov.au naming). `given: $.paths[*]~` passes each path key (e.g. "/api/data/devices/{device_id}") as +// the single `input` argument. We split on "/", ignore "{param}" segments, and check each static segment. +// +// Pluralization is heuristic. To keep false positives down we DO NOT try to pluralize every word; instead +// we flag a segment only when it is clearly singular (does not end in "s") AND is not in the ALLOWLIST of +// non-collection segments (namespaces, versions, actions, status/singleton sub-resources, uncountable +// nouns). Projects can edit ALLOWLIST below — this file is copier-managed but locally customizable. + +// Segments that are legitimately not plural collections. +var ALLOWLIST = [ + // namespace / versioning + "api", "v1", "v2", "v3", + // uncountable / mass nouns + "data", "series", "telemetry", "equipment", "info", "metadata", "media", "config", "configuration", + // health / status / lifecycle singletons + "health", "healthcheck", "healthz", "readyz", "livez", "status", "shutdown", "ping", "version", + // common actions, filters and singleton sub-resources + "me", "self", "search", "online", "offline", "push", "polling", "dashboard", "summary", "latest", "current", + // non-REST endpoints (not resource collections) + "graphql" +]; + +function getSchema() { + return { name: "resourcesPlural" }; +} + +function isAllowed(seg) { + var lower = seg.toLowerCase(); + for (var i = 0; i < ALLOWLIST.length; i++) { + if (ALLOWLIST[i] === lower) { + return true; + } + } + return false; +} + +// Lenient: anything ending in "s" is treated as already plural. This intentionally under-flags words like +// "status"/"analysis" (safe: those are allowlisted or uncountable) so we never wrongly flag a plural. +function looksPlural(seg) { + return seg.length > 1 && seg.charAt(seg.length - 1) === "s"; +} + +function runRule(input) { + if (typeof input !== "string") { + return []; + } + var results = []; + var segments = input.split("/"); + for (var i = 0; i < segments.length; i++) { + var seg = segments[i]; + if (seg === "") { + continue; // leading slash / empty + } + if (seg.charAt(0) === "{") { + continue; // path parameter, not a resource segment + } + if (isAllowed(seg) || looksPlural(seg)) { + continue; + } + results.push({ + message: "path segment '" + seg + "' should be a plural resource noun (or added to the allowlist if it is not a collection)" + }); + } + return results; +} diff --git a/template/.config/vacuum-ignore.yaml.jinja b/template/.config/vacuum-ignore.yaml.jinja new file mode 100644 index 000000000..d68256e7e --- /dev/null +++ b/template/.config/vacuum-ignore.yaml.jinja @@ -0,0 +1,24 @@ +{% raw %}# Ignored vacuum findings, with justification. +# +# owasp-define-error-validation requires every operation to document a 400/422/4XX validation-error +# response. These five endpoints take no path/query/body parameters, so there is no honest client-error +# to document. The rule stays enabled so it enforces error documentation on all *future* endpoints; only +# these input-less operations are exempted. +owasp-define-error-validation: + - "$.paths['/api/shutdown'].get.responses" + +# ValidationError is generated by FastAPI (the 422 body); its `loc` union contains an int we cannot +# annotate with a format/bounds without owning the model, so exempt that one generated node. +# HTTPValidationError is generated by FastAPI; its `detail` property is exempt because we cannot attach a +# field-level example to a generated model (a schema-level example is injected in custom_openapi instead).{% endraw %}{% if backend_uses_graphql %}{% raw %} +# /api/graphql's 200 response body is arbitrary JSON shaped by the caller's GraphQL query/GraphiQL page; +# there is no single representative example to attach without misleadingly implying a fixed response shape.{% endraw %}{% endif %}{% raw %} +oas3-missing-example: + - "$.components.schemas['HTTPValidationError'].properties['detail']"{% endraw %}{% if backend_uses_graphql %}{% raw %} + - "$.paths['/api/graphql'].get.responses['200'].content['application/json']" + - "$.paths['/api/graphql'].post.responses['200'].content['application/json']"{% endraw %}{% endif %}{% raw %} + +owasp-integer-format: + - "$.components.schemas['ValidationError'].properties['loc'].items.anyOf[1]" +owasp-integer-limit: + - "$.components.schemas['ValidationError'].properties['loc'].items.anyOf[1]"{% endraw %} diff --git a/template/.config/vacuum-ruleset.yaml b/template/.config/vacuum-ruleset.yaml new file mode 100644 index 000000000..3425accf8 --- /dev/null +++ b/template/.config/vacuum-ruleset.yaml @@ -0,0 +1,183 @@ +# Curated vacuum ruleset for the backend OpenAPI schema. +# +# Extends vacuum's built-in OpenAPI "recommended" set, then disables two rules that are noise/false +# positives for a FastAPI-generated spec and enables a handful of opt-in best-practice rules plus three +# OWASP data-hardening rules. Every non-default rule carries a comment explaining WHY. +# +# The target document is the OpenAPI 3.1.0 snapshot the backend emits (also the canonical spec fed to +# Kiota codegen): +# backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json +# +# `formats` is intentionally omitted from every custom rule below: this repo only ever lints one 3.1.0 +# document, so scoping by format would be dead configuration. +extends: + - - vacuum:oas + - recommended +rules: + # DISABLED: flags identical description strings, but ours repeat legitimately — the same field + # (e.g. SensorInfo.id) is inherited across many response models, and the same 404 text is reused + # across endpoints. Forcing unique wording would make the docs less consistent, not better. + description-duplication: "off" + + # DISABLED: `servers` is meaningless here — single-server, air-gapped device, no multi-environment + # URLs to document. FastAPI's default "/ - This server" relative entry is all Swagger UI needs. + oas3-api-servers: "off" + + # DISABLED: false positive — it does not follow `$ref`, so enum/model-typed properties + # (severity, direction, status, measurementType, etc.) that reference a fully-typed component are wrongly + # flagged as "missing type". Every code-level workaround trips a different recommended rule + # (allOf -> no-unnecessary-combinator; bare $ref -> oas3-missing-example and loses the field + # description), so disabling this single shallow rule is the cleanest option. The fields keep their + # normal Field(description=, examples=). A FastAPI-generated spec can otherwise only trigger this on + # `Any`-typed fields, which are rare and usually unavoidable (e.g. the generated ValidationError.input). + oas-missing-type: "off" + + # ENABLED: guarantees every tag used by an operation is declared at the top level with real metadata — + # this drives navigation/grouping in docs and Kiota-generated client namespaces. + # `true` pulls vacuum's built-in definition verbatim (it's an opt-in rule, absent from `recommended`). + openapi-tags: true + + # ENABLED: a tag with no description is useless to a consumer reading the docs — enforce a + # human-readable purpose for each of system/debug/devices/data/alarms. + tag-description: true + + # ENABLED: keeps each operation to exactly one tag so it appears once in generated navigation/clients + # (multi-tagging duplicates operations across groups and confuses codegen). We already tag singly. + operation-singular-tag: true + + # ENABLED: a POST with no documented success response is an incomplete contract; ensures every + # create/action endpoint declares its 2xx/3xx (we already return 201) so clients know the success shape. + post-response-success: true + + # ENABLED: correctness guard — every entry in a schema's `required` list must actually exist in + # `properties`. Catches real bugs (typo'd/removed field left in required) that break client codegen. + required-fields-defined: true + + # ENABLED (OWASP): three data-hardening rules worth keeping even on an air-gapped/no-auth API. The + # rest of the OWASP pack (auth/HTTPS/rate-limit/protection-*/string-restricted) is intentionally NOT + # enabled for this trusted air-gapped, no-auth deployment; revisit if the API is ever exposed/authed. + owasp-define-error-validation: true + + # Integers must declare int32/int64 `format` so codegen picks the right numeric type. + # (Config, not `true`: OWASP rules aren't in the extended set, so `true` would be dropped.) + owasp-integer-format: true + + # Integers must declare min/max bounds (input validation / resource-exhaustion guard). + owasp-integer-limit: true + + # ENABLED: path id parameters must be explicit and resource-qualified, never a bare generic + # identifier. Bare `{id}` is ambiguous in a multi-resource spec and produces collision-prone Kiota + # client method names (every resource collapses to `by_id`). The denylist is Tier 1 only + # (unambiguously generic tokens). Tier 2 natural-key tokens (code|slug|name|type) are intentionally + # excluded because they are often legitimate (e.g. /countries/{code}); add them only if wanted. + path-no-generic-id: + description: Path id parameters must be explicit, not a bare generic identifier. + given: $.paths[*]~ + severity: error + then: + function: pattern + functionOptions: + notMatch: "\\{(id|pk|fk|uid|guid|uuid|oid|key|ref|hash|idx|index|num|no|identifier|value|item|object|entity|record|data|thing)\\}" + + # ENABLED: path PARAMETERS must be snake_case. The kebab-case + lowercase half of our hybrid casing + # decision (kebab static segments, snake_case params) is ALREADY enforced by the recommended set's + # `paths-kebab-case` rule, which flags non-kebab STATIC segments and ignores `{param}` segments + # entirely. This rule adds the parameter half that `paths-kebab-case` does not cover. FastAPI binds a + # path parameter's name to a Python function argument (which cannot contain hyphens), so snake_case is + # the only practical casing for parameters. + path-param-snake-case: + description: Path parameters must be snake_case. + given: $.paths[*][*].parameters[?(@.in=='path')].name + severity: warn + then: + function: casing + functionOptions: + type: snake + + # ENABLED: query parameters must be camelCase — body params are already camelCase, so matching that + # for query params keeps frontend clients consistent instead of converting between casings per field. + query-param-camel-case: + description: Query parameters must be camelCase. + given: $.paths[*][*].parameters[?(@.in=='query')].name + severity: warn + then: + function: casing + functionOptions: + type: camel + + # ENABLED (gov.au API Response): response bodies must be application/json. `application/problem+json` + # is also allowed because custom_openapi emits RFC 7807 ProblemDetails error bodies with that media + # type. Fires zero violations against the current snapshot. + response-media-type-json: + description: Response bodies must be application/json (or application/problem+json for errors). + given: $.paths[*][*].responses[*].content[*]~ + severity: warn + then: + function: pattern + functionOptions: + match: "^application/(json|problem\\+json)$" + + # ENABLED (gov.au API Request): request bodies must be application/json. Fires zero violations against + # the current snapshot. + request-media-type-json: + description: Request bodies must be application/json. + given: $.paths[*][*].requestBody.content[*]~ + severity: warn + then: + function: pattern + functionOptions: + match: "^application/json$" + + # ENABLED: POST request bodies must not REQUIRE an id — the backend generates it (a client may still + # supply an optional one). This targets each entry of the resolved request-body schema's `required` + # array and asserts none is literally "id"; if `required` is absent, no nodes match and the rule + # passes. (A schema-of-schema `not/contains` approach was tried first but vacuum reported it as a + # false positive on every POST body, so this pattern-based form is used instead.) + create-body-no-required-id: + description: POST request bodies must not require an id (backend-generated). + given: $.paths[*].post.requestBody.content.*.schema.required[*] + resolved: true + severity: warn + then: + function: pattern + functionOptions: + notMatch: "^id$" + + # ENABLED (gov.au Naming: nouns not verbs). The recommended set's `no-http-verbs-in-path` already + # catches HTTP-method names used as path segments; this adds the non-HTTP action verbs it does not + # cover. Fires zero violations against the current snapshot. + no-action-verbs-in-path: + description: Path segments must be nouns, not action verbs. + given: $.paths[*]~ + severity: warn + then: + function: pattern + functionOptions: + notMatch: "/(list|create|add|update|edit|remove|fetch|retrieve|make)(-|/|$)" + + # ENABLED (gov.au API Response): a 201 Created must return the new resource's Location. This fires on + # POST operations that declare a 201 response, so genuine resource creates (add device, create + # threshold, create view) declare + emit a Location header. Non-create POST actions should return a + # non-201 success (e.g. push measurements returns 202 Accepted) so this rule does not apply to them. + # (A `function: schema` form with a nested `required: [Location]` was tried first but vacuum reported + # it as a false positive even when the header was present, so this truthy field check is used instead.) + location-header-on-201: + description: 201 Created responses must declare a Location header. + given: $.paths[*].post.responses['201'] + severity: warn + then: + field: headers.Location + function: truthy + + # ENABLED (gov.au Naming: plural resources). Static path segments should be plural resource nouns. + # Declarative casing/pattern rules cannot pluralize, so this uses a custom JavaScript function + # (.config/vacuum-functions/resourcesPlural.js) run via `vacuum lint -f .config/vacuum-functions`. The + # check is heuristic: it flags a static segment only when it is clearly singular (does not end in "s") + # and is not in the function's ALLOWLIST of non-collection segments (namespaces, actions, uncountable + # nouns). Tune that allowlist in the JS file rather than here. + resources-plural: + description: Path resource segments should be plural nouns. + given: $.paths[*]~ + severity: warn + then: + function: resourcesPlural diff --git a/template/.gitignore b/template/.gitignore index cc7c8b5be..3230070b3 100644 --- a/template/.gitignore +++ b/template/.gitignore @@ -123,4 +123,7 @@ dist # macOS dev cleanliness **/.DS_Store +# vacuum OpenAPI lint reports (generated ad-hoc; the linter runs against the committed snapshot) +vacuum-report-*.json + # Ignores specific to this repository diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index c9a65001f..2d513d510 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -339,6 +339,27 @@ repos: # use require_serial so that script is only called once per commit require_serial: true # print the number of files as a sanity-check + verbose: true + + - repo: local + hooks: + - id: vacuum-openapi + name: vacuum OpenAPI schema lint + # Lints the generated OpenAPI snapshot against the curated ruleset. + # `language: node` with additional_dependencies lets pre-commit install vacuum in an + # isolated env, so this runs in CI without any global/devcontainer install. Fails on warn-or-worse. + entry: vacuum lint --no-banner --no-style --fail-severity warn -d --no-clip -f .config/vacuum-functions -r .config/vacuum-ruleset.yaml --ignore-file .config/vacuum-ignore.yaml backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json + language: node + additional_dependencies: ['@quobix/vacuum@{% endraw %}{{ vacuum_openapi_version }}{% raw %}'] + files: | + (?x)^( + backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema\.json| + .config/vacuum-ruleset\.yaml| + .config/vacuum-ignore\.yaml| + .config/vacuum-functions/.*\.js + )$ + pass_filenames: false + require_serial: true verbose: true{% endraw %}{% endif %}{% raw %} # Updating repo config/tooling files 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..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 @@ -74,9 +74,26 @@ async def lifespan(app: FastAPI): logger.info("Shutting down FastAPI application") {% endraw %}{% endif %}{% raw %} + +API_DESCRIPTION = {% endraw %}{{ backend_rest_api_description | tojson }}{% raw %} + +OPENAPI_APP_SPECIFIC_TAGS: list[dict[str, str]] = [ + # Insert app specific openapi tags here +] + +OPENAPI_TAGS = [ + {"name": "system", "description": "Server health and lifecycle operations."},{% endraw %}{% if backend_uses_graphql %}{% 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, +] + 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", @@ -92,10 +109,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", @@ -120,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)) @@ -151,7 +180,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 %} 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"} 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") 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 %} 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() 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