Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
3 changes: 3 additions & 0 deletions docs/adr/0002-gateway-web-api-prefix.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions docs/http-service.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -98,7 +98,7 @@ Response `200`:
}
```

### `POST /v1/links`
### `POST /api/v1/web/links`

Request:

Expand Down
6 changes: 3 additions & 3 deletions packages/web/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
47 changes: 26 additions & 21 deletions packages/web/test/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] });
Expand All @@ -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: [] });
Expand All @@ -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" }),
Expand All @@ -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({
Expand All @@ -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: [] });
Expand All @@ -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({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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);
});
Expand All @@ -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,
Expand All @@ -327,28 +329,28 @@ 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,
})
).response.status,
).toBe(400);
expect(
(
await json(app, "/v1/links", {
await json(app, "/api/v1/web/links", {
url: "https://example.test",
waitMs: 100,
})
).response.status,
).toBe(400);
expect(
(
await json(app, "/v1/fetch", {
await json(app, "/api/v1/web/fetch", {
url: "https://example.test",
mode: "full",
section_id: "intro",
Expand All @@ -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,
})
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion packages/web/test/openapi-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading