@usegraft/content-api 0.0.0-canary-20260901211203
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +459 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anderson Joseph
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @usegraft/content-api
|
|
2
|
+
|
|
3
|
+
> A versioned, read-only HTTP transport for Graft's authored content index.
|
|
4
|
+
|
|
5
|
+
Part of [Graft](https://github.com/AndersonDesign1/graft), a CMS built so an AI agent is the primary operator.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i @usegraft/content-api
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Serve authored content
|
|
14
|
+
|
|
15
|
+
`graft serve` mounts this handler at `/api/content/v1`. You can also mount it in any runtime that accepts Web `Request` and `Response` objects.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createContentApiHandler } from "@usegraft/content-api";
|
|
19
|
+
import { createDb, createDbIndexReader } from "@usegraft/db";
|
|
20
|
+
import { collections } from "./graft.config";
|
|
21
|
+
|
|
22
|
+
const database = createDb(process.env.DATABASE_URL!);
|
|
23
|
+
|
|
24
|
+
export const GET = createContentApiHandler({
|
|
25
|
+
collections: Object.keys(collections),
|
|
26
|
+
branch: "main",
|
|
27
|
+
index: createDbIndexReader(database.db),
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The handler serves:
|
|
32
|
+
|
|
33
|
+
- `GET /api/content/v1/documents?collection=pages`
|
|
34
|
+
- `GET /api/content/v1/documents?collection=pages&slug=home`
|
|
35
|
+
- `GET /api/content/v1/search?collection=pages&query=pricing`
|
|
36
|
+
|
|
37
|
+
An endpoint represents one branch. It does not accept a branch query parameter, so a caller cannot switch a production endpoint to preview content.
|
|
38
|
+
|
|
39
|
+
The handler does not authenticate callers. Put a proxy or check Authorization before invoking it if the index is not public. The handler does not close its reader or database. The application that created those handles owns their lifecycle.
|
|
40
|
+
|
|
41
|
+
## Use the existing typed SDK
|
|
42
|
+
|
|
43
|
+
The remote reader implements `ContentIndexReader`, so the framework-agnostic client and framework adapters need no HTTP-specific API.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { createContentApiReader } from "@usegraft/content-api";
|
|
47
|
+
import { createClient } from "@usegraft/sdk-core";
|
|
48
|
+
import { collections } from "./graft.config";
|
|
49
|
+
|
|
50
|
+
const graft = createClient({
|
|
51
|
+
index: createContentApiReader({
|
|
52
|
+
endpoint: "https://cms.example.com/api/content/v1",
|
|
53
|
+
headers: { authorization: `Bearer ${process.env.GRAFT_CONTENT_TOKEN}` },
|
|
54
|
+
}),
|
|
55
|
+
collections,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const page = await graft.getDocument("pages", "home");
|
|
59
|
+
const hits = await graft.searchDocuments("pages", "pricing");
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Pass `fetch` to `createContentApiReader` when the runtime needs a custom implementation. `headers` are static and sent on every request, which suits an auth token or proxy header.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
MIT. [Repository](https://github.com/AndersonDesign1/graft)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ContentIndexReader } from '@usegraft/contracts';
|
|
2
|
+
|
|
3
|
+
type ContentApiHandler = (request: Request) => Promise<Response>;
|
|
4
|
+
interface ContentApiRateLimit {
|
|
5
|
+
/** Requests allowed per identity per window. */
|
|
6
|
+
limit: number;
|
|
7
|
+
/** Window length in seconds. Also the `Retry-After` value on a refusal. */
|
|
8
|
+
windowSeconds: number;
|
|
9
|
+
}
|
|
10
|
+
interface ContentApiHandlerOptions {
|
|
11
|
+
/** Collection names this endpoint may expose. */
|
|
12
|
+
collections: readonly string[];
|
|
13
|
+
/** The one branch represented by this endpoint. Callers cannot override it. */
|
|
14
|
+
branch: string;
|
|
15
|
+
/** Reader owned by the caller. The handler never closes it. */
|
|
16
|
+
index: ContentIndexReader;
|
|
17
|
+
/**
|
|
18
|
+
* Per-identity backstop. Omitted means unlimited, which is the right default
|
|
19
|
+
* for a handler mounted behind something that already has a limiter — but a
|
|
20
|
+
* mount facing the open internet wants one, because these routes run database
|
|
21
|
+
* listings and full-text searches for callers this handler never
|
|
22
|
+
* authenticates.
|
|
23
|
+
*/
|
|
24
|
+
rateLimit?: ContentApiRateLimit;
|
|
25
|
+
/**
|
|
26
|
+
* How many proxies in front of this handler are ours. Forwarded to
|
|
27
|
+
* `rateIdentity`; zero (the default) means `x-forwarded-for` is never read
|
|
28
|
+
* and every unidentified caller shares one bucket.
|
|
29
|
+
*/
|
|
30
|
+
trustedProxyHops?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Origins allowed to read responses from a browser.
|
|
33
|
+
*
|
|
34
|
+
* Omitted means no CORS headers at all, which is same-origin only — the
|
|
35
|
+
* correct default, because publishing an endpoint to other origins is a
|
|
36
|
+
* decision for whoever deploys it and not one a library should make on their
|
|
37
|
+
* behalf. `@usegraft/sdk-react` needs this set whenever the app and the
|
|
38
|
+
* content API are on different origins, which is the ordinary case.
|
|
39
|
+
*
|
|
40
|
+
* An explicit list is echoed back per request (with `Vary: Origin`, so a
|
|
41
|
+
* cache cannot serve one origin's response to another). `"*"` allows any
|
|
42
|
+
* origin and is reasonable for content that is public anyway — which this
|
|
43
|
+
* handler's content is, since it authenticates nobody.
|
|
44
|
+
*/
|
|
45
|
+
allowedOrigins?: readonly string[] | "*";
|
|
46
|
+
}
|
|
47
|
+
interface ContentApiReaderOptions {
|
|
48
|
+
/**
|
|
49
|
+
* API base URL: absolute (https://cms.example.com/api/content/v1), or a
|
|
50
|
+
* same-origin path (/api/content/v1) when running in a browser, where it
|
|
51
|
+
* resolves against the page origin.
|
|
52
|
+
*/
|
|
53
|
+
endpoint: string | URL;
|
|
54
|
+
/** Fetch implementation for non-browser runtimes, tests, or instrumentation. */
|
|
55
|
+
fetch?: typeof globalThis.fetch;
|
|
56
|
+
/** Static headers sent with every request, such as Authorization. */
|
|
57
|
+
headers?: Record<string, string>;
|
|
58
|
+
}
|
|
59
|
+
declare function createContentApiHandler(options: ContentApiHandlerOptions): ContentApiHandler;
|
|
60
|
+
/** Create a ContentIndexReader that reads a remote /api/content/v1 endpoint. */
|
|
61
|
+
declare function createContentApiReader(options: ContentApiReaderOptions): ContentIndexReader;
|
|
62
|
+
|
|
63
|
+
export { type ContentApiHandlerOptions, type ContentApiRateLimit, type ContentApiReaderOptions, createContentApiHandler, createContentApiReader };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
ErrorCodes,
|
|
4
|
+
GraftError,
|
|
5
|
+
rateIdentity
|
|
6
|
+
} from "@usegraft/contracts";
|
|
7
|
+
var CONTENT_API_BASE = "/api/content/v1";
|
|
8
|
+
var MAX_QUERY_LIMIT = 500;
|
|
9
|
+
var JSON_CONTENT_TYPE = "application/json; charset=utf-8";
|
|
10
|
+
function isRecord(value) {
|
|
11
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
function isErrorCode(value) {
|
|
14
|
+
return typeof value === "string" && Object.hasOwn(ErrorCodes, value);
|
|
15
|
+
}
|
|
16
|
+
function parseGraftError(value) {
|
|
17
|
+
if (!isRecord(value) || !isErrorCode(value.error) || typeof value.message !== "string") {
|
|
18
|
+
return void 0;
|
|
19
|
+
}
|
|
20
|
+
if (value.fix !== void 0 && typeof value.fix !== "string") return void 0;
|
|
21
|
+
if (value.details !== void 0 && !isRecord(value.details)) return void 0;
|
|
22
|
+
const json2 = {
|
|
23
|
+
error: value.error,
|
|
24
|
+
message: value.message
|
|
25
|
+
};
|
|
26
|
+
if (typeof value.fix === "string") json2.fix = value.fix;
|
|
27
|
+
if (isRecord(value.details)) json2.details = value.details;
|
|
28
|
+
return new GraftError({
|
|
29
|
+
code: json2.error,
|
|
30
|
+
message: json2.message,
|
|
31
|
+
fix: json2.fix,
|
|
32
|
+
details: json2.details
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function protocolError(message, details) {
|
|
36
|
+
return new GraftError({
|
|
37
|
+
code: "FUNCTION_EXECUTION_FAILED",
|
|
38
|
+
message,
|
|
39
|
+
fix: "Check that endpoint points to a compatible /api/content/v1 server and that no proxy is replacing its JSON response.",
|
|
40
|
+
details
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function stringField(value, field, location) {
|
|
44
|
+
const raw = value[field];
|
|
45
|
+
if (typeof raw !== "string") {
|
|
46
|
+
throw protocolError(`Content API returned a malformed row at ${location}.`, {
|
|
47
|
+
location,
|
|
48
|
+
field
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return raw;
|
|
52
|
+
}
|
|
53
|
+
function parseWireRow(value, location) {
|
|
54
|
+
if (!isRecord(value)) {
|
|
55
|
+
throw protocolError(`Content API returned a malformed row at ${location}.`, { location });
|
|
56
|
+
}
|
|
57
|
+
const data = value.data;
|
|
58
|
+
if (!isRecord(data)) {
|
|
59
|
+
throw protocolError(`Content API returned a malformed row at ${location}.`, {
|
|
60
|
+
location,
|
|
61
|
+
field: "data"
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (typeof value.deleted !== "boolean") {
|
|
65
|
+
throw protocolError(`Content API returned a malformed row at ${location}.`, {
|
|
66
|
+
location,
|
|
67
|
+
field: "deleted"
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const search = value.search;
|
|
71
|
+
if (search !== null && typeof search !== "string") {
|
|
72
|
+
throw protocolError(`Content API returned a malformed row at ${location}.`, {
|
|
73
|
+
location,
|
|
74
|
+
field: "search"
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const updatedAtRaw = stringField(value, "updatedAt", location);
|
|
78
|
+
const updatedAt = new Date(updatedAtRaw);
|
|
79
|
+
if (Number.isNaN(updatedAt.getTime())) {
|
|
80
|
+
throw protocolError(`Content API returned an invalid updatedAt at ${location}.`, {
|
|
81
|
+
location,
|
|
82
|
+
updatedAt: updatedAtRaw
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
branchId: stringField(value, "branchId", location),
|
|
87
|
+
collection: stringField(value, "collection", location),
|
|
88
|
+
slug: stringField(value, "slug", location),
|
|
89
|
+
data,
|
|
90
|
+
body: stringField(value, "body", location),
|
|
91
|
+
contentHash: stringField(value, "contentHash", location),
|
|
92
|
+
sourcePath: stringField(value, "sourcePath", location),
|
|
93
|
+
deleted: value.deleted,
|
|
94
|
+
updatedAt,
|
|
95
|
+
search
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function parseRowsPayload(value) {
|
|
99
|
+
if (!isRecord(value) || !Array.isArray(value.rows)) {
|
|
100
|
+
throw protocolError('Content API documents response must be an object with a "rows" array.');
|
|
101
|
+
}
|
|
102
|
+
return value.rows.map((row, index) => parseWireRow(row, `rows[${index}]`));
|
|
103
|
+
}
|
|
104
|
+
function parseHitsPayload(value) {
|
|
105
|
+
if (!isRecord(value) || !Array.isArray(value.hits)) {
|
|
106
|
+
throw protocolError('Content API search response must be an object with a "hits" array.');
|
|
107
|
+
}
|
|
108
|
+
return value.hits.map((value2, index) => {
|
|
109
|
+
const location = `hits[${index}]`;
|
|
110
|
+
if (!isRecord(value2) || typeof value2.rank !== "number" || !Number.isFinite(value2.rank) || typeof value2.snippet !== "string") {
|
|
111
|
+
throw protocolError(`Content API returned a malformed search hit at ${location}.`, {
|
|
112
|
+
location
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
row: parseWireRow(value2.row, `${location}.row`),
|
|
117
|
+
rank: value2.rank,
|
|
118
|
+
snippet: value2.snippet
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function toWireRow(row) {
|
|
123
|
+
return {
|
|
124
|
+
branchId: row.branchId,
|
|
125
|
+
collection: row.collection,
|
|
126
|
+
slug: row.slug,
|
|
127
|
+
data: row.data,
|
|
128
|
+
body: row.body,
|
|
129
|
+
contentHash: row.contentHash,
|
|
130
|
+
sourcePath: row.sourcePath,
|
|
131
|
+
deleted: row.deleted,
|
|
132
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
133
|
+
search: row.search
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function json(value, status = 200, extra) {
|
|
137
|
+
const headers = new Headers();
|
|
138
|
+
if (extra) {
|
|
139
|
+
for (const [name, headerValue] of Object.entries(extra)) {
|
|
140
|
+
headers.set(name, headerValue);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
headers.set("content-type", JSON_CONTENT_TYPE);
|
|
144
|
+
return new Response(JSON.stringify(value), { status, headers });
|
|
145
|
+
}
|
|
146
|
+
function responseHeadersFor(error) {
|
|
147
|
+
if (error.code === "METHOD_NOT_ALLOWED") return { allow: "GET" };
|
|
148
|
+
if (error.code === "RATE_LIMITED") {
|
|
149
|
+
const retryAfter = error.details?.retryAfter;
|
|
150
|
+
if (typeof retryAfter === "number") return { "retry-after": String(retryAfter) };
|
|
151
|
+
}
|
|
152
|
+
return void 0;
|
|
153
|
+
}
|
|
154
|
+
function statusFor(error) {
|
|
155
|
+
switch (error.code) {
|
|
156
|
+
case "COLLECTION_NOT_FOUND":
|
|
157
|
+
case "ROUTE_NOT_FOUND":
|
|
158
|
+
return 404;
|
|
159
|
+
case "METHOD_NOT_ALLOWED":
|
|
160
|
+
return 405;
|
|
161
|
+
case "INPUT_VALIDATION_FAILED":
|
|
162
|
+
return 400;
|
|
163
|
+
case "RATE_LIMITED":
|
|
164
|
+
return 429;
|
|
165
|
+
default:
|
|
166
|
+
return 500;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function inputError(message, fix, details) {
|
|
170
|
+
return new GraftError({ code: "INPUT_VALIDATION_FAILED", message, fix, details });
|
|
171
|
+
}
|
|
172
|
+
function parseLimit(url) {
|
|
173
|
+
const raw = url.searchParams.get("limit");
|
|
174
|
+
if (raw === null) return void 0;
|
|
175
|
+
const value = Number(raw);
|
|
176
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
177
|
+
throw inputError(
|
|
178
|
+
`"${raw}" is not a valid limit.`,
|
|
179
|
+
`Pass limit as a positive integer. Values above ${MAX_QUERY_LIMIT} are capped at ${MAX_QUERY_LIMIT}.`,
|
|
180
|
+
{ limit: raw }
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return Math.min(value, MAX_QUERY_LIMIT);
|
|
184
|
+
}
|
|
185
|
+
function parseOffset(url) {
|
|
186
|
+
const raw = url.searchParams.get("offset");
|
|
187
|
+
if (raw === null) return void 0;
|
|
188
|
+
const value = Number(raw);
|
|
189
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
190
|
+
throw inputError(`"${raw}" is not a valid offset.`, "Pass offset as a non-negative integer.", {
|
|
191
|
+
offset: raw
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
function requiredParam(url, name) {
|
|
197
|
+
const value = url.searchParams.get(name)?.trim() ?? "";
|
|
198
|
+
if (value === "") {
|
|
199
|
+
throw inputError(
|
|
200
|
+
`${name} query param is required.`,
|
|
201
|
+
`GET ${CONTENT_API_BASE}/${name === "query" ? "search" : "documents"}?collection=<name>${name === "query" ? "&query=<text>" : ""}`,
|
|
202
|
+
{ parameter: name }
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
function assertNoBranchOverride(url) {
|
|
208
|
+
if (url.searchParams.has("branch")) {
|
|
209
|
+
throw inputError(
|
|
210
|
+
"The content API does not accept a branch query param.",
|
|
211
|
+
"Use the endpoint mounted for the branch you need. Each endpoint represents exactly one branch.",
|
|
212
|
+
{ branch: url.searchParams.get("branch") }
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function createRateLimiter(limit, windowSeconds) {
|
|
217
|
+
const windowMs = windowSeconds * 1e3;
|
|
218
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
219
|
+
const SWEEP_THRESHOLD = 4096;
|
|
220
|
+
return (identity, now) => {
|
|
221
|
+
if (buckets.size >= SWEEP_THRESHOLD) {
|
|
222
|
+
for (const [key, bucket2] of buckets) {
|
|
223
|
+
if (bucket2.resetAt <= now) buckets.delete(key);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const bucket = buckets.get(identity);
|
|
227
|
+
if (bucket === void 0 || bucket.resetAt <= now) {
|
|
228
|
+
buckets.set(identity, { count: 1, resetAt: now + windowMs });
|
|
229
|
+
return void 0;
|
|
230
|
+
}
|
|
231
|
+
bucket.count += 1;
|
|
232
|
+
if (bucket.count <= limit) return void 0;
|
|
233
|
+
return Math.max(1, Math.ceil((bucket.resetAt - now) / 1e3));
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function corsHeaders(request, allowed) {
|
|
237
|
+
if (allowed === void 0) return void 0;
|
|
238
|
+
const origin = request.headers.get("origin");
|
|
239
|
+
if (origin === null) return void 0;
|
|
240
|
+
if (allowed === "*") {
|
|
241
|
+
return { "access-control-allow-origin": "*" };
|
|
242
|
+
}
|
|
243
|
+
if (!allowed.includes(origin)) return void 0;
|
|
244
|
+
return { "access-control-allow-origin": origin, vary: "Origin" };
|
|
245
|
+
}
|
|
246
|
+
function createContentApiHandler(options) {
|
|
247
|
+
const collections = new Set(options.collections);
|
|
248
|
+
const consume = options.rateLimit === void 0 ? void 0 : createRateLimiter(options.rateLimit.limit, options.rateLimit.windowSeconds);
|
|
249
|
+
const trustedProxyHops = options.trustedProxyHops ?? 0;
|
|
250
|
+
return async (request) => {
|
|
251
|
+
const url = new URL(request.url);
|
|
252
|
+
const cors = corsHeaders(request, options.allowedOrigins);
|
|
253
|
+
if (request.method === "OPTIONS" && cors !== void 0) {
|
|
254
|
+
return new Response(null, {
|
|
255
|
+
status: 204,
|
|
256
|
+
headers: {
|
|
257
|
+
...cors,
|
|
258
|
+
"access-control-allow-methods": "GET, OPTIONS",
|
|
259
|
+
"access-control-allow-headers": request.headers.get("access-control-request-headers") ?? "authorization,content-type",
|
|
260
|
+
"access-control-max-age": "86400"
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const route = url.pathname === `${CONTENT_API_BASE}/documents` ? "documents" : url.pathname === `${CONTENT_API_BASE}/search` ? "search" : void 0;
|
|
266
|
+
if (route === void 0) {
|
|
267
|
+
throw new GraftError({
|
|
268
|
+
code: "ROUTE_NOT_FOUND",
|
|
269
|
+
message: `Nothing is mounted at ${url.pathname}.`,
|
|
270
|
+
fix: `Use GET ${CONTENT_API_BASE}/documents or GET ${CONTENT_API_BASE}/search.`,
|
|
271
|
+
details: { pathname: url.pathname }
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (request.method !== "GET") {
|
|
275
|
+
throw new GraftError({
|
|
276
|
+
code: "METHOD_NOT_ALLOWED",
|
|
277
|
+
message: `Content API reads use GET, not ${request.method}.`,
|
|
278
|
+
fix: `Send a GET request to ${url.pathname}.`,
|
|
279
|
+
details: { method: request.method }
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (consume !== void 0 && options.rateLimit !== void 0) {
|
|
283
|
+
const retryAfter = consume(rateIdentity(request, trustedProxyHops), Date.now());
|
|
284
|
+
if (retryAfter !== void 0) {
|
|
285
|
+
throw new GraftError({
|
|
286
|
+
code: "RATE_LIMITED",
|
|
287
|
+
message: `This content endpoint allows ${options.rateLimit.limit} requests per ${options.rateLimit.windowSeconds}s per caller.`,
|
|
288
|
+
fix: `Wait ${retryAfter}s \u2014 the Retry-After header says how long \u2014 then retry. Cache reads at your CDN if you need a higher sustained rate.`,
|
|
289
|
+
details: {
|
|
290
|
+
limit: options.rateLimit.limit,
|
|
291
|
+
windowSeconds: options.rateLimit.windowSeconds,
|
|
292
|
+
retryAfter
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
assertNoBranchOverride(url);
|
|
298
|
+
const collection = requiredParam(url, "collection");
|
|
299
|
+
if (!collections.has(collection)) {
|
|
300
|
+
throw new GraftError({
|
|
301
|
+
code: "COLLECTION_NOT_FOUND",
|
|
302
|
+
message: `Collection "${collection}" is not registered on this content endpoint.`,
|
|
303
|
+
fix: `Use one of the registered collections: ${[...collections].join(", ") || "(none)"}.`,
|
|
304
|
+
details: { collection, registered: [...collections] }
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
const limit = parseLimit(url);
|
|
308
|
+
if (route === "documents") {
|
|
309
|
+
const slugRaw = url.searchParams.get("slug");
|
|
310
|
+
const slug = slugRaw === null ? void 0 : slugRaw.trim();
|
|
311
|
+
if (slug === "") {
|
|
312
|
+
throw inputError(
|
|
313
|
+
"slug cannot be empty when provided.",
|
|
314
|
+
"Omit slug to list the collection, or pass a non-empty document slug.",
|
|
315
|
+
{ slug: slugRaw }
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const rows = await options.index.readContent({
|
|
319
|
+
collection,
|
|
320
|
+
slug,
|
|
321
|
+
limit,
|
|
322
|
+
offset: parseOffset(url),
|
|
323
|
+
branch: options.branch
|
|
324
|
+
});
|
|
325
|
+
return json({ rows: rows.map(toWireRow) }, 200, cors);
|
|
326
|
+
}
|
|
327
|
+
const query = requiredParam(url, "query");
|
|
328
|
+
const hits = await options.index.searchContent({
|
|
329
|
+
collections: [collection],
|
|
330
|
+
query,
|
|
331
|
+
limit,
|
|
332
|
+
branch: options.branch
|
|
333
|
+
});
|
|
334
|
+
return json(
|
|
335
|
+
{
|
|
336
|
+
hits: hits.map(
|
|
337
|
+
({ row, rank, snippet }) => ({
|
|
338
|
+
row: toWireRow(row),
|
|
339
|
+
rank,
|
|
340
|
+
snippet
|
|
341
|
+
})
|
|
342
|
+
)
|
|
343
|
+
},
|
|
344
|
+
200,
|
|
345
|
+
cors
|
|
346
|
+
);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
const graftError = error instanceof GraftError ? error : protocolError(
|
|
349
|
+
`Content API failed to read its index: ${error instanceof Error ? error.message : String(error)}`
|
|
350
|
+
);
|
|
351
|
+
const extra = { ...responseHeadersFor(graftError), ...cors };
|
|
352
|
+
return json(
|
|
353
|
+
graftError.toJSON(),
|
|
354
|
+
statusFor(graftError),
|
|
355
|
+
Object.keys(extra).length > 0 ? extra : void 0
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function normalizeEndpoint(endpoint) {
|
|
361
|
+
const url = endpoint instanceof URL ? new URL(endpoint) : resolveEndpointUrl(endpoint);
|
|
362
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
363
|
+
url.search = "";
|
|
364
|
+
url.hash = "";
|
|
365
|
+
return url;
|
|
366
|
+
}
|
|
367
|
+
function resolveEndpointUrl(endpoint) {
|
|
368
|
+
const base = globalThis.location?.href;
|
|
369
|
+
try {
|
|
370
|
+
return base === void 0 ? new URL(endpoint) : new URL(endpoint, base);
|
|
371
|
+
} catch {
|
|
372
|
+
throw new GraftError({
|
|
373
|
+
code: "CONFIG_INVALID",
|
|
374
|
+
message: `\`endpoint\` is not a valid URL: ${endpoint}`,
|
|
375
|
+
fix: base === void 0 ? "Outside a browser there is no page origin to resolve a relative path against. Pass an absolute endpoint, e.g. https://cms.example.com/api/content/v1." : "Pass an absolute endpoint (https://cms.example.com/api/content/v1) or a same-origin path (/api/content/v1).",
|
|
376
|
+
details: { endpoint, base }
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function createContentApiReader(options) {
|
|
381
|
+
const endpoint = normalizeEndpoint(options.endpoint);
|
|
382
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
383
|
+
const headers = new Headers();
|
|
384
|
+
if (options.headers) {
|
|
385
|
+
for (const [name, headerValue] of Object.entries(options.headers)) {
|
|
386
|
+
headers.set(name, headerValue);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (!headers.has("accept")) headers.set("accept", "application/json");
|
|
390
|
+
const getJson = async (route, params) => {
|
|
391
|
+
const url = new URL(endpoint);
|
|
392
|
+
url.pathname = `${endpoint.pathname}/${route}`;
|
|
393
|
+
url.search = params.toString();
|
|
394
|
+
let response;
|
|
395
|
+
try {
|
|
396
|
+
response = await fetchImpl(url, { method: "GET", headers });
|
|
397
|
+
} catch (error) {
|
|
398
|
+
throw protocolError(
|
|
399
|
+
`Content API request to ${url.toString()} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
400
|
+
{ endpoint: url.toString() }
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
let payload;
|
|
404
|
+
try {
|
|
405
|
+
payload = await response.json();
|
|
406
|
+
} catch {
|
|
407
|
+
throw protocolError(`Content API returned non-JSON HTTP ${response.status}.`, {
|
|
408
|
+
endpoint: url.toString(),
|
|
409
|
+
status: response.status
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
if (!response.ok) {
|
|
413
|
+
const remoteError = parseGraftError(payload);
|
|
414
|
+
if (remoteError) throw remoteError;
|
|
415
|
+
throw protocolError(`Content API returned HTTP ${response.status} without a GraftError.`, {
|
|
416
|
+
endpoint: url.toString(),
|
|
417
|
+
status: response.status
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
if (!isRecord(payload)) {
|
|
421
|
+
throw protocolError("Content API returned JSON that was not an object.", {
|
|
422
|
+
endpoint: url.toString()
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return payload;
|
|
426
|
+
};
|
|
427
|
+
return {
|
|
428
|
+
async readContent(options2) {
|
|
429
|
+
const params = new URLSearchParams({ collection: options2.collection });
|
|
430
|
+
if (options2.slug !== void 0) params.set("slug", options2.slug);
|
|
431
|
+
if (options2.limit !== void 0) params.set("limit", String(options2.limit));
|
|
432
|
+
if (options2.offset !== void 0) params.set("offset", String(options2.offset));
|
|
433
|
+
return parseRowsPayload(await getJson("documents", params));
|
|
434
|
+
},
|
|
435
|
+
async searchContent(options2) {
|
|
436
|
+
if (options2.collections?.length !== 1) {
|
|
437
|
+
throw new GraftError({
|
|
438
|
+
code: "INPUT_VALIDATION_FAILED",
|
|
439
|
+
message: "The content API reader needs exactly one collection for search.",
|
|
440
|
+
fix: "Pass collections: [name]. Graft SDK searchDocuments already does this.",
|
|
441
|
+
details: { collections: options2.collections }
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
const params = new URLSearchParams({
|
|
445
|
+
collection: options2.collections[0],
|
|
446
|
+
query: options2.query
|
|
447
|
+
});
|
|
448
|
+
if (options2.limit !== void 0) params.set("limit", String(options2.limit));
|
|
449
|
+
return parseHitsPayload(await getJson("search", params));
|
|
450
|
+
},
|
|
451
|
+
// The reader owns no server or database handle.
|
|
452
|
+
async close() {
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
export {
|
|
457
|
+
createContentApiHandler,
|
|
458
|
+
createContentApiReader
|
|
459
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@usegraft/content-api",
|
|
3
|
+
"version": "0.0.0-canary-20260901211203",
|
|
4
|
+
"description": "A versioned read-only HTTP transport for Graft's authored content index.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent",
|
|
7
|
+
"ai",
|
|
8
|
+
"cms",
|
|
9
|
+
"content-api",
|
|
10
|
+
"graft",
|
|
11
|
+
"headless-cms",
|
|
12
|
+
"http",
|
|
13
|
+
"sdk",
|
|
14
|
+
"typescript"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/AndersonDesign1/graft#readme",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/AndersonDesign1/graft.git",
|
|
21
|
+
"directory": "packages/content-api"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"module": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@usegraft/contracts": "0.0.0-canary-20260901211203"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@usegraft/db": "0.0.0-canary-20260901211203"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22.16"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
50
|
+
"dev": "tsup src/index.ts --format esm --watch",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"test": "vitest run"
|
|
53
|
+
}
|
|
54
|
+
}
|