@ankhorage/data-sources 2.0.4 → 2.0.5
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/CHANGELOG.md +6 -0
- package/dist/discovery/index.d.ts +3 -0
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/index.js +42 -30
- package/dist/discovery/index.js.map +1 -1
- package/dist/discovery/openapiDiscoveryPlan.d.ts +12 -0
- package/dist/discovery/openapiDiscoveryPlan.d.ts.map +1 -0
- package/dist/discovery/openapiDiscoveryPlan.js +41 -0
- package/dist/discovery/openapiDiscoveryPlan.js.map +1 -0
- package/dist/discovery/scopeOpenApiDocument.d.ts +13 -0
- package/dist/discovery/scopeOpenApiDocument.d.ts.map +1 -0
- package/dist/discovery/scopeOpenApiDocument.js +27 -0
- package/dist/discovery/scopeOpenApiDocument.js.map +1 -0
- package/package.json +1 -1
- package/src/discovery/index.test.ts +147 -0
- package/src/discovery/index.ts +49 -30
- package/src/discovery/openapiDiscoveryPlan.ts +58 -0
- package/src/discovery/scopeOpenApiDocument.ts +45 -0
package/CHANGELOG.md
CHANGED
|
@@ -58,7 +58,10 @@ export type IntrospectGraphQlApiResult = {
|
|
|
58
58
|
readonly diagnostics: readonly DataSourceDiagnostic[];
|
|
59
59
|
readonly status?: number;
|
|
60
60
|
};
|
|
61
|
+
/*** Creates the ordered, deduplicated URLs probed during OpenAPI discovery. */
|
|
61
62
|
export declare function createOpenApiDiscoveryCandidates(rawUrl: string, conventionalPaths?: readonly string[]): readonly string[];
|
|
63
|
+
/*** Discovers and imports an OpenAPI document while preserving the requested service boundary. */
|
|
62
64
|
export declare function discoverOpenApi(input: DiscoverOpenApiInput): Promise<DiscoverOpenApiResult>;
|
|
65
|
+
/*** Introspects a GraphQL endpoint and normalizes its schema into the canonical API definition. */
|
|
63
66
|
export declare function introspectGraphQlApi(input: IntrospectGraphQlApiInput): Promise<IntrospectGraphQlApiResult>;
|
|
64
67
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/discovery/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,aAAa,EACb,iBAAiB,EACjB,oBAAoB,EACpB,4BAA4B,EAC5B,yBAAyB,EAC1B,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/discovery/index.ts"],"names":[],"mappings":"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"}
|
package/dist/discovery/index.js
CHANGED
|
@@ -1,36 +1,29 @@
|
|
|
1
1
|
import { createGraphQlApi, createGraphQlIntrospectionRequest, } from '../graphql';
|
|
2
2
|
import { importOpenApiDocument } from '../openapi';
|
|
3
3
|
import { isSuccessfulStatus, normalizeCandidateUrl, parseHttpUrl, parseJsonResponse, readRecord, } from './http';
|
|
4
|
+
import { createOpenApiDiscoveryPlan } from './openapiDiscoveryPlan';
|
|
5
|
+
import { scopeOpenApiDocument } from './scopeOpenApiDocument';
|
|
4
6
|
export const DEFAULT_OPENAPI_DISCOVERY_PATHS = [
|
|
5
7
|
'openapi.json',
|
|
6
8
|
'swagger.json',
|
|
7
9
|
'api/openapi.json',
|
|
8
10
|
'v3/api-docs',
|
|
9
11
|
];
|
|
12
|
+
/*** Creates the ordered, deduplicated URLs probed during OpenAPI discovery. */
|
|
10
13
|
export function createOpenApiDiscoveryCandidates(rawUrl, conventionalPaths = DEFAULT_OPENAPI_DISCOVERY_PATHS) {
|
|
11
|
-
|
|
12
|
-
if (parsed === undefined)
|
|
13
|
-
return [];
|
|
14
|
-
const exact = normalizeCandidateUrl(parsed);
|
|
15
|
-
const serviceBase = new URL(exact);
|
|
16
|
-
if (!serviceBase.pathname.endsWith('/'))
|
|
17
|
-
serviceBase.pathname = `${serviceBase.pathname}/`;
|
|
18
|
-
const candidates = [exact];
|
|
19
|
-
for (const path of conventionalPaths) {
|
|
20
|
-
const normalizedPath = path.replace(/^\/+/, '');
|
|
21
|
-
candidates.push(normalizeCandidateUrl(new URL(normalizedPath, serviceBase)));
|
|
22
|
-
candidates.push(normalizeCandidateUrl(new URL(normalizedPath, `${parsed.origin}/`)));
|
|
23
|
-
}
|
|
24
|
-
return [...new Set(candidates)];
|
|
14
|
+
return createOpenApiDiscoveryPlan(rawUrl, conventionalPaths).candidates.map((candidate) => candidate.url);
|
|
25
15
|
}
|
|
16
|
+
/*** Discovers and imports an OpenAPI document while preserving the requested service boundary. */
|
|
26
17
|
export async function discoverOpenApi(input) {
|
|
27
|
-
const
|
|
28
|
-
|
|
18
|
+
const conventionalPaths = input.conventionalPaths ?? DEFAULT_OPENAPI_DISCOVERY_PATHS;
|
|
19
|
+
const plan = createOpenApiDiscoveryPlan(input.url, conventionalPaths);
|
|
20
|
+
if (plan.candidates.length === 0) {
|
|
29
21
|
return discoveryFailure(input.id, [], 'OpenAPI discovery requires a valid HTTP or HTTPS URL.');
|
|
30
22
|
}
|
|
31
23
|
const attempts = [];
|
|
32
|
-
for (const candidate of candidates) {
|
|
33
|
-
const
|
|
24
|
+
for (const candidate of plan.candidates) {
|
|
25
|
+
const requestedScope = candidate.scopeToRequestedService ? plan.requestedScope : undefined;
|
|
26
|
+
const probed = await probeOpenApiCandidate(input, candidate.url, requestedScope);
|
|
34
27
|
attempts.push(probed.attempt);
|
|
35
28
|
if (probed.result === undefined)
|
|
36
29
|
continue;
|
|
@@ -38,7 +31,7 @@ export async function discoverOpenApi(input) {
|
|
|
38
31
|
? {
|
|
39
32
|
ok: true,
|
|
40
33
|
data: probed.result.data,
|
|
41
|
-
documentUrl: candidate,
|
|
34
|
+
documentUrl: candidate.url,
|
|
42
35
|
attempts,
|
|
43
36
|
diagnostics: probed.result.diagnostics ?? [],
|
|
44
37
|
}
|
|
@@ -46,6 +39,7 @@ export async function discoverOpenApi(input) {
|
|
|
46
39
|
}
|
|
47
40
|
return discoveryFailure(input.id, attempts, 'No supported OpenAPI document was found at the supplied URL or conventional locations.');
|
|
48
41
|
}
|
|
42
|
+
/*** Introspects a GraphQL endpoint and normalizes its schema into the canonical API definition. */
|
|
49
43
|
export async function introspectGraphQlApi(input) {
|
|
50
44
|
const endpoint = parseHttpUrl(input.endpointUrl);
|
|
51
45
|
if (endpoint === undefined) {
|
|
@@ -80,6 +74,7 @@ export async function introspectGraphQlApi(input) {
|
|
|
80
74
|
? { ok: true, data: result.data, diagnostics: result.diagnostics ?? [] }
|
|
81
75
|
: { ok: false, diagnostics: result.diagnostics };
|
|
82
76
|
}
|
|
77
|
+
/*** Fetches GraphQL introspection without exposing transport failures beyond the discovery boundary. */
|
|
83
78
|
async function fetchGraphQlIntrospection(input, endpoint) {
|
|
84
79
|
try {
|
|
85
80
|
return await input.fetch(normalizeCandidateUrl(endpoint), {
|
|
@@ -96,7 +91,8 @@ async function fetchGraphQlIntrospection(input, endpoint) {
|
|
|
96
91
|
return undefined;
|
|
97
92
|
}
|
|
98
93
|
}
|
|
99
|
-
|
|
94
|
+
/*** Probes one OpenAPI candidate and scopes origin-root documents before canonical import. */
|
|
95
|
+
async function probeOpenApiCandidate(input, candidate, requestedScope) {
|
|
100
96
|
let response;
|
|
101
97
|
try {
|
|
102
98
|
response = await input.fetch(candidate, {
|
|
@@ -119,16 +115,7 @@ async function probeOpenApiCandidate(input, candidate) {
|
|
|
119
115
|
attempt: { url: candidate, outcome: 'unsupported-document', status: response.status },
|
|
120
116
|
};
|
|
121
117
|
}
|
|
122
|
-
const result =
|
|
123
|
-
id: input.id,
|
|
124
|
-
document: parsed,
|
|
125
|
-
baseUrl: input.baseUrl,
|
|
126
|
-
credential: input.credential,
|
|
127
|
-
documentUrl: candidate,
|
|
128
|
-
name: input.name,
|
|
129
|
-
description: input.description,
|
|
130
|
-
metadata: input.metadata,
|
|
131
|
-
});
|
|
118
|
+
const result = importOpenApiCandidate(input, candidate, parsed, requestedScope);
|
|
132
119
|
return {
|
|
133
120
|
attempt: {
|
|
134
121
|
url: candidate,
|
|
@@ -138,12 +125,35 @@ async function probeOpenApiCandidate(input, candidate) {
|
|
|
138
125
|
result,
|
|
139
126
|
};
|
|
140
127
|
}
|
|
128
|
+
/*** Applies service scoping and imports one validated OpenAPI candidate. */
|
|
129
|
+
function importOpenApiCandidate(input, candidate, parsed, requestedScope) {
|
|
130
|
+
let document = parsed;
|
|
131
|
+
if (requestedScope !== undefined) {
|
|
132
|
+
const scoped = scopeOpenApiDocument(parsed, input.id, requestedScope, candidate);
|
|
133
|
+
if (!scoped.ok)
|
|
134
|
+
return { ok: false, diagnostics: [scoped.diagnostic] };
|
|
135
|
+
const { document: scopedDocument } = scoped;
|
|
136
|
+
document = scopedDocument;
|
|
137
|
+
}
|
|
138
|
+
return importOpenApiDocument({
|
|
139
|
+
id: input.id,
|
|
140
|
+
document,
|
|
141
|
+
baseUrl: input.baseUrl,
|
|
142
|
+
credential: input.credential,
|
|
143
|
+
documentUrl: candidate,
|
|
144
|
+
name: input.name,
|
|
145
|
+
description: input.description,
|
|
146
|
+
metadata: input.metadata,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/*** Checks whether parsed JSON has the minimal OpenAPI document shape required for import. */
|
|
141
150
|
function isOpenApiDocument(value) {
|
|
142
151
|
const record = readRecord(value);
|
|
143
152
|
return (record !== undefined &&
|
|
144
153
|
typeof record.openapi === 'string' &&
|
|
145
154
|
readRecord(record.paths) !== undefined);
|
|
146
155
|
}
|
|
156
|
+
/*** Reads a GraphQL introspection schema from a parsed response payload. */
|
|
147
157
|
function readGraphQlIntrospection(value) {
|
|
148
158
|
const payload = readRecord(value);
|
|
149
159
|
const data = readRecord(payload?.data);
|
|
@@ -151,6 +161,7 @@ function readGraphQlIntrospection(value) {
|
|
|
151
161
|
return undefined;
|
|
152
162
|
return data;
|
|
153
163
|
}
|
|
164
|
+
/*** Builds a canonical OpenAPI discovery failure with safe diagnostics. */
|
|
154
165
|
function discoveryFailure(apiId, attempts, message) {
|
|
155
166
|
return {
|
|
156
167
|
ok: false,
|
|
@@ -158,6 +169,7 @@ function discoveryFailure(apiId, attempts, message) {
|
|
|
158
169
|
diagnostics: [{ code: 'missing-schema', apiId, message, severity: 'error' }],
|
|
159
170
|
};
|
|
160
171
|
}
|
|
172
|
+
/*** Builds a canonical GraphQL introspection failure with API identity. */
|
|
161
173
|
function graphqlFailure(apiId, message, code = 'invalid-config') {
|
|
162
174
|
return {
|
|
163
175
|
ok: false,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/discovery/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n CredentialRef,\n DataContractValue,\n DataSourceDiagnostic,\n ExternalGraphQlApiDefinition,\n ExternalRestApiDefinition,\n} from '@ankhorage/contracts/data';\n\nimport {\n createGraphQlApi,\n createGraphQlIntrospectionRequest,\n type GraphQlIntrospectionResult,\n} from '../graphql';\nimport { importOpenApiDocument, type OpenApiDocumentObject } from '../openapi';\nimport {\n isSuccessfulStatus,\n normalizeCandidateUrl,\n parseHttpUrl,\n parseJsonResponse,\n readRecord,\n} from './http';\n\nexport const DEFAULT_OPENAPI_DISCOVERY_PATHS = [\n 'openapi.json',\n 'swagger.json',\n 'api/openapi.json',\n 'v3/api-docs',\n] as const;\n\nexport interface ExternalApiFetchInit {\n readonly method: 'GET' | 'POST';\n readonly headers: Readonly<Record<string, string>>;\n readonly body?: string;\n}\n\nexport interface ExternalApiFetchResponse {\n readonly status: number;\n text(): Promise<string>;\n}\n\nexport type ExternalApiFetch = (\n url: string,\n init: ExternalApiFetchInit,\n) => Promise<ExternalApiFetchResponse>;\n\nexport type OpenApiDiscoveryAttemptOutcome =\n | 'http-error'\n | 'invalid-document'\n | 'matched'\n | 'network-error'\n | 'parse-error'\n | 'unsupported-document';\n\nexport interface OpenApiDiscoveryAttempt {\n readonly url: string;\n readonly outcome: OpenApiDiscoveryAttemptOutcome;\n readonly status?: number;\n}\n\nexport interface DiscoverOpenApiInput {\n readonly id: string;\n readonly url: string;\n readonly fetch: ExternalApiFetch;\n readonly baseUrl?: string;\n readonly credential?: CredentialRef;\n readonly name?: string;\n readonly description?: string;\n readonly metadata?: DataContractValue;\n readonly conventionalPaths?: readonly string[];\n}\n\nexport type DiscoverOpenApiResult =\n | {\n readonly ok: true;\n readonly data: ExternalRestApiDefinition;\n readonly documentUrl: string;\n readonly attempts: readonly OpenApiDiscoveryAttempt[];\n readonly diagnostics: readonly DataSourceDiagnostic[];\n }\n | {\n readonly ok: false;\n readonly attempts: readonly OpenApiDiscoveryAttempt[];\n readonly diagnostics: readonly DataSourceDiagnostic[];\n };\n\nexport interface IntrospectGraphQlApiInput {\n readonly id: string;\n readonly endpointUrl: string;\n readonly fetch: ExternalApiFetch;\n readonly headers?: Readonly<Record<string, string>>;\n readonly credential?: CredentialRef;\n readonly name?: string;\n readonly description?: string;\n readonly schemaVersion?: string;\n readonly metadata?: DataContractValue;\n}\n\nexport type IntrospectGraphQlApiResult =\n | {\n readonly ok: true;\n readonly data: ExternalGraphQlApiDefinition;\n readonly diagnostics: readonly DataSourceDiagnostic[];\n }\n | {\n readonly ok: false;\n readonly diagnostics: readonly DataSourceDiagnostic[];\n readonly status?: number;\n };\n\nexport function createOpenApiDiscoveryCandidates(\n rawUrl: string,\n conventionalPaths: readonly string[] = DEFAULT_OPENAPI_DISCOVERY_PATHS,\n): readonly string[] {\n const parsed = parseHttpUrl(rawUrl);\n if (parsed === undefined) return [];\n\n const exact = normalizeCandidateUrl(parsed);\n const serviceBase = new URL(exact);\n if (!serviceBase.pathname.endsWith('/')) serviceBase.pathname = `${serviceBase.pathname}/`;\n\n const candidates = [exact];\n for (const path of conventionalPaths) {\n const normalizedPath = path.replace(/^\\/+/, '');\n candidates.push(normalizeCandidateUrl(new URL(normalizedPath, serviceBase)));\n candidates.push(normalizeCandidateUrl(new URL(normalizedPath, `${parsed.origin}/`)));\n }\n\n return [...new Set(candidates)];\n}\n\nexport async function discoverOpenApi(input: DiscoverOpenApiInput): Promise<DiscoverOpenApiResult> {\n const candidates = createOpenApiDiscoveryCandidates(input.url, input.conventionalPaths);\n if (candidates.length === 0) {\n return discoveryFailure(input.id, [], 'OpenAPI discovery requires a valid HTTP or HTTPS URL.');\n }\n\n const attempts: OpenApiDiscoveryAttempt[] = [];\n for (const candidate of candidates) {\n const probed = await probeOpenApiCandidate(input, candidate);\n attempts.push(probed.attempt);\n if (probed.result === undefined) continue;\n return probed.result.ok\n ? {\n ok: true,\n data: probed.result.data,\n documentUrl: candidate,\n attempts,\n diagnostics: probed.result.diagnostics ?? [],\n }\n : { ok: false, attempts, diagnostics: probed.result.diagnostics };\n }\n\n return discoveryFailure(\n input.id,\n attempts,\n 'No supported OpenAPI document was found at the supplied URL or conventional locations.',\n );\n}\n\nexport async function introspectGraphQlApi(\n input: IntrospectGraphQlApiInput,\n): Promise<IntrospectGraphQlApiResult> {\n const endpoint = parseHttpUrl(input.endpointUrl);\n if (endpoint === undefined) {\n return graphqlFailure(input.id, 'GraphQL introspection requires a valid HTTP or HTTPS URL.');\n }\n\n const response = await fetchGraphQlIntrospection(input, endpoint);\n if (response === undefined) {\n return graphqlFailure(input.id, 'GraphQL introspection request failed.', 'network-error');\n }\n if (!isSuccessfulStatus(response.status)) {\n return {\n ...graphqlFailure(\n input.id,\n `GraphQL introspection returned HTTP status ${response.status}.`,\n 'network-error',\n ),\n status: response.status,\n };\n }\n\n const introspection = readGraphQlIntrospection(await parseJsonResponse(response));\n if (introspection === undefined) {\n return graphqlFailure(\n input.id,\n 'GraphQL introspection response did not contain data.__schema.',\n 'parse-error',\n );\n }\n\n const result = createGraphQlApi({\n id: input.id,\n endpointUrl: normalizeCandidateUrl(endpoint),\n credential: input.credential,\n name: input.name,\n description: input.description,\n introspection,\n introspectionEnabled: true,\n schemaVersion: input.schemaVersion,\n metadata: input.metadata,\n });\n return result.ok\n ? { ok: true, data: result.data, diagnostics: result.diagnostics ?? [] }\n : { ok: false, diagnostics: result.diagnostics };\n}\n\nasync function fetchGraphQlIntrospection(\n input: IntrospectGraphQlApiInput,\n endpoint: URL,\n): Promise<ExternalApiFetchResponse | undefined> {\n try {\n return await input.fetch(normalizeCandidateUrl(endpoint), {\n method: 'POST',\n headers: {\n accept: 'application/json',\n 'content-type': 'application/json',\n ...(input.headers ?? {}),\n },\n body: JSON.stringify(createGraphQlIntrospectionRequest()),\n });\n } catch {\n return undefined;\n }\n}\n\ninterface OpenApiProbeResult {\n readonly attempt: OpenApiDiscoveryAttempt;\n readonly result?: ReturnType<typeof importOpenApiDocument>;\n}\n\nasync function probeOpenApiCandidate(\n input: DiscoverOpenApiInput,\n candidate: string,\n): Promise<OpenApiProbeResult> {\n let response: ExternalApiFetchResponse;\n try {\n response = await input.fetch(candidate, {\n method: 'GET',\n headers: { accept: 'application/json, application/vnd.oai.openapi+json' },\n });\n } catch {\n return { attempt: { url: candidate, outcome: 'network-error' } };\n }\n\n if (!isSuccessfulStatus(response.status)) {\n return { attempt: { url: candidate, outcome: 'http-error', status: response.status } };\n }\n\n const parsed = await parseJsonResponse(response);\n if (parsed === undefined) {\n return { attempt: { url: candidate, outcome: 'parse-error', status: response.status } };\n }\n if (!isOpenApiDocument(parsed)) {\n return {\n attempt: { url: candidate, outcome: 'unsupported-document', status: response.status },\n };\n }\n\n const result = importOpenApiDocument({\n id: input.id,\n document: parsed,\n baseUrl: input.baseUrl,\n credential: input.credential,\n documentUrl: candidate,\n name: input.name,\n description: input.description,\n metadata: input.metadata,\n });\n return {\n attempt: {\n url: candidate,\n outcome: result.ok ? 'matched' : 'invalid-document',\n status: response.status,\n },\n result,\n };\n}\n\nfunction isOpenApiDocument(value: unknown): value is OpenApiDocumentObject {\n const record = readRecord(value);\n return (\n record !== undefined &&\n typeof record.openapi === 'string' &&\n readRecord(record.paths) !== undefined\n );\n}\n\nfunction readGraphQlIntrospection(value: unknown): GraphQlIntrospectionResult | undefined {\n const payload = readRecord(value);\n const data = readRecord(payload?.data);\n if (readRecord(data?.__schema) === undefined) return undefined;\n return data;\n}\n\nfunction discoveryFailure(\n apiId: string,\n attempts: readonly OpenApiDiscoveryAttempt[],\n message: string,\n): DiscoverOpenApiResult {\n return {\n ok: false,\n attempts,\n diagnostics: [{ code: 'missing-schema', apiId, message, severity: 'error' }],\n };\n}\n\nfunction graphqlFailure(\n apiId: string,\n message: string,\n code: DataSourceDiagnostic['code'] = 'invalid-config',\n): Extract<IntrospectGraphQlApiResult, { readonly ok: false }> {\n return {\n ok: false,\n diagnostics: [{ code, apiId, message, severity: 'error' }],\n };\n}\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/discovery/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n CredentialRef,\n DataContractValue,\n DataSourceDiagnostic,\n ExternalGraphQlApiDefinition,\n ExternalRestApiDefinition,\n} from '@ankhorage/contracts/data';\n\nimport {\n createGraphQlApi,\n createGraphQlIntrospectionRequest,\n type GraphQlIntrospectionResult,\n} from '../graphql';\nimport { importOpenApiDocument, type OpenApiDocumentObject } from '../openapi';\nimport {\n isSuccessfulStatus,\n normalizeCandidateUrl,\n parseHttpUrl,\n parseJsonResponse,\n readRecord,\n} from './http';\nimport { createOpenApiDiscoveryPlan } from './openapiDiscoveryPlan';\nimport { scopeOpenApiDocument } from './scopeOpenApiDocument';\n\nexport const DEFAULT_OPENAPI_DISCOVERY_PATHS = [\n 'openapi.json',\n 'swagger.json',\n 'api/openapi.json',\n 'v3/api-docs',\n] as const;\n\nexport interface ExternalApiFetchInit {\n readonly method: 'GET' | 'POST';\n readonly headers: Readonly<Record<string, string>>;\n readonly body?: string;\n}\n\nexport interface ExternalApiFetchResponse {\n readonly status: number;\n text(): Promise<string>;\n}\n\nexport type ExternalApiFetch = (\n url: string,\n init: ExternalApiFetchInit,\n) => Promise<ExternalApiFetchResponse>;\n\nexport type OpenApiDiscoveryAttemptOutcome =\n | 'http-error'\n | 'invalid-document'\n | 'matched'\n | 'network-error'\n | 'parse-error'\n | 'unsupported-document';\n\nexport interface OpenApiDiscoveryAttempt {\n readonly url: string;\n readonly outcome: OpenApiDiscoveryAttemptOutcome;\n readonly status?: number;\n}\n\nexport interface DiscoverOpenApiInput {\n readonly id: string;\n readonly url: string;\n readonly fetch: ExternalApiFetch;\n readonly baseUrl?: string;\n readonly credential?: CredentialRef;\n readonly name?: string;\n readonly description?: string;\n readonly metadata?: DataContractValue;\n readonly conventionalPaths?: readonly string[];\n}\n\nexport type DiscoverOpenApiResult =\n | {\n readonly ok: true;\n readonly data: ExternalRestApiDefinition;\n readonly documentUrl: string;\n readonly attempts: readonly OpenApiDiscoveryAttempt[];\n readonly diagnostics: readonly DataSourceDiagnostic[];\n }\n | {\n readonly ok: false;\n readonly attempts: readonly OpenApiDiscoveryAttempt[];\n readonly diagnostics: readonly DataSourceDiagnostic[];\n };\n\nexport interface IntrospectGraphQlApiInput {\n readonly id: string;\n readonly endpointUrl: string;\n readonly fetch: ExternalApiFetch;\n readonly headers?: Readonly<Record<string, string>>;\n readonly credential?: CredentialRef;\n readonly name?: string;\n readonly description?: string;\n readonly schemaVersion?: string;\n readonly metadata?: DataContractValue;\n}\n\nexport type IntrospectGraphQlApiResult =\n | {\n readonly ok: true;\n readonly data: ExternalGraphQlApiDefinition;\n readonly diagnostics: readonly DataSourceDiagnostic[];\n }\n | {\n readonly ok: false;\n readonly diagnostics: readonly DataSourceDiagnostic[];\n readonly status?: number;\n };\n\n/*** Creates the ordered, deduplicated URLs probed during OpenAPI discovery. */\nexport function createOpenApiDiscoveryCandidates(\n rawUrl: string,\n conventionalPaths: readonly string[] = DEFAULT_OPENAPI_DISCOVERY_PATHS,\n): readonly string[] {\n return createOpenApiDiscoveryPlan(rawUrl, conventionalPaths).candidates.map(\n (candidate) => candidate.url,\n );\n}\n\n/*** Discovers and imports an OpenAPI document while preserving the requested service boundary. */\nexport async function discoverOpenApi(input: DiscoverOpenApiInput): Promise<DiscoverOpenApiResult> {\n const conventionalPaths = input.conventionalPaths ?? DEFAULT_OPENAPI_DISCOVERY_PATHS;\n const plan = createOpenApiDiscoveryPlan(input.url, conventionalPaths);\n if (plan.candidates.length === 0) {\n return discoveryFailure(input.id, [], 'OpenAPI discovery requires a valid HTTP or HTTPS URL.');\n }\n\n const attempts: OpenApiDiscoveryAttempt[] = [];\n for (const candidate of plan.candidates) {\n const requestedScope = candidate.scopeToRequestedService ? plan.requestedScope : undefined;\n const probed = await probeOpenApiCandidate(input, candidate.url, requestedScope);\n attempts.push(probed.attempt);\n if (probed.result === undefined) continue;\n return probed.result.ok\n ? {\n ok: true,\n data: probed.result.data,\n documentUrl: candidate.url,\n attempts,\n diagnostics: probed.result.diagnostics ?? [],\n }\n : { ok: false, attempts, diagnostics: probed.result.diagnostics };\n }\n\n return discoveryFailure(\n input.id,\n attempts,\n 'No supported OpenAPI document was found at the supplied URL or conventional locations.',\n );\n}\n\n/*** Introspects a GraphQL endpoint and normalizes its schema into the canonical API definition. */\nexport async function introspectGraphQlApi(\n input: IntrospectGraphQlApiInput,\n): Promise<IntrospectGraphQlApiResult> {\n const endpoint = parseHttpUrl(input.endpointUrl);\n if (endpoint === undefined) {\n return graphqlFailure(input.id, 'GraphQL introspection requires a valid HTTP or HTTPS URL.');\n }\n\n const response = await fetchGraphQlIntrospection(input, endpoint);\n if (response === undefined) {\n return graphqlFailure(input.id, 'GraphQL introspection request failed.', 'network-error');\n }\n if (!isSuccessfulStatus(response.status)) {\n return {\n ...graphqlFailure(\n input.id,\n `GraphQL introspection returned HTTP status ${response.status}.`,\n 'network-error',\n ),\n status: response.status,\n };\n }\n\n const introspection = readGraphQlIntrospection(await parseJsonResponse(response));\n if (introspection === undefined) {\n return graphqlFailure(\n input.id,\n 'GraphQL introspection response did not contain data.__schema.',\n 'parse-error',\n );\n }\n\n const result = createGraphQlApi({\n id: input.id,\n endpointUrl: normalizeCandidateUrl(endpoint),\n credential: input.credential,\n name: input.name,\n description: input.description,\n introspection,\n introspectionEnabled: true,\n schemaVersion: input.schemaVersion,\n metadata: input.metadata,\n });\n return result.ok\n ? { ok: true, data: result.data, diagnostics: result.diagnostics ?? [] }\n : { ok: false, diagnostics: result.diagnostics };\n}\n\n/*** Fetches GraphQL introspection without exposing transport failures beyond the discovery boundary. */\nasync function fetchGraphQlIntrospection(\n input: IntrospectGraphQlApiInput,\n endpoint: URL,\n): Promise<ExternalApiFetchResponse | undefined> {\n try {\n return await input.fetch(normalizeCandidateUrl(endpoint), {\n method: 'POST',\n headers: {\n accept: 'application/json',\n 'content-type': 'application/json',\n ...(input.headers ?? {}),\n },\n body: JSON.stringify(createGraphQlIntrospectionRequest()),\n });\n } catch {\n return undefined;\n }\n}\n\ninterface OpenApiProbeResult {\n readonly attempt: OpenApiDiscoveryAttempt;\n readonly result?: ReturnType<typeof importOpenApiDocument>;\n}\n\n/*** Probes one OpenAPI candidate and scopes origin-root documents before canonical import. */\nasync function probeOpenApiCandidate(\n input: DiscoverOpenApiInput,\n candidate: string,\n requestedScope: string | undefined,\n): Promise<OpenApiProbeResult> {\n let response: ExternalApiFetchResponse;\n try {\n response = await input.fetch(candidate, {\n method: 'GET',\n headers: { accept: 'application/json, application/vnd.oai.openapi+json' },\n });\n } catch {\n return { attempt: { url: candidate, outcome: 'network-error' } };\n }\n\n if (!isSuccessfulStatus(response.status)) {\n return { attempt: { url: candidate, outcome: 'http-error', status: response.status } };\n }\n\n const parsed = await parseJsonResponse(response);\n if (parsed === undefined) {\n return { attempt: { url: candidate, outcome: 'parse-error', status: response.status } };\n }\n if (!isOpenApiDocument(parsed)) {\n return {\n attempt: { url: candidate, outcome: 'unsupported-document', status: response.status },\n };\n }\n\n const result = importOpenApiCandidate(input, candidate, parsed, requestedScope);\n return {\n attempt: {\n url: candidate,\n outcome: result.ok ? 'matched' : 'invalid-document',\n status: response.status,\n },\n result,\n };\n}\n\n/*** Applies service scoping and imports one validated OpenAPI candidate. */\nfunction importOpenApiCandidate(\n input: DiscoverOpenApiInput,\n candidate: string,\n parsed: OpenApiDocumentObject,\n requestedScope: string | undefined,\n): ReturnType<typeof importOpenApiDocument> {\n let document = parsed;\n if (requestedScope !== undefined) {\n const scoped = scopeOpenApiDocument(parsed, input.id, requestedScope, candidate);\n if (!scoped.ok) return { ok: false, diagnostics: [scoped.diagnostic] };\n const { document: scopedDocument } = scoped;\n document = scopedDocument;\n }\n return importOpenApiDocument({\n id: input.id,\n document,\n baseUrl: input.baseUrl,\n credential: input.credential,\n documentUrl: candidate,\n name: input.name,\n description: input.description,\n metadata: input.metadata,\n });\n}\n\n/*** Checks whether parsed JSON has the minimal OpenAPI document shape required for import. */\nfunction isOpenApiDocument(value: unknown): value is OpenApiDocumentObject {\n const record = readRecord(value);\n return (\n record !== undefined &&\n typeof record.openapi === 'string' &&\n readRecord(record.paths) !== undefined\n );\n}\n\n/*** Reads a GraphQL introspection schema from a parsed response payload. */\nfunction readGraphQlIntrospection(value: unknown): GraphQlIntrospectionResult | undefined {\n const payload = readRecord(value);\n const data = readRecord(payload?.data);\n if (readRecord(data?.__schema) === undefined) return undefined;\n return data;\n}\n\n/*** Builds a canonical OpenAPI discovery failure with safe diagnostics. */\nfunction discoveryFailure(\n apiId: string,\n attempts: readonly OpenApiDiscoveryAttempt[],\n message: string,\n): DiscoverOpenApiResult {\n return {\n ok: false,\n attempts,\n diagnostics: [{ code: 'missing-schema', apiId, message, severity: 'error' }],\n };\n}\n\n/*** Builds a canonical GraphQL introspection failure with API identity. */\nfunction graphqlFailure(\n apiId: string,\n message: string,\n code: DataSourceDiagnostic['code'] = 'invalid-config',\n): Extract<IntrospectGraphQlApiResult, { readonly ok: false }> {\n return {\n ok: false,\n diagnostics: [{ code, apiId, message, severity: 'error' }],\n };\n}\n"]}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*** Builds the ordered OpenAPI probe plan and the normalized service scope requested by the caller. */
|
|
2
|
+
export declare function createOpenApiDiscoveryPlan(rawUrl: string, conventionalPaths: readonly string[]): OpenApiDiscoveryPlan;
|
|
3
|
+
interface OpenApiDiscoveryPlan {
|
|
4
|
+
readonly candidates: readonly OpenApiDiscoveryCandidate[];
|
|
5
|
+
readonly requestedScope: string | undefined;
|
|
6
|
+
}
|
|
7
|
+
interface OpenApiDiscoveryCandidate {
|
|
8
|
+
readonly url: string;
|
|
9
|
+
readonly scopeToRequestedService: boolean;
|
|
10
|
+
}
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=openapiDiscoveryPlan.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openapiDiscoveryPlan.d.ts","sourceRoot":"","sources":["../../src/discovery/openapiDiscoveryPlan.ts"],"names":[],"mappings":"AAEA,uGAAuG;AACvG,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,MAAM,EACd,iBAAiB,EAAE,SAAS,MAAM,EAAE,GACnC,oBAAoB,CAmCtB;AAED,UAAU,oBAAoB;IAC5B,QAAQ,CAAC,UAAU,EAAE,SAAS,yBAAyB,EAAE,CAAC;IAC1D,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC;CAC7C;AAED,UAAU,yBAAyB;IACjC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,uBAAuB,EAAE,OAAO,CAAC;CAC3C"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { normalizeCandidateUrl, parseHttpUrl } from './http';
|
|
2
|
+
/*** Builds the ordered OpenAPI probe plan and the normalized service scope requested by the caller. */
|
|
3
|
+
export function createOpenApiDiscoveryPlan(rawUrl, conventionalPaths) {
|
|
4
|
+
const parsed = parseHttpUrl(rawUrl);
|
|
5
|
+
if (parsed === undefined)
|
|
6
|
+
return { candidates: [], requestedScope: undefined };
|
|
7
|
+
const exact = normalizeCandidateUrl(parsed);
|
|
8
|
+
const serviceBase = new URL(exact);
|
|
9
|
+
if (!serviceBase.pathname.endsWith('/'))
|
|
10
|
+
serviceBase.pathname = `${serviceBase.pathname}/`;
|
|
11
|
+
const candidates = new Map();
|
|
12
|
+
candidates.set(exact, { url: exact, scopeToRequestedService: false });
|
|
13
|
+
for (const path of conventionalPaths) {
|
|
14
|
+
const normalizedPath = path.replace(/^\/+/, '');
|
|
15
|
+
const serviceUrl = normalizeCandidateUrl(new URL(normalizedPath, serviceBase));
|
|
16
|
+
const rootUrl = normalizeCandidateUrl(new URL(normalizedPath, `${parsed.origin}/`));
|
|
17
|
+
if (!candidates.has(serviceUrl)) {
|
|
18
|
+
candidates.set(serviceUrl, { url: serviceUrl, scopeToRequestedService: false });
|
|
19
|
+
}
|
|
20
|
+
if (!candidates.has(rootUrl)) {
|
|
21
|
+
candidates.set(rootUrl, { url: rootUrl, scopeToRequestedService: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const requestedPath = normalizeServicePath(parsed.pathname);
|
|
25
|
+
const isExplicitRootDocument = conventionalPaths.some((path) => {
|
|
26
|
+
const normalizedPath = path.replace(/^\/+/, '');
|
|
27
|
+
const rootCandidate = new URL(normalizedPath, `${parsed.origin}/`);
|
|
28
|
+
return (rootCandidate.origin === parsed.origin &&
|
|
29
|
+
normalizeServicePath(rootCandidate.pathname) === requestedPath);
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
candidates: [...candidates.values()],
|
|
33
|
+
requestedScope: requestedPath === '/' || isExplicitRootDocument ? undefined : requestedPath,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/*** Normalizes trailing slashes for segment-safe service-path comparisons. */
|
|
37
|
+
function normalizeServicePath(pathname) {
|
|
38
|
+
const normalized = pathname.replace(/\/+$/g, '');
|
|
39
|
+
return normalized.length === 0 ? '/' : normalized;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=openapiDiscoveryPlan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openapiDiscoveryPlan.js","sourceRoot":"","sources":["../../src/discovery/openapiDiscoveryPlan.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAE7D,uGAAuG;AACvG,MAAM,UAAU,0BAA0B,CACxC,MAAc,EACd,iBAAoC;IAEpC,MAAM,MAAM,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;IACpC,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,EAAE,UAAU,EAAE,EAAE,EAAE,cAAc,EAAE,SAAS,EAAE,CAAC;IAE/E,MAAM,KAAK,GAAG,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAC5C,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;IACnC,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,WAAW,CAAC,QAAQ,GAAG,GAAG,WAAW,CAAC,QAAQ,GAAG,CAAC;IAE3F,MAAM,UAAU,GAAG,IAAI,GAAG,EAAqC,CAAC;IAChE,UAAU,CAAC,GAAG,CAAC,KAAK,EAAE,EAAE,GAAG,EAAE,KAAK,EAAE,uBAAuB,EAAE,KAAK,EAAE,CAAC,CAAC;IACtE,KAAK,MAAM,IAAI,IAAI,iBAAiB,EAAE,CAAC;QACrC,MAAM,cAAc,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QAChD,MAAM,UAAU,GAAG,qBAAqB,CAAC,IAAI,GAAG,CAAC,cAAc,EAAE,WAAW,CAAC,CAAC,CAAC;QAC/E,MAAM,OAAO,GAAG,qBAAqB,CAAC,IAAI,GAAG,CAAC,cAAc,EAAE,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QACpF,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,CAAC;YAChC,UAAU,CAAC,GAAG,CAAC,UAAU,EAAE,EAAE,GAAG,EAAE,UAAU,EAAE,uBAAuB,EAAE,KAAK,EAAE,CAAC,CAAC;QAClF,CAAC;QACD,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;YAC7B,UAAU,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,uBAAuB,EAAE,IAAI,EAAE,CAAC,CAAC;QAC3E,CAAC;IACH,CAAC;IAED,MAAM,aAAa,GAAG,oBAAoB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC5D,MAAM,sBAAsB,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE;QAC7D,MAAM,cAAc,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QAChD,MAAM,aAAa,GAAG,IAAI,GAAG,CAAC,cAAc,EAAE,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;QACnE,OAAO,CACL,aAAa,CAAC,MAAM,KAAK,MAAM,CAAC,MAAM;YACtC,oBAAoB,CAAC,aAAa,CAAC,QAAQ,CAAC,KAAK,aAAa,CAC/D,CAAC;IACJ,CAAC,CAAC,CAAC;IACH,OAAO;QACL,UAAU,EAAE,CAAC,GAAG,UAAU,CAAC,MAAM,EAAE,CAAC;QACpC,cAAc,EAAE,aAAa,KAAK,GAAG,IAAI,sBAAsB,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,aAAa;KAC5F,CAAC;AACJ,CAAC;AAYD,8EAA8E;AAC9E,SAAS,oBAAoB,CAAC,QAAgB;IAC5C,MAAM,UAAU,GAAG,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IACjD,OAAO,UAAU,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,UAAU,CAAC;AACpD,CAAC","sourcesContent":["import { normalizeCandidateUrl, parseHttpUrl } from './http';\n\n/*** Builds the ordered OpenAPI probe plan and the normalized service scope requested by the caller. */\nexport function createOpenApiDiscoveryPlan(\n rawUrl: string,\n conventionalPaths: readonly string[],\n): OpenApiDiscoveryPlan {\n const parsed = parseHttpUrl(rawUrl);\n if (parsed === undefined) return { candidates: [], requestedScope: undefined };\n\n const exact = normalizeCandidateUrl(parsed);\n const serviceBase = new URL(exact);\n if (!serviceBase.pathname.endsWith('/')) serviceBase.pathname = `${serviceBase.pathname}/`;\n\n const candidates = new Map<string, OpenApiDiscoveryCandidate>();\n candidates.set(exact, { url: exact, scopeToRequestedService: false });\n for (const path of conventionalPaths) {\n const normalizedPath = path.replace(/^\\/+/, '');\n const serviceUrl = normalizeCandidateUrl(new URL(normalizedPath, serviceBase));\n const rootUrl = normalizeCandidateUrl(new URL(normalizedPath, `${parsed.origin}/`));\n if (!candidates.has(serviceUrl)) {\n candidates.set(serviceUrl, { url: serviceUrl, scopeToRequestedService: false });\n }\n if (!candidates.has(rootUrl)) {\n candidates.set(rootUrl, { url: rootUrl, scopeToRequestedService: true });\n }\n }\n\n const requestedPath = normalizeServicePath(parsed.pathname);\n const isExplicitRootDocument = conventionalPaths.some((path) => {\n const normalizedPath = path.replace(/^\\/+/, '');\n const rootCandidate = new URL(normalizedPath, `${parsed.origin}/`);\n return (\n rootCandidate.origin === parsed.origin &&\n normalizeServicePath(rootCandidate.pathname) === requestedPath\n );\n });\n return {\n candidates: [...candidates.values()],\n requestedScope: requestedPath === '/' || isExplicitRootDocument ? undefined : requestedPath,\n };\n}\n\ninterface OpenApiDiscoveryPlan {\n readonly candidates: readonly OpenApiDiscoveryCandidate[];\n readonly requestedScope: string | undefined;\n}\n\ninterface OpenApiDiscoveryCandidate {\n readonly url: string;\n readonly scopeToRequestedService: boolean;\n}\n\n/*** Normalizes trailing slashes for segment-safe service-path comparisons. */\nfunction normalizeServicePath(pathname: string): string {\n const normalized = pathname.replace(/\\/+$/g, '');\n return normalized.length === 0 ? '/' : normalized;\n}\n"]}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DataSourceDiagnostic } from '@ankhorage/contracts/data';
|
|
2
|
+
import type { OpenApiDocumentObject } from '../openapi';
|
|
3
|
+
/*** Filters an origin-root OpenAPI document to the requested service path without rewriting paths. */
|
|
4
|
+
export declare function scopeOpenApiDocument(document: OpenApiDocumentObject, apiId: string, requestedScope: string, documentUrl: string): ScopeOpenApiDocumentResult;
|
|
5
|
+
type ScopeOpenApiDocumentResult = {
|
|
6
|
+
readonly ok: true;
|
|
7
|
+
readonly document: OpenApiDocumentObject;
|
|
8
|
+
} | {
|
|
9
|
+
readonly ok: false;
|
|
10
|
+
readonly diagnostic: DataSourceDiagnostic;
|
|
11
|
+
};
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=scopeOpenApiDocument.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scopeOpenApiDocument.d.ts","sourceRoot":"","sources":["../../src/discovery/scopeOpenApiDocument.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AAEtE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAExD,sGAAsG;AACtG,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,qBAAqB,EAC/B,KAAK,EAAE,MAAM,EACb,cAAc,EAAE,MAAM,EACtB,WAAW,EAAE,MAAM,GAClB,0BAA0B,CAoB5B;AAED,KAAK,0BAA0B,GAC3B;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,qBAAqB,CAAA;CAAE,GAC/D;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,oBAAoB,CAAA;CAAE,CAAC"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/*** Filters an origin-root OpenAPI document to the requested service path without rewriting paths. */
|
|
2
|
+
export function scopeOpenApiDocument(document, apiId, requestedScope, documentUrl) {
|
|
3
|
+
const paths = Object.fromEntries(Object.entries(document.paths ?? {}).filter(([path]) => path === requestedScope || path.startsWith(`${requestedScope}/`)));
|
|
4
|
+
if (Object.keys(paths).length > 0)
|
|
5
|
+
return { ok: true, document: { ...document, paths } };
|
|
6
|
+
return {
|
|
7
|
+
ok: false,
|
|
8
|
+
diagnostic: {
|
|
9
|
+
code: 'missing-schema',
|
|
10
|
+
apiId,
|
|
11
|
+
message: `OpenAPI document '${redactDiagnosticUrl(documentUrl)}' contains no paths ` +
|
|
12
|
+
`within requested service scope '${requestedScope}'.`,
|
|
13
|
+
path: 'paths',
|
|
14
|
+
severity: 'error',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/*** Removes credentials, query parameters, and fragments from a URL included in diagnostics. */
|
|
19
|
+
function redactDiagnosticUrl(rawUrl) {
|
|
20
|
+
const url = new URL(rawUrl);
|
|
21
|
+
url.username = '';
|
|
22
|
+
url.password = '';
|
|
23
|
+
url.search = '';
|
|
24
|
+
url.hash = '';
|
|
25
|
+
return url.toString();
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=scopeOpenApiDocument.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scopeOpenApiDocument.js","sourceRoot":"","sources":["../../src/discovery/scopeOpenApiDocument.ts"],"names":[],"mappings":"AAIA,sGAAsG;AACtG,MAAM,UAAU,oBAAoB,CAClC,QAA+B,EAC/B,KAAa,EACb,cAAsB,EACtB,WAAmB;IAEnB,MAAM,KAAK,GAAG,MAAM,CAAC,WAAW,CAC9B,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,CACzC,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,IAAI,KAAK,cAAc,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,cAAc,GAAG,CAAC,CAC7E,CACF,CAAC;IACF,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,GAAG,QAAQ,EAAE,KAAK,EAAE,EAAE,CAAC;IAEzF,OAAO;QACL,EAAE,EAAE,KAAK;QACT,UAAU,EAAE;YACV,IAAI,EAAE,gBAAgB;YACtB,KAAK;YACL,OAAO,EACL,qBAAqB,mBAAmB,CAAC,WAAW,CAAC,sBAAsB;gBAC3E,mCAAmC,cAAc,IAAI;YACvD,IAAI,EAAE,OAAO;YACb,QAAQ,EAAE,OAAO;SAClB;KACF,CAAC;AACJ,CAAC;AAMD,gGAAgG;AAChG,SAAS,mBAAmB,CAAC,MAAc;IACzC,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC;IAC5B,GAAG,CAAC,QAAQ,GAAG,EAAE,CAAC;IAClB,GAAG,CAAC,QAAQ,GAAG,EAAE,CAAC;IAClB,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC;IAChB,GAAG,CAAC,IAAI,GAAG,EAAE,CAAC;IACd,OAAO,GAAG,CAAC,QAAQ,EAAE,CAAC;AACxB,CAAC","sourcesContent":["import type { DataSourceDiagnostic } from '@ankhorage/contracts/data';\n\nimport type { OpenApiDocumentObject } from '../openapi';\n\n/*** Filters an origin-root OpenAPI document to the requested service path without rewriting paths. */\nexport function scopeOpenApiDocument(\n document: OpenApiDocumentObject,\n apiId: string,\n requestedScope: string,\n documentUrl: string,\n): ScopeOpenApiDocumentResult {\n const paths = Object.fromEntries(\n Object.entries(document.paths ?? {}).filter(\n ([path]) => path === requestedScope || path.startsWith(`${requestedScope}/`),\n ),\n );\n if (Object.keys(paths).length > 0) return { ok: true, document: { ...document, paths } };\n\n return {\n ok: false,\n diagnostic: {\n code: 'missing-schema',\n apiId,\n message:\n `OpenAPI document '${redactDiagnosticUrl(documentUrl)}' contains no paths ` +\n `within requested service scope '${requestedScope}'.`,\n path: 'paths',\n severity: 'error',\n },\n };\n}\n\ntype ScopeOpenApiDocumentResult =\n | { readonly ok: true; readonly document: OpenApiDocumentObject }\n | { readonly ok: false; readonly diagnostic: DataSourceDiagnostic };\n\n/*** Removes credentials, query parameters, and fragments from a URL included in diagnostics. */\nfunction redactDiagnosticUrl(rawUrl: string): string {\n const url = new URL(rawUrl);\n url.username = '';\n url.password = '';\n url.search = '';\n url.hash = '';\n return url.toString();\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ankhorage/data-sources",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.5",
|
|
5
5
|
"description": "Headless toolkit for importing, normalizing, and executing canonical Ankhorage API and data-source configs.",
|
|
6
6
|
"homepage": "https://github.com/ankhorage/data-sources#readme",
|
|
7
7
|
"bugs": {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, it } from 'bun:test';
|
|
2
2
|
|
|
3
|
+
import { buildEndpointTestRequest } from '../test-runner';
|
|
3
4
|
import {
|
|
4
5
|
createOpenApiDiscoveryCandidates,
|
|
5
6
|
discoverOpenApi,
|
|
@@ -25,6 +26,34 @@ function openApiDocument() {
|
|
|
25
26
|
} as const;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
function gatewayOpenApiDocument() {
|
|
30
|
+
return {
|
|
31
|
+
openapi: '3.1.0',
|
|
32
|
+
info: { title: 'Shared Gateway', version: '1.0.0' },
|
|
33
|
+
servers: [{ url: 'https://api.example.com' }],
|
|
34
|
+
paths: {
|
|
35
|
+
'/health': { get: {} },
|
|
36
|
+
'/v1/chess/openings': { get: {} },
|
|
37
|
+
'/v1/poker/health': { get: {} },
|
|
38
|
+
'/v1/poker/training/tasks': { get: {} },
|
|
39
|
+
'/v1/poker/training/tasks/{taskId}': { get: {} },
|
|
40
|
+
'/v1/poker/training/tasks/{taskId}/answer': { post: {} },
|
|
41
|
+
'/v1/poker/training/tasks/by-slug/{slug}': { get: {} },
|
|
42
|
+
'/v1/poker-admin/status': { get: {} },
|
|
43
|
+
'/v1/pokerface/status': { get: {} },
|
|
44
|
+
},
|
|
45
|
+
} as const;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function serviceLocalOpenApiDocument() {
|
|
49
|
+
return {
|
|
50
|
+
openapi: '3.1.0',
|
|
51
|
+
info: { title: 'Poker API', version: '1.0.0' },
|
|
52
|
+
servers: [{ url: 'https://api.example.com/v1/poker' }],
|
|
53
|
+
paths: { '/health': { get: {} } },
|
|
54
|
+
} as const;
|
|
55
|
+
}
|
|
56
|
+
|
|
28
57
|
function graphQlPayload() {
|
|
29
58
|
return {
|
|
30
59
|
data: {
|
|
@@ -100,6 +129,124 @@ it('falls back to OpenAPI conventional locations without leaking response data',
|
|
|
100
129
|
expect(JSON.stringify(result)).not.toContain('hidden');
|
|
101
130
|
});
|
|
102
131
|
|
|
132
|
+
it('scopes root OpenAPI discovery to the requested service and preserves runtime URLs', async () => {
|
|
133
|
+
const calls: string[] = [];
|
|
134
|
+
const fetch: ExternalApiFetch = (url) => {
|
|
135
|
+
calls.push(url);
|
|
136
|
+
return Promise.resolve(
|
|
137
|
+
url === 'https://api.example.com/openapi.json'
|
|
138
|
+
? jsonResponse(gatewayOpenApiDocument())
|
|
139
|
+
: jsonResponse({ private: 'hidden' }, 404),
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
const result = await discoverOpenApi({
|
|
143
|
+
id: 'poker',
|
|
144
|
+
url: 'https://api.example.com/v1/poker/?tenant=private#fragment',
|
|
145
|
+
conventionalPaths: ['openapi.json'],
|
|
146
|
+
fetch,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(calls).toEqual([
|
|
150
|
+
'https://api.example.com/v1/poker/?tenant=private',
|
|
151
|
+
'https://api.example.com/v1/poker/openapi.json',
|
|
152
|
+
'https://api.example.com/openapi.json',
|
|
153
|
+
]);
|
|
154
|
+
expect(result.ok).toBe(true);
|
|
155
|
+
if (result.ok) {
|
|
156
|
+
expect(result.documentUrl).toBe('https://api.example.com/openapi.json');
|
|
157
|
+
expect(result.data.baseUrl).toBe('https://api.example.com');
|
|
158
|
+
expect(result.data.openApi?.url).toBe('https://api.example.com/openapi.json');
|
|
159
|
+
expect(
|
|
160
|
+
Object.values(result.data.endpoints)
|
|
161
|
+
.map((endpoint) => endpoint.path)
|
|
162
|
+
.sort(),
|
|
163
|
+
).toEqual([
|
|
164
|
+
'/v1/poker/health',
|
|
165
|
+
'/v1/poker/training/tasks',
|
|
166
|
+
'/v1/poker/training/tasks/by-slug/{slug}',
|
|
167
|
+
'/v1/poker/training/tasks/{taskId}',
|
|
168
|
+
'/v1/poker/training/tasks/{taskId}/answer',
|
|
169
|
+
]);
|
|
170
|
+
const request = await buildEndpointTestRequest({
|
|
171
|
+
api: result.data,
|
|
172
|
+
endpointId: 'v1-poker-training-tasks',
|
|
173
|
+
operationId: 'get-v1-poker-training-tasks',
|
|
174
|
+
dryRun: true,
|
|
175
|
+
});
|
|
176
|
+
expect(request.ok).toBe(true);
|
|
177
|
+
if (request.ok) {
|
|
178
|
+
expect(request.request.url).toBe('https://api.example.com/v1/poker/training/tasks');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('keeps service-local OpenAPI documents unscoped', async () => {
|
|
184
|
+
const fetch: ExternalApiFetch = (url) =>
|
|
185
|
+
Promise.resolve(
|
|
186
|
+
url === 'https://api.example.com/v1/poker/openapi.json'
|
|
187
|
+
? jsonResponse(serviceLocalOpenApiDocument())
|
|
188
|
+
: jsonResponse({}, 404),
|
|
189
|
+
);
|
|
190
|
+
const result = await discoverOpenApi({
|
|
191
|
+
id: 'poker',
|
|
192
|
+
url: 'https://api.example.com/v1/poker/',
|
|
193
|
+
conventionalPaths: ['openapi.json'],
|
|
194
|
+
fetch,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(result.ok).toBe(true);
|
|
198
|
+
if (result.ok) {
|
|
199
|
+
expect(result.documentUrl).toBe('https://api.example.com/v1/poker/openapi.json');
|
|
200
|
+
expect(result.data.baseUrl).toBe('https://api.example.com/v1/poker');
|
|
201
|
+
expect(result.data.endpoints.health?.path).toBe('/health');
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('keeps root host and explicit root-document discovery unscoped', async () => {
|
|
206
|
+
for (const url of ['https://api.example.com/', 'https://api.example.com/openapi.json']) {
|
|
207
|
+
const result = await discoverOpenApi({
|
|
208
|
+
id: 'gateway',
|
|
209
|
+
url,
|
|
210
|
+
conventionalPaths: ['openapi.json'],
|
|
211
|
+
fetch: (candidate) =>
|
|
212
|
+
Promise.resolve(
|
|
213
|
+
candidate === 'https://api.example.com/openapi.json'
|
|
214
|
+
? jsonResponse(gatewayOpenApiDocument())
|
|
215
|
+
: jsonResponse({}, 404),
|
|
216
|
+
),
|
|
217
|
+
});
|
|
218
|
+
expect(result.ok).toBe(true);
|
|
219
|
+
if (result.ok) expect(Object.keys(result.data.endpoints)).toHaveLength(9);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('fails safely when a root document has no paths in the requested scope', async () => {
|
|
224
|
+
const result = await discoverOpenApi({
|
|
225
|
+
id: 'missing',
|
|
226
|
+
url: 'https://api.example.com/v1/missing',
|
|
227
|
+
conventionalPaths: ['openapi.json'],
|
|
228
|
+
fetch: (url) =>
|
|
229
|
+
Promise.resolve(
|
|
230
|
+
url === 'https://api.example.com/openapi.json'
|
|
231
|
+
? jsonResponse(gatewayOpenApiDocument())
|
|
232
|
+
: jsonResponse({ private: 'hidden' }, 404),
|
|
233
|
+
),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(result.ok).toBe(false);
|
|
237
|
+
if (!result.ok) {
|
|
238
|
+
expect(result.attempts.at(-1)).toEqual({
|
|
239
|
+
url: 'https://api.example.com/openapi.json',
|
|
240
|
+
outcome: 'invalid-document',
|
|
241
|
+
status: 200,
|
|
242
|
+
});
|
|
243
|
+
expect(result.diagnostics[0]).toMatchObject({ code: 'missing-schema', path: 'paths' });
|
|
244
|
+
expect(result.diagnostics[0]?.message).toContain("requested service scope '/v1/missing'");
|
|
245
|
+
expect(result.diagnostics[0]?.message).toContain('https://api.example.com/openapi.json');
|
|
246
|
+
expect(JSON.stringify(result)).not.toContain('hidden');
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
103
250
|
it('introspects GraphQL without echoing trusted headers', async () => {
|
|
104
251
|
const result = await introspectGraphQlApi({
|
|
105
252
|
id: 'catalog',
|
package/src/discovery/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
parseJsonResponse,
|
|
20
20
|
readRecord,
|
|
21
21
|
} from './http';
|
|
22
|
+
import { createOpenApiDiscoveryPlan } from './openapiDiscoveryPlan';
|
|
23
|
+
import { scopeOpenApiDocument } from './scopeOpenApiDocument';
|
|
22
24
|
|
|
23
25
|
export const DEFAULT_OPENAPI_DISCOVERY_PATHS = [
|
|
24
26
|
'openapi.json',
|
|
@@ -107,43 +109,35 @@ export type IntrospectGraphQlApiResult =
|
|
|
107
109
|
readonly status?: number;
|
|
108
110
|
};
|
|
109
111
|
|
|
112
|
+
/*** Creates the ordered, deduplicated URLs probed during OpenAPI discovery. */
|
|
110
113
|
export function createOpenApiDiscoveryCandidates(
|
|
111
114
|
rawUrl: string,
|
|
112
115
|
conventionalPaths: readonly string[] = DEFAULT_OPENAPI_DISCOVERY_PATHS,
|
|
113
116
|
): readonly string[] {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const exact = normalizeCandidateUrl(parsed);
|
|
118
|
-
const serviceBase = new URL(exact);
|
|
119
|
-
if (!serviceBase.pathname.endsWith('/')) serviceBase.pathname = `${serviceBase.pathname}/`;
|
|
120
|
-
|
|
121
|
-
const candidates = [exact];
|
|
122
|
-
for (const path of conventionalPaths) {
|
|
123
|
-
const normalizedPath = path.replace(/^\/+/, '');
|
|
124
|
-
candidates.push(normalizeCandidateUrl(new URL(normalizedPath, serviceBase)));
|
|
125
|
-
candidates.push(normalizeCandidateUrl(new URL(normalizedPath, `${parsed.origin}/`)));
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return [...new Set(candidates)];
|
|
117
|
+
return createOpenApiDiscoveryPlan(rawUrl, conventionalPaths).candidates.map(
|
|
118
|
+
(candidate) => candidate.url,
|
|
119
|
+
);
|
|
129
120
|
}
|
|
130
121
|
|
|
122
|
+
/*** Discovers and imports an OpenAPI document while preserving the requested service boundary. */
|
|
131
123
|
export async function discoverOpenApi(input: DiscoverOpenApiInput): Promise<DiscoverOpenApiResult> {
|
|
132
|
-
const
|
|
133
|
-
|
|
124
|
+
const conventionalPaths = input.conventionalPaths ?? DEFAULT_OPENAPI_DISCOVERY_PATHS;
|
|
125
|
+
const plan = createOpenApiDiscoveryPlan(input.url, conventionalPaths);
|
|
126
|
+
if (plan.candidates.length === 0) {
|
|
134
127
|
return discoveryFailure(input.id, [], 'OpenAPI discovery requires a valid HTTP or HTTPS URL.');
|
|
135
128
|
}
|
|
136
129
|
|
|
137
130
|
const attempts: OpenApiDiscoveryAttempt[] = [];
|
|
138
|
-
for (const candidate of candidates) {
|
|
139
|
-
const
|
|
131
|
+
for (const candidate of plan.candidates) {
|
|
132
|
+
const requestedScope = candidate.scopeToRequestedService ? plan.requestedScope : undefined;
|
|
133
|
+
const probed = await probeOpenApiCandidate(input, candidate.url, requestedScope);
|
|
140
134
|
attempts.push(probed.attempt);
|
|
141
135
|
if (probed.result === undefined) continue;
|
|
142
136
|
return probed.result.ok
|
|
143
137
|
? {
|
|
144
138
|
ok: true,
|
|
145
139
|
data: probed.result.data,
|
|
146
|
-
documentUrl: candidate,
|
|
140
|
+
documentUrl: candidate.url,
|
|
147
141
|
attempts,
|
|
148
142
|
diagnostics: probed.result.diagnostics ?? [],
|
|
149
143
|
}
|
|
@@ -157,6 +151,7 @@ export async function discoverOpenApi(input: DiscoverOpenApiInput): Promise<Disc
|
|
|
157
151
|
);
|
|
158
152
|
}
|
|
159
153
|
|
|
154
|
+
/*** Introspects a GraphQL endpoint and normalizes its schema into the canonical API definition. */
|
|
160
155
|
export async function introspectGraphQlApi(
|
|
161
156
|
input: IntrospectGraphQlApiInput,
|
|
162
157
|
): Promise<IntrospectGraphQlApiResult> {
|
|
@@ -205,6 +200,7 @@ export async function introspectGraphQlApi(
|
|
|
205
200
|
: { ok: false, diagnostics: result.diagnostics };
|
|
206
201
|
}
|
|
207
202
|
|
|
203
|
+
/*** Fetches GraphQL introspection without exposing transport failures beyond the discovery boundary. */
|
|
208
204
|
async function fetchGraphQlIntrospection(
|
|
209
205
|
input: IntrospectGraphQlApiInput,
|
|
210
206
|
endpoint: URL,
|
|
@@ -229,9 +225,11 @@ interface OpenApiProbeResult {
|
|
|
229
225
|
readonly result?: ReturnType<typeof importOpenApiDocument>;
|
|
230
226
|
}
|
|
231
227
|
|
|
228
|
+
/*** Probes one OpenAPI candidate and scopes origin-root documents before canonical import. */
|
|
232
229
|
async function probeOpenApiCandidate(
|
|
233
230
|
input: DiscoverOpenApiInput,
|
|
234
231
|
candidate: string,
|
|
232
|
+
requestedScope: string | undefined,
|
|
235
233
|
): Promise<OpenApiProbeResult> {
|
|
236
234
|
let response: ExternalApiFetchResponse;
|
|
237
235
|
try {
|
|
@@ -257,16 +255,7 @@ async function probeOpenApiCandidate(
|
|
|
257
255
|
};
|
|
258
256
|
}
|
|
259
257
|
|
|
260
|
-
const result =
|
|
261
|
-
id: input.id,
|
|
262
|
-
document: parsed,
|
|
263
|
-
baseUrl: input.baseUrl,
|
|
264
|
-
credential: input.credential,
|
|
265
|
-
documentUrl: candidate,
|
|
266
|
-
name: input.name,
|
|
267
|
-
description: input.description,
|
|
268
|
-
metadata: input.metadata,
|
|
269
|
-
});
|
|
258
|
+
const result = importOpenApiCandidate(input, candidate, parsed, requestedScope);
|
|
270
259
|
return {
|
|
271
260
|
attempt: {
|
|
272
261
|
url: candidate,
|
|
@@ -277,6 +266,33 @@ async function probeOpenApiCandidate(
|
|
|
277
266
|
};
|
|
278
267
|
}
|
|
279
268
|
|
|
269
|
+
/*** Applies service scoping and imports one validated OpenAPI candidate. */
|
|
270
|
+
function importOpenApiCandidate(
|
|
271
|
+
input: DiscoverOpenApiInput,
|
|
272
|
+
candidate: string,
|
|
273
|
+
parsed: OpenApiDocumentObject,
|
|
274
|
+
requestedScope: string | undefined,
|
|
275
|
+
): ReturnType<typeof importOpenApiDocument> {
|
|
276
|
+
let document = parsed;
|
|
277
|
+
if (requestedScope !== undefined) {
|
|
278
|
+
const scoped = scopeOpenApiDocument(parsed, input.id, requestedScope, candidate);
|
|
279
|
+
if (!scoped.ok) return { ok: false, diagnostics: [scoped.diagnostic] };
|
|
280
|
+
const { document: scopedDocument } = scoped;
|
|
281
|
+
document = scopedDocument;
|
|
282
|
+
}
|
|
283
|
+
return importOpenApiDocument({
|
|
284
|
+
id: input.id,
|
|
285
|
+
document,
|
|
286
|
+
baseUrl: input.baseUrl,
|
|
287
|
+
credential: input.credential,
|
|
288
|
+
documentUrl: candidate,
|
|
289
|
+
name: input.name,
|
|
290
|
+
description: input.description,
|
|
291
|
+
metadata: input.metadata,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/*** Checks whether parsed JSON has the minimal OpenAPI document shape required for import. */
|
|
280
296
|
function isOpenApiDocument(value: unknown): value is OpenApiDocumentObject {
|
|
281
297
|
const record = readRecord(value);
|
|
282
298
|
return (
|
|
@@ -286,6 +302,7 @@ function isOpenApiDocument(value: unknown): value is OpenApiDocumentObject {
|
|
|
286
302
|
);
|
|
287
303
|
}
|
|
288
304
|
|
|
305
|
+
/*** Reads a GraphQL introspection schema from a parsed response payload. */
|
|
289
306
|
function readGraphQlIntrospection(value: unknown): GraphQlIntrospectionResult | undefined {
|
|
290
307
|
const payload = readRecord(value);
|
|
291
308
|
const data = readRecord(payload?.data);
|
|
@@ -293,6 +310,7 @@ function readGraphQlIntrospection(value: unknown): GraphQlIntrospectionResult |
|
|
|
293
310
|
return data;
|
|
294
311
|
}
|
|
295
312
|
|
|
313
|
+
/*** Builds a canonical OpenAPI discovery failure with safe diagnostics. */
|
|
296
314
|
function discoveryFailure(
|
|
297
315
|
apiId: string,
|
|
298
316
|
attempts: readonly OpenApiDiscoveryAttempt[],
|
|
@@ -305,6 +323,7 @@ function discoveryFailure(
|
|
|
305
323
|
};
|
|
306
324
|
}
|
|
307
325
|
|
|
326
|
+
/*** Builds a canonical GraphQL introspection failure with API identity. */
|
|
308
327
|
function graphqlFailure(
|
|
309
328
|
apiId: string,
|
|
310
329
|
message: string,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { normalizeCandidateUrl, parseHttpUrl } from './http';
|
|
2
|
+
|
|
3
|
+
/*** Builds the ordered OpenAPI probe plan and the normalized service scope requested by the caller. */
|
|
4
|
+
export function createOpenApiDiscoveryPlan(
|
|
5
|
+
rawUrl: string,
|
|
6
|
+
conventionalPaths: readonly string[],
|
|
7
|
+
): OpenApiDiscoveryPlan {
|
|
8
|
+
const parsed = parseHttpUrl(rawUrl);
|
|
9
|
+
if (parsed === undefined) return { candidates: [], requestedScope: undefined };
|
|
10
|
+
|
|
11
|
+
const exact = normalizeCandidateUrl(parsed);
|
|
12
|
+
const serviceBase = new URL(exact);
|
|
13
|
+
if (!serviceBase.pathname.endsWith('/')) serviceBase.pathname = `${serviceBase.pathname}/`;
|
|
14
|
+
|
|
15
|
+
const candidates = new Map<string, OpenApiDiscoveryCandidate>();
|
|
16
|
+
candidates.set(exact, { url: exact, scopeToRequestedService: false });
|
|
17
|
+
for (const path of conventionalPaths) {
|
|
18
|
+
const normalizedPath = path.replace(/^\/+/, '');
|
|
19
|
+
const serviceUrl = normalizeCandidateUrl(new URL(normalizedPath, serviceBase));
|
|
20
|
+
const rootUrl = normalizeCandidateUrl(new URL(normalizedPath, `${parsed.origin}/`));
|
|
21
|
+
if (!candidates.has(serviceUrl)) {
|
|
22
|
+
candidates.set(serviceUrl, { url: serviceUrl, scopeToRequestedService: false });
|
|
23
|
+
}
|
|
24
|
+
if (!candidates.has(rootUrl)) {
|
|
25
|
+
candidates.set(rootUrl, { url: rootUrl, scopeToRequestedService: true });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const requestedPath = normalizeServicePath(parsed.pathname);
|
|
30
|
+
const isExplicitRootDocument = conventionalPaths.some((path) => {
|
|
31
|
+
const normalizedPath = path.replace(/^\/+/, '');
|
|
32
|
+
const rootCandidate = new URL(normalizedPath, `${parsed.origin}/`);
|
|
33
|
+
return (
|
|
34
|
+
rootCandidate.origin === parsed.origin &&
|
|
35
|
+
normalizeServicePath(rootCandidate.pathname) === requestedPath
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
candidates: [...candidates.values()],
|
|
40
|
+
requestedScope: requestedPath === '/' || isExplicitRootDocument ? undefined : requestedPath,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface OpenApiDiscoveryPlan {
|
|
45
|
+
readonly candidates: readonly OpenApiDiscoveryCandidate[];
|
|
46
|
+
readonly requestedScope: string | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface OpenApiDiscoveryCandidate {
|
|
50
|
+
readonly url: string;
|
|
51
|
+
readonly scopeToRequestedService: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/*** Normalizes trailing slashes for segment-safe service-path comparisons. */
|
|
55
|
+
function normalizeServicePath(pathname: string): string {
|
|
56
|
+
const normalized = pathname.replace(/\/+$/g, '');
|
|
57
|
+
return normalized.length === 0 ? '/' : normalized;
|
|
58
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DataSourceDiagnostic } from '@ankhorage/contracts/data';
|
|
2
|
+
|
|
3
|
+
import type { OpenApiDocumentObject } from '../openapi';
|
|
4
|
+
|
|
5
|
+
/*** Filters an origin-root OpenAPI document to the requested service path without rewriting paths. */
|
|
6
|
+
export function scopeOpenApiDocument(
|
|
7
|
+
document: OpenApiDocumentObject,
|
|
8
|
+
apiId: string,
|
|
9
|
+
requestedScope: string,
|
|
10
|
+
documentUrl: string,
|
|
11
|
+
): ScopeOpenApiDocumentResult {
|
|
12
|
+
const paths = Object.fromEntries(
|
|
13
|
+
Object.entries(document.paths ?? {}).filter(
|
|
14
|
+
([path]) => path === requestedScope || path.startsWith(`${requestedScope}/`),
|
|
15
|
+
),
|
|
16
|
+
);
|
|
17
|
+
if (Object.keys(paths).length > 0) return { ok: true, document: { ...document, paths } };
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
diagnostic: {
|
|
22
|
+
code: 'missing-schema',
|
|
23
|
+
apiId,
|
|
24
|
+
message:
|
|
25
|
+
`OpenAPI document '${redactDiagnosticUrl(documentUrl)}' contains no paths ` +
|
|
26
|
+
`within requested service scope '${requestedScope}'.`,
|
|
27
|
+
path: 'paths',
|
|
28
|
+
severity: 'error',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type ScopeOpenApiDocumentResult =
|
|
34
|
+
| { readonly ok: true; readonly document: OpenApiDocumentObject }
|
|
35
|
+
| { readonly ok: false; readonly diagnostic: DataSourceDiagnostic };
|
|
36
|
+
|
|
37
|
+
/*** Removes credentials, query parameters, and fragments from a URL included in diagnostics. */
|
|
38
|
+
function redactDiagnosticUrl(rawUrl: string): string {
|
|
39
|
+
const url = new URL(rawUrl);
|
|
40
|
+
url.username = '';
|
|
41
|
+
url.password = '';
|
|
42
|
+
url.search = '';
|
|
43
|
+
url.hash = '';
|
|
44
|
+
return url.toString();
|
|
45
|
+
}
|