@lukoweb/apitogo 0.2.10 → 0.2.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo",
3
- "version": "0.2.10",
3
+ "version": "0.2.13",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -0,0 +1,220 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parse } from "yaml";
3
+ import type { ResolvedGateway } from "./resolve.js";
4
+
5
+ /** What the gateway checks, in the terms the exported OpenAPI file uses. */
6
+ export type ReferenceOptions = {
7
+ /** The address callers use. Replaces every server of the spec. */
8
+ gatewayBaseUrl: string;
9
+ /** The customer's own API address. It must never appear in the result. */
10
+ originUrl?: string;
11
+ jwtRequired: boolean;
12
+ oidcMetadataUrl?: string;
13
+ apiKeyRequired: boolean;
14
+ apiKeyHeader: string;
15
+ /** Action keys such as `get:/orders`. */
16
+ publicOperations: ReadonlySet<string>;
17
+ deniedOperations: ReadonlySet<string>;
18
+ };
19
+
20
+ /** Header of the API key when the gateway does not say otherwise (same as the platform's OpenAPI export). */
21
+ export const DEFAULT_KEY_HEADER = "Ocp-Apim-Subscription-Key";
22
+
23
+ const METHODS = [
24
+ "get",
25
+ "put",
26
+ "post",
27
+ "delete",
28
+ "options",
29
+ "head",
30
+ "patch",
31
+ "trace",
32
+ ] as const;
33
+
34
+ type Json = Record<string, unknown>;
35
+
36
+ const isObject = (value: unknown): value is Json =>
37
+ typeof value === "object" && value !== null && !Array.isArray(value);
38
+
39
+ /** `GET /orders/` becomes `get:/orders`, the key the platform uses for an operation. */
40
+ export function actionKey(method: string, path: string): string {
41
+ const trimmed = path.trim();
42
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
43
+ const normalized =
44
+ withSlash.length > 1 ? withSlash.replace(/\/+$/, "") || "/" : withSlash;
45
+ return `${method.trim().toLowerCase()}:${normalized}`;
46
+ }
47
+
48
+ /** A config entry like `GET /orders` as an action key, or undefined when it has no method and path. */
49
+ export function configActionKey(entry: string): string | undefined {
50
+ const match = /^\s*([A-Za-z]+)\s+(\S+)\s*$/.exec(entry);
51
+ return match?.[1] && match[2] ? actionKey(match[1], match[2]) : undefined;
52
+ }
53
+
54
+ /**
55
+ * What the platform's OpenAPI export is made with, from the config: a token is checked when `access.jwt` is on (the
56
+ * default when `auth` is set), a key unless `access.apiKey` is off.
57
+ */
58
+ export function referenceOptions(
59
+ gateway: ResolvedGateway,
60
+ gatewayBaseUrl: string,
61
+ keyHeaderName?: string,
62
+ ): ReferenceOptions {
63
+ const access = gateway.policies.access;
64
+ const jwt = access?.jwt ?? gateway.auth !== undefined;
65
+ const keys = (list?: string[]) =>
66
+ new Set(
67
+ (list ?? []).flatMap((entry) => {
68
+ const key = configActionKey(entry);
69
+ return key ? [key] : [];
70
+ }),
71
+ );
72
+ return {
73
+ gatewayBaseUrl,
74
+ originUrl: gateway.origin.url,
75
+ jwtRequired: jwt && gateway.auth !== undefined,
76
+ oidcMetadataUrl: jwt ? gateway.auth?.metadataUrl : undefined,
77
+ apiKeyRequired: access?.apiKey ?? true,
78
+ apiKeyHeader: keyHeaderName ?? access?.apiKeyHeader ?? DEFAULT_KEY_HEADER,
79
+ publicOperations: keys(gateway.policies.operations?.public),
80
+ deniedOperations: keys(gateway.policies.operations?.deny),
81
+ };
82
+ }
83
+
84
+ export class ReferenceSpecError extends Error {
85
+ constructor(message: string) {
86
+ super(message);
87
+ this.name = "ReferenceSpecError";
88
+ }
89
+ }
90
+
91
+ function operationsOf(paths: Json): [string, Json, (typeof METHODS)[number]][] {
92
+ return Object.entries(paths).flatMap(([path, item]) =>
93
+ isObject(item)
94
+ ? METHODS.filter((method) => isObject(item[method])).map(
95
+ (method) =>
96
+ [path, item, method] as [string, Json, (typeof METHODS)[number]],
97
+ )
98
+ : [],
99
+ );
100
+ }
101
+
102
+ /**
103
+ * The document the portal's API page shows. It follows what the platform exports for outside docs tools: servers
104
+ * point at the gateway, the security schemes say what the gateway really checks, denied operations are gone, public
105
+ * operations carry no security, and the origin address never shows (it is replaced by the gateway address).
106
+ */
107
+ export function buildReferenceSpec(
108
+ specText: string,
109
+ options: ReferenceOptions,
110
+ ): Json {
111
+ let root: unknown;
112
+ try {
113
+ root = parse(specText);
114
+ } catch {
115
+ throw new ReferenceSpecError("The OpenAPI file could not be read.");
116
+ }
117
+ if (!isObject(root) || typeof root.openapi !== "string")
118
+ throw new ReferenceSpecError("The OpenAPI file could not be read.");
119
+
120
+ const doc = structuredClone(root);
121
+ const paths = isObject(doc.paths) ? doc.paths : {};
122
+
123
+ // Denied operations are removed; a path with nothing left goes too.
124
+ for (const [path, item, method] of operationsOf(paths))
125
+ if (options.deniedOperations.has(actionKey(method, path)))
126
+ delete item[method];
127
+ for (const [path, item] of Object.entries(paths))
128
+ if (!isObject(item) || !METHODS.some((method) => method in item))
129
+ delete paths[path];
130
+
131
+ // Public operations need neither token nor key.
132
+ for (const [path, item, method] of operationsOf(paths))
133
+ if (options.publicOperations.has(actionKey(method, path)))
134
+ (item[method] as Json).security = [];
135
+
136
+ // Servers on paths and operations would point somewhere else, often at the origin.
137
+ doc.servers = [{ url: options.gatewayBaseUrl.replace(/\/+$/, "") }];
138
+ for (const item of Object.values(paths)) {
139
+ if (!isObject(item)) continue;
140
+ delete item.servers;
141
+ for (const method of METHODS) {
142
+ const operation = item[method];
143
+ if (isObject(operation)) delete operation.servers;
144
+ }
145
+ }
146
+
147
+ // The document describes the gateway, not the origin: the origin's own schemes are replaced.
148
+ const schemes: Json = {};
149
+ const requirement: Json = {};
150
+ if (options.jwtRequired) {
151
+ schemes.bearerAuth = {
152
+ type: "http",
153
+ scheme: "bearer",
154
+ bearerFormat: "JWT",
155
+ };
156
+ requirement.bearerAuth = [];
157
+ }
158
+ if (options.oidcMetadataUrl)
159
+ schemes.openIdAuth = {
160
+ type: "openIdConnect",
161
+ openIdConnectUrl: options.oidcMetadataUrl,
162
+ };
163
+ if (options.apiKeyRequired) {
164
+ schemes.apiKeyAuth = {
165
+ type: "apiKey",
166
+ in: "header",
167
+ name: options.apiKeyHeader,
168
+ };
169
+ requirement.apiKeyAuth = [];
170
+ }
171
+ const components = isObject(doc.components) ? doc.components : {};
172
+ if (Object.keys(schemes).length > 0) components.securitySchemes = schemes;
173
+ else delete components.securitySchemes;
174
+ if (Object.keys(components).length > 0) doc.components = components;
175
+ else delete doc.components;
176
+ // One requirement with both schemes means both are needed together.
177
+ doc.security = Object.keys(requirement).length > 0 ? [requirement] : [];
178
+ for (const [, item, method] of operationsOf(paths)) {
179
+ const operation = item[method] as Json;
180
+ if (Array.isArray(operation.security) && operation.security.length > 0)
181
+ delete operation.security;
182
+ }
183
+
184
+ return JSON.parse(hideOrigin(JSON.stringify(doc), options)) as Json;
185
+ }
186
+
187
+ /** The origin address must not show anywhere, not even in a description. */
188
+ function hideOrigin(text: string, options: ReferenceOptions): string {
189
+ const origin = options.originUrl?.trim().replace(/\/+$/, "");
190
+ if (!origin) return text;
191
+ const gateway = options.gatewayBaseUrl.trim().replace(/\/+$/, "");
192
+ // JSON.stringify leaves slashes as they are, so the plain address is what to look for.
193
+ const pattern = new RegExp(
194
+ origin.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"),
195
+ "gi",
196
+ );
197
+ return text.replace(pattern, () => gateway);
198
+ }
199
+
200
+ /** Reads the gateway's OpenAPI file and builds the reference document. Returns JSON text, one key order per input. */
201
+ export async function renderReferenceSpec(
202
+ gateway: ResolvedGateway,
203
+ gatewayBaseUrl: string,
204
+ keyHeaderName?: string,
205
+ ): Promise<string> {
206
+ const file = gateway.specs[0];
207
+ if (!file)
208
+ throw new ReferenceSpecError("gateway.specs lists no OpenAPI file.");
209
+ let text: string;
210
+ try {
211
+ text = await readFile(file, "utf8");
212
+ } catch {
213
+ throw new ReferenceSpecError(`Cannot read the OpenAPI file ${file}.`);
214
+ }
215
+ const spec = buildReferenceSpec(
216
+ text,
217
+ referenceOptions(gateway, gatewayBaseUrl, keyHeaderName),
218
+ );
219
+ return `${JSON.stringify(spec, null, 2)}\n`;
220
+ }
@@ -65,6 +65,14 @@ export {
65
65
  writeManifest,
66
66
  } from "./manifest.js";
