@nuxtseo/protocol 0.1.0

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.
Files changed (44) hide show
  1. package/dist/analytics-codecs/analytics-row.d.ts +265 -0
  2. package/dist/analytics-codecs/analytics-row.js +175 -0
  3. package/dist/analytics-codecs/analytics-web-vitals.d.ts +57 -0
  4. package/dist/analytics-codecs/analytics-web-vitals.js +76 -0
  5. package/dist/analytics-codecs/capabilities.d.ts +35 -0
  6. package/dist/analytics-codecs/capabilities.js +76 -0
  7. package/dist/analytics-codecs/index.d.ts +3 -0
  8. package/dist/analytics-codecs/index.js +3 -0
  9. package/dist/cli-pairing.d.ts +67 -0
  10. package/dist/cli-pairing.js +52 -0
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/v1/account.d.ts +715 -0
  14. package/dist/v1/account.js +123 -0
  15. package/dist/v1/actions.d.ts +2591 -0
  16. package/dist/v1/actions.js +460 -0
  17. package/dist/v1/analytics.d.ts +6145 -0
  18. package/dist/v1/analytics.js +296 -0
  19. package/dist/v1/audit.d.ts +4053 -0
  20. package/dist/v1/audit.js +609 -0
  21. package/dist/v1/backlinks.d.ts +1002 -0
  22. package/dist/v1/backlinks.js +239 -0
  23. package/dist/v1/content.d.ts +2745 -0
  24. package/dist/v1/content.js +326 -0
  25. package/dist/v1/core.d.ts +301 -0
  26. package/dist/v1/core.js +333 -0
  27. package/dist/v1/documents.d.ts +4 -0
  28. package/dist/v1/documents.js +216 -0
  29. package/dist/v1/gsc.d.ts +5172 -0
  30. package/dist/v1/gsc.js +583 -0
  31. package/dist/v1/index.d.ts +49 -0
  32. package/dist/v1/index.js +52 -0
  33. package/dist/v1/pages.d.ts +1404 -0
  34. package/dist/v1/pages.js +287 -0
  35. package/dist/v1/performance.d.ts +1379 -0
  36. package/dist/v1/performance.js +198 -0
  37. package/dist/v1/research.d.ts +1173 -0
  38. package/dist/v1/research.js +174 -0
  39. package/dist/v1/sitemaps.d.ts +1506 -0
  40. package/dist/v1/sitemaps.js +199 -0
  41. package/dist/v1/sites.d.ts +591 -0
  42. package/dist/v1/sites.js +101 -0
  43. package/generated/openapi.v1.json +1 -0
  44. package/package.json +131 -0
