From aee370bad09a6e2f1e8e29fbd3a29d541360a331 Mon Sep 17 00:00:00 2001 From: Sergey Yedrikov Date: Wed, 22 Apr 2026 13:43:57 -0400 Subject: [PATCH 1/3] LCORE-1958: Fix duplicate A2A operationIds and OpenAPI response metadata Separate GET/POST operationId values on /a2a, tighten route summaries and descriptions for the spec, and fix Responses streaming OpenAPI (200 text/event- stream: drop invalid sibling description; clarify JSON success description). Regenerate docs/openapi.json for those paths only (no global tag list yet). --- docs/openapi.json | 53 ++++++++++++++----- src/app/endpoints/a2a.py | 87 +++++++++++++++++++++++++++++++- src/app/endpoints/a2a_openapi.py | 32 ++++++++++++ src/models/responses.py | 1 - 4 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 src/app/endpoints/a2a_openapi.py diff --git a/docs/openapi.json b/docs/openapi.json index 3b03f6ac8..3bf56a9a0 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -9205,7 +9205,7 @@ }, "responses": { "200": { - "description": "Successful response", + "description": "Successful response. For `text/event-stream`, the body is a Server-Sent Events stream.", "content": { "application/json": { "schema": { @@ -9262,8 +9262,7 @@ "schema": { "type": "string" }, - "example": "event: response.created\ndata: {\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"resp_abc\",\"object\":\"response\",\"created_at\":1704067200,\"status\":\"in_progress\",\"model\":\"openai/gpt-4o-mini\",\"output\":[],\"store\":true,\"text\":{\"format\":{\"type\":\"text\"}},\"conversation\":\"0d21ba731f21f798dc9680125d5d6f49\",\"available_quotas\":{},\"output_text\":\"\"}}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"sequence_number\":1,\"response_id\":\"resp_abc\",\"output_index\":0,\"item\":{\"id\":\"msg_abc\",\"type\":\"message\",\"status\":\"in_progress\",\"role\":\"assistant\",\"content\":[]}}\n\n...\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"sequence_number\":30,\"response\":{\"id\":\"resp_abc\",\"object\":\"response\",\"created_at\":1704067200,\"status\":\"completed\",\"model\":\"openai/gpt-4o-mini\",\"output\":[{\"id\":\"msg_abc\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello! How can I help?\",\"annotations\":[]}]}],\"store\":true,\"text\":{\"format\":{\"type\":\"text\"}},\"usage\":{\"input_tokens\":10,\"output_tokens\":6,\"total_tokens\":16,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens_details\":{\"reasoning_tokens\":0}},\"conversation\":\"0d21ba731f21f798dc9680125d5d6f49\",\"available_quotas\":{\"daily\":1000,\"monthly\":50000},\"output_text\":\"Hello! How can I help?\"}}\n\ndata: [DONE]\n\n", - "description": "SSE stream of events" + "example": "event: response.created\ndata: {\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"resp_abc\",\"object\":\"response\",\"created_at\":1704067200,\"status\":\"in_progress\",\"model\":\"openai/gpt-4o-mini\",\"output\":[],\"store\":true,\"text\":{\"format\":{\"type\":\"text\"}},\"conversation\":\"0d21ba731f21f798dc9680125d5d6f49\",\"available_quotas\":{},\"output_text\":\"\"}}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"sequence_number\":1,\"response_id\":\"resp_abc\",\"output_index\":0,\"item\":{\"id\":\"msg_abc\",\"type\":\"message\",\"status\":\"in_progress\",\"role\":\"assistant\",\"content\":[]}}\n\n...\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"sequence_number\":30,\"response\":{\"id\":\"resp_abc\",\"object\":\"response\",\"created_at\":1704067200,\"status\":\"completed\",\"model\":\"openai/gpt-4o-mini\",\"output\":[{\"id\":\"msg_abc\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello! How can I help?\",\"annotations\":[]}]}],\"store\":true,\"text\":{\"format\":{\"type\":\"text\"}},\"usage\":{\"input_tokens\":10,\"output_tokens\":6,\"total_tokens\":16,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens_details\":{\"reasoning_tokens\":0}},\"conversation\":\"0d21ba731f21f798dc9680125d5d6f49\",\"available_quotas\":{\"daily\":1000,\"monthly\":50000},\"output_text\":\"Hello! How can I help?\"}}\n\ndata: [DONE]\n\n" } } }, @@ -10600,15 +10599,30 @@ "tags": [ "a2a" ], - "summary": "Handle A2A Jsonrpc", - "description": "Handle A2A JSON-RPC requests following the A2A protocol specification.\n\nThis endpoint uses the DefaultRequestHandler from the A2A SDK to handle\nall JSON-RPC requests including message/send, message/stream, etc.\n\nThe A2A SDK application is created per-request to include authentication\ncontext while still leveraging FastAPI's authorization middleware.\n\nAutomatically detects streaming requests (message/stream JSON-RPC method)\nand returns a StreamingResponse to enable real-time chunk delivery.\n\nArgs:\n request: FastAPI request object\n auth: Authentication tuple\n mcp_headers: MCP headers for context propagation\n\nReturns:\n JSON-RPC response or streaming response", + "summary": "Handle A2A JSON-RPC GET", + "description": "Handle GET on /a2a for A2A JSON-RPC requests following the A2A protocol specification.", "operationId": "handle_a2a_jsonrpc_a2a_get", "responses": { "200": { - "description": "Successful Response", + "description": "Successful response: buffered JSON-RPC or HTTP payload for non-streaming calls, or a Server-Sent Events stream when the JSON-RPC method is message/stream.", "content": { "application/json": { - "schema": {} + "schema": { + "type": "object", + "description": "JSON-RPC 2.0 response or A2A-over-HTTP payload" + }, + "example": { + "jsonrpc": "2.0", + "id": "1", + "result": {} + } + }, + "text/event-stream": { + "schema": { + "type": "string", + "format": "text/event-stream" + }, + "example": "data: {\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{}}\n\n" } } } @@ -10618,15 +10632,30 @@ "tags": [ "a2a" ], - "summary": "Handle A2A Jsonrpc", - "description": "Handle A2A JSON-RPC requests following the A2A protocol specification.\n\nThis endpoint uses the DefaultRequestHandler from the A2A SDK to handle\nall JSON-RPC requests including message/send, message/stream, etc.\n\nThe A2A SDK application is created per-request to include authentication\ncontext while still leveraging FastAPI's authorization middleware.\n\nAutomatically detects streaming requests (message/stream JSON-RPC method)\nand returns a StreamingResponse to enable real-time chunk delivery.\n\nArgs:\n request: FastAPI request object\n auth: Authentication tuple\n mcp_headers: MCP headers for context propagation\n\nReturns:\n JSON-RPC response or streaming response", - "operationId": "handle_a2a_jsonrpc_a2a_get", + "summary": "Handle A2A JSON-RPC POST", + "description": "Handle POST on /a2a for A2A JSON-RPC requests following the A2A protocol specification.", + "operationId": "handle_a2a_jsonrpc_a2a_post", "responses": { "200": { - "description": "Successful Response", + "description": "Successful response: buffered JSON-RPC or HTTP payload for non-streaming calls, or a Server-Sent Events stream when the JSON-RPC method is message/stream.", "content": { "application/json": { - "schema": {} + "schema": { + "type": "object", + "description": "JSON-RPC 2.0 response or A2A-over-HTTP payload" + }, + "example": { + "jsonrpc": "2.0", + "id": "1", + "result": {} + } + }, + "text/event-stream": { + "schema": { + "type": "string", + "format": "text/event-stream" + }, + "example": "data: {\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{}}\n\n" } } } diff --git a/src/app/endpoints/a2a.py b/src/app/endpoints/a2a.py index 0c9fe2a75..be27389a3 100644 --- a/src/app/endpoints/a2a.py +++ b/src/app/endpoints/a2a.py @@ -36,6 +36,7 @@ from starlette.responses import Response, StreamingResponse from a2a_storage import A2AContextStore, A2AStorageFactory +from app.endpoints.a2a_openapi import a2a_jsonrpc_responses from authentication import get_auth_dependency from authentication.interface import AuthTuple from authorization.middleware import authorize @@ -692,12 +693,94 @@ async def _create_a2a_app( return a2a_app.build() -@router.api_route("/a2a", methods=["GET", "POST"], response_model=None) +@router.get( + "/a2a", + response_model=None, + responses=a2a_jsonrpc_responses, + operation_id="handle_a2a_jsonrpc_a2a_get", + summary="Handle A2A JSON-RPC GET", + description=( + "Handle GET on /a2a for A2A JSON-RPC requests following the A2A protocol specification." + ), +) +@authorize(Action.A2A_JSONRPC) +async def handle_a2a_jsonrpc_get( + request: Request, + auth: Annotated[AuthTuple, Depends(auth_dependency)], + mcp_headers: McpHeaders = Depends(mcp_headers_dependency), +) -> Response | StreamingResponse: + """Serve A2A JSON-RPC over HTTP GET on ``/a2a``. + + Thin wrapper that delegates to ``_handle_a2a_jsonrpc`` so GET and POST share + the same processing path while keeping distinct OpenAPI operation metadata. + + Args: + request: Incoming ASGI/FastAPI request (body, scope, headers). + auth: Resolved authentication tuple from ``auth_dependency`` (user + identity and bearer token used to build the per-request A2A app). + mcp_headers: MCP-related headers from ``mcp_headers_dependency``, forwarded + into the A2A executor for downstream tool/context propagation. + + Returns: + ``Response`` with the full buffered JSON-RPC (or HTTP) payload when the + request is non-streaming, or ``StreamingResponse`` (SSE) when the + JSON-RPC method is ``message/stream`` and chunks are streamed to the + client. Error conditions are generally expressed as JSON-RPC or HTTP + responses rather than by raising from this wrapper. + + Raises: + HTTPException: If authentication or ``@authorize`` rejects the request + before or while entering the handler chain. + """ + return await _handle_a2a_jsonrpc(request, auth, mcp_headers) + + +@router.post( + "/a2a", + response_model=None, + responses=a2a_jsonrpc_responses, + operation_id="handle_a2a_jsonrpc_a2a_post", + summary="Handle A2A JSON-RPC POST", + description=( + "Handle POST on /a2a for A2A JSON-RPC requests following the A2A protocol specification." + ), +) @authorize(Action.A2A_JSONRPC) -async def handle_a2a_jsonrpc( # pylint: disable=too-many-locals,too-many-statements +async def handle_a2a_jsonrpc_post( request: Request, auth: Annotated[AuthTuple, Depends(auth_dependency)], mcp_headers: McpHeaders = Depends(mcp_headers_dependency), +) -> Response | StreamingResponse: + """Serve A2A JSON-RPC over HTTP POST on ``/a2a``. + + Thin wrapper that delegates to ``_handle_a2a_jsonrpc`` so GET and POST share + the same processing path while keeping distinct OpenAPI operation metadata. + + Args: + request: Incoming ASGI/FastAPI request (body, scope, headers). + auth: Resolved authentication tuple from ``auth_dependency`` (user + identity and bearer token used to build the per-request A2A app). + mcp_headers: MCP-related headers from ``mcp_headers_dependency``, forwarded + into the A2A executor for downstream tool/context propagation. + + Returns: + ``Response`` with the full buffered JSON-RPC (or HTTP) payload when the + request is non-streaming, or ``StreamingResponse`` (SSE) when the + JSON-RPC method is ``message/stream`` and chunks are streamed to the + client. Error conditions are generally expressed as JSON-RPC or HTTP + responses rather than by raising from this wrapper. + + Raises: + HTTPException: If authentication or ``@authorize`` rejects the request + before or while entering the handler chain. + """ + return await _handle_a2a_jsonrpc(request, auth, mcp_headers) + + +async def _handle_a2a_jsonrpc( # pylint: disable=too-many-locals,too-many-statements + request: Request, + auth: AuthTuple, + mcp_headers: McpHeaders, ) -> Response | StreamingResponse: """ Handle A2A JSON-RPC requests following the A2A protocol specification. diff --git a/src/app/endpoints/a2a_openapi.py b/src/app/endpoints/a2a_openapi.py new file mode 100644 index 000000000..e5990e665 --- /dev/null +++ b/src/app/endpoints/a2a_openapi.py @@ -0,0 +1,32 @@ +"""OpenAPI-only metadata for A2A JSON-RPC routes.""" + +from typing import Any, Final + +from constants import MEDIA_TYPE_EVENT_STREAM, MEDIA_TYPE_JSON + +# 200 may be buffered JSON-RPC (application/json) or SSE (text/event-stream). +a2a_jsonrpc_responses: Final[dict[int | str, dict[str, Any]]] = { + 200: { + "description": "Successful response", + "content": { + MEDIA_TYPE_JSON: { + "schema": { + "type": "object", + "description": "JSON-RPC 2.0 response or A2A-over-HTTP payload", + }, + "example": {"jsonrpc": "2.0", "id": "1", "result": {}}, + }, + MEDIA_TYPE_EVENT_STREAM: { + "schema": { + "type": "string", + "description": ( + "Server-Sent Events stream when " + "the JSON-RPC method is message/stream" + ), + "format": MEDIA_TYPE_EVENT_STREAM, + }, + "example": 'data: {"jsonrpc":"2.0","id":"1","result":{}}\n\n', + }, + }, + }, +} diff --git a/src/models/responses.py b/src/models/responses.py index f1b526da4..4e7f8fa74 100644 --- a/src/models/responses.py +++ b/src/models/responses.py @@ -1681,7 +1681,6 @@ def openapi_response(cls) -> dict[str, Any]: "application/json": {"example": json_example} if json_example else {}, "text/event-stream": { "schema": {"type": "string"}, - "description": "SSE stream of events", "example": sse_example, }, } From a0ad26f9b7d975d99bf6751a645e3f4f64e77468 Mon Sep 17 00:00:00 2001 From: Sergey Yedrikov Date: Wed, 22 Apr 2026 13:44:08 -0400 Subject: [PATCH 2/3] LCORE-1958: Declare global OpenAPI tags for Spectral operation-tag-defined Add _OPENAPI_TAGS / openapi_tags on FastAPI, pass tags through scripts/generate_openapi_schema.py, and regenerate docs/openapi.json with a root-level tags array and normalized server URL. Document tag maintenance in docs/contributing/openapi-tags-and-spectral.md (tag list section). --- .../contributing/openapi-tags-and-spectral.md | 15 +++ docs/openapi.json | 114 ++++++++++++++++-- scripts/generate_openapi_schema.py | 1 + src/app/main.py | 32 ++++- 4 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 docs/contributing/openapi-tags-and-spectral.md diff --git a/docs/contributing/openapi-tags-and-spectral.md b/docs/contributing/openapi-tags-and-spectral.md new file mode 100644 index 000000000..3fc46692a --- /dev/null +++ b/docs/contributing/openapi-tags-and-spectral.md @@ -0,0 +1,15 @@ +# OpenAPI tags and Spectral + +## Global tag list (`_OPENAPI_TAGS`) + +In `src/app/endpoints/`, route tags come from **`APIRouter(tags=[...])`**, which FastAPI uses when it builds the OpenAPI description for each operation. + +The OpenAPI document must list those tags at the top level for tools like [Spectral](https://stoplight.io/open-api/) rule **`operation-tag-defined`** to pass, so we keep **`_OPENAPI_TAGS`** in **`src/app/main.py`** and pass it into the **`FastAPI`** app as **`openapi_tags`**. + +**When you add a new router or change `tags=[...]` to use a new tag name**, add a matching entry to **`_OPENAPI_TAGS`** (same `name` string, plus a short `description` for the docs). + +The schema generator **`scripts/generate_openapi_schema.py`** passes **`tags=app.openapi_tags`** into **`get_openapi()`** so **`docs/openapi.json`** includes the top-level `tags` array. Regenerate after tag changes: + +```bash +uv run scripts/generate_openapi_schema.py docs/openapi.json +``` diff --git a/docs/openapi.json b/docs/openapi.json index 3bf56a9a0..aa2b6a3df 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -17,7 +17,7 @@ }, "servers": [ { - "url": "http://localhost:8080/", + "url": "http://localhost:8080", "description": "Locally running service" } ], @@ -9205,7 +9205,7 @@ }, "responses": { "200": { - "description": "Successful response. For `text/event-stream`, the body is a Server-Sent Events stream.", + "description": "Successful response", "content": { "application/json": { "schema": { @@ -10604,7 +10604,7 @@ "operationId": "handle_a2a_jsonrpc_a2a_get", "responses": { "200": { - "description": "Successful response: buffered JSON-RPC or HTTP payload for non-streaming calls, or a Server-Sent Events stream when the JSON-RPC method is message/stream.", + "description": "Successful response", "content": { "application/json": { "schema": { @@ -10620,7 +10620,8 @@ "text/event-stream": { "schema": { "type": "string", - "format": "text/event-stream" + "format": "text/event-stream", + "description": "Server-Sent Events stream when the JSON-RPC method is message/stream" }, "example": "data: {\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{}}\n\n" } @@ -10637,7 +10638,7 @@ "operationId": "handle_a2a_jsonrpc_a2a_post", "responses": { "200": { - "description": "Successful response: buffered JSON-RPC or HTTP payload for non-streaming calls, or a Server-Sent Events stream when the JSON-RPC method is message/stream.", + "description": "Successful response", "content": { "application/json": { "schema": { @@ -10653,7 +10654,8 @@ "text/event-stream": { "schema": { "type": "string", - "format": "text/event-stream" + "format": "text/event-stream", + "description": "Server-Sent Events stream when the JSON-RPC method is message/stream" }, "example": "data: {\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{}}\n\n" } @@ -20507,5 +20509,103 @@ ] } } - } + }, + "tags": [ + { + "name": "a2a", + "description": "Agent-to-Agent (A2A) protocol." + }, + { + "name": "authorized", + "description": "Authorization probe." + }, + { + "name": "config", + "description": "Service configuration." + }, + { + "name": "conversations_v1", + "description": "Conversations API v1." + }, + { + "name": "conversations_v2", + "description": "Conversations API v2." + }, + { + "name": "feedback", + "description": "User feedback." + }, + { + "name": "health", + "description": "Health and readiness probes." + }, + { + "name": "info", + "description": "Service information." + }, + { + "name": "mcp-auth", + "description": "MCP client authentication options." + }, + { + "name": "mcp-servers", + "description": "MCP server registration." + }, + { + "name": "metrics", + "description": "Prometheus metrics." + }, + { + "name": "models", + "description": "LLM models." + }, + { + "name": "prompts", + "description": "Prompt management." + }, + { + "name": "providers", + "description": "Inference providers." + }, + { + "name": "query", + "description": "Non-streaming query." + }, + { + "name": "rags", + "description": "RAG configuration." + }, + { + "name": "responses", + "description": "OpenAI-compatible Responses API." + }, + { + "name": "rlsapi-v1", + "description": "RLS API v1 (inference)." + }, + { + "name": "root", + "description": "Service root." + }, + { + "name": "shields", + "description": "Safety shields." + }, + { + "name": "streaming_query", + "description": "Streaming query (SSE)." + }, + { + "name": "streaming_query_interrupt", + "description": "Streaming interrupt." + }, + { + "name": "tools", + "description": "Tools." + }, + { + "name": "vector-stores", + "description": "Vector stores and files." + } + ] } \ No newline at end of file diff --git a/scripts/generate_openapi_schema.py b/scripts/generate_openapi_schema.py index f471e3604..e66cfc774 100644 --- a/scripts/generate_openapi_schema.py +++ b/scripts/generate_openapi_schema.py @@ -85,6 +85,7 @@ def read_version_from_pyproject(): license_info=app.license_info, servers=app.servers, contact=app.contact, + tags=app.openapi_tags, ) # dump the schema into file diff --git a/src/app/main.py b/src/app/main.py index fab5d9573..627a0cf8a 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -3,6 +3,7 @@ import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from typing import Final import sentry_sdk # pyright: ignore[reportMissingImports] from fastapi import FastAPI, HTTPException @@ -34,6 +35,34 @@ service_name = configuration.configuration.name +# Global OpenAPI tags so every operation tag is declared (Spectral: operation-tag-defined). +_OPENAPI_TAGS: Final[list[dict[str, str]]] = [ + {"name": "a2a", "description": "Agent-to-Agent (A2A) protocol."}, + {"name": "authorized", "description": "Authorization probe."}, + {"name": "config", "description": "Service configuration."}, + {"name": "conversations_v1", "description": "Conversations API v1."}, + {"name": "conversations_v2", "description": "Conversations API v2."}, + {"name": "feedback", "description": "User feedback."}, + {"name": "health", "description": "Health and readiness probes."}, + {"name": "info", "description": "Service information."}, + {"name": "mcp-auth", "description": "MCP client authentication options."}, + {"name": "mcp-servers", "description": "MCP server registration."}, + {"name": "metrics", "description": "Prometheus metrics."}, + {"name": "models", "description": "LLM models."}, + {"name": "prompts", "description": "Prompt management."}, + {"name": "providers", "description": "Inference providers."}, + {"name": "query", "description": "Non-streaming query."}, + {"name": "rags", "description": "RAG configuration."}, + {"name": "responses", "description": "OpenAI-compatible Responses API."}, + {"name": "rlsapi-v1", "description": "RLS API v1 (inference)."}, + {"name": "root", "description": "Service root."}, + {"name": "shields", "description": "Safety shields."}, + {"name": "streaming_query", "description": "Streaming query (SSE)."}, + {"name": "streaming_query_interrupt", "description": "Streaming interrupt."}, + {"name": "tools", "description": "Tools."}, + {"name": "vector-stores", "description": "Vector stores and files."}, +] + # running on FastAPI startup @asynccontextmanager @@ -111,8 +140,9 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: "url": "https://www.apache.org/licenses/LICENSE-2.0.html", }, servers=[ - {"url": "http://localhost:8080/", "description": "Locally running service"} + {"url": "http://localhost:8080", "description": "Locally running service"} ], + openapi_tags=_OPENAPI_TAGS, lifespan=lifespan, ) From c2b7827c4ee33695266f87828c99e042f44f2725 Mon Sep 17 00:00:00 2001 From: Sergey Yedrikov Date: Wed, 22 Apr 2026 13:44:18 -0400 Subject: [PATCH 3/3] LCORE-1958: Add Spectral OpenAPI linting (verify + CI) Add .spectral.yaml, make lint-openapi (optional npx locally), wire into verify, GitHub workflow to regenerate-check docs/openapi.json and run Spectral, and CONTRIBUTING prerequisites for Node. Extend contributing doc with Spectral section. --- .github/workflows/openapi_spectral.yaml | 42 +++++++++++++++++++ .spectral.yaml | 9 ++++ CONTRIBUTING.md | 7 ++++ Makefile | 8 ++++ .../contributing/openapi-tags-and-spectral.md | 12 ++++++ 5 files changed, 78 insertions(+) create mode 100644 .github/workflows/openapi_spectral.yaml create mode 100644 .spectral.yaml diff --git a/.github/workflows/openapi_spectral.yaml b/.github/workflows/openapi_spectral.yaml new file mode 100644 index 000000000..c1c4084a4 --- /dev/null +++ b/.github/workflows/openapi_spectral.yaml @@ -0,0 +1,42 @@ +# OpenAPI: regenerate check (Spectral + committed docs/openapi.json drift). +# - scripts/generate_openapi_schema.py builds the spec from FastAPI (app.main). +# - CI fails if docs/openapi.json does not match generator output (run locally: +# uv run scripts/generate_openapi_schema.py docs/openapi.json). +name: OpenAPI (Spectral) + +on: + - push + - pull_request + +jobs: + spectral: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + - name: Install dependencies + # Same pattern as local dev (CONTRIBUTING.md): dev + llslibdev for a full app import. + run: uv sync --group dev --group llslibdev + - name: Install PDM + # scripts/generate_openapi_schema.py asserts OpenAPI info.version matches `pdm show --version`. + run: uv pip install pdm + - name: Verify docs/openapi.json matches generator + run: | + set -euo pipefail + uv run python scripts/generate_openapi_schema.py /tmp/openapi-generated.json + if ! diff -u docs/openapi.json /tmp/openapi-generated.json; then + echo "::error::docs/openapi.json is out of date. Regenerate with: uv run scripts/generate_openapi_schema.py docs/openapi.json" + exit 1 + fi + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Spectral lint + run: npx --yes @stoplight/spectral-cli@6 lint docs/openapi.json --fail-severity error --display-only-failures diff --git a/.spectral.yaml b/.spectral.yaml new file mode 100644 index 000000000..31b510839 --- /dev/null +++ b/.spectral.yaml @@ -0,0 +1,9 @@ +# Spectral (OpenAPI) — https://meta.stoplight.io/docs/spectral/ +# +# Full Stoplight OAS ruleset. `oas3-valid-media-example` is off: examples in +# docs/openapi.json are often partial and produced ~600 warnings with little +# signal until examples are aligned with schemas. Re-enable when tightening +# examples (set to "warn" or "error"). +extends: spectral:oas +rules: + oas3-valid-media-example: off diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2183c0845..e1c72c03f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,7 @@ * [Pre-commit hook settings](#pre-commit-hook-settings) * [Code coverage measurement](#code-coverage-measurement) * [Linters](#linters) + * [OpenAPI (Spectral)](#openapi-spectral) * [Type hints checks](#type-hints-checks) * [Ruff](#ruff) * [Pylint](#pylint) @@ -48,6 +49,7 @@ - git - Python 3.12 or 3.13 - pip +- **Node.js** (18 or newer; **npm** and **`npx`** ship with Node). **`make verify`** runs **`lint-openapi`**, which calls **`npx --yes @stoplight/spectral-cli@6`** (see `Makefile`). If **`npx`** is not installed, **`lint-openapi` is skipped** with a message so **`make verify` still succeeds** locally; install Node to run the OpenAPI check. **CI** always runs Spectral (see `.github/workflows/openapi_spectral.yaml`). The development requires at least [Python 3.12](https://docs.python.org/3/whatsnew/3.12.html) due to significant improvement on performance, optimizations which benefit modern ML, AI, LLM, NL stacks, and improved asynchronous processing capabilities. It is also possible to use Python 3.13. @@ -57,6 +59,7 @@ The development requires at least [Python 3.12](https://docs.python.org/3/whatsn 1. `pip install --user uv` 1. `uv --version` -- should return no error +1. Install [Node.js](https://nodejs.org/en/download) (LTS is fine) or use your OS package manager, e.g. Fedora: `sudo dnf install nodejs`, macOS with [Homebrew](https://brew.sh/): `brew install node`. Confirm `node --version` and `npx --version` work. CI uses Node 22 for Spectral (see `.github/workflows/openapi_spectral.yaml`). @@ -217,6 +220,10 @@ Code coverage reports are generated in JSON and also in format compatible with [ _Black_, _Ruff_, Pyright, _Pylint_, __Pydocstyle__, __Mypy__, and __Bandit__ tools are used as linters. There are a bunch of linter rules enabled for this repository. All of them are specified in `pyproject.toml`, such as in sections `[tool.ruff]` and `[tool.pylint."MESSAGES CONTROL"]`. Some specific rules can be disabled using `ignore` parameter (empty now). +### OpenAPI (Spectral) + +OpenAPI is linted with [Spectral](https://stoplight.io/open-api/) via **`npx --yes @stoplight/spectral-cli@6`** in the **`lint-openapi`** target (`make lint-openapi`, part of **`make verify`**). If **`npx`** is missing, **`lint-openapi`** skips Spectral locally; install **Node.js** to run it (see [Prerequisites](#prerequisites) and [Tooling installation](#tooling-installation)). **CI** always runs the Spectral step. If you introduce a **new** router tag (`APIRouter(tags=[...])`), you must also extend the global tag list in `src/app/main.py` and regenerate `docs/openapi.json`. See **[docs/contributing/openapi-tags-and-spectral.md](docs/contributing/openapi-tags-and-spectral.md)**. + ### Type hints checks diff --git a/Makefile b/Makefile index fdf257f7f..b89451549 100644 --- a/Makefile +++ b/Makefile @@ -113,6 +113,13 @@ docstyle: ## Check the docstring style using Docstyle checker ruff: ## Check source code using Ruff linter uv run ruff check src tests --per-file-ignores=tests/*:S101 --per-file-ignores=scripts/*:S101 +lint-openapi: ## Lint docs/openapi.json (Spectral OAS ruleset; fail on error) + @if command -v npx >/dev/null 2>&1; then \ + npx --yes @stoplight/spectral-cli@6 lint docs/openapi.json --fail-severity error --display-only-failures; \ + else \ + echo "lint-openapi: skipping Spectral (npx not found). Install Node.js for OpenAPI lint locally; CI still runs it."; \ + fi + verify: ## Run all linters $(MAKE) black $(MAKE) pylint @@ -120,6 +127,7 @@ verify: ## Run all linters $(MAKE) ruff $(MAKE) docstyle $(MAKE) check-types + $(MAKE) lint-openapi distribution-archives: ## Generate distribution archives to be uploaded into Python registry rm -rf dist diff --git a/docs/contributing/openapi-tags-and-spectral.md b/docs/contributing/openapi-tags-and-spectral.md index 3fc46692a..30f77ac01 100644 --- a/docs/contributing/openapi-tags-and-spectral.md +++ b/docs/contributing/openapi-tags-and-spectral.md @@ -13,3 +13,15 @@ The schema generator **`scripts/generate_openapi_schema.py`** passes **`tags=app ```bash uv run scripts/generate_openapi_schema.py docs/openapi.json ``` + +## Linting (`make lint-openapi`) + +Spectral is configured in **`.spectral.yaml`** (extends `spectral:oas`). Run: + +```bash +make lint-openapi +``` + +This is part of **`make verify`**. If **`npx`** is not on your **`PATH`**, the Makefile **skips** Spectral and prints a short message so **`make verify`** can still pass; install Node.js to run the check locally. **CI** (`.github/workflows/openapi_spectral.yaml`) always runs Spectral. Failures are driven by **error**-severity rules. + +The rule **`oas3-valid-media-example`** (examples must match schemas) is **turned off** in **`.spectral.yaml`** because the generated spec carries many partial examples and produced hundreds of noisy warnings. Turn it back on when examples are brought in line with schemas.