diff --git a/CONTEXT.md b/CONTEXT.md index c50bab2..da0a783 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -53,10 +53,16 @@ The versioned public distribution of Guion Web: its npm packages, GHCR container _Avoid_: Checked-in OpenAPI file, independently versioned schema **HTTP Service**: -The Hono-based `/v1` JSON API shipped by `web serve` and the GHCR image. It +The Hono-based `/api/v1/web` JSON API shipped by `web serve` and the GHCR image. It uses server-local credentials, Bridge Route, and optional DeepSeek provider configuration; clients do not select providers or submit a generic Bridge command. Its page-reading routes use the same `render: "http" | "browser"`, `mode`, and `section_id` contract; the browser executable name appears only in operator setup. _Avoid_: Remote MCP, public service + +**Gateway Web API prefix**: +The `/api/v1/web` versioned path namespace for the HTTP Service's research +operations when exposed through FlickNote Gateway. It identifies Guion Web as +the owning service without changing its operation contracts. +_Avoid_: Bare `/v1` API, generic web route diff --git a/README.md b/README.md index ff9cb0b..96aedda 100644 --- a/README.md +++ b/README.md @@ -76,11 +76,11 @@ docker run --rm -p 8787:8787 \ Every HTTP operation is a versioned JSON `POST` route. Request and response schemas are generated into `openapi.yaml` from the same route definitions: -| Route | Request | Purpose | -| ------------ | --------------------------------------------------------- | --------------------------------------------------------------- | -| `/v1/search` | `{ "query": "..." }` | Server-selected search: Bridge→Exa by default, or DeepSeek only | -| `/v1/fetch` | `{ "url", "mode?", "section_id?", "render?", "waitMs?" }` | Fetch Markdown | -| `/v1/links` | `{ "url", "limit?", "render?", "waitMs?" }` | List page HTTP(S) links | +| Route | Request | Purpose | +| -------------------- | --------------------------------------------------------- | --------------------------------------------------------------- | +| `/api/v1/web/search` | `{ "query": "..." }` | Server-selected search: Bridge→Exa by default, or DeepSeek only | +| `/api/v1/web/fetch` | `{ "url", "mode?", "section_id?", "render?", "waitMs?" }` | Fetch Markdown | +| `/api/v1/web/links` | `{ "url", "limit?", "render?", "waitMs?" }` | List page HTTP(S) links | The complete human-readable contract is in the [HTTP service reference](docs/http-service.md). diff --git a/docs/adr/0002-gateway-web-api-prefix.md b/docs/adr/0002-gateway-web-api-prefix.md new file mode 100644 index 0000000..a553335 --- /dev/null +++ b/docs/adr/0002-gateway-web-api-prefix.md @@ -0,0 +1,3 @@ +# Gateway Web API prefix + +Guion Web's HTTP Service uses `/api/v1/web/*` instead of bare `/v1/*` routes so its direct service contract and the FlickNote Gateway contract are identical. This assigns the service a clear owner namespace in FlickNote's versioned API surface without introducing path rewriting or a compatibility alias. diff --git a/docs/http-service.md b/docs/http-service.md index 0eaadb2..988a11d 100644 --- a/docs/http-service.md +++ b/docs/http-service.md @@ -1,7 +1,7 @@ # Guion Web personal HTTP service The personal service is a single-trust-boundary JSON API. It exposes exactly -three `POST` routes: `/v1/search`, `/v1/fetch`, and `/v1/links`. It is intended +three `POST` routes: `/api/v1/web/search`, `/api/v1/web/fetch`, and `/api/v1/web/links`. It is intended for an operator and trusted agents, not for public or multi-tenant traffic. ## Calling the service @@ -11,7 +11,7 @@ responses are JSON and preserve the shared Guion page-reading vocabulary. Unknown fields, malformed JSON, missing required fields, and values outside the constraints below are rejected before an operation is called. -### `POST /v1/search` +### `POST /api/v1/web/search` Request: @@ -47,7 +47,7 @@ Response `200`: `provider` is `"Kepos Bridge"`, `"Exa"`, or `"DeepSeek"`; each result has string `title`, `link`, and `snippet` fields plus an integer `position`. -### `POST /v1/fetch` +### `POST /api/v1/web/fetch` Request: @@ -98,7 +98,7 @@ Response `200`: } ``` -### `POST /v1/links` +### `POST /api/v1/web/links` Request: diff --git a/packages/web/src/http.ts b/packages/web/src/http.ts index bde5415..85afa17 100644 --- a/packages/web/src/http.ts +++ b/packages/web/src/http.ts @@ -185,7 +185,7 @@ const commonResponses = { const searchRoute = createRoute({ method: "post", - path: "/v1/search", + path: "/api/v1/web/search", operationId: "search", summary: "Search the web", description: @@ -199,7 +199,7 @@ const searchRoute = createRoute({ const fetchRoute = createRoute({ method: "post", - path: "/v1/fetch", + path: "/api/v1/web/fetch", operationId: "fetch", summary: "Fetch a web page", description: @@ -213,7 +213,7 @@ const fetchRoute = createRoute({ const linksRoute = createRoute({ method: "post", - path: "/v1/links", + path: "/api/v1/web/links", operationId: "links", summary: "List page links", description: diff --git a/packages/web/test/http.test.ts b/packages/web/test/http.test.ts index c52c1da..a06c94d 100644 --- a/packages/web/test/http.test.ts +++ b/packages/web/test/http.test.ts @@ -67,7 +67,7 @@ describe("personal HTTP service", () => { const ops = operations(); const app = createHttpApp({ ...dependencies(), operations: ops }); - const result = await json(app, "/v1/search", { query: "empty" }); + const result = await json(app, "/api/v1/web/search", { query: "empty" }); expect(result.response.status).toBe(200); expect(result.body).toEqual({ provider: "Kepos Bridge", results: [] }); @@ -91,7 +91,7 @@ describe("personal HTTP service", () => { }); const app = createHttpApp({ ...dependencies(), operations: ops }); - const result = await json(app, "/v1/search", { query: "fallback" }); + const result = await json(app, "/api/v1/web/search", { query: "fallback" }); expect(result.response.status).toBe(200); expect(result.body).toEqual({ provider: "Exa", results: [] }); @@ -112,7 +112,7 @@ describe("personal HTTP service", () => { }); const app = createHttpApp({ ...dependencies(), operations: ops }); - const response = await app.request("/v1/search", { + const response = await app.request("/api/v1/web/search", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query: "cancel" }), @@ -132,7 +132,7 @@ describe("personal HTTP service", () => { }); const app = createHttpApp({ ...dependencies(), operations: ops }); - const result = await json(app, "/v1/search", { query: "timeout" }); + const result = await json(app, "/api/v1/web/search", { query: "timeout" }); expect(result.response.status).toBe(504); expect(result.body).toEqual({ @@ -155,7 +155,7 @@ describe("personal HTTP service", () => { environment: { WEB_SEARCH_PROVIDER: "deepseek" }, }); - const result = await json(app, "/v1/search", { query: "selected" }); + const result = await json(app, "/api/v1/web/search", { query: "selected" }); expect(result.response.status).toBe(200); expect(result.body).toEqual({ provider: "DeepSeek", results: [] }); @@ -181,7 +181,7 @@ describe("personal HTTP service", () => { environment: { WEB_SEARCH_PROVIDER: "deepseek" }, }); - const result = await json(app, "/v1/search", { query: "failure" }); + const result = await json(app, "/api/v1/web/search", { query: "failure" }); expect(result.response.status).toBe(502); expect(result.body).toEqual({ @@ -218,7 +218,7 @@ describe("personal HTTP service", () => { }); const app = createHttpApp({ ...dependencies(), operations: ops }); - const result = await json(app, "/v1/fetch", { + const result = await json(app, "/api/v1/web/fetch", { url: "https://example.test", render: "browser", waitMs: 0, @@ -266,7 +266,7 @@ describe("personal HTTP service", () => { }, { url: "https://example.test", section_id: "intro" }, ]) { - const result = await json(app, "/v1/fetch", body); + const result = await json(app, "/api/v1/web/fetch", body); expect(result.response.status).toBe(200); } expect(ops.fetch).toHaveBeenNthCalledWith( @@ -287,7 +287,9 @@ describe("personal HTTP service", () => { { url: "https://example.test", mode: "invalid" }, { url: "https://example.test", full: true }, ]) { - expect((await json(app, "/v1/fetch", body)).response.status).toBe(400); + expect((await json(app, "/api/v1/web/fetch", body)).response.status).toBe( + 400, + ); } expect(ops.fetch).toHaveBeenCalledTimes(5); }); @@ -302,7 +304,7 @@ describe("personal HTTP service", () => { }); const app = createHttpApp({ ...dependencies(), operations: ops }); - const result = await json(app, "/v1/links", { + const result = await json(app, "/api/v1/web/links", { url: "https://example.test", render: "browser", waitMs: 0, @@ -327,12 +329,12 @@ describe("personal HTTP service", () => { const ops = operations(); const app = createHttpApp({ ...dependencies(), operations: ops }); - expect((await json(app, "/v1/search", { query: "" })).response.status).toBe( - 400, - ); + expect( + (await json(app, "/api/v1/web/search", { query: "" })).response.status, + ).toBe(400); expect( ( - await json(app, "/v1/fetch", { + await json(app, "/api/v1/web/fetch", { url: "https://example.test", waitMs: 100, }) @@ -340,7 +342,7 @@ describe("personal HTTP service", () => { ).toBe(400); expect( ( - await json(app, "/v1/links", { + await json(app, "/api/v1/web/links", { url: "https://example.test", waitMs: 100, }) @@ -348,7 +350,7 @@ describe("personal HTTP service", () => { ).toBe(400); expect( ( - await json(app, "/v1/fetch", { + await json(app, "/api/v1/web/fetch", { url: "https://example.test", mode: "full", section_id: "intro", @@ -357,7 +359,7 @@ describe("personal HTTP service", () => { ).toBe(400); expect( ( - await json(app, "/v1/fetch", { + await json(app, "/api/v1/web/fetch", { url: "https://example.test", tree: true, }) @@ -367,7 +369,7 @@ describe("personal HTTP service", () => { expect(ops.fetch).not.toHaveBeenCalled(); expect(ops.links).not.toHaveBeenCalled(); - const malformed = await app.request("/v1/search", { + const malformed = await app.request("/api/v1/web/search", { method: "POST", headers: { "content-type": "application/json" }, body: "not-json", @@ -377,6 +379,9 @@ describe("personal HTTP service", () => { }); it.each([ + "/v1/search", + "/v1/fetch", + "/v1/links", "/v1/docs/resolve", "/v1/docs/fetch", "/v1/source-search", @@ -405,9 +410,9 @@ describe("personal HTTP service", () => { expect(document.openapi).toBe("3.1.0"); expect(document.info.version).toBe("1.2.3"); expect(Object.keys(document.paths ?? {}).sort()).toEqual([ - "/v1/fetch", - "/v1/links", - "/v1/search", + "/api/v1/web/fetch", + "/api/v1/web/links", + "/api/v1/web/search", ]); expect( (document.components?.schemas as any).SearchResponse.properties.provider diff --git a/packages/web/test/openapi-artifact.test.ts b/packages/web/test/openapi-artifact.test.ts index c37cf52..a850093 100644 --- a/packages/web/test/openapi-artifact.test.ts +++ b/packages/web/test/openapi-artifact.test.ts @@ -9,7 +9,11 @@ import { describe, expect, it } from "vitest"; import { generateOpenAPI } from "../src/generate-openapi.js"; const packageRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); -const expectedPaths = ["/v1/fetch", "/v1/links", "/v1/search"]; +const expectedPaths = [ + "/api/v1/web/fetch", + "/api/v1/web/links", + "/api/v1/web/search", +]; describe("release OpenAPI artifact", () => { it("serializes and parses the versioned contract from a test-owned file", async () => {