diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c6882b..cc3de56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: workflow_dispatch: push: branches: [ "main" ] + tags: [ "*" ] paths-ignore: - "**/*.md" - "CODEOWNERS" @@ -46,3 +47,30 @@ jobs: - name: Test with pytest run: | pytest + - name: Export OpenAPI + run: | + PYTHONPATH=. python scripts/export_openapi.py + - name: Upload OpenAPI artifact + uses: actions/upload-artifact@v4 + with: + name: openapi.json + path: clients/openapi.json + + publish-openapi-release: + if: startsWith(github.ref, 'refs/tags/') + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download OpenAPI artifact + uses: actions/download-artifact@v4 + with: + name: openapi.json + path: artifacts + - name: Attach openapi.json to GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: artifacts/openapi.json + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 4a961c3..0940639 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,7 @@ logs/ # Local data data/ + +# Generated API clients (keep README + thin Python wrapper) +clients/go/*.go +clients/python/generated/ diff --git a/Makefile b/Makefile index 83b425d..374f23c 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,13 @@ .PHONY: help install install-dev \ db-up db-down docker-up docker-down docker-demo docker-logs \ init migrate demo ingest explain clusters ask \ - api web web-serve worker test test-unit test-int test-cov lint format clean + api web web-serve worker test test-unit test-int test-cov lint format \ + openapi client-go client-python clean PYTHON := python PIP := pip SAMPLE := sample_data/sample_incident +OPENAPI := clients/openapi.json help: @echo "raglogs — incident explanation tool" @@ -38,6 +40,9 @@ help: @echo " make test-cov Unit tests with coverage" @echo " make lint Ruff lint" @echo " make format Ruff format" + @echo " make openapi Export OpenAPI schema to clients/openapi.json" + @echo " make client-go Generate Go client (oapi-codegen; no-op if missing)" + @echo " make client-python Optional OpenAPI Python generator, or use src/clients/v1.py" @echo " make clean Remove build artifacts" # ── Setup ───────────────────────────────────────────────────────────────────── @@ -146,6 +151,37 @@ lint: format: ruff format src/ tests/ +# ── OpenAPI / clients ───────────────────────────────────────────────────────── + +openapi: + PYTHONPATH=. $(PYTHON) scripts/export_openapi.py + +# Generates clients/go/client.go when oapi-codegen is installed. +# Missing binary: print install hint and exit 0 so CI without Go still passes. +client-go: openapi + @if command -v oapi-codegen >/dev/null 2>&1; then \ + mkdir -p clients/go; \ + oapi-codegen -generate client,types -package raglogs -o clients/go/client.go $(OPENAPI); \ + echo "Wrote clients/go/client.go"; \ + else \ + echo "oapi-codegen not found. Install with:"; \ + echo " go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest"; \ + echo "Then re-run: make client-go"; \ + fi + +# Committed client is src/clients/v1.py. Optional generator dump is gitignored. +client-python: openapi + @echo "Committed typed client: src/clients/v1.py (targets /v1)." + @if command -v openapi-python-client >/dev/null 2>&1; then \ + mkdir -p clients/python/generated; \ + openapi-python-client generate --path $(OPENAPI) --output-path clients/python/generated --overwrite; \ + echo "Wrote clients/python/generated/"; \ + else \ + echo "Optional generator openapi-python-client not installed."; \ + echo " pip install openapi-python-client"; \ + echo "Using the thin committed client instead (src/clients/v1.py)."; \ + fi + # ── Cleanup ─────────────────────────────────────────────────────────────────── clean: diff --git a/README.md b/README.md index 4dd4b87..843b687 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,7 @@ raglogs ingest --adapter loki --param query='{namespace="prod"}' \ ``` ```bash -curl -X POST http://localhost:8000/ingestions \ +curl -X POST http://localhost:8000/v1/ingestions \ -H "Content-Type: application/json" \ -d '{"adapter":"loki","params":{"query":"{app=\"api\"}"},"since":"1h"}' ``` @@ -833,7 +833,7 @@ Fingerprinting can still split one incident across multiple clusters when wordin - **Disabled (default).** `EMBEDDINGS_PROVIDER=disabled` skips the merge pass entirely. Clustering is fingerprint-only and deterministic — the same logs always produce the same clusters. - **Enabled.** With `openai` or `local`, representatives are embedded at analysis time (in memory; not written to pgvector). Pairs with cosine similarity ≥ `CLUSTER_MERGE_SIMILARITY_THRESHOLD` (default **0.92**) are merged via connected components. Merged `count` is the sum of member counts; services and levels are summed; `first_seen` is the earliest timestamp and `last_seen` the latest; importance is recomputed. The canonical fingerprint is the member with the highest importance score. `ClusterRun.algorithm` is `fingerprint+semantic` when embeddings were used, even if no pair crossed the threshold. - **Fail open.** If the embeddings backend is missing, raises, or returns unusable vectors, clustering continues with the fingerprint-only set. -- **Ask vs merge.** Semantic `ask` uses the *stored* `log_embeddings` table (populated by `raglogs ingest --with-embeddings`) and `ASK_SEMANTIC_MIN_SIMILARITY` (default **0.75**). Cluster merge still uses its own in-memory pass and threshold. Similar-incident search (`POST /query/similar` / historical cluster ANN) is not in this release. Compare still applies its own heuristic collapse for webhook retries / queue growth after clustering. +- **Ask vs merge.** Semantic `ask` uses the *stored* `log_embeddings` table (populated by `raglogs ingest --with-embeddings`) and `ASK_SEMANTIC_MIN_SIMILARITY` (default **0.75**). Cluster merge still uses its own in-memory pass and threshold. Similar-incident search (`POST /v1/query/similar` / historical cluster ANN) is not in this release. Compare still applies its own heuristic collapse for webhook retries / queue growth after clustering. Local embeddings require the optional extra: `pip install 'raglogs[local-embeddings]'` (`sentence-transformers`). If that import fails, merge is skipped. @@ -915,7 +915,7 @@ export AUTH_ENABLED=true raglogs keys create --role admin --name "local" # copy the rlk_… secret from the panel — it is shown only once -curl -X POST http://localhost:8000/query/explain \ +curl -X POST http://localhost:8000/v1/query/explain \ -H "Authorization: Bearer rlk_…" \ -H "Content-Type: application/json" \ -d '{"since": "30m", "no_llm": true}' @@ -923,9 +923,9 @@ curl -X POST http://localhost:8000/query/explain \ | Role | Allowed | |---|---| -| `ingest` | `POST /ingestions` | -| `query` | `GET /ingestions*`, `POST /query/*`, web UI (`GET /`, `/static`), OpenAPI (`/docs`) | -| `admin` | everything, including `GET /config` | +| `ingest` | `POST /v1/ingestions` (and the deprecated `/ingestions` alias) | +| `query` | `GET /v1/ingestions*`, `POST /v1/query/*`, web UI (`GET /`, `/static`), OpenAPI (`/docs`) | +| `admin` | everything, including `GET /v1/config` | `GET /health` and `GET /metrics` (path reserved; no Prometheus body yet) are always unauthenticated. `/docs` is **not** exempt. @@ -949,22 +949,28 @@ If auth is disabled and the process binds a non-loopback address (`0.0.0.0`, `:: | Method | Endpoint | Description | |---|---|---| -| `GET` | `/health` | Service and DB health check | -| `POST` | `/ingestions` | Enqueue an ingest job (`adapter`: `file`, `cloudwatch`, or `datadog`) | -| `GET` | `/ingestions` | List recent completed ingestion jobs, newest first | -| `GET` | `/ingestions/{job_id}` | Poll ingestion job status | -| `GET` | `/ingestions/latest` | ID of the most recently completed ingestion job, if any | -| `POST` | `/query/explain` | Explain a time window | -| `POST` | `/query/ask` | Answer a natural language question | -| `POST` | `/query/clusters` | List top clusters | -| `POST` | `/query/timeline` | Reconstruct incident timeline for a window | -| `POST` | `/query/compare` | Diff two time windows (same semantics as `raglogs compare`) | -| `GET` | `/config` | Read effective configuration | +| `GET` | `/health` | Service and DB health check (unversioned) | +| `POST` | `/v1/ingestions` | Enqueue an ingest job (`adapter`: `file`, `cloudwatch`, `datadog`, `loki`, or `k8s`) | +| `GET` | `/v1/ingestions` | List recent completed ingestion jobs, newest first | +| `GET` | `/v1/ingestions/{job_id}` | Poll ingestion job status | +| `GET` | `/v1/ingestions/latest` | ID of the most recently completed ingestion job, if any | +| `POST` | `/v1/query/explain` | Explain a time window | +| `POST` | `/v1/query/ask` | Answer a natural language question | +| `POST` | `/v1/query/clusters` | List top clusters | +| `POST` | `/v1/query/timeline` | Reconstruct incident timeline for a window | +| `POST` | `/v1/query/compare` | Diff two time windows (same semantics as `raglogs compare`) | +| `GET` | `/v1/config` | Read effective configuration | + +Unversioned `/ingestions`, `/query/*`, and `/config` remain as **deprecated aliases** for one release. They behave the same as the `/v1` paths and send `Deprecation: true` plus a `Link: ; rel="successor-version"` header. `/health`, the web UI (`/`), and `/static` stay unversioned. + +**Compatibility policy.** Additive changes stay in `v1`. Breaking path or method removals require `v2`. JSON response bodies are unchanged in this release (a stable evidence schema is tracked separately as G7). + +**OpenAPI and clients.** Export the spec with `make openapi` (`clients/openapi.json`). CI uploads that file as a workflow artifact and attaches it to GitHub Releases on tags. A thin typed Python client ships as `src.clients.v1.RaglogsClient` (targets `/v1`). `make client-go` runs [oapi-codegen](https://github.com/oapi-codegen/oapi-codegen) into `clients/go/` when the binary is installed; otherwise it prints the install command and exits 0. See `clients/README.md`. **Example** ```bash -curl -X POST http://localhost:8000/query/explain \ +curl -X POST http://localhost:8000/v1/query/explain \ -H "Content-Type: application/json" \ -d '{"since": "30m", "no_llm": true}' ``` @@ -987,20 +993,20 @@ curl -X POST http://localhost:8000/query/explain \ } ``` -**Explain** — `POST /query/explain` accepts the same window filters as the CLI. Optional `"format": "markdown"` adds a paste-ready `markdown` incident report field alongside the JSON payload (same shape as `raglogs explain --format markdown`). +**Explain** — `POST /v1/query/explain` accepts the same window filters as the CLI. Optional `"format": "markdown"` adds a paste-ready `markdown` incident report field alongside the JSON payload (same shape as `raglogs explain --format markdown`). -**Timeline** — `POST /query/timeline` accepts the same window filters as the CLI (`since` or `from_time`/`to_time`, optional `service`, `env`, `all_ingestions`, `ingestion_job_id`). Set `"format": "text"` to include a plain-text `text` field alongside `events`. +**Timeline** — `POST /v1/query/timeline` accepts the same window filters as the CLI (`since` or `from_time`/`to_time`, optional `service`, `env`, `all_ingestions`, `ingestion_job_id`). Set `"format": "text"` to include a plain-text `text` field alongside `events`. ```bash -curl -X POST http://localhost:8000/query/timeline \ +curl -X POST http://localhost:8000/v1/query/timeline \ -H "Content-Type: application/json" \ -d '{"since": "2h", "format": "json"}' ``` -**Compare** — `POST /query/compare` matches `raglogs compare`: either `"since"` + `"baseline"` (durations, window A ends at request time) or explicit `window_a_from` / `window_a_to` / `window_b_from` / `window_b_to`. Optional `"format": "text"` adds a rendered `text` field. +**Compare** — `POST /v1/query/compare` matches `raglogs compare`: either `"since"` + `"baseline"` (durations, window A ends at request time) or explicit `window_a_from` / `window_a_to` / `window_b_from` / `window_b_to`. Optional `"format": "text"` adds a rendered `text` field. ```bash -curl -X POST http://localhost:8000/query/compare \ +curl -X POST http://localhost:8000/v1/query/compare \ -H "Content-Type: application/json" \ -d '{"since": "30m", "baseline": "24h"}' ``` @@ -1028,11 +1034,11 @@ just the server. Pick a time window (presets or a duration like `2h`), then switch between the **Explain**, **Timeline**, **Compare**, and **Ask** tabs. The **ingestion** dropdown in the top bar lists your 25 most recent completed ingestions (via -`GET /ingestions`) and defaults to the latest one, matching the CLI; pick a +`GET /v1/ingestions`) and defaults to the latest one, matching the CLI; pick a different ingestion or "All ingestions" to change what a query is scoped to. The UI is server-rendered (Jinja2 + vanilla JS/CSS, no CORS, no node/npm) and -calls the same `/query/*` JSON endpoints listed above. +calls the same `/v1/query/*` JSON endpoints listed above. With default `AUTH_ENABLED=false` the UI is open — fine for local dev. When auth is on, load the UI with a `query` or `admin` bearer token (the browser @@ -1060,6 +1066,13 @@ make api make lint make format +# Export OpenAPI spec (clients/openapi.json) +make openapi + +# Optional generated clients (see clients/README.md) +make client-go +make client-python + # Full clean make clean ``` @@ -1073,6 +1086,7 @@ raglogs/ │ ├── api/routes/ FastAPI route handlers │ ├── api/auth/ API keys, roles, OIDC, bind-host guard │ ├── cli/commands/ Typer CLI commands +│ ├── clients/ Thin typed HTTP client targeting /v1 │ ├── config/ Pydantic settings │ ├── core/ │ │ ├── clustering/ Fingerprint grouping, semantic merge, importance scoring @@ -1088,6 +1102,7 @@ raglogs/ │ ├── db/ SQLAlchemy models, session management │ └── utils/ Time window parsing, hashing helpers ├── migrations/ Alembic migration scripts +├── clients/ OpenAPI spec + client codegen docs ├── sample_data/ Demo incident logs (deploy, billing, api) └── tests/ ├── unit/ Tests — parsers, normalization, clustering, time diff --git a/clients/README.md b/clients/README.md new file mode 100644 index 0000000..ca55276 --- /dev/null +++ b/clients/README.md @@ -0,0 +1,42 @@ +# OpenAPI and generated clients + +Canonical HTTP routes live under `/v1/`. Export the spec with: + +```bash +make openapi +# writes clients/openapi.json +``` + +`clients/openapi.json` is the published contract. CI uploads it as a workflow +artifact on every push/PR and attaches it to GitHub Releases on tags. + +## Python + +A thin typed httpx client ships in-tree: + +```python +from src.clients.v1 import RaglogsClient + +with RaglogsClient("http://localhost:8000", token="rlk_…") as client: + explanation = client.explain(since="30m", no_llm=True) +``` + +`make client-python` optionally runs `openapi-python-client` into +`clients/python/generated/` when that tool is installed. Generated trees are +gitignored; prefer the committed client for day-to-day use. + +```bash +pip install openapi-python-client # optional +make client-python +``` + +## Go + +```bash +go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest +make client-go +``` + +`make client-go` writes `clients/go/client.go` when `oapi-codegen` is on +`PATH`. If the binary is missing it prints the install command above and +exits 0 so CI is not required to have Go. diff --git a/clients/openapi.json b/clients/openapi.json new file mode 100644 index 0000000..c1d6818 --- /dev/null +++ b/clients/openapi.json @@ -0,0 +1,1762 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "raglogs", + "description": "Incident explanation API \u2014 ask your logs what happened.\n\nCanonical query, ingest, and config routes live under `/v1/`. Unversioned\n`/ingestions`, `/query`, and `/config` paths are deprecated aliases for one\nrelease and include a `Deprecation: true` header.\n\nCompatibility: additive changes stay in `v1`; breaking changes require `v2`.\nJSON response bodies are unchanged in this release.\n", + "version": "0.1.0" + }, + "paths": { + "/health": { + "get": { + "tags": [ + "health" + ], + "summary": "Health Check", + "operationId": "health_check_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/v1/ingestions": { + "get": { + "tags": [ + "ingestions" + ], + "summary": "List Ingestions", + "description": "List recent completed ingestion jobs, newest first. Used by the web UI's ingestion picker.", + "operationId": "v1_ingestions_list_ingestions__v1_ingestions", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IngestionListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "ingestions" + ], + "summary": "Create Ingestion", + "description": "Enqueue an ingest job. Returns immediately with worker_job_id.\nPoll GET /ingestions/jobs/{worker_job_id} for progress.\nWhen done, use ingestion_job_id from the result to scope /query/explain.", + "operationId": "v1_ingestions_create_ingestion__v1_ingestions", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IngestRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnqueuedResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/ingestions/jobs/{worker_job_id}": { + "get": { + "tags": [ + "ingestions" + ], + "summary": "Get Worker Job Status", + "description": "Poll the status of an enqueued ingest job.\nWhen status == 'done', result.ingestion_job_id is ready for /query/explain.", + "operationId": "v1_ingestions_get_worker_job_status__v1_ingestions_jobs__worker_job_id", + "parameters": [ + { + "name": "worker_job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Worker Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkerJobStatus" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/ingestions/latest": { + "get": { + "tags": [ + "ingestions" + ], + "summary": "Get Latest Ingestion", + "description": "Return the ID of the most recently completed ingestion job, or null if\nnone exists yet \u2014 the same default the CLI and GET /ingestions apply\n(latest ingestion, not all ingestions merged). Not currently called by\nthe web UI (its picker defaults to GET /ingestions[0] instead), but kept\nas a lighter-weight lookup for other integrations.\n\nRegistered before /{ingestion_job_id} so \"latest\" isn't swallowed by\nthat path param.", + "operationId": "v1_ingestions_get_latest_ingestion__v1_ingestions_latest", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LatestIngestionResponse" + } + } + } + } + } + } + }, + "/v1/ingestions/{ingestion_job_id}": { + "get": { + "tags": [ + "ingestions" + ], + "summary": "Get Ingestion Detail", + "description": "Fetch an IngestionJob record by its ID.", + "operationId": "v1_ingestions_get_ingestion_detail__v1_ingestions__ingestion_job_id", + "parameters": [ + { + "name": "ingestion_job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Ingestion Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IngestionJobDetail" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ingestions": { + "get": { + "tags": [ + "ingestions" + ], + "summary": "List Ingestions", + "description": "List recent completed ingestion jobs, newest first. Used by the web UI's ingestion picker.", + "operationId": "legacy_ingestions_list_ingestions__ingestions", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IngestionListResponse" + } + } + } + } + }, + "deprecated": true + }, + "post": { + "tags": [ + "ingestions" + ], + "summary": "Create Ingestion", + "description": "Enqueue an ingest job. Returns immediately with worker_job_id.\nPoll GET /ingestions/jobs/{worker_job_id} for progress.\nWhen done, use ingestion_job_id from the result to scope /query/explain.", + "operationId": "legacy_ingestions_create_ingestion__ingestions", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IngestRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnqueuedResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "deprecated": true + } + }, + "/ingestions/jobs/{worker_job_id}": { + "get": { + "tags": [ + "ingestions" + ], + "summary": "Get Worker Job Status", + "description": "Poll the status of an enqueued ingest job.\nWhen status == 'done', result.ingestion_job_id is ready for /query/explain.", + "operationId": "legacy_ingestions_get_worker_job_status__ingestions_jobs__worker_job_id", + "deprecated": true, + "parameters": [ + { + "name": "worker_job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Worker Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkerJobStatus" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ingestions/latest": { + "get": { + "tags": [ + "ingestions" + ], + "summary": "Get Latest Ingestion", + "description": "Return the ID of the most recently completed ingestion job, or null if\nnone exists yet \u2014 the same default the CLI and GET /ingestions apply\n(latest ingestion, not all ingestions merged). Not currently called by\nthe web UI (its picker defaults to GET /ingestions[0] instead), but kept\nas a lighter-weight lookup for other integrations.\n\nRegistered before /{ingestion_job_id} so \"latest\" isn't swallowed by\nthat path param.", + "operationId": "legacy_ingestions_get_latest_ingestion__ingestions_latest", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LatestIngestionResponse" + } + } + } + } + }, + "deprecated": true + } + }, + "/ingestions/{ingestion_job_id}": { + "get": { + "tags": [ + "ingestions" + ], + "summary": "Get Ingestion Detail", + "description": "Fetch an IngestionJob record by its ID.", + "operationId": "legacy_ingestions_get_ingestion_detail__ingestions__ingestion_job_id", + "deprecated": true, + "parameters": [ + { + "name": "ingestion_job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Ingestion Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IngestionJobDetail" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/query/explain": { + "post": { + "tags": [ + "query" + ], + "summary": "Explain Endpoint", + "operationId": "v1_query_explain_endpoint__v1_query_explain", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExplainRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/query/explain": { + "post": { + "tags": [ + "query" + ], + "summary": "Explain Endpoint", + "operationId": "legacy_query_explain_endpoint__query_explain", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExplainRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "deprecated": true + } + }, + "/v1/query/ask": { + "post": { + "tags": [ + "query" + ], + "summary": "Ask Endpoint", + "operationId": "v1_query_ask_endpoint__v1_query_ask", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AskRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/query/ask": { + "post": { + "tags": [ + "query" + ], + "summary": "Ask Endpoint", + "operationId": "legacy_query_ask_endpoint__query_ask", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AskRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "deprecated": true + } + }, + "/v1/query/clusters": { + "post": { + "tags": [ + "query" + ], + "summary": "Clusters Endpoint", + "operationId": "v1_query_clusters_endpoint__v1_query_clusters", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClustersRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/query/clusters": { + "post": { + "tags": [ + "query" + ], + "summary": "Clusters Endpoint", + "operationId": "legacy_query_clusters_endpoint__query_clusters", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClustersRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "deprecated": true + } + }, + "/v1/query/timeline": { + "post": { + "tags": [ + "query" + ], + "summary": "Timeline Endpoint", + "operationId": "v1_query_timeline_endpoint__v1_query_timeline", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimelineRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/query/timeline": { + "post": { + "tags": [ + "query" + ], + "summary": "Timeline Endpoint", + "operationId": "legacy_query_timeline_endpoint__query_timeline", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimelineRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "deprecated": true + } + }, + "/v1/query/compare": { + "post": { + "tags": [ + "query" + ], + "summary": "Compare Endpoint", + "operationId": "v1_query_compare_endpoint__v1_query_compare", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompareRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/query/compare": { + "post": { + "tags": [ + "query" + ], + "summary": "Compare Endpoint", + "operationId": "legacy_query_compare_endpoint__query_compare", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompareRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "deprecated": true + } + }, + "/v1/config": { + "get": { + "tags": [ + "config" + ], + "summary": "Get Config", + "operationId": "v1_config_get_config__v1_config", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/config": { + "get": { + "tags": [ + "config" + ], + "summary": "Get Config", + "operationId": "legacy_config_get_config__config", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "deprecated": true + } + } + }, + "components": { + "schemas": { + "AskRequest": { + "properties": { + "question": { + "type": "string", + "title": "Question" + }, + "since": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + }, + "from_time": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "From Time" + }, + "to_time": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "To Time" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Service" + }, + "ingestion_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ingestion Job Id" + } + }, + "type": "object", + "required": [ + "question" + ], + "title": "AskRequest" + }, + "ClustersRequest": { + "properties": { + "since": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + }, + "from_time": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "From Time" + }, + "to_time": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "To Time" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Service" + }, + "env": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Env" + }, + "top": { + "type": "integer", + "title": "Top", + "default": 15 + }, + "ingestion_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ingestion Job Id" + } + }, + "type": "object", + "title": "ClustersRequest" + }, + "CompareRequest": { + "properties": { + "since": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + }, + "baseline": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Baseline" + }, + "window_a_from": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Window A From" + }, + "window_a_to": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Window A To" + }, + "window_b_from": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Window B From" + }, + "window_b_to": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Window B To" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Service" + }, + "env": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Env" + }, + "all_ingestions": { + "type": "boolean", + "title": "All Ingestions", + "default": false + }, + "ingestion_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ingestion Job Id" + }, + "format": { + "type": "string", + "enum": [ + "json", + "text" + ], + "title": "Format", + "default": "json" + } + }, + "type": "object", + "title": "CompareRequest" + }, + "EnqueuedResponse": { + "properties": { + "worker_job_id": { + "type": "string", + "title": "Worker Job Id" + }, + "status": { + "type": "string", + "title": "Status" + } + }, + "type": "object", + "required": [ + "worker_job_id", + "status" + ], + "title": "EnqueuedResponse" + }, + "ExplainRequest": { + "properties": { + "since": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + }, + "from_time": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "From Time" + }, + "to_time": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "To Time" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Service" + }, + "env": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Env" + }, + "no_llm": { + "type": "boolean", + "title": "No Llm", + "default": false + }, + "max_clusters": { + "type": "integer", + "title": "Max Clusters", + "default": 10 + }, + "baseline_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Baseline Window" + }, + "ingestion_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ingestion Job Id" + }, + "force_refresh": { + "type": "boolean", + "title": "Force Refresh", + "default": false + }, + "format": { + "type": "string", + "enum": [ + "json", + "markdown" + ], + "title": "Format", + "default": "json" + } + }, + "type": "object", + "title": "ExplainRequest" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "HealthResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "db": { + "type": "string", + "title": "Db" + }, + "worker_queue_depth": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Worker Queue Depth" + }, + "adapters": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Adapters" + } + }, + "type": "object", + "required": [ + "status", + "db", + "worker_queue_depth", + "adapters" + ], + "title": "HealthResponse" + }, + "IngestRequest": { + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Paths", + "default": [] + }, + "recursive": { + "type": "boolean", + "title": "Recursive", + "default": false + }, + "format": { + "type": "string", + "title": "Format", + "default": "auto" + }, + "source_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Name" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Service" + }, + "env": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Env" + }, + "adapter": { + "type": "string", + "title": "Adapter", + "default": "file" + }, + "params": { + "additionalProperties": true, + "type": "object", + "title": "Params", + "default": {} + }, + "since": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + }, + "from_time": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "From Time" + }, + "to_time": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "To Time" + }, + "resume_ingestion_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Resume Ingestion Job Id" + }, + "with_embeddings": { + "type": "boolean", + "title": "With Embeddings", + "default": false + } + }, + "type": "object", + "title": "IngestRequest" + }, + "IngestionJobDetail": { + "properties": { + "ingestion_job_id": { + "type": "string", + "title": "Ingestion Job Id" + }, + "status": { + "type": "string", + "title": "Status" + }, + "file_count": { + "type": "integer", + "title": "File Count" + }, + "line_count": { + "type": "integer", + "title": "Line Count" + }, + "parsed_count": { + "type": "integer", + "title": "Parsed Count" + }, + "error_count": { + "type": "integer", + "title": "Error Count" + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Message" + }, + "source_adapter": { + "type": "string", + "title": "Source Adapter" + }, + "source_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Ref" + }, + "created_at": { + "type": "string", + "title": "Created At" + }, + "started_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Started At" + }, + "finished_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Finished At" + } + }, + "type": "object", + "required": [ + "ingestion_job_id", + "status", + "file_count", + "line_count", + "parsed_count", + "error_count", + "error_message", + "source_adapter", + "source_ref", + "created_at", + "started_at", + "finished_at" + ], + "title": "IngestionJobDetail" + }, + "IngestionListResponse": { + "properties": { + "ingestions": { + "items": { + "$ref": "#/components/schemas/IngestionSummary" + }, + "type": "array", + "title": "Ingestions" + } + }, + "type": "object", + "required": [ + "ingestions" + ], + "title": "IngestionListResponse" + }, + "IngestionSummary": { + "properties": { + "ingestion_job_id": { + "type": "string", + "title": "Ingestion Job Id" + }, + "source_name": { + "type": "string", + "title": "Source Name" + }, + "parsed_count": { + "type": "integer", + "title": "Parsed Count" + }, + "finished_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Finished At" + } + }, + "type": "object", + "required": [ + "ingestion_job_id", + "source_name", + "parsed_count", + "finished_at" + ], + "title": "IngestionSummary" + }, + "LatestIngestionResponse": { + "properties": { + "ingestion_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ingestion Job Id" + } + }, + "type": "object", + "required": [ + "ingestion_job_id" + ], + "title": "LatestIngestionResponse" + }, + "TimelineRequest": { + "properties": { + "since": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + }, + "from_time": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "From Time" + }, + "to_time": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "To Time" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Service" + }, + "env": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Env" + }, + "all_ingestions": { + "type": "boolean", + "title": "All Ingestions", + "default": false + }, + "ingestion_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ingestion Job Id" + }, + "format": { + "type": "string", + "enum": [ + "json", + "text" + ], + "title": "Format", + "default": "json" + } + }, + "type": "object", + "title": "TimelineRequest" + }, + "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" + }, + "WorkerJobStatus": { + "properties": { + "worker_job_id": { + "type": "string", + "title": "Worker Job Id" + }, + "status": { + "type": "string", + "title": "Status" + }, + "ingestion_job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ingestion Job Id" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "created_at": { + "type": "string", + "title": "Created At" + }, + "started_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Started At" + }, + "finished_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Finished At" + }, + "result": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Result" + } + }, + "type": "object", + "required": [ + "worker_job_id", + "status", + "ingestion_job_id", + "error", + "created_at", + "started_at", + "finished_at", + "result" + ], + "title": "WorkerJobStatus" + } + } + } +} diff --git a/clients/python/raglogs_client.py b/clients/python/raglogs_client.py new file mode 100644 index 0000000..cfa7b38 --- /dev/null +++ b/clients/python/raglogs_client.py @@ -0,0 +1,8 @@ +# Re-export of the committed typed Python client (targets /v1). +# +# Prefer: from src.clients.v1 import RaglogsClient +# Optional generated dump: make client-python → clients/python/generated/ + +from src.clients.v1 import RaglogsAPIError, RaglogsClient + +__all__ = ["RaglogsAPIError", "RaglogsClient"] diff --git a/scripts/export_openapi.py b/scripts/export_openapi.py new file mode 100644 index 0000000..3962d79 --- /dev/null +++ b/scripts/export_openapi.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Export the FastAPI OpenAPI schema to clients/openapi.json. + +Works the same way CI tests do: ``PYTHONPATH=. python scripts/export_openapi.py``. +Does not start a server or connect to the database. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.api.app import app # noqa: E402 + + +def main() -> None: + out_dir = ROOT / "clients" + out_dir.mkdir(parents=True, exist_ok=True) + out = out_dir / "openapi.json" + schema = app.openapi() + out.write_text(json.dumps(schema, indent=2) + "\n", encoding="utf-8") + print(f"Wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/src/api/app.py b/src/api/app.py index 3f39e21..1e1af07 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -1,16 +1,30 @@ +from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from pathlib import Path +import re -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI from fastapi.responses import ORJSONResponse +from fastapi.routing import APIRoute from fastapi.staticfiles import StaticFiles from src.api.auth.middleware import AuthMiddleware +from src.api.deprecation import DeprecationHeaderMiddleware from src.api.routes import ask, clusters, compare_windows, config, explain, health, ingestions, timeline, ui +_OPENAPI_DESCRIPTION = """Incident explanation API — ask your logs what happened. + +Canonical query, ingest, and config routes live under `/v1/`. Unversioned +`/ingestions`, `/query`, and `/config` paths are deprecated aliases for one +release and include a `Deprecation: true` header. + +Compatibility: additive changes stay in `v1`; breaking changes require `v2`. +JSON response bodies are unchanged in this release. +""" + @asynccontextmanager -async def lifespan(app: FastAPI): +async def lifespan(app: FastAPI) -> AsyncIterator[None]: from src.api.auth.bind_guard import warn_if_insecure_bind from src.config import get_settings from src.db.session import check_connection @@ -25,26 +39,58 @@ async def lifespan(app: FastAPI): yield +def _unique_id(mount_prefix: str) -> Callable[[APIRoute], str]: + """Build OpenAPI operationIds that include the mount prefix (v1 vs alias).""" + slug = mount_prefix.strip("/").replace("/", "_") or "root" + + def generate(route: APIRoute) -> str: + raw = f"{slug}_{route.name}_{route.path_format}" + return re.sub(r"\W", "_", raw).strip("_") + + return generate + + app = FastAPI( title="raglogs", - description="Incident explanation API — ask your logs what happened.", + description=_OPENAPI_DESCRIPTION, version="0.1.0", default_response_class=ORJSONResponse, lifespan=lifespan, ) +# Last added middleware is outermost: deprecation headers apply even to auth errors. app.add_middleware(AuthMiddleware) +app.add_middleware(DeprecationHeaderMiddleware) app.include_router(health.router, tags=["health"]) -app.include_router(ingestions.router, prefix="/ingestions", tags=["ingestions"]) -app.include_router(explain.router, prefix="/query", tags=["query"]) -app.include_router(ask.router, prefix="/query", tags=["query"]) -app.include_router(clusters.router, prefix="/query", tags=["query"]) -app.include_router(timeline.router, prefix="/query", tags=["query"]) -app.include_router(compare_windows.router, prefix="/query", tags=["query"]) -app.include_router(config.router, prefix="/config", tags=["config"]) app.include_router(ui.router, tags=["ui"]) + +def _include_v1_and_alias(router: APIRouter, *, suffix: str, tags: list[str]) -> None: + """Mount a router at ``/v1{suffix}`` (canonical) and ``{suffix}`` (deprecated).""" + app.include_router( + router, + prefix=f"/v1{suffix}", + tags=tags, + generate_unique_id_function=_unique_id(f"v1{suffix}"), + ) + app.include_router( + router, + prefix=suffix, + tags=tags, + deprecated=True, + generate_unique_id_function=_unique_id(f"legacy{suffix}"), + ) + + +_include_v1_and_alias(ingestions.router, suffix="/ingestions", tags=["ingestions"]) +_include_v1_and_alias(explain.router, suffix="/query", tags=["query"]) +_include_v1_and_alias(ask.router, suffix="/query", tags=["query"]) +_include_v1_and_alias(clusters.router, suffix="/query", tags=["query"]) +_include_v1_and_alias(timeline.router, suffix="/query", tags=["query"]) +_include_v1_and_alias(compare_windows.router, suffix="/query", tags=["query"]) +_include_v1_and_alias(config.router, suffix="/config", tags=["config"]) + app.mount( "/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), diff --git a/src/api/auth/roles.py b/src/api/auth/roles.py index bc0f22b..bdd8d9b 100644 --- a/src/api/auth/roles.py +++ b/src/api/auth/roles.py @@ -3,6 +3,9 @@ `admin` is included in every non-exempt set. Scope is stored on keys for later G8 isolation; this module does not filter log queries by scope. +A leading `/v1` or `/v2` (any `/v`) is stripped before matching, so +`POST /v1/ingestions` uses the same roles as `POST /ingestions`. + Exempt (no auth): GET/HEAD `/health`, `/metrics` (prefix match). `/docs` is not exempt — OpenAPI can leak adapter-oriented config. @@ -21,11 +24,14 @@ from __future__ import annotations +import re + INGEST_ROLES: frozenset[str] = frozenset({"ingest", "admin"}) QUERY_ROLES: frozenset[str] = frozenset({"query", "admin"}) ADMIN_ROLES: frozenset[str] = frozenset({"admin"}) EXEMPT_PREFIXES: tuple[str, ...] = ("/health", "/metrics") +_API_VERSION_PREFIX = re.compile(r"^/v\d+(?=/|$)") def _normalize_path(path: str) -> str: @@ -36,9 +42,15 @@ def _normalize_path(path: str) -> str: return path +def _strip_api_version(path: str) -> str: + """Map `/v1/query/explain` onto `/query/explain` for role matching.""" + stripped = _API_VERSION_PREFIX.sub("", path, count=1) + return stripped if stripped else "/" + + def is_exempt_path(path: str) -> bool: """True for `/health` and `/metrics` (and nested paths under those).""" - normalized = _normalize_path(path) + normalized = _strip_api_version(_normalize_path(path)) for prefix in EXEMPT_PREFIXES: if normalized == prefix or normalized.startswith(prefix + "/"): return True @@ -50,7 +62,7 @@ def required_roles(method: str, path: str) -> frozenset[str] | None: if is_exempt_path(path): return None - normalized = _normalize_path(path) + normalized = _strip_api_version(_normalize_path(path)) verb = method.upper() if normalized == "/ingestions" or normalized.startswith("/ingestions/"): diff --git a/src/api/deprecation.py b/src/api/deprecation.py new file mode 100644 index 0000000..358100f --- /dev/null +++ b/src/api/deprecation.py @@ -0,0 +1,67 @@ +"""Mark unversioned ingest/query/config aliases as deprecated. + +Canonical routes live under ``/v1/``. Unversioned ``/ingestions``, ``/query``, +and ``/config`` stay mounted for one release and send ``Deprecation: true`` +plus a ``Link`` successor-version header. Health, the web UI, and ``/static`` +are not deprecated. +""" + +from __future__ import annotations + +import re +from collections.abc import Awaitable, Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +_DEPRECATED_ROOTS: tuple[str, ...] = ("/ingestions", "/query", "/config") +_API_VERSION_PREFIX = re.compile(r"^/v\d+(?=/|$)") + + +def _normalize_path(path: str) -> str: + if not path: + return "/" + if len(path) > 1 and path.endswith("/"): + return path.rstrip("/") + return path + + +def is_versioned_api_path(path: str) -> bool: + """True for ``/v1``, ``/v2``, … and anything under those prefixes.""" + return _API_VERSION_PREFIX.match(_normalize_path(path)) is not None + + +def is_deprecated_alias(path: str) -> bool: + """True for unversioned ingest/query/config paths (not ``/v1/...``).""" + normalized = _normalize_path(path) + if is_versioned_api_path(normalized): + return False + for root in _DEPRECATED_ROOTS: + if normalized == root or normalized.startswith(root + "/"): + return True + return False + + +def successor_path(path: str) -> str: + """Map an unversioned alias onto its canonical ``/v1`` path.""" + normalized = _normalize_path(path) + if is_versioned_api_path(normalized): + return normalized + return "/v1" + normalized + + +class DeprecationHeaderMiddleware(BaseHTTPMiddleware): + """Attach RFC 8594 deprecation headers on unversioned API aliases.""" + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + response = await call_next(request) + path = request.url.path + if is_deprecated_alias(path): + response.headers["Deprecation"] = "true" + response.headers["Link"] = f'<{successor_path(path)}>; rel="successor-version"' + return response diff --git a/src/api/static/js/app.js b/src/api/static/js/app.js index 3965b9e..ddf7d3d 100644 --- a/src/api/static/js/app.js +++ b/src/api/static/js/app.js @@ -38,7 +38,7 @@ function formatErrorDetail(detail, fallback) { async function loadIngestions() { const select = document.getElementById("ingestion-select"); try { - const resp = await fetch("/ingestions"); + const resp = await fetch("/v1/ingestions"); const data = await resp.json(); if (!resp.ok) { throw new Error(formatErrorDetail(data.detail, "Failed to load ingestions")); diff --git a/src/api/templates/index.html b/src/api/templates/index.html index 8637616..d465502 100644 --- a/src/api/templates/index.html +++ b/src/api/templates/index.html @@ -28,7 +28,7 @@
-
+
@@ -47,7 +47,7 @@
- +
@@ -66,7 +66,7 @@
- +
@@ -86,7 +86,7 @@
- +
diff --git a/src/clients/__init__.py b/src/clients/__init__.py new file mode 100644 index 0000000..3f6513b --- /dev/null +++ b/src/clients/__init__.py @@ -0,0 +1,5 @@ +"""Typed HTTP clients for the raglogs API.""" + +from src.clients.v1 import RaglogsAPIError, RaglogsClient + +__all__ = ["RaglogsAPIError", "RaglogsClient"] diff --git a/src/clients/v1.py b/src/clients/v1.py new file mode 100644 index 0000000..559392a --- /dev/null +++ b/src/clients/v1.py @@ -0,0 +1,253 @@ +"""Thin typed httpx client targeting the canonical ``/v1`` HTTP API. + +JSON response shapes are unchanged from the unversioned aliases; this client +only pins the URL prefix. Generate a fuller client with ``make client-go`` / +``make client-python`` from ``clients/openapi.json``. +""" + +from __future__ import annotations + +from typing import Any, Optional + +import httpx + + +class RaglogsAPIError(Exception): + """Raised when the API returns a non-success status code.""" + + def __init__(self, status_code: int, body: Any) -> None: + self.status_code = status_code + self.body = body + super().__init__(f"HTTP {status_code}: {body}") + + +def _omit_none(payload: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in payload.items() if value is not None} + + +class RaglogsClient: + """Synchronous httpx client for raglogs ``/v1`` query and ingest routes.""" + + def __init__( + self, + base_url: str = "http://localhost:8000", + token: Optional[str] = None, + timeout: float = 30.0, + client: Optional[httpx.Client] = None, + ) -> None: + self._base_url = base_url.rstrip("/") + self._token = token + self._owns_client = client is None + self._client = client or httpx.Client(timeout=timeout) + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def __enter__(self) -> RaglogsClient: + return self + + def __exit__(self, *args: object) -> None: + self.close() + + def _headers(self) -> dict[str, str]: + headers = {"Accept": "application/json"} + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + return headers + + def _url(self, path: str) -> str: + if not path.startswith("/"): + path = "/" + path + if not self._base_url: + return path + return self._base_url + path + + def _request( + self, + method: str, + path: str, + json_body: Optional[dict[str, Any]] = None, + ) -> Any: + response = self._client.request( + method, + self._url(path), + headers=self._headers(), + json=json_body, + ) + if response.status_code >= 400: + try: + body: Any = response.json() + except ValueError: + body = response.text + raise RaglogsAPIError(response.status_code, body) + if response.status_code == 204 or not response.content: + return None + return response.json() + + def _post(self, path: str, body: dict[str, Any]) -> Any: + return self._request("POST", path, json_body=_omit_none(body)) + + def _get(self, path: str) -> Any: + return self._request("GET", path) + + def health(self) -> dict[str, Any]: + """GET /health (unversioned).""" + return self._get("/health") + + def get_config(self) -> dict[str, Any]: + """GET /v1/config.""" + return self._get("/v1/config") + + def create_ingestion(self, **body: Any) -> dict[str, Any]: + """POST /v1/ingestions — enqueue an ingest job.""" + return self._post("/v1/ingestions", body) + + def list_ingestions(self) -> dict[str, Any]: + """GET /v1/ingestions.""" + return self._get("/v1/ingestions") + + def get_ingestion(self, ingestion_job_id: str) -> dict[str, Any]: + """GET /v1/ingestions/{ingestion_job_id}.""" + return self._get(f"/v1/ingestions/{ingestion_job_id}") + + def explain( + self, + *, + since: Optional[str] = None, + from_time: Optional[str] = None, + to_time: Optional[str] = None, + service: Optional[str] = None, + env: Optional[str] = None, + no_llm: bool = False, + max_clusters: int = 10, + baseline_window: Optional[str] = None, + ingestion_job_id: Optional[str] = None, + force_refresh: bool = False, + format: str = "json", + ) -> dict[str, Any]: + """POST /v1/query/explain.""" + return self._post( + "/v1/query/explain", + { + "since": since, + "from_time": from_time, + "to_time": to_time, + "service": service, + "env": env, + "no_llm": no_llm, + "max_clusters": max_clusters, + "baseline_window": baseline_window, + "ingestion_job_id": ingestion_job_id, + "force_refresh": force_refresh, + "format": format, + }, + ) + + def ask( + self, + *, + question: str, + since: Optional[str] = None, + from_time: Optional[str] = None, + to_time: Optional[str] = None, + service: Optional[str] = None, + ingestion_job_id: Optional[str] = None, + ) -> dict[str, Any]: + """POST /v1/query/ask.""" + return self._post( + "/v1/query/ask", + { + "question": question, + "since": since, + "from_time": from_time, + "to_time": to_time, + "service": service, + "ingestion_job_id": ingestion_job_id, + }, + ) + + def clusters( + self, + *, + since: Optional[str] = None, + from_time: Optional[str] = None, + to_time: Optional[str] = None, + service: Optional[str] = None, + env: Optional[str] = None, + top: int = 15, + ingestion_job_id: Optional[str] = None, + ) -> dict[str, Any]: + """POST /v1/query/clusters.""" + return self._post( + "/v1/query/clusters", + { + "since": since, + "from_time": from_time, + "to_time": to_time, + "service": service, + "env": env, + "top": top, + "ingestion_job_id": ingestion_job_id, + }, + ) + + def timeline( + self, + *, + since: Optional[str] = None, + from_time: Optional[str] = None, + to_time: Optional[str] = None, + service: Optional[str] = None, + env: Optional[str] = None, + all_ingestions: bool = False, + ingestion_job_id: Optional[str] = None, + format: str = "json", + ) -> dict[str, Any]: + """POST /v1/query/timeline.""" + return self._post( + "/v1/query/timeline", + { + "since": since, + "from_time": from_time, + "to_time": to_time, + "service": service, + "env": env, + "all_ingestions": all_ingestions, + "ingestion_job_id": ingestion_job_id, + "format": format, + }, + ) + + def compare( + self, + *, + since: Optional[str] = None, + baseline: Optional[str] = None, + window_a_from: Optional[str] = None, + window_a_to: Optional[str] = None, + window_b_from: Optional[str] = None, + window_b_to: Optional[str] = None, + service: Optional[str] = None, + env: Optional[str] = None, + all_ingestions: bool = False, + ingestion_job_id: Optional[str] = None, + format: str = "json", + ) -> dict[str, Any]: + """POST /v1/query/compare.""" + return self._post( + "/v1/query/compare", + { + "since": since, + "baseline": baseline, + "window_a_from": window_a_from, + "window_a_to": window_a_to, + "window_b_from": window_b_from, + "window_b_to": window_b_to, + "service": service, + "env": env, + "all_ingestions": all_ingestions, + "ingestion_job_id": ingestion_job_id, + "format": format, + }, + ) diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 487a6da..ae47887 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -485,6 +485,16 @@ def test_static_js_served(self): resp = client.get("/static/js/app.js") assert resp.status_code == 200 + def test_ui_calls_versioned_endpoints(self): + html = client.get("/") + assert "/v1/query/explain" in html.text + assert "/v1/query/timeline" in html.text + assert "/v1/query/compare" in html.text + assert "/v1/query/ask" in html.text + js = client.get("/static/js/app.js") + assert "/v1/ingestions" in js.text + assert 'fetch("/ingestions")' not in js.text + # ── POST /query/explain ─────────────────────────────────────────────────────── @@ -868,3 +878,121 @@ def test_none_and_empty_ingestion_id_differ(self): ws = datetime(2026, 3, 12, 13, 0, 0, tzinfo=timezone.utc) we = datetime(2026, 3, 12, 14, 0, 0, tzinfo=timezone.utc) assert _cache_key(ws, we, None, None, None) != _cache_key(ws, we, None, None, "") + + +# ── /v1 aliases and deprecation headers ─────────────────────────────────────── + +class TestAPIVersioning: + def test_v1_explain_matches_unversioned_body(self): + mock_result = MagicMock() + mock_result.window_start = datetime(2026, 3, 12, 13, 0, 0, tzinfo=timezone.utc) + mock_result.window_end = datetime(2026, 3, 12, 14, 0, 0, tzinfo=timezone.utc) + mock_result.summary_text = "Incident summary" + mock_result.confidence = "high" + mock_result.mode = "rules" + mock_result.total_logs = 10 + mock_result.services_affected = ["api"] + mock_result.primary_cluster = None + mock_result.secondary_clusters = [] + mock_result.trigger_candidates = [] + mock_result.evidence_items = [] + + mock_db = _ctx_db() + with patch("src.db.session.get_db", side_effect=lambda: mock_db), \ + patch("src.core.explain.summarizer.explain_window", return_value=mock_result), \ + patch("src.api.routes.explain._load_from_cache", return_value=None), \ + patch("src.api.routes.explain._save_to_cache"): + unversioned = client.post("/query/explain", json={"since": "1h"}) + versioned = client.post("/v1/query/explain", json={"since": "1h"}) + + assert unversioned.status_code == 200 + assert versioned.status_code == 200 + assert unversioned.json() == versioned.json() + + def test_unversioned_explain_sends_deprecation_header(self): + mock_db = _ctx_db() + result = MagicMock() + result.window_start = datetime(2026, 3, 12, 13, 0, 0, tzinfo=timezone.utc) + result.window_end = datetime(2026, 3, 12, 14, 0, 0, tzinfo=timezone.utc) + result.summary_text = "x" + result.confidence = "low" + result.mode = "rules" + result.total_logs = 0 + result.services_affected = [] + result.primary_cluster = None + result.secondary_clusters = [] + result.trigger_candidates = [] + result.evidence_items = [] + with patch("src.db.session.get_db", side_effect=lambda: mock_db), \ + patch("src.core.explain.summarizer.explain_window", return_value=result), \ + patch("src.api.routes.explain._load_from_cache", return_value=None), \ + patch("src.api.routes.explain._save_to_cache"): + resp = client.post("/query/explain", json={"since": "1h"}) + + assert resp.status_code == 200 + assert resp.headers.get("deprecation") == "true" + assert "/v1/query/explain" in resp.headers.get("link", "") + assert "successor-version" in resp.headers.get("link", "") + + def test_v1_explain_has_no_deprecation_header(self): + mock_db = _ctx_db() + result = MagicMock() + result.window_start = datetime(2026, 3, 12, 13, 0, 0, tzinfo=timezone.utc) + result.window_end = datetime(2026, 3, 12, 14, 0, 0, tzinfo=timezone.utc) + result.summary_text = "x" + result.confidence = "low" + result.mode = "rules" + result.total_logs = 0 + result.services_affected = [] + result.primary_cluster = None + result.secondary_clusters = [] + result.trigger_candidates = [] + result.evidence_items = [] + with patch("src.db.session.get_db", side_effect=lambda: mock_db), \ + patch("src.core.explain.summarizer.explain_window", return_value=result), \ + patch("src.api.routes.explain._load_from_cache", return_value=None), \ + patch("src.api.routes.explain._save_to_cache"): + resp = client.post("/v1/query/explain", json={"since": "1h"}) + + assert resp.status_code == 200 + assert "deprecation" not in resp.headers + + def test_unversioned_config_sends_deprecation_header(self): + resp = client.get("/config") + assert resp.status_code == 200 + assert resp.headers.get("deprecation") == "true" + assert resp.headers.get("link") == '; rel="successor-version"' + + def test_v1_config_has_no_deprecation_header(self): + resp = client.get("/v1/config") + assert resp.status_code == 200 + assert resp.json() == client.get("/config").json() + assert "deprecation" not in resp.headers + + def test_health_and_ui_are_not_deprecated(self): + with patch("src.db.session.check_connection", return_value=False): + health = client.get("/health") + index = client.get("/") + static = client.get("/static/js/app.js") + assert health.status_code == 200 + assert "deprecation" not in health.headers + assert "deprecation" not in index.headers + assert "deprecation" not in static.headers + + def test_v1_ingestions_post_still_202(self): + wj_id = str(uuid.uuid4()) + mock_db = _ctx_db() + + def capture_add(obj): + obj.id = uuid.UUID(wj_id) + + mock_db.add.side_effect = capture_add + + with patch("src.adapters.file.adapter.discover_files", return_value=["f.log"]), \ + patch("src.db.session.get_db", side_effect=lambda: mock_db): + resp = client.post("/v1/ingestions", json={"paths": ["/logs"]}) + + assert resp.status_code == 202 + assert resp.json()["status"] == "pending" + assert "deprecation" not in resp.headers + diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 88075ba..aaf2d3f 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -138,6 +138,17 @@ def test_query_and_config_and_ui(self) -> None: assert required_roles("GET", "/") == frozenset({"query", "admin"}) assert required_roles("GET", "/static/js/app.js") == frozenset({"query", "admin"}) + def test_versioned_paths_match_unversioned_roles(self) -> None: + assert required_roles("POST", "/v1/ingestions") == frozenset({"ingest", "admin"}) + assert required_roles("GET", "/v1/ingestions") == frozenset({"query", "admin"}) + assert required_roles("GET", "/v1/ingestions/latest") == frozenset({"query", "admin"}) + assert required_roles("POST", "/v1/query/explain") == frozenset({"query", "admin"}) + assert required_roles("POST", "/v1/query/ask") == frozenset({"query", "admin"}) + assert required_roles("GET", "/v1/config") == frozenset({"admin"}) + assert required_roles("POST", "/v2/query/explain") == frozenset({"query", "admin"}) + assert required_roles("GET", "/health") is None + assert required_roles("GET", "/v1/health") is None + # ── Bind-host guard ─────────────────────────────────────────────────────────── @@ -295,6 +306,52 @@ def test_query_role_cannot_get_config(self) -> None: resp = client.get("/config", headers={"Authorization": "Bearer rlk_queryrolexx"}) assert resp.status_code == 403 + def test_v1_explain_401_without_header_when_auth_enabled(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()): + resp = client.post("/v1/query/explain", json={"since": "1h"}) + assert resp.status_code == 401 + assert resp.json()["error_code"] == "AUTH_UNAUTHORIZED" + assert "deprecation" not in resp.headers + + def test_unversioned_explain_401_still_deprecated(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()): + resp = client.post("/query/explain", json={"since": "1h"}) + assert resp.status_code == 401 + assert resp.headers.get("deprecation") == "true" + + def test_query_role_can_post_v1_explain(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()), \ + patch("src.api.auth.keys.lookup_api_key", return_value=_key("query")): + resp = client.post( + "/v1/query/explain", + json={}, + headers={"Authorization": "Bearer rlk_queryrolexx"}, + ) + assert resp.status_code == 400 + assert resp.status_code != 403 + + def test_query_role_cannot_post_v1_ingestions(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()), \ + patch("src.api.auth.keys.lookup_api_key", return_value=_key("query")): + resp = client.post( + "/v1/ingestions", + json={"paths": ["/logs"]}, + headers={"Authorization": "Bearer rlk_queryrolexx"}, + ) + assert resp.status_code == 403 + assert resp.json()["error_code"] == "AUTH_FORBIDDEN" + + def test_ingest_role_can_post_v1_ingestions(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()), \ + patch("src.api.auth.keys.lookup_api_key", return_value=_key("ingest")): + resp = client.post( + "/v1/ingestions", + json={}, + headers={"Authorization": "Bearer rlk_ingestrole1"}, + ) + assert resp.status_code == 422 + assert resp.status_code != 403 + def test_docs_not_exempt(self) -> None: with patch("src.config.get_settings", return_value=_auth_settings()): resp = client.get("/docs") diff --git a/tests/unit/test_openapi_contract.py b/tests/unit/test_openapi_contract.py new file mode 100644 index 0000000..133ffef --- /dev/null +++ b/tests/unit/test_openapi_contract.py @@ -0,0 +1,78 @@ +"""OpenAPI contract tests — fail CI if a canonical /v1 path is removed. + +Loads the schema from ``app.openapi()`` (no live server). JSON response +shapes are not asserted here; that is G7. +""" + +from __future__ import annotations + +from src.api.app import app +from src.api.deprecation import is_deprecated_alias, is_versioned_api_path, successor_path + +REQUIRED_GET: tuple[str, ...] = ( + "/health", + "/v1/ingestions", + "/v1/config", +) + +REQUIRED_POST: tuple[str, ...] = ( + "/v1/query/explain", + "/v1/query/ask", + "/v1/query/timeline", + "/v1/query/compare", + "/v1/query/clusters", + "/v1/ingestions", +) + + +def test_canonical_v1_paths_exist() -> None: + paths = app.openapi()["paths"] + for path in (*REQUIRED_GET, *REQUIRED_POST): + assert path in paths, f"missing OpenAPI path {path}" + + +def test_required_methods() -> None: + paths = app.openapi()["paths"] + for path in REQUIRED_GET: + assert "get" in paths[path], f"{path} must allow GET" + for path in REQUIRED_POST: + assert "post" in paths[path], f"{path} must allow POST" + + +def test_v1_operations_are_not_deprecated() -> None: + paths = app.openapi()["paths"] + explain = paths["/v1/query/explain"]["post"] + assert not explain.get("deprecated") + ingest = paths["/v1/ingestions"]["post"] + assert not ingest.get("deprecated") + + +def test_unversioned_aliases_are_deprecated_in_schema() -> None: + paths = app.openapi()["paths"] + assert "/query/explain" in paths + assert paths["/query/explain"]["post"].get("deprecated") is True + assert paths["/ingestions"]["post"].get("deprecated") is True + assert paths["/config"]["get"].get("deprecated") is True + + +def test_health_is_unversioned() -> None: + paths = app.openapi()["paths"] + assert "/health" in paths + assert "/v1/health" not in paths + assert "get" in paths["/health"] + + +def test_deprecation_helpers() -> None: + assert is_deprecated_alias("/query/explain") is True + assert is_deprecated_alias("/ingestions") is True + assert is_deprecated_alias("/config") is True + assert is_deprecated_alias("/v1/query/explain") is False + assert is_deprecated_alias("/v1/ingestions") is False + assert is_deprecated_alias("/health") is False + assert is_deprecated_alias("/") is False + assert is_deprecated_alias("/static/js/app.js") is False + assert is_versioned_api_path("/v1/query/explain") is True + assert is_versioned_api_path("/v2/ingestions") is True + assert is_versioned_api_path("/query/explain") is False + assert successor_path("/query/explain") == "/v1/query/explain" + assert successor_path("/ingestions/latest") == "/v1/ingestions/latest" diff --git a/tests/unit/test_v1_client.py b/tests/unit/test_v1_client.py new file mode 100644 index 0000000..f464149 --- /dev/null +++ b/tests/unit/test_v1_client.py @@ -0,0 +1,61 @@ +"""Tests for the thin typed /v1 httpx client (no database).""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from src.api.app import app +from src.clients.v1 import RaglogsAPIError, RaglogsClient + + +def _explain_result() -> MagicMock: + result = MagicMock() + result.window_start = datetime(2026, 3, 12, 13, 0, 0, tzinfo=timezone.utc) + result.window_end = datetime(2026, 3, 12, 14, 0, 0, tzinfo=timezone.utc) + result.summary_text = "ok" + result.confidence = "low" + result.mode = "rules" + result.total_logs = 0 + result.services_affected = [] + result.primary_cluster = None + result.secondary_clusters = [] + result.trigger_candidates = [] + result.evidence_items = [] + return result + + +def test_explain_targets_v1_and_returns_body() -> None: + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=False) + + http = TestClient(app, raise_server_exceptions=False) + with patch("src.db.session.get_db", side_effect=lambda: mock_db), \ + patch("src.core.explain.summarizer.explain_window", return_value=_explain_result()), \ + patch("src.api.routes.explain._load_from_cache", return_value=None), \ + patch("src.api.routes.explain._save_to_cache"): + client = RaglogsClient(base_url="http://testserver", client=http) + payload = client.explain(since="1h", no_llm=True) + + assert payload["summary"] == "ok" + assert payload["confidence"] == "low" + + +def test_health_uses_unversioned_path() -> None: + http = TestClient(app, raise_server_exceptions=False) + with patch("src.db.session.check_connection", return_value=False): + client = RaglogsClient(base_url="http://testserver", client=http) + payload = client.health() + assert payload["status"] == "degraded" + + +def test_api_error_on_missing_window() -> None: + http = TestClient(app, raise_server_exceptions=False) + client = RaglogsClient(base_url="http://testserver", client=http) + with pytest.raises(RaglogsAPIError) as exc: + client.explain() + assert exc.value.status_code == 400