@@ -0,0 +1,333 @@
1
+ import { z } from 'zod';
2
+ export const PUBLIC_V1_VERSION = '1.0';
3
+ export const PUBLIC_V1_PATH_PREFIX = '/api/v1';
4
+ export const PUBLIC_V1_RESPONSE_HEADERS = {
5
+ requestId: 'X-Request-Id',
6
+ version: 'X-NuxtSEO-Version',
7
+ operation: 'X-NuxtSEO-Operation',
8
+ rateLimitPolicy: 'RateLimit-Policy',
9
+ rateLimit: 'RateLimit',
10
+ retryAfter: 'Retry-After',
11
+ deprecation: 'Deprecation',
12
+ sunset: 'Sunset',
13
+ };
14
+ // Retain the existing authenticated Pro transport ceiling across supported adapters.
15
+ // The public adapter partitions this quota by authoritative principal and
16
+ // operation, so one expensive operation cannot starve unrelated reads.
17
+ export const publicV1StandardRatePolicy = {
18
+ name: 'public-v1-fixed-window',
19
+ limit: 50,
20
+ windowSeconds: 60,
21
+ };
22
+ export const PUBLIC_V1_FEATURES = [
23
+ 'account',
24
+ 'actions',
25
+ 'analytics',
26
+ 'audit',
27
+ 'backlinks',
28
+ 'content',
29
+ 'mentions',
30
+ 'page',
31
+ 'research',
32
+ 'sites',
33
+ ];
34
+ export const PUBLIC_V1_METHODS = ['DELETE', 'GET', 'PATCH', 'POST'];
35
+ export const PUBLIC_V1_CREDENTIALS = ['api_token', 'session'];
36
+ export const PUBLIC_V1_SCOPES = [
37
+ 'account:read',
38
+ 'actions:read',
39
+ 'actions:write',
40
+ 'analytics:read',
41
+ 'audit:read',
42
+ 'content:read',
43
+ 'content:write',
44
+ 'page:read',
45
+ 'page:write',
46
+ 'research:read',
47
+ 'research:write',
48
+ 'sites:read',
49
+ 'sites:write',
50
+ ];
51
+ export const PUBLIC_V1_ROLES = ['viewer', 'member', 'admin', 'owner'];
52
+ export const PUBLIC_V1_ERROR_CODES = [
53
+ 'invalid_request',
54
+ 'unauthorized',
55
+ 'forbidden',
56
+ 'not_found',
57
+ 'site_ambiguous',
58
+ 'auth_expired',
59
+ 'entitlement_required',
60
+ 'quota_exhausted',
61
+ 'provider_unavailable',
62
+ 'stale_evidence',
63
+ 'conflict',
64
+ 'rate_limited',
65
+ 'contract_violation',
66
+ 'internal_error',
67
+ ];
68
+ export const PUBLIC_V1_ERROR_STATUS = {
69
+ invalid_request: 400,
70
+ unauthorized: 401,
71
+ auth_expired: 401,
72
+ forbidden: 403,
73
+ entitlement_required: 403,
74
+ not_found: 404,
75
+ site_ambiguous: 409,
76
+ stale_evidence: 409,
77
+ conflict: 409,
78
+ quota_exhausted: 429,
79
+ rate_limited: 429,
80
+ contract_violation: 500,
81
+ internal_error: 500,
82
+ provider_unavailable: 503,
83
+ };
84
+ function pathParameterNames(path) {
85
+ return Array.from(path.matchAll(/\{([^{}]+)\}/g), match => match[1]);
86
+ }
87
+ function isSafePathTemplate(path) {
88
+ return /^\/api\/v1\/(?:[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?|\{[a-z][A-Za-z0-9]*\})(?:\/(?:[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?|\{[a-z][A-Za-z0-9]*\}))*$/.test(path);
89
+ }
90
+ function objectSchemaKeys(schema) {
91
+ return schema instanceof z.ZodObject ? Object.keys(schema.shape) : null;
92
+ }
93
+ function assertRequestLocations(request, operationId) {
94
+ for (const location of ['params', 'query', 'headers', 'body']) {
95
+ if (!(location in request) || request[location] === undefined)
96
+ throw new TypeError(`${operationId}: request.${location} must be a schema or null`);
97
+ }
98
+ for (const location of ['params', 'query', 'headers']) {
99
+ const schema = request[location];
100
+ if (schema !== null && objectSchemaKeys(schema) === null)
101
+ throw new TypeError(`${operationId}: request.${location} must be an object schema or null`);
102
+ }
103
+ for (const key of request.query ? objectSchemaKeys(request.query) ?? [] : []) {
104
+ if (!/^[a-z][A-Za-z0-9]*$/.test(key))
105
+ throw new TypeError(`${operationId}: query key ${key} must be a safe camelCase identifier`);
106
+ }
107
+ for (const key of request.headers ? objectSchemaKeys(request.headers) ?? [] : []) {
108
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(key))
109
+ throw new TypeError(`${operationId}: header key ${key} must be lowercase kebab-case`);
110
+ }
111
+ }
112
+ function assertPathContract(operation) {
113
+ const names = pathParameterNames(operation.path);
114
+ if (new Set(names).size !== names.length)
115
+ throw new TypeError(`${operation.id}: path parameters must be unique`);
116
+ if (names.length === 0) {
117
+ if (operation.request.params !== null)
118
+ throw new TypeError(`${operation.id}: params must be null when the path has no parameters`);
119
+ return;
120
+ }
121
+ if (operation.request.params === null)
122
+ throw new TypeError(`${operation.id}: path parameters require a params schema`);
123
+ const keys = objectSchemaKeys(operation.request.params);
124
+ if (!keys)
125
+ throw new TypeError(`${operation.id}: params must be an object schema`);
126
+ if (names.join('\0') !== keys.join('\0'))
127
+ throw new TypeError(`${operation.id}: params schema keys must match path parameter order`);
128
+ }
129
+ function assertExampleLocation(operation, example, location) {
130
+ const schema = operation.request[location];
131
+ const supplied = Object.hasOwn(example.request, location);
132
+ const value = supplied ? example.request[location] : undefined;
133
+ if (schema === null) {
134
+ if (value !== undefined)
135
+ throw new TypeError(`${operation.id}: example supplies request.${location} without a schema`);
136
+ return;
137
+ }
138
+ const parsed = schema.safeParse(supplied ? value : location === 'body' ? undefined : {});
139
+ if (!parsed.success)
140
+ throw new TypeError(`${operation.id}: example request.${location} does not satisfy its schema`);
141
+ }
142
+ function assertExamples(operation) {
143
+ if (operation.docs.examples.length === 0)
144
+ throw new TypeError(`${operation.id}: at least one executable docs example is required`);
145
+ for (const example of operation.docs.examples) {
146
+ for (const location of ['params', 'query', 'headers', 'body'])
147
+ assertExampleLocation(operation, example, location);
148
+ const response = operation.responses[example.response.status];
149
+ if (!response)
150
+ throw new TypeError(`${operation.id}: example uses undeclared response status ${example.response.status}`);
151
+ if (!response.producer.safeParse(example.response.body).success)
152
+ throw new TypeError(`${operation.id}: example response does not satisfy the producer schema`);
153
+ }
154
+ }
155
+ function assertOperation(operation) {
156
+ if (!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)+$/.test(operation.id))
157
+ throw new TypeError(`${operation.id}: operation ID must be a stable dotted lowercase identifier`);
158
+ if (!isSafePathTemplate(operation.path))
159
+ throw new TypeError(`${operation.id}: path must be a safe literal /api/v1 template`);
160
+ assertRequestLocations(operation.request, operation.id);
161
+ assertPathContract(operation);
162
+ if (operation.semantics.kind === 'query') {
163
+ if (operation.semantics.sideEffects !== 'none' || operation.semantics.retry !== 'idempotent')
164
+ throw new TypeError(`${operation.id}: queries must be side-effect-free and retryable`);
165
+ if (operation.semantics.readConsistency === null)
166
+ throw new TypeError(`${operation.id}: queries must declare read consistency`);
167
+ }
168
+ else {
169
+ if (operation.semantics.sideEffects !== 'state' || operation.semantics.readConsistency !== null)
170
+ throw new TypeError(`${operation.id}: mutations require state side effects and no read consistency`);
171
+ }
172
+ if (operation.semantics.kind === 'mutation'
173
+ && operation.semantics.retry === 'idempotent'
174
+ && operation.policy.idempotency._tag !== 'required') {
175
+ throw new TypeError(`${operation.id}: retryable mutations require an idempotency policy`);
176
+ }
177
+ if (operation.policy.idempotency._tag === 'required') {
178
+ if (operation.semantics.kind !== 'mutation')
179
+ throw new TypeError(`${operation.id}: query operations cannot require idempotency keys`);
180
+ if (!Number.isSafeInteger(operation.policy.idempotency.retentionSeconds) || operation.policy.idempotency.retentionSeconds < 1)
181
+ throw new TypeError(`${operation.id}: idempotency retention must be a positive number of seconds`);
182
+ }
183
+ if (operation.auth.credentials.length === 0 || operation.auth.scopes.length === 0)
184
+ throw new TypeError(`${operation.id}: auth credentials and scopes are required`);
185
+ if (new Set(operation.auth.credentials).size !== operation.auth.credentials.length)
186
+ throw new TypeError(`${operation.id}: auth credentials must be unique`);
187
+ if (new Set(operation.auth.scopes).size !== operation.auth.scopes.length)
188
+ throw new TypeError(`${operation.id}: auth scopes must be unique`);
189
+ if (operation.errors.length === 0 || new Set(operation.errors).size !== operation.errors.length)
190
+ throw new TypeError(`${operation.id}: unique stable errors are required`);
191
+ if (!operation.errorResponse.producer || !operation.errorResponse.client)
192
+ throw new TypeError(`${operation.id}: error response schemas are required`);
193
+ if (Object.keys(operation.responses).length === 0)
194
+ throw new TypeError(`${operation.id}: at least one success response is required`);
195
+ for (const status of Object.keys(operation.responses).map(Number)) {
196
+ if (!Number.isInteger(status) || status < 200 || status >= 300)
197
+ throw new TypeError(`${operation.id}: response status ${status} must be successful`);
198
+ }
199
+ if (operation.semantics.kind === 'query' && operation.resources.changes.length > 0)
200
+ throw new TypeError(`${operation.id}: queries cannot change resources`);
201
+ const params = new Set(pathParameterNames(operation.path));
202
+ for (const reference of [...operation.resources.reads, ...operation.resources.changes]) {
203
+ if (reference.idFrom.startsWith('params.') && !params.has(reference.idFrom.slice(7)))
204
+ throw new TypeError(`${operation.id}: resource reference ${reference.idFrom} is not a path parameter`);
205
+ }
206
+ if (!operation.docs.summary || !operation.docs.description || operation.docs.tags.length === 0)
207
+ throw new TypeError(`${operation.id}: public documentation metadata is incomplete`);
208
+ if (operation.lifecycle.sunset && !operation.lifecycle.deprecated)
209
+ throw new TypeError(`${operation.id}: a sunset requires a deprecated version`);
210
+ if (operation.lifecycle.deprecated && Number.isNaN(Date.parse(operation.lifecycle.deprecated.at)))
211
+ throw new TypeError(`${operation.id}: deprecation must declare an RFC-compatible date`);
212
+ if (operation.lifecycle.sunset && Number.isNaN(Date.parse(operation.lifecycle.sunset)))
213
+ throw new TypeError(`${operation.id}: sunset must declare an RFC-compatible date`);
214
+ if (operation.lifecycle.deprecated && operation.lifecycle.sunset
215
+ && Date.parse(operation.lifecycle.sunset) < Date.parse(operation.lifecycle.deprecated.at)) {
216
+ throw new TypeError(`${operation.id}: sunset cannot precede deprecation`);
217
+ }
218
+ if (operation.policy.meter && (!Number.isInteger(operation.policy.meter.units) || operation.policy.meter.units < 1))
219
+ throw new TypeError(`${operation.id}: meter units must be a positive integer`);
220
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(operation.policy.rate.name))
221
+ throw new TypeError(`${operation.id}: rate policy name must be lowercase kebab-case`);
222
+ if (!Number.isSafeInteger(operation.policy.rate.limit) || operation.policy.rate.limit < 1)
223
+ throw new TypeError(`${operation.id}: rate policy limit must be a positive integer`);
224
+ if (!Number.isSafeInteger(operation.policy.rate.windowSeconds) || operation.policy.rate.windowSeconds < 1)
225
+ throw new TypeError(`${operation.id}: rate policy window must be a positive number of seconds`);
226
+ if (!operation.errors.includes('rate_limited'))
227
+ throw new TypeError(`${operation.id}: rate-limited operations must declare rate_limited`);
228
+ assertExamples(operation);
229
+ }
230
+ export function defineOperation(operation) {
231
+ assertOperation(operation);
232
+ return operation;
233
+ }
234
+ export function defineRegistry(registry) {
235
+ const ids = new Set();
236
+ const routes = new Set();
237
+ for (const [key, operation] of Object.entries(registry.operations)) {
238
+ assertOperation(operation);
239
+ if (!operation.id.startsWith(`${registry.feature}.`))
240
+ throw new TypeError(`${registry.feature}: ${operation.id} must use the feature namespace`);
241
+ if (!key)
242
+ throw new TypeError(`${registry.feature}: operation registry keys cannot be empty`);
243
+ if (ids.has(operation.id))
244
+ throw new TypeError(`${registry.feature}: duplicate operation ID ${operation.id}`);
245
+ ids.add(operation.id);
246
+ const route = `${operation.method} ${operation.path}`;
247
+ if (routes.has(route))
248
+ throw new TypeError(`${registry.feature}: duplicate method/path ${route}`);
249
+ routes.add(route);
250
+ }
251
+ return registry;
252
+ }
253
+ export function defineProtocol(protocol) {
254
+ const ids = new Set();
255
+ const routes = new Set();
256
+ for (const registry of Object.values(protocol.registries)) {
257
+ defineRegistry(registry);
258
+ for (const operation of Object.values(registry.operations)) {
259
+ if (ids.has(operation.id))
260
+ throw new TypeError(`Duplicate operation ID ${operation.id}`);
261
+ ids.add(operation.id);
262
+ const route = `${operation.method} ${operation.path}`;
263
+ if (routes.has(route))
264
+ throw new TypeError(`Duplicate public method/path ${route}`);
265
+ routes.add(route);
266
+ }
267
+ }
268
+ return protocol;
269
+ }
270
+ export function listOperations(protocol) {
271
+ const entries = [];
272
+ for (const registry of Object.values(protocol.registries)) {
273
+ for (const operation of Object.values(registry.operations))
274
+ entries.push({ registry, operation });
275
+ }
276
+ return entries;
277
+ }
278
+ export function defineResponseObject(producerShape, clientShape) {
279
+ return {
280
+ producer: z.strictObject(producerShape),
281
+ client: z.looseObject(clientShape ?? producerShape),
282
+ };
283
+ }
284
+ export function buildOperationPath(operation, params) {
285
+ const names = pathParameterNames(operation.path);
286
+ if (names.length === 0) {
287
+ if (params !== undefined && params !== null && (typeof params !== 'object' || Object.keys(params).length > 0))
288
+ throw new TypeError(`${operation.id}: this operation has no path parameters`);
289
+ return operation.path;
290
+ }
291
+ if (operation.request.params === null)
292
+ throw new TypeError(`${operation.id}: operation has an invalid path contract`);
293
+ const parsed = operation.request.params.parse(params);
294
+ return operation.path.replace(/\{([^{}]+)\}/g, (_match, name) => {
295
+ const value = parsed[name];
296
+ if (typeof value !== 'string' && typeof value !== 'number')
297
+ throw new TypeError(`${operation.id}: path parameter ${name} must serialize as a string or number`);
298
+ const serialized = String(value);
299
+ if (!serialized || serialized === '.' || serialized === '..')
300
+ throw new TypeError(`${operation.id}: path parameter ${name} cannot be empty or a dot segment`);
301
+ return encodeURIComponent(serialized);
302
+ });
303
+ }
304
+ export const publicV1ErrorDetailsSchema = z.record(z.string(), z.unknown());
305
+ export const publicV1ErrorProducerSchema = z.strictObject({
306
+ code: z.enum(PUBLIC_V1_ERROR_CODES),
307
+ message: z.string().min(1),
308
+ requestId: z.string().min(1),
309
+ retryable: z.boolean(),
310
+ details: publicV1ErrorDetailsSchema,
311
+ });
312
+ export const publicV1ErrorClientSchema = z.looseObject({
313
+ code: z.enum(PUBLIC_V1_ERROR_CODES),
314
+ message: z.string().min(1),
315
+ requestId: z.string().min(1),
316
+ retryable: z.boolean(),
317
+ details: publicV1ErrorDetailsSchema,
318
+ });
319
+ export const publicV1ErrorEnvelope = defineResponseObject({ error: publicV1ErrorProducerSchema }, { error: publicV1ErrorClientSchema });
320
+ export const publicV1MetaProducerSchema = z.strictObject({
321
+ requestId: z.string().min(1),
322
+ version: z.literal(PUBLIC_V1_VERSION),
323
+ });
324
+ export const publicV1MetaClientSchema = z.looseObject({
325
+ requestId: z.string().min(1),
326
+ version: z.literal(PUBLIC_V1_VERSION),
327
+ });
328
+ export function defineSuccessEnvelope(data, meta = {
329
+ producer: publicV1MetaProducerSchema,
330
+ client: publicV1MetaClientSchema,
331
+ }) {
332
+ return defineResponseObject({ data: data.producer, meta: meta.producer }, { data: data.client, meta: meta.client });
333
+ }
@@ -0,0 +1,4 @@
1
+ import type { PublicV1ProtocolLike } from './core.js';
2
+ export type ContractDocument = Record<string, unknown>;
3
+ export declare function serializeContractDocument(document: ContractDocument): string;
4
+ export declare function buildOpenApiDocument(protocol: PublicV1ProtocolLike): ContractDocument;
@@ -0,0 +1,216 @@
1
+ import { z } from 'zod';
2
+ import { listOperations, PUBLIC_V1_ERROR_STATUS, PUBLIC_V1_RESPONSE_HEADERS } from './core.js';
3
+ function compareStrings(left, right) {
4
+ return left < right ? -1 : left > right ? 1 : 0;
5
+ }
6
+ function orderedJson(value) {
7
+ if (Array.isArray(value))
8
+ return value.map(orderedJson);
9
+ if (value === null || typeof value !== 'object')
10
+ return value;
11
+ return Object.fromEntries(Object.entries(value)
12
+ .sort(([left], [right]) => compareStrings(left, right))
13
+ .map(([key, item]) => [key, orderedJson(item)]));
14
+ }
15
+ export function serializeContractDocument(document) {
16
+ return `${JSON.stringify(orderedJson(document))}\n`;
17
+ }
18
+ function jsonSchema(schema, io = 'output') {
19
+ // Request codecs/transforms document the wire input. Zod otherwise rejects
20
+ // transforms before OpenAPI generation reaches their representable side.
21
+ const document = z.toJSONSchema(schema, { io });
22
+ const { $schema: _schema, ...body } = document;
23
+ return orderedJson(body);
24
+ }
25
+ function rewriteDefinitionRefs(value, references) {
26
+ if (Array.isArray(value))
27
+ return value.map(item => rewriteDefinitionRefs(item, references));
28
+ if (value === null || typeof value !== 'object')
29
+ return value;
30
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => {
31
+ if (key === '$ref' && typeof item === 'string' && item.startsWith('#/$defs/')) {
32
+ const name = item.slice('#/$defs/'.length);
33
+ const reference = references[name];
34
+ if (!reference)
35
+ throw new TypeError(`JSON Schema references unknown definition ${name}`);
36
+ return [key, reference];
37
+ }
38
+ return [key, rewriteDefinitionRefs(item, references)];
39
+ }));
40
+ }
41
+ function registerComponentSchema(schema, name, components, io = 'output') {
42
+ const document = jsonSchema(schema, io);
43
+ const { $defs, ...body } = document;
44
+ if ($defs) {
45
+ const definitions = $defs;
46
+ const references = Object.fromEntries(Object.keys(definitions).map(definition => [definition, `#/components/schemas/${name}.${definition}`]));
47
+ for (const [definition, value] of Object.entries(definitions)) {
48
+ const componentName = `${name}.${definition}`;
49
+ if (componentName in components)
50
+ throw new TypeError(`Duplicate generated component schema ${componentName}`);
51
+ components[componentName] = rewriteDefinitionRefs(value, references);
52
+ }
53
+ components[name] = rewriteDefinitionRefs(body, references);
54
+ }
55
+ else {
56
+ components[name] = body;
57
+ }
58
+ return { $ref: `#/components/schemas/${name}` };
59
+ }
60
+ function objectParameters(schema, location, operation, components) {
61
+ if (schema === null)
62
+ return [];
63
+ const document = jsonSchema(schema, 'input');
64
+ const properties = document.properties;
65
+ if (!properties)
66
+ throw new TypeError(`OpenAPI ${location} schema must compile to object properties`);
67
+ const required = new Set(Array.isArray(document.required) ? document.required : []);
68
+ const component = `${operation.id}.${location}.request`;
69
+ registerComponentSchema(schema, component, components, 'input');
70
+ const requestLocation = { header: 'headers', path: 'params', query: 'query' };
71
+ const example = operation.docs.examples[0]?.request[requestLocation[location]];
72
+ return Object.keys(properties).map(name => ({
73
+ in: location,
74
+ name,
75
+ required: location === 'path' || required.has(name),
76
+ schema: { $ref: `#/components/schemas/${component}/properties/${name}` },
77
+ ...(example && Object.hasOwn(example, name) ? { example: example[name] } : {}),
78
+ }));
79
+ }
80
+ function commonResponseHeaders(operation) {
81
+ return {
82
+ [PUBLIC_V1_RESPONSE_HEADERS.requestId]: { schema: { type: 'string' } },
83
+ [PUBLIC_V1_RESPONSE_HEADERS.version]: { schema: { const: '1.0', type: 'string' } },
84
+ [PUBLIC_V1_RESPONSE_HEADERS.operation]: { schema: { const: operation.id, type: 'string' } },
85
+ [PUBLIC_V1_RESPONSE_HEADERS.rateLimitPolicy]: {
86
+ description: 'Active quota policy. This is present even when the request did not consume quota.',
87
+ schema: { type: 'string' },
88
+ },
89
+ [PUBLIC_V1_RESPONSE_HEADERS.rateLimit]: {
90
+ description: 'Remaining quota and reset metadata after a valid scoped principal consumes quota.',
91
+ schema: { type: 'string' },
92
+ },
93
+ [PUBLIC_V1_RESPONSE_HEADERS.retryAfter]: {
94
+ description: 'Delay in seconds before a retry when the response is retryable.',
95
+ schema: { type: 'integer', minimum: 0 },
96
+ },
97
+ ...(operation.lifecycle.deprecated
98
+ ? { [PUBLIC_V1_RESPONSE_HEADERS.deprecation]: { schema: { type: 'string' } } }
99
+ : {}),
100
+ ...(operation.lifecycle.sunset
101
+ ? { [PUBLIC_V1_RESPONSE_HEADERS.sunset]: { schema: { type: 'string', format: 'http-date' } } }
102
+ : {}),
103
+ };
104
+ }
105
+ function idempotencyParameters(operation) {
106
+ const policy = operation.policy.idempotency;
107
+ if (policy._tag === 'none')
108
+ return [];
109
+ return [{
110
+ description: `Required for safe replay. Results remain replayable for ${policy.retentionSeconds} seconds.`,
111
+ in: 'header',
112
+ name: policy.header,
113
+ required: true,
114
+ schema: { type: 'string', minLength: 1, maxLength: 255 },
115
+ }];
116
+ }
117
+ function successResponses(operation, components) {
118
+ return Object.fromEntries(Object.entries(operation.responses).map(([status, response]) => {
119
+ const component = `${operation.id}.${status}.response`;
120
+ const example = operation.docs.examples.find(item => item.response.status === Number(status))?.response.body;
121
+ return [status, {
122
+ description: 'Successful response.',
123
+ headers: commonResponseHeaders(operation),
124
+ content: {
125
+ 'application/json': {
126
+ schema: registerComponentSchema(response.producer, component, components),
127
+ ...(example === undefined ? {} : { example }),
128
+ },
129
+ },
130
+ }];
131
+ }));
132
+ }
133
+ function errorResponses(operation, components) {
134
+ const component = `${operation.id}.error`;
135
+ const schema = registerComponentSchema(operation.errorResponse.producer, component, components);
136
+ const byStatus = new Map();
137
+ for (const code of operation.errors) {
138
+ const status = PUBLIC_V1_ERROR_STATUS[code];
139
+ byStatus.set(status, [...(byStatus.get(status) ?? []), code]);
140
+ }
141
+ return Object.fromEntries(Array.from(byStatus.entries(), ([status, codes]) => [String(status), {
142
+ 'description': `Stable errors: ${codes.join(', ')}.`,
143
+ 'x-nuxtseo-error-codes': codes,
144
+ 'headers': commonResponseHeaders(operation),
145
+ 'content': { 'application/json': { schema } },
146
+ }]));
147
+ }
148
+ function requestBody(operation, components) {
149
+ if (operation.request.body === null)
150
+ return undefined;
151
+ const component = `${operation.id}.body.request`;
152
+ const example = operation.docs.examples.find(item => Object.hasOwn(item.request, 'body'))?.request.body;
153
+ return {
154
+ required: true,
155
+ content: {
156
+ 'application/json': {
157
+ schema: registerComponentSchema(operation.request.body, component, components, 'input'),
158
+ ...(example === undefined ? {} : { example }),
159
+ },
160
+ },
161
+ };
162
+ }
163
+ function operationDocument(operation, components) {
164
+ const parameters = [
165
+ ...objectParameters(operation.request.params, 'path', operation, components),
166
+ ...objectParameters(operation.request.query, 'query', operation, components),
167
+ ...objectParameters(operation.request.headers, 'header', operation, components),
168
+ ...idempotencyParameters(operation),
169
+ ];
170
+ const body = requestBody(operation, components);
171
+ return {
172
+ 'operationId': operation.id,
173
+ 'summary': operation.docs.summary,
174
+ 'description': operation.docs.description,
175
+ 'tags': operation.docs.tags,
176
+ 'security': operation.auth.credentials.map(credential => credential === 'session'
177
+ ? { sessionAuth: [] }
178
+ : { bearerAuth: [] }),
179
+ ...(parameters.length === 0 ? {} : { parameters }),
180
+ ...(body ? { requestBody: body } : {}),
181
+ 'responses': {
182
+ ...successResponses(operation, components),
183
+ ...errorResponses(operation, components),
184
+ },
185
+ 'x-nuxtseo-auth': operation.auth,
186
+ 'x-nuxtseo-lifecycle': operation.lifecycle,
187
+ 'x-nuxtseo-policy': operation.policy,
188
+ 'x-nuxtseo-resources': operation.resources,
189
+ 'x-nuxtseo-semantics': operation.semantics,
190
+ };
191
+ }
192
+ export function buildOpenApiDocument(protocol) {
193
+ const components = {};
194
+ const paths = {};
195
+ for (const { operation } of listOperations(protocol)) {
196
+ const path = paths[operation.path] ?? {};
197
+ path[operation.method.toLowerCase()] = operationDocument(operation, components);
198
+ paths[operation.path] = path;
199
+ }
200
+ return orderedJson({
201
+ openapi: '3.1.0',
202
+ info: {
203
+ title: 'NuxtSEO Public API',
204
+ version: protocol.version,
205
+ description: 'Stable Team-scoped operations used by NuxtSEO clients.',
206
+ },
207
+ paths,
208
+ components: {
209
+ securitySchemes: {
210
+ bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'NuxtSEO API token' },
211
+ sessionAuth: { type: 'apiKey', in: 'cookie', name: 'nuxt-session' },
212
+ },
213
+ schemas: components,
214
+ },
215
+ });
216
+ }