@lukoweb/apitogo 0.2.2 → 0.2.12
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/dist/cli/cli.js +2344 -534
- package/dist/declarations/cli/platform/client/platform-api.d.ts +114 -0
- package/package.json +1 -1
- package/src/config/v2/gateway-reference.ts +220 -0
- package/src/config/v2/gateway-request.ts +123 -0
- package/src/config/v2/index.ts +19 -0
- package/src/config/v2/ledger.ts +16 -0
- package/src/config/v2/manifest.ts +70 -0
- package/src/config/v2/resolve.ts +2 -0
- package/src/config/v2/rules.ts +64 -0
- package/src/config/v2/schema.ts +34 -0
- package/src/config/v2/services.ts +17 -0
- package/src/config/v2/wrapper.ts +81 -3
|
@@ -318,6 +318,105 @@ export interface PageFileDto {
|
|
|
318
318
|
isFile: boolean;
|
|
319
319
|
order: number;
|
|
320
320
|
}
|
|
321
|
+
export interface GatewaySpecUploadDto {
|
|
322
|
+
specId: string;
|
|
323
|
+
sha256: string;
|
|
324
|
+
openApiVersion: string;
|
|
325
|
+
operationCount: number;
|
|
326
|
+
added: number;
|
|
327
|
+
updated: number;
|
|
328
|
+
removed: number;
|
|
329
|
+
unchanged: boolean;
|
|
330
|
+
}
|
|
331
|
+
export interface GatewayConfigAcceptedDto {
|
|
332
|
+
reconciliationId: string;
|
|
333
|
+
desiredHash: string;
|
|
334
|
+
state: string;
|
|
335
|
+
unchanged: boolean;
|
|
336
|
+
}
|
|
337
|
+
export interface GatewayReconciliationDto {
|
|
338
|
+
id: string;
|
|
339
|
+
kind: string;
|
|
340
|
+
state: "Pending" | "Running" | "Succeeded" | "Failed" | "Superseded";
|
|
341
|
+
desiredHash?: string | null;
|
|
342
|
+
errorCode?: string | null;
|
|
343
|
+
errorMessage?: string | null;
|
|
344
|
+
createdAtUtc: string;
|
|
345
|
+
startedAtUtc?: string | null;
|
|
346
|
+
finishedAtUtc?: string | null;
|
|
347
|
+
}
|
|
348
|
+
export interface GatewayControlStatusDto {
|
|
349
|
+
provider: string;
|
|
350
|
+
tier: string;
|
|
351
|
+
status: string;
|
|
352
|
+
baseUrl: string;
|
|
353
|
+
desiredHash?: string | null;
|
|
354
|
+
observedHash?: string | null;
|
|
355
|
+
inSync: boolean;
|
|
356
|
+
origin?: {
|
|
357
|
+
mode: string;
|
|
358
|
+
url?: string | null;
|
|
359
|
+
health: string;
|
|
360
|
+
lastProbeAtUtc?: string | null;
|
|
361
|
+
latencyMs?: number | null;
|
|
362
|
+
lastErrorCode?: string | null;
|
|
363
|
+
healthChangedAtUtc?: string | null;
|
|
364
|
+
} | null;
|
|
365
|
+
operationCount: number;
|
|
366
|
+
specSha256?: string | null;
|
|
367
|
+
cors?: {
|
|
368
|
+
origin: string;
|
|
369
|
+
source: string;
|
|
370
|
+
applied: boolean;
|
|
371
|
+
}[] | null;
|
|
372
|
+
openApiPath?: string | null;
|
|
373
|
+
openApiHash?: string | null;
|
|
374
|
+
exportAccess?: string | null;
|
|
375
|
+
latestReconciliation?: GatewayReconciliationDto | null;
|
|
376
|
+
}
|
|
377
|
+
export interface GatewayCapabilitiesResponse {
|
|
378
|
+
provider: string;
|
|
379
|
+
tier: string;
|
|
380
|
+
capabilities: Record<string, boolean>;
|
|
381
|
+
}
|
|
382
|
+
export interface GatewayCheckResultDto {
|
|
383
|
+
ok: boolean;
|
|
384
|
+
tier: string;
|
|
385
|
+
issues: {
|
|
386
|
+
code: string;
|
|
387
|
+
field: string;
|
|
388
|
+
message: string;
|
|
389
|
+
hint: string;
|
|
390
|
+
}[];
|
|
391
|
+
specOperationCount?: number | null;
|
|
392
|
+
}
|
|
393
|
+
export interface GatewayDryRunDto {
|
|
394
|
+
ok: boolean;
|
|
395
|
+
issues: {
|
|
396
|
+
code: string;
|
|
397
|
+
field: string;
|
|
398
|
+
message: string;
|
|
399
|
+
hint: string;
|
|
400
|
+
}[];
|
|
401
|
+
changes?: {
|
|
402
|
+
wouldCreate: boolean;
|
|
403
|
+
inSync: boolean;
|
|
404
|
+
operationsAdded: string[];
|
|
405
|
+
operationsRemoved: string[];
|
|
406
|
+
nowPublic: string[];
|
|
407
|
+
nowProtected: string[];
|
|
408
|
+
nowDenied: string[];
|
|
409
|
+
originChanged: boolean;
|
|
410
|
+
policyChanges: string[];
|
|
411
|
+
hasChanges: boolean;
|
|
412
|
+
} | null;
|
|
413
|
+
activeKeys: number;
|
|
414
|
+
comparedWithGateway: boolean;
|
|
415
|
+
}
|
|
416
|
+
export interface GatewayTeardownAcceptedDto {
|
|
417
|
+
reconciliationId: string;
|
|
418
|
+
state: string;
|
|
419
|
+
}
|
|
321
420
|
export interface ProjectSecretDto {
|
|
322
421
|
name: string;
|
|
323
422
|
updatedAtUtc: string;
|
|
@@ -407,6 +506,21 @@ export declare class PlatformApiClient {
|
|
|
407
506
|
setCustomDomain(projectId: string, body: SetUserCustomDomainRequest): Promise<UserCustomDomainResponse>;
|
|
408
507
|
deleteCustomDomain(projectId: string): Promise<void>;
|
|
409
508
|
updateBackendOpenApi(projectId: string, openApiPath: string): Promise<BackendDeploymentDetailDto>;
|
|
509
|
+
putGatewaySpec(projectId: string, content: string | Uint8Array): Promise<GatewaySpecUploadDto>;
|
|
510
|
+
putGatewayConfig(projectId: string, body: unknown): Promise<GatewayConfigAcceptedDto>;
|
|
511
|
+
getGatewayReconciliation(projectId: string, reconciliationId: string): Promise<GatewayReconciliationDto>;
|
|
512
|
+
getGatewayStatus(projectId: string): Promise<GatewayControlStatusDto>;
|
|
513
|
+
getGatewayOpenApi(projectId: string): Promise<string>;
|
|
514
|
+
getGatewayCapabilities(projectId: string): Promise<GatewayCapabilitiesResponse>;
|
|
515
|
+
checkGateway(projectId: string, body: {
|
|
516
|
+
config: unknown;
|
|
517
|
+
spec?: string;
|
|
518
|
+
}): Promise<GatewayCheckResultDto>;
|
|
519
|
+
planGateway(projectId: string, body: {
|
|
520
|
+
config: unknown;
|
|
521
|
+
spec?: string;
|
|
522
|
+
}): Promise<GatewayDryRunDto>;
|
|
523
|
+
deleteGateway(projectId: string): Promise<GatewayTeardownAcceptedDto>;
|
|
410
524
|
getDevPortalBackend(projectId: string): Promise<DevPortalBackendDto | null>;
|
|
411
525
|
getDevPortalSetupStatus(projectId: string): Promise<DevPortalSetupStatusDto>;
|
|
412
526
|
getDevPortalSettings(projectId: string): Promise<DevPortalSettingsDto | null>;
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { ResolvedGateway } from "./resolve.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The body of `PUT /gateway/config`: the wanted state of the gateway (Gateway design doc, 8.9 and 8.12).
|
|
5
|
+
* Only names of secrets are in it, never values.
|
|
6
|
+
*/
|
|
7
|
+
export type GatewayConfigBody = {
|
|
8
|
+
provider: string;
|
|
9
|
+
origin: {
|
|
10
|
+
mode: "managed" | "proxy";
|
|
11
|
+
url?: string;
|
|
12
|
+
healthPath?: string;
|
|
13
|
+
timeoutSeconds?: number;
|
|
14
|
+
auth?: { type: string; name?: string; value?: string };
|
|
15
|
+
};
|
|
16
|
+
auth?: {
|
|
17
|
+
mode: string;
|
|
18
|
+
metadataUrl?: string;
|
|
19
|
+
issuers?: string[];
|
|
20
|
+
audiences?: string[];
|
|
21
|
+
};
|
|
22
|
+
export?: { access?: string };
|
|
23
|
+
/** Sites of this project that call the gateway from a browser. The platform adds them to the allowed origins. */
|
|
24
|
+
corsDerived?: DerivedCorsSite[];
|
|
25
|
+
policies?: {
|
|
26
|
+
access?: {
|
|
27
|
+
jwt?: boolean;
|
|
28
|
+
apiKey?: boolean;
|
|
29
|
+
apiKeyHeader?: string;
|
|
30
|
+
bindKeyToToken?: boolean;
|
|
31
|
+
};
|
|
32
|
+
rateLimit?: { calls: number; periodSeconds: number; scope?: string };
|
|
33
|
+
quota?: { calls: number; periodDays: number; scope?: string };
|
|
34
|
+
cors?: string[];
|
|
35
|
+
operations?: { public?: string[]; deny?: string[] };
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type DerivedCorsSite = { origin: string; source: "portal" | "docs" };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The browser origins of this project's own sites: the portal (from `services.json`) and an external docs site
|
|
43
|
+
* (from the config). Only the origin is kept, never a path. Sorted, so the same sites always give the same body.
|
|
44
|
+
*/
|
|
45
|
+
export function derivedCorsSites(sites: {
|
|
46
|
+
portalUrl?: string;
|
|
47
|
+
docsUrl?: string;
|
|
48
|
+
}): DerivedCorsSite[] {
|
|
49
|
+
const originOf = (url?: string) => {
|
|
50
|
+
if (!url) return undefined;
|
|
51
|
+
try {
|
|
52
|
+
const { protocol, origin } = new URL(url);
|
|
53
|
+
return protocol === "https:" ? origin : undefined;
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const found = [
|
|
59
|
+
{ origin: originOf(sites.docsUrl), source: "docs" as const },
|
|
60
|
+
{ origin: originOf(sites.portalUrl), source: "portal" as const },
|
|
61
|
+
].flatMap((site) => (site.origin ? [{ ...site, origin: site.origin }] : []));
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
return found
|
|
64
|
+
.filter((site) => !seen.has(site.origin) && seen.add(site.origin))
|
|
65
|
+
.sort((a, b) => (a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const defined = <T extends Record<string, unknown>>(value: T): T =>
|
|
69
|
+
Object.fromEntries(
|
|
70
|
+
Object.entries(value).filter(([, item]) => item !== undefined),
|
|
71
|
+
) as T;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Turns the resolved `gateway` section into the request body. The spec files are not part of it: they are uploaded
|
|
75
|
+
* with `PUT /gateway/spec`. Fields the platform fills with defaults are left out when the config does not set them,
|
|
76
|
+
* so the same config always gives the same body (and the same hash).
|
|
77
|
+
*/
|
|
78
|
+
export function gatewayConfigBody(
|
|
79
|
+
gateway: ResolvedGateway,
|
|
80
|
+
derivedCors: DerivedCorsSite[] = [],
|
|
81
|
+
): GatewayConfigBody {
|
|
82
|
+
const { origin, auth, policies } = gateway;
|
|
83
|
+
const body: GatewayConfigBody = {
|
|
84
|
+
provider: gateway.provider,
|
|
85
|
+
origin: defined({
|
|
86
|
+
mode: origin.mode,
|
|
87
|
+
url: origin.url,
|
|
88
|
+
healthPath: origin.healthPath,
|
|
89
|
+
timeoutSeconds: origin.timeoutSeconds,
|
|
90
|
+
auth: origin.auth ? defined({ ...origin.auth }) : undefined,
|
|
91
|
+
}),
|
|
92
|
+
};
|
|
93
|
+
if (auth) body.auth = defined({ ...auth });
|
|
94
|
+
if (gateway.export) body.export = defined({ ...gateway.export });
|
|
95
|
+
if (derivedCors.length > 0) body.corsDerived = derivedCors;
|
|
96
|
+
if (Object.keys(policies).length > 0) {
|
|
97
|
+
body.policies = defined({
|
|
98
|
+
access: policies.access ? defined({ ...policies.access }) : undefined,
|
|
99
|
+
rateLimit: policies.rateLimit
|
|
100
|
+
? defined({ ...policies.rateLimit })
|
|
101
|
+
: undefined,
|
|
102
|
+
quota: policies.quota ? defined({ ...policies.quota }) : undefined,
|
|
103
|
+
cors: policies.cors,
|
|
104
|
+
operations: policies.operations
|
|
105
|
+
? defined({ ...policies.operations })
|
|
106
|
+
: undefined,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return body;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Stable JSON text of a value: object keys sorted, so equal content gives equal text. */
|
|
113
|
+
export function stableJson(value: unknown): string {
|
|
114
|
+
return JSON.stringify(value, (_key, item) =>
|
|
115
|
+
item && typeof item === "object" && !Array.isArray(item)
|
|
116
|
+
? Object.fromEntries(
|
|
117
|
+
Object.entries(item as Record<string, unknown>).sort(([a], [b]) =>
|
|
118
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
: item,
|
|
122
|
+
);
|
|
123
|
+
}
|
package/src/config/v2/index.ts
CHANGED
|
@@ -54,15 +54,32 @@ export {
|
|
|
54
54
|
missingManifestServices,
|
|
55
55
|
parseManifest,
|
|
56
56
|
readManifest,
|
|
57
|
+
removeServicesFromManifest,
|
|
57
58
|
requiredServices,
|
|
58
59
|
SUPPORTED_MANIFEST_SCHEMA,
|
|
59
60
|
type ServiceManifest,
|
|
60
61
|
serviceReady,
|
|
61
62
|
stubManifest,
|
|
63
|
+
withGatewayEntry,
|
|
62
64
|
withPortalEntry,
|
|
63
65
|
writeManifest,
|
|
64
66
|
} from "./manifest.js";
|
|
65
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";
|
|
76
|
+
export {
|
|
77
|
+
type DerivedCorsSite,
|
|
78
|
+
derivedCorsSites,
|
|
79
|
+
type GatewayConfigBody,
|
|
80
|
+
gatewayConfigBody,
|
|
81
|
+
stableJson,
|
|
82
|
+
} from "./gateway-request.js";
|
|
66
83
|
export {
|
|
67
84
|
consoleLinks,
|
|
68
85
|
planPortal,
|
|
@@ -84,6 +101,7 @@ export {
|
|
|
84
101
|
planAddService,
|
|
85
102
|
planRemoveService,
|
|
86
103
|
SERVICES,
|
|
104
|
+
clearProjectId,
|
|
87
105
|
ServiceEditError,
|
|
88
106
|
setProjectId,
|
|
89
107
|
type ServiceEdit,
|
|
@@ -91,6 +109,7 @@ export {
|
|
|
91
109
|
} from "./services.js";
|
|
92
110
|
export { checkDependencies } from "./rules.js";
|
|
93
111
|
export {
|
|
112
|
+
clearServices,
|
|
94
113
|
emptyLedger,
|
|
95
114
|
type Ledger,
|
|
96
115
|
LEDGER_VERSION,
|
package/src/config/v2/ledger.ts
CHANGED
|
@@ -217,3 +217,19 @@ export async function runPipeline(
|
|
|
217
217
|
}
|
|
218
218
|
return outcomes;
|
|
219
219
|
}
|
|
220
|
+
|
|
221
|
+
/** Forgets the recorded steps of services that no longer exist on the platform. Other services keep their entries. */
|
|
222
|
+
export async function clearServices(
|
|
223
|
+
state: Pick<StatePaths, "file">,
|
|
224
|
+
services: readonly string[],
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
const ledger = await readLedger(state);
|
|
227
|
+
const remaining = Object.fromEntries(
|
|
228
|
+
Object.entries(ledger.services).filter(
|
|
229
|
+
([name]) => !services.includes(name),
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
if (Object.keys(remaining).length === Object.keys(ledger.services).length)
|
|
233
|
+
return;
|
|
234
|
+
await writeLedger(state, { ...ledger, services: remaining });
|
|
235
|
+
}
|
|
@@ -271,6 +271,54 @@ export function withPortalEntry(
|
|
|
271
271
|
};
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Adds the gateway entry a gateway deploy learned. Only public values: the base URL and what the platform says the
|
|
276
|
+
* gateway can do. Entries of other services are kept as they are.
|
|
277
|
+
*/
|
|
278
|
+
export function withGatewayEntry(
|
|
279
|
+
existing: ServiceManifest | undefined,
|
|
280
|
+
info: {
|
|
281
|
+
projectId: string;
|
|
282
|
+
projectName: string;
|
|
283
|
+
provider: string;
|
|
284
|
+
baseUrl: string;
|
|
285
|
+
capabilities: Record<string, boolean>;
|
|
286
|
+
openapiUrl?: string;
|
|
287
|
+
openapiHash?: string;
|
|
288
|
+
keyHeaderName?: string;
|
|
289
|
+
now?: Date;
|
|
290
|
+
},
|
|
291
|
+
): ServiceManifest {
|
|
292
|
+
const base: ServiceManifest =
|
|
293
|
+
existing ??
|
|
294
|
+
parseManifest({
|
|
295
|
+
schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
|
|
296
|
+
project: { name: info.projectName },
|
|
297
|
+
});
|
|
298
|
+
const baseUrl = info.baseUrl.replace(/\/+$/, "");
|
|
299
|
+
return {
|
|
300
|
+
...base,
|
|
301
|
+
schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
|
|
302
|
+
generatedAt: (info.now ?? new Date()).toISOString(),
|
|
303
|
+
preview: undefined,
|
|
304
|
+
project: { ...base.project, id: info.projectId, name: info.projectName },
|
|
305
|
+
services: {
|
|
306
|
+
...base.services,
|
|
307
|
+
gateway: {
|
|
308
|
+
...base.services.gateway,
|
|
309
|
+
provider: info.provider,
|
|
310
|
+
status: "ready",
|
|
311
|
+
baseUrl,
|
|
312
|
+
audience: baseUrl,
|
|
313
|
+
capabilities: info.capabilities,
|
|
314
|
+
...(info.openapiUrl ? { openapiUrl: info.openapiUrl } : {}),
|
|
315
|
+
...(info.openapiHash ? { openapiHash: info.openapiHash } : {}),
|
|
316
|
+
...(info.keyHeaderName ? { keyHeaderName: info.keyHeaderName } : {}),
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
274
322
|
/** Replaces `services.json` atomically. Public values only: this file ships inside the portal. */
|
|
275
323
|
export async function writeManifest(
|
|
276
324
|
state: Pick<StatePaths, "dir">,
|
|
@@ -291,3 +339,25 @@ export async function writeManifest(
|
|
|
291
339
|
throw error;
|
|
292
340
|
}
|
|
293
341
|
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Drops the entries of services that were torn down. The file is deleted when no service is left, because a
|
|
345
|
+
* manifest with no services says nothing and would only look like a deployed project.
|
|
346
|
+
*/
|
|
347
|
+
export async function removeServicesFromManifest(
|
|
348
|
+
state: Pick<StatePaths, "dir">,
|
|
349
|
+
names: readonly ManifestServiceName[],
|
|
350
|
+
): Promise<void> {
|
|
351
|
+
const existing = await readManifest(state);
|
|
352
|
+
if (!existing) return;
|
|
353
|
+
const services = Object.fromEntries(
|
|
354
|
+
Object.entries(existing.services).filter(
|
|
355
|
+
([name]) => !(names as readonly string[]).includes(name),
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
if (Object.keys(services).length === 0) {
|
|
359
|
+
await rm(manifestPathFor(state), { force: true });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
await writeManifest(state, { ...existing, services });
|
|
363
|
+
}
|
package/src/config/v2/resolve.ts
CHANGED
|
@@ -35,6 +35,7 @@ export type ResolvedGateway = {
|
|
|
35
35
|
/** Absolute paths. */
|
|
36
36
|
specs: string[];
|
|
37
37
|
auth?: NonNullable<ConfigV2["gateway"]>["auth"];
|
|
38
|
+
export?: NonNullable<ConfigV2["gateway"]>["export"];
|
|
38
39
|
policies: NonNullable<NonNullable<ConfigV2["gateway"]>["policies"]>;
|
|
39
40
|
};
|
|
40
41
|
|
|
@@ -149,6 +150,7 @@ export function resolveConfigV2(
|
|
|
149
150
|
},
|
|
150
151
|
specs: gateway.specs.map((spec) => path.resolve(configDir, spec)),
|
|
151
152
|
auth: gateway.auth,
|
|
153
|
+
export: gateway.export,
|
|
152
154
|
policies: gateway.policies ?? {},
|
|
153
155
|
};
|
|
154
156
|
}
|