67
67
  export { MANIFEST_FILE, manifestPathFor } from "./paths.js";
68
+ export {
69
+ buildReferenceSpec,
70
+ DEFAULT_KEY_HEADER,
71
+ type ReferenceOptions,
72
+ ReferenceSpecError,
73
+ referenceOptions,
74
+ renderReferenceSpec,
75
+ } from "./gateway-reference.js";
68
76
  export {
69
77
  type DerivedCorsSite,
70
78
  derivedCorsSites,
@@ -1,6 +1,10 @@
1
1
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { ConfigError, type ConfigIssue } from "./errors.js";
4
+ import {
5
+ ReferenceSpecError,
6
+ renderReferenceSpec,
7
+ } from "./gateway-reference.js";
4
8
  import {
5
9
  hasCapability,
6
10
  missingManifestServices,
@@ -43,8 +47,10 @@ const PLANS_CONSOLE_CAPABILITIES = [
43
47
  export type PortalPlan = {
44
48
  /** Docs pages, navigation and search APItoGo builds. */
45
49
  docs: boolean;
46
- /** OpenAPI reference pages and playground. */
50
+ /** OpenAPI reference pages and playground, built from the gateway's OpenAPI file. */
47
51
  apiReference: boolean;
52
+ /** Where the reference points its calls, and the header of the API key; set when `apiReference` is true. */
53
+ gatewayReference?: { baseUrl: string; keyHeaderName?: string };
48
54
  /** OpenID sign-in against the identity issuer. */
49
55
  auth?: {
50
56
  issuer: string;
@@ -144,11 +150,11 @@ export function planPortal(
144
150
  const skipped: PortalPlan["skipped"] = [];
145
151
  const skip = (piece: string, reason: string) =>
146
152
  void skipped.push({ piece, reason });
147
- const { identity, plans } = manifest.services;
153
+ const { identity, plans, gateway } = manifest.services;
148
154
 
149
155
  const plan: PortalPlan = {
150
156
  docs: profile.modules.docs,
151
- apiReference: profile.pipelines.openapi,
157
+ apiReference: false,
152
158
  userPanel: false,
153
159
  signup: false,
154
160
  skipped,
@@ -157,6 +163,22 @@ export function planPortal(
157
163
 
158
164
  if (!profile.enabled) return plan;
159
165
 
166
+ // The API page is built from the gateway's OpenAPI file and calls the gateway, so it needs the gateway's address.
167
+ if (profile.pipelines.openapi) {
168
+ if (!gateway) skip("api reference", "services.json has no gateway entry");
169
+ else if (!serviceReady(gateway))
170
+ skip("api reference", `gateway is ${gateway.status}, not ready`);
171
+ else if (!gateway.baseUrl)
172
+ skip("api reference", "the gateway entry has no base URL yet");
173
+ else {
174
+ plan.apiReference = true;
175
+ plan.gatewayReference = {
176
+ baseUrl: gateway.baseUrl,
177
+ keyHeaderName: gateway.keyHeaderName,
178
+ };
179
+ }
180
+ }
181
+
160
182
  if (!profile.console.enabled) {
161
183
  skip("console", "portal.console.enabled is false");
162
184
  return plan;
@@ -207,6 +229,12 @@ const json = (value: unknown) => JSON.stringify(value, null, 2);
207
229
  /** Name of the site config the wrapper imports, next to it in `generated/`. */
208
230
  export const SITE_CONFIG_FILE = "apitogo.config.json";
209
231
 
232
+ /** The gateway's OpenAPI document as the portal shows it, inside `generated/`. */
233
+ export const REFERENCE_SPEC_FILE = "openapi/gateway.json";
234
+
235
+ /** Route of the API reference page. */
236
+ export const API_REFERENCE_PATH = "api";
237
+
210
238
  /**
211
239
  * The site settings (title, modules, docs globs, theme, sign-in) in the shape the existing build reads from
212
240
  * `apitogo.config.json`. It is generated, never edited, and lives in `generated/`.
@@ -237,6 +265,24 @@ export function buildSiteConfig(
237
265
  },
238
266
  }
239
267
  : {}),
268
+ ...(plan.apiReference
269
+ ? {
270
+ apis: [
271
+ {
272
+ type: "file",
273
+ input: `./${REFERENCE_SPEC_FILE}`,
274
+ path: API_REFERENCE_PATH,
275
+ },
276
+ ],
277
+ navigation: [
278
+ {
279
+ type: "link",
280
+ to: `/${API_REFERENCE_PATH}`,
281
+ label: "API Reference",
282
+ },
283
+ ],
284
+ }
285
+ : {}),
240
286
  ...(theme !== undefined ? { theme } : {}),
241
287
  ...(plan.auth ? { authentication: { type: "openid", ...plan.auth } } : {}),
242
288
  ...(plan.preview ? { preview: true } : {}),
@@ -298,6 +344,8 @@ export type PreparedPortal = {
298
344
  wrapper: string;
299
345
  /** Contents of the generated site config the wrapper imports. */
300
346
  siteConfig: Record<string, unknown>;
347
+ /** The gateway's OpenAPI document for the API page (JSON text), when the plan builds one. */
348
+ referenceSpec?: string;
301
349
  /** The manifest to ship at the site root, when there is a deployed one. */
302
350
  publishedManifest?: ServiceManifest;
303
351
  /** Absolute path the wrapper was written to, under the state directory's `generated/`. */
@@ -363,6 +411,25 @@ export async function preparePortal(
363
411
  }
364
412
 
365
413
  const plan = planPortal(profile, effective);
414
+ let referenceSpec: string | undefined;
415
+ if (plan.apiReference && plan.gatewayReference && resolved.gateway) {
416
+ try {
417
+ referenceSpec = await renderReferenceSpec(
418
+ resolved.gateway,
419
+ plan.gatewayReference.baseUrl,
420
+ plan.gatewayReference.keyHeaderName,
421
+ );
422
+ } catch (error) {
423
+ if (!(error instanceof ReferenceSpecError)) throw error;
424
+ // `check` reports a bad OpenAPI file as an error. The build goes on without the page.
425
+ plan.apiReference = false;
426
+ plan.gatewayReference = undefined;
427
+ plan.skipped.push({
428
+ piece: "api reference",
429
+ reason: `${error.message} The API page is left out.`,
430
+ });
431
+ }
432
+ }
366
433
  const siteConfig = buildSiteConfig(resolved, plan, await readTheme(resolved));
367
434
  const wrapper = renderWrapper(plan);
368
435
  const wrapperPath = path.join(resolved.state.generatedDir, WRAPPER_FILE);
@@ -372,6 +439,16 @@ export async function preparePortal(
372
439
  path.join(path.dirname(wrapperPath), SITE_CONFIG_FILE),
373
440
  `${JSON.stringify(siteConfig, null, 2)}\n`,
374
441
  );
442
+ const referenceFile = path.join(
443
+ path.dirname(wrapperPath),
444
+ REFERENCE_SPEC_FILE,
445
+ );
446
+ if (referenceSpec !== undefined) {
447
+ await mkdir(path.dirname(referenceFile), { recursive: true });
448
+ await writeFile(referenceFile, referenceSpec);
449
+ } else {
450
+ await rm(referenceFile, { force: true });
451
+ }
375
452
  const temporary = `${wrapperPath}.${process.pid}.tmp`;
376
453
  try {
377
454
  await writeFile(temporary, wrapper, "utf8");
@@ -386,6 +463,7 @@ export async function preparePortal(
386
463
  plan,
387
464
  wrapper,
388
465
  siteConfig,
466
+ referenceSpec,
389
467
  // Without a console there are no console links to offer, so a docs-only portal ships no manifest.
390
468
  publishedManifest:
391
469
  manifestSource === "file" && profile.console.enabled