@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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @ankhorage/data-sources
2
2
 
3
+ ## 2.0.5
4
+
5
+ ### Patch Changes
6
+
7
+ - 43264ba: Scope OpenAPI documents discovered at a shared origin root to the service path requested by the caller.
8
+
3
9
  ## 2.0.4
4
10
 
5
11
  ### Patch Changes
@@ -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;AAgBnC,eAAO,MAAM,+BAA+B,8EAKlC,CAAC;AAEX,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,MAAM,EAAE,KAAK,GAAG,MAAM,CAAC;IAChC,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;CACzB;AAED,MAAM,MAAM,gBAAgB,GAAG,CAC7B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,oBAAoB,KACvB,OAAO,CAAC,wBAAwB,CAAC,CAAC;AAEvC,MAAM,MAAM,8BAA8B,GACtC,YAAY,GACZ,kBAAkB,GAClB,SAAS,GACT,eAAe,GACf,aAAa,GACb,sBAAsB,CAAC;AAE3B,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,EAAE,8BAA8B,CAAC;IACjD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAC;IACjC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,UAAU,CAAC,EAAE,aAAa,CAAC;IACpC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IACtC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAChD;AAED,MAAM,MAAM,qBAAqB,GAC7B;IACE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,IAAI,EAAE,yBAAyB,CAAC;IACzC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,SAAS,uBAAuB,EAAE,CAAC;IACtD,QAAQ,CAAC,WAAW,EAAE,SAAS,oBAAoB,EAAE,CAAC;CACvD,GACD;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,QAAQ,EAAE,SAAS,uBAAuB,EAAE,CAAC;IACtD,QAAQ,CAAC,WAAW,EAAE,SAAS,oBAAoB,EAAE,CAAC;CACvD,CAAC;AAEN,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAC;IACjC,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD,QAAQ,CAAC,UAAU,CAAC,EAAE,aAAa,CAAC;IACpC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,QAAQ,CAAC,EAAE,iBAAiB,CAAC;CACvC;AAED,MAAM,MAAM,0BAA0B,GAClC;IACE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,IAAI,EAAE,4BAA4B,CAAC;IAC5C,QAAQ,CAAC,WAAW,EAAE,SAAS,oBAAoB,EAAE,CAAC;CACvD,GACD;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,WAAW,EAAE,SAAS,oBAAoB,EAAE,CAAC;IACtD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AAEN,wBAAgB,gCAAgC,CAC9C,MAAM,EAAE,MAAM,EACd,iBAAiB,GAAE,SAAS,MAAM,EAAoC,GACrE,SAAS,MAAM,EAAE,CAgBnB;AAED,wBAAsB,eAAe,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CA2BjG;AAED,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,yBAAyB,GAC/B,OAAO,CAAC,0BAA0B,CAAC,CA4CrC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/discovery/index.ts"],"names":[],"mappings":"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"}
@@ -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
- const parsed = parseHttpUrl(rawUrl);
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 candidates = createOpenApiDiscoveryCandidates(input.url, input.conventionalPaths);
28
- if (candidates.length === 0) {
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 probed = await probeOpenApiCandidate(input, candidate);
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
- async function probeOpenApiCandidate(input, candidate) {
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 = importOpenApiDocument({
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",
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',
@@ -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
- const parsed = parseHttpUrl(rawUrl);
115
- if (parsed === undefined) return [];
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 candidates = createOpenApiDiscoveryCandidates(input.url, input.conventionalPaths);
133
- if (candidates.length === 0) {
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 probed = await probeOpenApiCandidate(input, candidate);
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 = importOpenApiDocument({
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
+ }