@ankhorage/data-sources 0.5.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @ankhorage/data-sources
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4922dcd: Add transport-injected OpenAPI URL discovery and GraphQL introspection primitives for canonical external data-source authoring.
8
+
3
9
  ## 0.5.5
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  # @ankhorage/data-sources
5
5
 
6
- ![license: MIT](././paradox/badges/license.svg) ![npm: v0.5.3](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg)
6
+ ![license: MIT](././paradox/badges/license.svg) ![npm: v0.5.5](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg)
7
7
 
8
8
  Provider-neutral data source and endpoint system for APIs, databases, and runtime bindings.
9
9
 
@@ -17,6 +17,7 @@ Provider-neutral data source and endpoint system for APIs, databases, and runtim
17
17
  - [Export graph](././paradox/diagrams/export-graph.mmd)
18
18
  - [createManagedApiResourceSchema sequence](././paradox/diagrams/sequences/create-managed-api-resource-schema.mmd)
19
19
  - [createManualRestDataSource sequence](././paradox/diagrams/sequences/create-manual-rest-data-source.mmd)
20
+ - [createOpenApiDiscoveryCandidates sequence](././paradox/diagrams/sequences/create-open-api-discovery-candidates.mmd)
20
21
  - [normalizeGraphQlIntrospectionOperations sequence](././paradox/diagrams/sequences/normalize-graph-ql-introspection-operations.mmd)
21
22
  - [normalizeGraphQlIntrospectionSchemas sequence](././paradox/diagrams/sequences/normalize-graph-ql-introspection-schemas.mmd)
22
23
  - [normalizeManualRestDataSource sequence](././paradox/diagrams/sequences/normalize-manual-rest-data-source.mmd)
@@ -37,6 +38,12 @@ graph TD
37
38
  module_src_ankh_provider_ts["src/ankh.provider.ts"]
38
39
  package__ankhorage_data_sources -.-> module_src_ankh_provider_ts
39
40
  module_src_ankh_provider_ts --> module_src_index_ts
41
+ module_src_cli_index_ts["src/cli/index.ts"]
42
+ package__ankhorage_data_sources -.-> module_src_cli_index_ts
43
+ module_src_discovery_index_ts["src/discovery/index.ts"]
44
+ package__ankhorage_data_sources -.-> module_src_discovery_index_ts
45
+ module_src_discovery_index_ts --> module_src_graphql_index_ts
46
+ module_src_discovery_index_ts --> module_src_openapi_index_ts
40
47
  module_src_graphql_index_ts["src/graphql/index.ts"]
41
48
  package__ankhorage_data_sources -.-> module_src_graphql_index_ts
42
49
  module_src_index_ts["src/index.ts"]
@@ -0,0 +1,64 @@
1
+ import type { CredentialRef, DataContractValue, DataSourceDiagnostic, GraphQlDataSourceConfig, OpenApiDataSourceConfig } from '@ankhorage/contracts/data';
2
+ export declare const DEFAULT_OPENAPI_DISCOVERY_PATHS: readonly ["openapi.json", "swagger.json", "api/openapi.json", "v3/api-docs"];
3
+ export interface ExternalApiFetchInit {
4
+ readonly method: 'GET' | 'POST';
5
+ readonly headers: Readonly<Record<string, string>>;
6
+ readonly body?: string;
7
+ }
8
+ export interface ExternalApiFetchResponse {
9
+ readonly status: number;
10
+ text(): Promise<string>;
11
+ }
12
+ export type ExternalApiFetch = (url: string, init: ExternalApiFetchInit) => Promise<ExternalApiFetchResponse>;
13
+ export type OpenApiDiscoveryAttemptOutcome = 'http-error' | 'invalid-document' | 'matched' | 'network-error' | 'parse-error' | 'unsupported-document';
14
+ export interface OpenApiDiscoveryAttempt {
15
+ readonly url: string;
16
+ readonly outcome: OpenApiDiscoveryAttemptOutcome;
17
+ readonly status?: number;
18
+ }
19
+ export interface DiscoverOpenApiDataSourceInput {
20
+ readonly id: string;
21
+ readonly url: string;
22
+ readonly fetch: ExternalApiFetch;
23
+ readonly baseUrl?: string;
24
+ readonly credential?: CredentialRef;
25
+ readonly name?: string;
26
+ readonly description?: string;
27
+ readonly metadata?: DataContractValue;
28
+ readonly conventionalPaths?: readonly string[];
29
+ }
30
+ export type DiscoverOpenApiDataSourceResult = {
31
+ readonly ok: true;
32
+ readonly data: OpenApiDataSourceConfig;
33
+ readonly documentUrl: string;
34
+ readonly attempts: readonly OpenApiDiscoveryAttempt[];
35
+ readonly diagnostics: readonly DataSourceDiagnostic[];
36
+ } | {
37
+ readonly ok: false;
38
+ readonly attempts: readonly OpenApiDiscoveryAttempt[];
39
+ readonly diagnostics: readonly DataSourceDiagnostic[];
40
+ };
41
+ export interface IntrospectGraphQlDataSourceInput {
42
+ readonly id: string;
43
+ readonly endpointUrl: string;
44
+ readonly fetch: ExternalApiFetch;
45
+ readonly headers?: Readonly<Record<string, string>>;
46
+ readonly credential?: CredentialRef;
47
+ readonly name?: string;
48
+ readonly description?: string;
49
+ readonly schemaVersion?: string;
50
+ readonly metadata?: DataContractValue;
51
+ }
52
+ export type IntrospectGraphQlDataSourceResult = {
53
+ readonly ok: true;
54
+ readonly data: GraphQlDataSourceConfig;
55
+ readonly diagnostics: readonly DataSourceDiagnostic[];
56
+ } | {
57
+ readonly ok: false;
58
+ readonly diagnostics: readonly DataSourceDiagnostic[];
59
+ readonly status?: number;
60
+ };
61
+ export declare function createOpenApiDiscoveryCandidates(rawUrl: string, conventionalPaths?: readonly string[]): readonly string[];
62
+ export declare function discoverOpenApiDataSource(input: DiscoverOpenApiDataSourceInput): Promise<DiscoverOpenApiDataSourceResult>;
63
+ export declare function introspectGraphQlDataSource(input: IntrospectGraphQlDataSourceInput): Promise<IntrospectGraphQlDataSourceResult>;
64
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/discovery/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,204 @@
1
+ import { createGraphQlDataSource, createGraphQlIntrospectionRequest, } from '../graphql';
2
+ import { importOpenApiDocument } from '../openapi';
3
+ export const DEFAULT_OPENAPI_DISCOVERY_PATHS = [
4
+ 'openapi.json',
5
+ 'swagger.json',
6
+ 'api/openapi.json',
7
+ 'v3/api-docs',
8
+ ];
9
+ export function createOpenApiDiscoveryCandidates(rawUrl, conventionalPaths = DEFAULT_OPENAPI_DISCOVERY_PATHS) {
10
+ const parsed = parseHttpUrl(rawUrl);
11
+ if (parsed === undefined)
12
+ return [];
13
+ const exact = normalizeCandidateUrl(parsed);
14
+ const serviceBase = new URL(exact);
15
+ if (!serviceBase.pathname.endsWith('/'))
16
+ serviceBase.pathname = `${serviceBase.pathname}/`;
17
+ const candidates = [exact];
18
+ for (const path of conventionalPaths) {
19
+ const normalizedPath = path.replace(/^\/+/, '');
20
+ candidates.push(normalizeCandidateUrl(new URL(normalizedPath, serviceBase)));
21
+ candidates.push(normalizeCandidateUrl(new URL(normalizedPath, `${parsed.origin}/`)));
22
+ }
23
+ return [...new Set(candidates)];
24
+ }
25
+ export async function discoverOpenApiDataSource(input) {
26
+ const candidates = createOpenApiDiscoveryCandidates(input.url, input.conventionalPaths);
27
+ if (candidates.length === 0) {
28
+ return discoveryFailure(input.id, [], 'OpenAPI discovery requires a valid HTTP or HTTPS URL.');
29
+ }
30
+ const attempts = [];
31
+ for (const candidate of candidates) {
32
+ const probed = await probeOpenApiCandidate(input, candidate);
33
+ attempts.push(probed.attempt);
34
+ if (probed.result !== undefined) {
35
+ return probed.result.ok
36
+ ? {
37
+ ok: true,
38
+ data: probed.result.data,
39
+ documentUrl: candidate,
40
+ attempts,
41
+ diagnostics: probed.result.diagnostics ?? [],
42
+ }
43
+ : { ok: false, attempts, diagnostics: probed.result.diagnostics };
44
+ }
45
+ }
46
+ return discoveryFailure(input.id, attempts, 'No supported OpenAPI document was found at the supplied URL or conventional locations.');
47
+ }
48
+ export async function introspectGraphQlDataSource(input) {
49
+ const endpoint = parseHttpUrl(input.endpointUrl);
50
+ if (endpoint === undefined) {
51
+ return graphqlFailure(input.id, 'GraphQL introspection requires a valid HTTP or HTTPS URL.');
52
+ }
53
+ const request = createGraphQlIntrospectionRequest();
54
+ let response;
55
+ try {
56
+ response = await input.fetch(normalizeCandidateUrl(endpoint), {
57
+ method: 'POST',
58
+ headers: {
59
+ accept: 'application/json',
60
+ 'content-type': 'application/json',
61
+ ...(input.headers ?? {}),
62
+ },
63
+ body: JSON.stringify(request),
64
+ });
65
+ }
66
+ catch {
67
+ return graphqlFailure(input.id, 'GraphQL introspection request failed.', 'network-error');
68
+ }
69
+ if (!isSuccessfulStatus(response.status)) {
70
+ return {
71
+ ...graphqlFailure(input.id, `GraphQL introspection returned HTTP status ${response.status}.`, 'network-error'),
72
+ status: response.status,
73
+ };
74
+ }
75
+ const parsed = await parseJsonResponse(response);
76
+ const introspection = readGraphQlIntrospection(parsed);
77
+ if (introspection === undefined) {
78
+ return graphqlFailure(input.id, 'GraphQL introspection response did not contain data.__schema.', 'parse-error');
79
+ }
80
+ const result = createGraphQlDataSource({
81
+ id: input.id,
82
+ endpointUrl: normalizeCandidateUrl(endpoint),
83
+ credential: input.credential,
84
+ name: input.name,
85
+ description: input.description,
86
+ introspection,
87
+ introspectionEnabled: true,
88
+ schemaVersion: input.schemaVersion,
89
+ metadata: input.metadata,
90
+ });
91
+ return result.ok
92
+ ? { ok: true, data: result.data, diagnostics: result.diagnostics ?? [] }
93
+ : { ok: false, diagnostics: result.diagnostics };
94
+ }
95
+ async function probeOpenApiCandidate(input, candidate) {
96
+ let response;
97
+ try {
98
+ response = await input.fetch(candidate, {
99
+ method: 'GET',
100
+ headers: {
101
+ accept: 'application/json, application/vnd.oai.openapi+json',
102
+ },
103
+ });
104
+ }
105
+ catch {
106
+ return { attempt: { url: candidate, outcome: 'network-error' } };
107
+ }
108
+ if (!isSuccessfulStatus(response.status)) {
109
+ return {
110
+ attempt: { url: candidate, outcome: 'http-error', status: response.status },
111
+ };
112
+ }
113
+ const parsed = await parseJsonResponse(response);
114
+ if (parsed === undefined) {
115
+ return {
116
+ attempt: { url: candidate, outcome: 'parse-error', status: response.status },
117
+ };
118
+ }
119
+ if (!isOpenApiDocument(parsed)) {
120
+ return {
121
+ attempt: { url: candidate, outcome: 'unsupported-document', status: response.status },
122
+ };
123
+ }
124
+ const result = importOpenApiDocument({
125
+ id: input.id,
126
+ document: parsed,
127
+ baseUrl: input.baseUrl,
128
+ credential: input.credential,
129
+ documentUrl: candidate,
130
+ name: input.name,
131
+ description: input.description,
132
+ metadata: input.metadata,
133
+ });
134
+ return {
135
+ attempt: {
136
+ url: candidate,
137
+ outcome: result.ok ? 'matched' : 'invalid-document',
138
+ status: response.status,
139
+ },
140
+ result,
141
+ };
142
+ }
143
+ function parseHttpUrl(rawUrl) {
144
+ try {
145
+ const parsed = new URL(rawUrl.trim());
146
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
147
+ return undefined;
148
+ if (parsed.username.length > 0 || parsed.password.length > 0)
149
+ return undefined;
150
+ return parsed;
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ }
156
+ function normalizeCandidateUrl(url) {
157
+ const normalized = new URL(url.toString());
158
+ normalized.hash = '';
159
+ return normalized.toString();
160
+ }
161
+ function isSuccessfulStatus(status) {
162
+ return status >= 200 && status < 300;
163
+ }
164
+ async function parseJsonResponse(response) {
165
+ try {
166
+ return JSON.parse(await response.text());
167
+ }
168
+ catch {
169
+ return undefined;
170
+ }
171
+ }
172
+ function isOpenApiDocument(value) {
173
+ const record = readRecord(value);
174
+ return (record !== undefined &&
175
+ typeof record.openapi === 'string' &&
176
+ readRecord(record.paths) !== undefined);
177
+ }
178
+ function readGraphQlIntrospection(value) {
179
+ const payload = readRecord(value);
180
+ const data = readRecord(payload?.data);
181
+ if (readRecord(data?.__schema) === undefined)
182
+ return undefined;
183
+ return data;
184
+ }
185
+ function isRecord(value) {
186
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
187
+ }
188
+ function readRecord(value) {
189
+ return isRecord(value) ? value : undefined;
190
+ }
191
+ function discoveryFailure(dataSourceId, attempts, message) {
192
+ return {
193
+ ok: false,
194
+ attempts,
195
+ diagnostics: [{ code: 'missing-schema', dataSourceId, message, severity: 'error' }],
196
+ };
197
+ }
198
+ function graphqlFailure(dataSourceId, message, code = 'invalid-config') {
199
+ return {
200
+ ok: false,
201
+ diagnostics: [{ code, dataSourceId, message, severity: 'error' }],
202
+ };
203
+ }
204
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/discovery/index.ts"],"names":[],"mappings":"AAQA,OAAO,EACL,uBAAuB,EACvB,iCAAiC,GAElC,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,qBAAqB,EAA8B,MAAM,YAAY,CAAC;AAE/E,MAAM,CAAC,MAAM,+BAA+B,GAAG;IAC7C,cAAc;IACd,cAAc;IACd,kBAAkB;IAClB,aAAa;CACL,CAAC;AAkFX,MAAM,UAAU,gCAAgC,CAC9C,MAAc,EACd,oBAAuC,+BAA+B;IAEtE,MAAM,MAAM,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;IACpC,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,EAAE,CAAC;IAEpC,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,CAAC,KAAK,CAAC,CAAC;IAC3B,KAAK,MAAM,IAAI,IAAI,iBAAiB,EAAE,CAAC;QACrC,MAAM,cAAc,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QAChD,UAAU,CAAC,IAAI,CAAC,qBAAqB,CAAC,IAAI,GAAG,CAAC,cAAc,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC;QAC7E,UAAU,CAAC,IAAI,CAAC,qBAAqB,CAAC,IAAI,GAAG,CAAC,cAAc,EAAE,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC;IACvF,CAAC;IAED,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC;AAClC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,KAAqC;IAErC,MAAM,UAAU,GAAG,gCAAgC,CAAC,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,iBAAiB,CAAC,CAAC;IACxF,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC5B,OAAO,gBAAgB,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,EAAE,uDAAuD,CAAC,CAAC;IACjG,CAAC;IAED,MAAM,QAAQ,GAA8B,EAAE,CAAC;IAC/C,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,MAAM,MAAM,GAAG,MAAM,qBAAqB,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC;QAC7D,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QAC9B,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;YAChC,OAAO,MAAM,CAAC,MAAM,CAAC,EAAE;gBACrB,CAAC,CAAC;oBACE,EAAE,EAAE,IAAI;oBACR,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI;oBACxB,WAAW,EAAE,SAAS;oBACtB,QAAQ;oBACR,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC,WAAW,IAAI,EAAE;iBAC7C;gBACH,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC;QACtE,CAAC;IACH,CAAC;IAED,OAAO,gBAAgB,CACrB,KAAK,CAAC,EAAE,EACR,QAAQ,EACR,wFAAwF,CACzF,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,2BAA2B,CAC/C,KAAuC;IAEvC,MAAM,QAAQ,GAAG,YAAY,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;IACjD,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B,OAAO,cAAc,CAAC,KAAK,CAAC,EAAE,EAAE,2DAA2D,CAAC,CAAC;IAC/F,CAAC;IAED,MAAM,OAAO,GAAG,iCAAiC,EAAE,CAAC;IACpD,IAAI,QAAkC,CAAC;IACvC,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,KAAK,CAAC,KAAK,CAAC,qBAAqB,CAAC,QAAQ,CAAC,EAAE;YAC5D,MAAM,EAAE,MAAM;YACd,OAAO,EAAE;gBACP,MAAM,EAAE,kBAAkB;gBAC1B,cAAc,EAAE,kBAAkB;gBAClC,GAAG,CAAC,KAAK,CAAC,OAAO,IAAI,EAAE,CAAC;aACzB;YACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;SAC9B,CAAC,CAAC;IACL,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,cAAc,CAAC,KAAK,CAAC,EAAE,EAAE,uCAAuC,EAAE,eAAe,CAAC,CAAC;IAC5F,CAAC;IAED,IAAI,CAAC,kBAAkB,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QACzC,OAAO;YACL,GAAG,cAAc,CACf,KAAK,CAAC,EAAE,EACR,8CAA8C,QAAQ,CAAC,MAAM,GAAG,EAChE,eAAe,CAChB;YACD,MAAM,EAAE,QAAQ,CAAC,MAAM;SACxB,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,iBAAiB,CAAC,QAAQ,CAAC,CAAC;IACjD,MAAM,aAAa,GAAG,wBAAwB,CAAC,MAAM,CAAC,CAAC;IACvD,IAAI,aAAa,KAAK,SAAS,EAAE,CAAC;QAChC,OAAO,cAAc,CACnB,KAAK,CAAC,EAAE,EACR,+DAA+D,EAC/D,aAAa,CACd,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,uBAAuB,CAAC;QACrC,EAAE,EAAE,KAAK,CAAC,EAAE;QACZ,WAAW,EAAE,qBAAqB,CAAC,QAAQ,CAAC;QAC5C,UAAU,EAAE,KAAK,CAAC,UAAU;QAC5B,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,WAAW,EAAE,KAAK,CAAC,WAAW;QAC9B,aAAa;QACb,oBAAoB,EAAE,IAAI;QAC1B,aAAa,EAAE,KAAK,CAAC,aAAa;QAClC,QAAQ,EAAE,KAAK,CAAC,QAAQ;KACzB,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC,EAAE;QACd,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,WAAW,EAAE,MAAM,CAAC,WAAW,IAAI,EAAE,EAAE;QACxE,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC;AACrD,CAAC;AAOD,KAAK,UAAU,qBAAqB,CAClC,KAAqC,EACrC,SAAiB;IAEjB,IAAI,QAAkC,CAAC;IACvC,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,KAAK,CAAC,KAAK,CAAC,SAAS,EAAE;YACtC,MAAM,EAAE,KAAK;YACb,OAAO,EAAE;gBACP,MAAM,EAAE,oDAAoD;aAC7D;SACF,CAAC,CAAC;IACL,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,SAAS,EAAE,OAAO,EAAE,eAAe,EAAE,EAAE,CAAC;IACnE,CAAC;IAED,IAAI,CAAC,kBAAkB,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QACzC,OAAO;YACL,OAAO,EAAE,EAAE,GAAG,EAAE,SAAS,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE;SAC5E,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,iBAAiB,CAAC,QAAQ,CAAC,CAAC;IACjD,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,OAAO;YACL,OAAO,EAAE,EAAE,GAAG,EAAE,SAAS,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE;SAC7E,CAAC;IACJ,CAAC;IAED,IAAI,CAAC,iBAAiB,CAAC,MAAM,CAAC,EAAE,CAAC;QAC/B,OAAO;YACL,OAAO,EAAE,EAAE,GAAG,EAAE,SAAS,EAAE,OAAO,EAAE,sBAAsB,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE;SACtF,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,qBAAqB,CAAC;QACnC,EAAE,EAAE,KAAK,CAAC,EAAE;QACZ,QAAQ,EAAE,MAAM;QAChB,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,UAAU,EAAE,KAAK,CAAC,UAAU;QAC5B,WAAW,EAAE,SAAS;QACtB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,WAAW,EAAE,KAAK,CAAC,WAAW;QAC9B,QAAQ,EAAE,KAAK,CAAC,QAAQ;KACzB,CAAC,CAAC;IAEH,OAAO;QACL,OAAO,EAAE;YACP,GAAG,EAAE,SAAS;YACd,OAAO,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,kBAAkB;YACnD,MAAM,EAAE,QAAQ,CAAC,MAAM;SACxB;QACD,MAAM;KACP,CAAC;AACJ,CAAC;AAED,SAAS,YAAY,CAAC,MAAc;IAClC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;QACtC,IAAI,MAAM,CAAC,QAAQ,KAAK,OAAO,IAAI,MAAM,CAAC,QAAQ,KAAK,QAAQ;YAAE,OAAO,SAAS,CAAC;QAClF,IAAI,MAAM,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC;YAAE,OAAO,SAAS,CAAC;QAC/E,OAAO,MAAM,CAAC;IAChB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,SAAS,qBAAqB,CAAC,GAAQ;IACrC,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC;IAC3C,UAAU,CAAC,IAAI,GAAG,EAAE,CAAC;IACrB,OAAO,UAAU,CAAC,QAAQ,EAAE,CAAC;AAC/B,CAAC;AAED,SAAS,kBAAkB,CAAC,MAAc;IACxC,OAAO,MAAM,IAAI,GAAG,IAAI,MAAM,GAAG,GAAG,CAAC;AACvC,CAAC;AAED,KAAK,UAAU,iBAAiB,CAAC,QAAkC;IACjE,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAY,CAAC;IACtD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,SAAS,iBAAiB,CAAC,KAAc;IACvC,MAAM,MAAM,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;IACjC,OAAO,CACL,MAAM,KAAK,SAAS;QACpB,OAAO,MAAM,CAAC,OAAO,KAAK,QAAQ;QAClC,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,SAAS,CACvC,CAAC;AACJ,CAAC;AAED,SAAS,wBAAwB,CAAC,KAAc;IAC9C,MAAM,OAAO,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;IAClC,MAAM,IAAI,GAAG,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IACvC,IAAI,UAAU,CAAC,IAAI,EAAE,QAAQ,CAAC,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAC/D,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;AAC9E,CAAC;AAED,SAAS,UAAU,CAAC,KAAc;IAChC,OAAO,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AAC7C,CAAC;AAED,SAAS,gBAAgB,CACvB,YAAoB,EACpB,QAA4C,EAC5C,OAAe;IAEf,OAAO;QACL,EAAE,EAAE,KAAK;QACT,QAAQ;QACR,WAAW,EAAE,CAAC,EAAE,IAAI,EAAE,gBAAgB,EAAE,YAAY,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;KACpF,CAAC;AACJ,CAAC;AAED,SAAS,cAAc,CACrB,YAAoB,EACpB,OAAe,EACf,OAAqC,gBAAgB;IAErD,OAAO;QACL,EAAE,EAAE,KAAK;QACT,WAAW,EAAE,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;KAClE,CAAC;AACJ,CAAC","sourcesContent":["import type {\n CredentialRef,\n DataContractValue,\n DataSourceDiagnostic,\n GraphQlDataSourceConfig,\n OpenApiDataSourceConfig,\n} from '@ankhorage/contracts/data';\n\nimport {\n createGraphQlDataSource,\n createGraphQlIntrospectionRequest,\n type GraphQlIntrospectionResult,\n} from '../graphql';\nimport { importOpenApiDocument, type OpenApiDocumentObject } from '../openapi';\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 DiscoverOpenApiDataSourceInput {\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 DiscoverOpenApiDataSourceResult =\n | {\n readonly ok: true;\n readonly data: OpenApiDataSourceConfig;\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 IntrospectGraphQlDataSourceInput {\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 IntrospectGraphQlDataSourceResult =\n | {\n readonly ok: true;\n readonly data: GraphQlDataSourceConfig;\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 discoverOpenApiDataSource(\n input: DiscoverOpenApiDataSourceInput,\n): Promise<DiscoverOpenApiDataSourceResult> {\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) {\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\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 introspectGraphQlDataSource(\n input: IntrospectGraphQlDataSourceInput,\n): Promise<IntrospectGraphQlDataSourceResult> {\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 request = createGraphQlIntrospectionRequest();\n let response: ExternalApiFetchResponse;\n try {\n response = 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(request),\n });\n } catch {\n return graphqlFailure(input.id, 'GraphQL introspection request failed.', 'network-error');\n }\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 parsed = await parseJsonResponse(response);\n const introspection = readGraphQlIntrospection(parsed);\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 = createGraphQlDataSource({\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\n return result.ok\n ? { ok: true, data: result.data, diagnostics: result.diagnostics ?? [] }\n : { ok: false, diagnostics: result.diagnostics };\n}\n\ninterface OpenApiProbeResult {\n readonly attempt: OpenApiDiscoveryAttempt;\n readonly result?: ReturnType<typeof importOpenApiDocument>;\n}\n\nasync function probeOpenApiCandidate(\n input: DiscoverOpenApiDataSourceInput,\n candidate: string,\n): Promise<OpenApiProbeResult> {\n let response: ExternalApiFetchResponse;\n try {\n response = await input.fetch(candidate, {\n method: 'GET',\n headers: {\n accept: 'application/json, application/vnd.oai.openapi+json',\n },\n });\n } catch {\n return { attempt: { url: candidate, outcome: 'network-error' } };\n }\n\n if (!isSuccessfulStatus(response.status)) {\n return {\n attempt: { url: candidate, outcome: 'http-error', status: response.status },\n };\n }\n\n const parsed = await parseJsonResponse(response);\n if (parsed === undefined) {\n return {\n attempt: { url: candidate, outcome: 'parse-error', status: response.status },\n };\n }\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\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 parseHttpUrl(rawUrl: string): URL | undefined {\n try {\n const parsed = new URL(rawUrl.trim());\n if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;\n if (parsed.username.length > 0 || parsed.password.length > 0) return undefined;\n return parsed;\n } catch {\n return undefined;\n }\n}\n\nfunction normalizeCandidateUrl(url: URL): string {\n const normalized = new URL(url.toString());\n normalized.hash = '';\n return normalized.toString();\n}\n\nfunction isSuccessfulStatus(status: number): boolean {\n return status >= 200 && status < 300;\n}\n\nasync function parseJsonResponse(response: ExternalApiFetchResponse): Promise<unknown> {\n try {\n return JSON.parse(await response.text()) as unknown;\n } catch {\n return undefined;\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 isRecord(value: unknown): value is Readonly<Record<string, unknown>> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction readRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {\n return isRecord(value) ? value : undefined;\n}\n\nfunction discoveryFailure(\n dataSourceId: string,\n attempts: readonly OpenApiDiscoveryAttempt[],\n message: string,\n): DiscoverOpenApiDataSourceResult {\n return {\n ok: false,\n attempts,\n diagnostics: [{ code: 'missing-schema', dataSourceId, message, severity: 'error' }],\n };\n}\n\nfunction graphqlFailure(\n dataSourceId: string,\n message: string,\n code: DataSourceDiagnostic['code'] = 'invalid-config',\n): Extract<IntrospectGraphQlDataSourceResult, { readonly ok: false }> {\n return {\n ok: false,\n diagnostics: [{ code, dataSourceId, message, severity: 'error' }],\n };\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { DataSourceConfig, DataSourceKind } from '@ankhorage/contracts/data';
2
+ export * from './discovery';
2
3
  export * from './graphql';
3
4
  export * from './managed-api';
4
5
  export * from './openapi';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAElF,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAC9B,cAAc,WAAW,CAAC;AAC1B,cAAc,QAAQ,CAAC;AACvB,cAAc,eAAe,CAAC;AAE9B,eAAO,MAAM,yBAAyB,EAAG,yBAAkC,CAAC;AAE5E,eAAO,MAAM,2BAA2B,oEAMM,CAAC;AAE/C,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,WAAW,EAAE,OAAO,yBAAyB,CAAC;IACvD,QAAQ,CAAC,cAAc,EAAE,OAAO,2BAA2B,CAAC;CAC7D;AAED,wBAAgB,yBAAyB,IAAI,sBAAsB,CAKlE;AAED,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,IAAI,cAAc,CAE9E;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,gBAAgB,GAAG,cAAc,CAE1E"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAElF,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAC9B,cAAc,WAAW,CAAC;AAC1B,cAAc,QAAQ,CAAC;AACvB,cAAc,eAAe,CAAC;AAE9B,eAAO,MAAM,yBAAyB,EAAG,yBAAkC,CAAC;AAE5E,eAAO,MAAM,2BAA2B,oEAMM,CAAC;AAE/C,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,WAAW,EAAE,OAAO,yBAAyB,CAAC;IACvD,QAAQ,CAAC,cAAc,EAAE,OAAO,2BAA2B,CAAC;CAC7D;AAED,wBAAgB,yBAAyB,IAAI,sBAAsB,CAKlE;AAED,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,IAAI,cAAc,CAE9E;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,gBAAgB,GAAG,cAAc,CAE1E"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './discovery';
1
2
  export * from './graphql';
2
3
  export * from './managed-api';
3
4
  export * from './openapi';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAC9B,cAAc,WAAW,CAAC;AAC1B,cAAc,QAAQ,CAAC;AACvB,cAAc,eAAe,CAAC;AAE9B,MAAM,CAAC,MAAM,yBAAyB,GAAG,yBAAkC,CAAC;AAE5E,MAAM,CAAC,MAAM,2BAA2B,GAAG;IACzC,UAAU;IACV,SAAS;IACT,aAAa;IACb,SAAS;IACT,MAAM;CACsC,CAAC;AAO/C,MAAM,UAAU,yBAAyB;IACvC,OAAO;QACL,WAAW,EAAE,yBAAyB;QACtC,cAAc,EAAE,2BAA2B;KAC5C,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,yBAAyB,CAAC,IAAY;IACpD,OAAO,2BAA2B,CAAC,IAAI,CAAC,CAAC,aAAa,EAAE,EAAE,CAAC,aAAa,KAAK,IAAI,CAAC,CAAC;AACrF,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,MAAwB;IACxD,OAAO,MAAM,CAAC,IAAI,CAAC;AACrB,CAAC","sourcesContent":["import type { DataSourceConfig, DataSourceKind } from '@ankhorage/contracts/data';\n\nexport * from './graphql';\nexport * from './managed-api';\nexport * from './openapi';\nexport * from './rest';\nexport * from './test-runner';\n\nexport const DATA_SOURCES_PACKAGE_NAME = '@ankhorage/data-sources' as const;\n\nexport const SUPPORTED_DATA_SOURCE_KINDS = [\n 'database',\n 'graphql',\n 'managed-api',\n 'openapi',\n 'rest',\n] as const satisfies readonly DataSourceKind[];\n\nexport interface DataSourcesPackageInfo {\n readonly packageName: typeof DATA_SOURCES_PACKAGE_NAME;\n readonly supportedKinds: typeof SUPPORTED_DATA_SOURCE_KINDS;\n}\n\nexport function getDataSourcesPackageInfo(): DataSourcesPackageInfo {\n return {\n packageName: DATA_SOURCES_PACKAGE_NAME,\n supportedKinds: SUPPORTED_DATA_SOURCE_KINDS,\n };\n}\n\nexport function isSupportedDataSourceKind(kind: string): kind is DataSourceKind {\n return SUPPORTED_DATA_SOURCE_KINDS.some((supportedKind) => supportedKind === kind);\n}\n\nexport function getDataSourceKind(source: DataSourceConfig): DataSourceKind {\n return source.kind;\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAC9B,cAAc,WAAW,CAAC;AAC1B,cAAc,QAAQ,CAAC;AACvB,cAAc,eAAe,CAAC;AAE9B,MAAM,CAAC,MAAM,yBAAyB,GAAG,yBAAkC,CAAC;AAE5E,MAAM,CAAC,MAAM,2BAA2B,GAAG;IACzC,UAAU;IACV,SAAS;IACT,aAAa;IACb,SAAS;IACT,MAAM;CACsC,CAAC;AAO/C,MAAM,UAAU,yBAAyB;IACvC,OAAO;QACL,WAAW,EAAE,yBAAyB;QACtC,cAAc,EAAE,2BAA2B;KAC5C,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,yBAAyB,CAAC,IAAY;IACpD,OAAO,2BAA2B,CAAC,IAAI,CAAC,CAAC,aAAa,EAAE,EAAE,CAAC,aAAa,KAAK,IAAI,CAAC,CAAC;AACrF,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,MAAwB;IACxD,OAAO,MAAM,CAAC,IAAI,CAAC;AACrB,CAAC","sourcesContent":["import type { DataSourceConfig, DataSourceKind } from '@ankhorage/contracts/data';\n\nexport * from './discovery';\nexport * from './graphql';\nexport * from './managed-api';\nexport * from './openapi';\nexport * from './rest';\nexport * from './test-runner';\n\nexport const DATA_SOURCES_PACKAGE_NAME = '@ankhorage/data-sources' as const;\n\nexport const SUPPORTED_DATA_SOURCE_KINDS = [\n 'database',\n 'graphql',\n 'managed-api',\n 'openapi',\n 'rest',\n] as const satisfies readonly DataSourceKind[];\n\nexport interface DataSourcesPackageInfo {\n readonly packageName: typeof DATA_SOURCES_PACKAGE_NAME;\n readonly supportedKinds: typeof SUPPORTED_DATA_SOURCE_KINDS;\n}\n\nexport function getDataSourcesPackageInfo(): DataSourcesPackageInfo {\n return {\n packageName: DATA_SOURCES_PACKAGE_NAME,\n supportedKinds: SUPPORTED_DATA_SOURCE_KINDS,\n };\n}\n\nexport function isSupportedDataSourceKind(kind: string): kind is DataSourceKind {\n return SUPPORTED_DATA_SOURCE_KINDS.some((supportedKind) => supportedKind === kind);\n}\n\nexport function getDataSourceKind(source: DataSourceConfig): DataSourceKind {\n return source.kind;\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/data-sources",
3
3
  "type": "module",
4
- "version": "0.5.5",
4
+ "version": "0.6.0",
5
5
  "description": "Headless toolkit for importing, generating, normalizing, and testing Ankhorage data-source configs.",
6
6
  "homepage": "https://github.com/ankhorage/data-sources#readme",
7
7
  "bugs": {
@@ -25,7 +25,7 @@
25
25
  "typescript"
26
26
  ],
27
27
  "dependencies": {
28
- "@ankhorage/contracts": "^1.18.3"
28
+ "@ankhorage/contracts": "^4.0.1"
29
29
  },
30
30
  "ankh": {
31
31
  "category": "data-sources",
@@ -0,0 +1,209 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import {
4
+ createOpenApiDiscoveryCandidates,
5
+ discoverOpenApiDataSource,
6
+ type ExternalApiFetch,
7
+ type ExternalApiFetchInit,
8
+ type ExternalApiFetchResponse,
9
+ introspectGraphQlDataSource,
10
+ } from './index';
11
+
12
+ function jsonResponse(value: unknown, status = 200): ExternalApiFetchResponse {
13
+ return {
14
+ status,
15
+ text: () => Promise.resolve(JSON.stringify(value)),
16
+ };
17
+ }
18
+
19
+ function createOpenApiDocument() {
20
+ return {
21
+ openapi: '3.1.0',
22
+ info: { title: 'Inventory API', version: '1.0.0' },
23
+ servers: [{ url: 'https://api.example.com' }],
24
+ paths: {
25
+ '/items': {
26
+ get: {
27
+ operationId: 'listItems',
28
+ responses: {
29
+ '200': {
30
+ description: 'Items',
31
+ content: {
32
+ 'application/json': {
33
+ schema: { type: 'array', items: { type: 'string' } },
34
+ },
35
+ },
36
+ },
37
+ },
38
+ },
39
+ },
40
+ },
41
+ } as const;
42
+ }
43
+
44
+ function createGraphQlPayload() {
45
+ return {
46
+ data: {
47
+ __schema: {
48
+ queryType: { name: 'Query' },
49
+ mutationType: null,
50
+ subscriptionType: null,
51
+ types: [
52
+ {
53
+ kind: 'OBJECT',
54
+ name: 'Query',
55
+ fields: [
56
+ {
57
+ name: 'items',
58
+ args: [],
59
+ type: {
60
+ kind: 'LIST',
61
+ name: null,
62
+ ofType: { kind: 'SCALAR', name: 'String' },
63
+ },
64
+ },
65
+ ],
66
+ },
67
+ ],
68
+ },
69
+ },
70
+ } as const;
71
+ }
72
+
73
+ describe('external API discovery', () => {
74
+ it('creates direct, service-relative, and origin-relative OpenAPI candidates', () => {
75
+ const candidates = createOpenApiDiscoveryCandidates('https://api.example.com/service');
76
+
77
+ expect(candidates).toContain('https://api.example.com/service');
78
+ expect(candidates).toContain('https://api.example.com/service/openapi.json');
79
+ expect(candidates).toContain('https://api.example.com/openapi.json');
80
+ expect(candidates).toContain('https://api.example.com/service/v3/api-docs');
81
+ expect(candidates).toContain('https://api.example.com/v3/api-docs');
82
+ });
83
+
84
+ it('rejects non-http URLs and URLs containing inline credentials', () => {
85
+ expect(createOpenApiDiscoveryCandidates('file:///tmp/openapi.json')).toEqual([]);
86
+ expect(
87
+ createOpenApiDiscoveryCandidates('https://user:secret@example.com/openapi.json'),
88
+ ).toEqual([]);
89
+ });
90
+
91
+ it('imports a directly supplied OpenAPI document URL', async () => {
92
+ const calls: string[] = [];
93
+ const fetch: ExternalApiFetch = (url) => {
94
+ calls.push(url);
95
+ return Promise.resolve(jsonResponse(createOpenApiDocument()));
96
+ };
97
+
98
+ const result = await discoverOpenApiDataSource({
99
+ id: 'inventory',
100
+ url: 'https://api.example.com/openapi.json',
101
+ fetch,
102
+ });
103
+
104
+ expect(result.ok).toBe(true);
105
+ expect(calls).toEqual(['https://api.example.com/openapi.json']);
106
+ if (result.ok) {
107
+ expect(result.documentUrl).toBe('https://api.example.com/openapi.json');
108
+ expect(result.data.kind).toBe('openapi');
109
+ expect(result.data.endpoints.items?.operations.listitems?.intent).toBe('read');
110
+ expect(result.attempts).toEqual([
111
+ {
112
+ url: 'https://api.example.com/openapi.json',
113
+ outcome: 'matched',
114
+ status: 200,
115
+ },
116
+ ]);
117
+ }
118
+ });
119
+
120
+ it('falls back to a conventional OpenAPI location', async () => {
121
+ const calls: string[] = [];
122
+ const fetch: ExternalApiFetch = (url) => {
123
+ calls.push(url);
124
+ return Promise.resolve(
125
+ url === 'https://api.example.com/service/openapi.json'
126
+ ? jsonResponse(createOpenApiDocument())
127
+ : jsonResponse({ message: 'not a schema' }, 404),
128
+ );
129
+ };
130
+
131
+ const result = await discoverOpenApiDataSource({
132
+ id: 'inventory',
133
+ url: 'https://api.example.com/service',
134
+ conventionalPaths: ['openapi.json'],
135
+ fetch,
136
+ });
137
+
138
+ expect(result.ok).toBe(true);
139
+ expect(calls).toEqual([
140
+ 'https://api.example.com/service',
141
+ 'https://api.example.com/service/openapi.json',
142
+ ]);
143
+ if (result.ok) {
144
+ expect(result.documentUrl).toBe('https://api.example.com/service/openapi.json');
145
+ expect(result.attempts.map((attempt) => attempt.outcome)).toEqual(['http-error', 'matched']);
146
+ }
147
+ });
148
+
149
+ it('returns safe diagnostics when no OpenAPI document can be discovered', async () => {
150
+ const fetch: ExternalApiFetch = () =>
151
+ Promise.resolve(jsonResponse({ error: 'private detail' }, 404));
152
+ const result = await discoverOpenApiDataSource({
153
+ id: 'missing',
154
+ url: 'https://api.example.com',
155
+ conventionalPaths: [],
156
+ fetch,
157
+ });
158
+
159
+ expect(result.ok).toBe(false);
160
+ expect(result.diagnostics[0]).toMatchObject({
161
+ code: 'missing-schema',
162
+ dataSourceId: 'missing',
163
+ severity: 'error',
164
+ });
165
+ expect(JSON.stringify(result)).not.toContain('private detail');
166
+ });
167
+
168
+ it('executes GraphQL introspection and normalizes discovered operations', async () => {
169
+ let capturedInit: ExternalApiFetchInit | undefined;
170
+ const fetch: ExternalApiFetch = (_url, init) => {
171
+ capturedInit = init;
172
+ return Promise.resolve(jsonResponse(createGraphQlPayload()));
173
+ };
174
+
175
+ const result = await introspectGraphQlDataSource({
176
+ id: 'catalog',
177
+ endpointUrl: 'https://api.example.com/graphql',
178
+ headers: { authorization: 'Bearer server-only' },
179
+ fetch,
180
+ });
181
+
182
+ expect(result.ok).toBe(true);
183
+ expect(capturedInit?.method).toBe('POST');
184
+ expect(capturedInit?.body).toContain('AnkhorageGraphQlIntrospection');
185
+ if (result.ok) {
186
+ expect(result.data.kind).toBe('graphql');
187
+ expect(result.data.endpoints.graphql?.operations['query.items']).toBeDefined();
188
+ expect(JSON.stringify(result)).not.toContain('server-only');
189
+ }
190
+ });
191
+
192
+ it('reports GraphQL transport and response-shape failures without response bodies', async () => {
193
+ const httpFailure = await introspectGraphQlDataSource({
194
+ id: 'catalog',
195
+ endpointUrl: 'https://api.example.com/graphql',
196
+ fetch: () => Promise.resolve(jsonResponse({ secret: 'hidden' }, 403)),
197
+ });
198
+ expect(httpFailure).toMatchObject({ ok: false, status: 403 });
199
+ expect(JSON.stringify(httpFailure)).not.toContain('hidden');
200
+
201
+ const shapeFailure = await introspectGraphQlDataSource({
202
+ id: 'catalog',
203
+ endpointUrl: 'https://api.example.com/graphql',
204
+ fetch: () => Promise.resolve(jsonResponse({ data: {} })),
205
+ });
206
+ expect(shapeFailure.ok).toBe(false);
207
+ expect(shapeFailure.diagnostics[0]?.code).toBe('parse-error');
208
+ });
209
+ });
@@ -0,0 +1,353 @@
1
+ import type {
2
+ CredentialRef,
3
+ DataContractValue,
4
+ DataSourceDiagnostic,
5
+ GraphQlDataSourceConfig,
6
+ OpenApiDataSourceConfig,
7
+ } from '@ankhorage/contracts/data';
8
+
9
+ import {
10
+ createGraphQlDataSource,
11
+ createGraphQlIntrospectionRequest,
12
+ type GraphQlIntrospectionResult,
13
+ } from '../graphql';
14
+ import { importOpenApiDocument, type OpenApiDocumentObject } from '../openapi';
15
+
16
+ export const DEFAULT_OPENAPI_DISCOVERY_PATHS = [
17
+ 'openapi.json',
18
+ 'swagger.json',
19
+ 'api/openapi.json',
20
+ 'v3/api-docs',
21
+ ] as const;
22
+
23
+ export interface ExternalApiFetchInit {
24
+ readonly method: 'GET' | 'POST';
25
+ readonly headers: Readonly<Record<string, string>>;
26
+ readonly body?: string;
27
+ }
28
+
29
+ export interface ExternalApiFetchResponse {
30
+ readonly status: number;
31
+ text(): Promise<string>;
32
+ }
33
+
34
+ export type ExternalApiFetch = (
35
+ url: string,
36
+ init: ExternalApiFetchInit,
37
+ ) => Promise<ExternalApiFetchResponse>;
38
+
39
+ export type OpenApiDiscoveryAttemptOutcome =
40
+ | 'http-error'
41
+ | 'invalid-document'
42
+ | 'matched'
43
+ | 'network-error'
44
+ | 'parse-error'
45
+ | 'unsupported-document';
46
+
47
+ export interface OpenApiDiscoveryAttempt {
48
+ readonly url: string;
49
+ readonly outcome: OpenApiDiscoveryAttemptOutcome;
50
+ readonly status?: number;
51
+ }
52
+
53
+ export interface DiscoverOpenApiDataSourceInput {
54
+ readonly id: string;
55
+ readonly url: string;
56
+ readonly fetch: ExternalApiFetch;
57
+ readonly baseUrl?: string;
58
+ readonly credential?: CredentialRef;
59
+ readonly name?: string;
60
+ readonly description?: string;
61
+ readonly metadata?: DataContractValue;
62
+ readonly conventionalPaths?: readonly string[];
63
+ }
64
+
65
+ export type DiscoverOpenApiDataSourceResult =
66
+ | {
67
+ readonly ok: true;
68
+ readonly data: OpenApiDataSourceConfig;
69
+ readonly documentUrl: string;
70
+ readonly attempts: readonly OpenApiDiscoveryAttempt[];
71
+ readonly diagnostics: readonly DataSourceDiagnostic[];
72
+ }
73
+ | {
74
+ readonly ok: false;
75
+ readonly attempts: readonly OpenApiDiscoveryAttempt[];
76
+ readonly diagnostics: readonly DataSourceDiagnostic[];
77
+ };
78
+
79
+ export interface IntrospectGraphQlDataSourceInput {
80
+ readonly id: string;
81
+ readonly endpointUrl: string;
82
+ readonly fetch: ExternalApiFetch;
83
+ readonly headers?: Readonly<Record<string, string>>;
84
+ readonly credential?: CredentialRef;
85
+ readonly name?: string;
86
+ readonly description?: string;
87
+ readonly schemaVersion?: string;
88
+ readonly metadata?: DataContractValue;
89
+ }
90
+
91
+ export type IntrospectGraphQlDataSourceResult =
92
+ | {
93
+ readonly ok: true;
94
+ readonly data: GraphQlDataSourceConfig;
95
+ readonly diagnostics: readonly DataSourceDiagnostic[];
96
+ }
97
+ | {
98
+ readonly ok: false;
99
+ readonly diagnostics: readonly DataSourceDiagnostic[];
100
+ readonly status?: number;
101
+ };
102
+
103
+ export function createOpenApiDiscoveryCandidates(
104
+ rawUrl: string,
105
+ conventionalPaths: readonly string[] = DEFAULT_OPENAPI_DISCOVERY_PATHS,
106
+ ): readonly string[] {
107
+ const parsed = parseHttpUrl(rawUrl);
108
+ if (parsed === undefined) return [];
109
+
110
+ const exact = normalizeCandidateUrl(parsed);
111
+ const serviceBase = new URL(exact);
112
+ if (!serviceBase.pathname.endsWith('/')) serviceBase.pathname = `${serviceBase.pathname}/`;
113
+
114
+ const candidates = [exact];
115
+ for (const path of conventionalPaths) {
116
+ const normalizedPath = path.replace(/^\/+/, '');
117
+ candidates.push(normalizeCandidateUrl(new URL(normalizedPath, serviceBase)));
118
+ candidates.push(normalizeCandidateUrl(new URL(normalizedPath, `${parsed.origin}/`)));
119
+ }
120
+
121
+ return [...new Set(candidates)];
122
+ }
123
+
124
+ export async function discoverOpenApiDataSource(
125
+ input: DiscoverOpenApiDataSourceInput,
126
+ ): Promise<DiscoverOpenApiDataSourceResult> {
127
+ const candidates = createOpenApiDiscoveryCandidates(input.url, input.conventionalPaths);
128
+ if (candidates.length === 0) {
129
+ return discoveryFailure(input.id, [], 'OpenAPI discovery requires a valid HTTP or HTTPS URL.');
130
+ }
131
+
132
+ const attempts: OpenApiDiscoveryAttempt[] = [];
133
+ for (const candidate of candidates) {
134
+ const probed = await probeOpenApiCandidate(input, candidate);
135
+ attempts.push(probed.attempt);
136
+ if (probed.result !== undefined) {
137
+ return probed.result.ok
138
+ ? {
139
+ ok: true,
140
+ data: probed.result.data,
141
+ documentUrl: candidate,
142
+ attempts,
143
+ diagnostics: probed.result.diagnostics ?? [],
144
+ }
145
+ : { ok: false, attempts, diagnostics: probed.result.diagnostics };
146
+ }
147
+ }
148
+
149
+ return discoveryFailure(
150
+ input.id,
151
+ attempts,
152
+ 'No supported OpenAPI document was found at the supplied URL or conventional locations.',
153
+ );
154
+ }
155
+
156
+ export async function introspectGraphQlDataSource(
157
+ input: IntrospectGraphQlDataSourceInput,
158
+ ): Promise<IntrospectGraphQlDataSourceResult> {
159
+ const endpoint = parseHttpUrl(input.endpointUrl);
160
+ if (endpoint === undefined) {
161
+ return graphqlFailure(input.id, 'GraphQL introspection requires a valid HTTP or HTTPS URL.');
162
+ }
163
+
164
+ const request = createGraphQlIntrospectionRequest();
165
+ let response: ExternalApiFetchResponse;
166
+ try {
167
+ response = await input.fetch(normalizeCandidateUrl(endpoint), {
168
+ method: 'POST',
169
+ headers: {
170
+ accept: 'application/json',
171
+ 'content-type': 'application/json',
172
+ ...(input.headers ?? {}),
173
+ },
174
+ body: JSON.stringify(request),
175
+ });
176
+ } catch {
177
+ return graphqlFailure(input.id, 'GraphQL introspection request failed.', 'network-error');
178
+ }
179
+
180
+ if (!isSuccessfulStatus(response.status)) {
181
+ return {
182
+ ...graphqlFailure(
183
+ input.id,
184
+ `GraphQL introspection returned HTTP status ${response.status}.`,
185
+ 'network-error',
186
+ ),
187
+ status: response.status,
188
+ };
189
+ }
190
+
191
+ const parsed = await parseJsonResponse(response);
192
+ const introspection = readGraphQlIntrospection(parsed);
193
+ if (introspection === undefined) {
194
+ return graphqlFailure(
195
+ input.id,
196
+ 'GraphQL introspection response did not contain data.__schema.',
197
+ 'parse-error',
198
+ );
199
+ }
200
+
201
+ const result = createGraphQlDataSource({
202
+ id: input.id,
203
+ endpointUrl: normalizeCandidateUrl(endpoint),
204
+ credential: input.credential,
205
+ name: input.name,
206
+ description: input.description,
207
+ introspection,
208
+ introspectionEnabled: true,
209
+ schemaVersion: input.schemaVersion,
210
+ metadata: input.metadata,
211
+ });
212
+
213
+ return result.ok
214
+ ? { ok: true, data: result.data, diagnostics: result.diagnostics ?? [] }
215
+ : { ok: false, diagnostics: result.diagnostics };
216
+ }
217
+
218
+ interface OpenApiProbeResult {
219
+ readonly attempt: OpenApiDiscoveryAttempt;
220
+ readonly result?: ReturnType<typeof importOpenApiDocument>;
221
+ }
222
+
223
+ async function probeOpenApiCandidate(
224
+ input: DiscoverOpenApiDataSourceInput,
225
+ candidate: string,
226
+ ): Promise<OpenApiProbeResult> {
227
+ let response: ExternalApiFetchResponse;
228
+ try {
229
+ response = await input.fetch(candidate, {
230
+ method: 'GET',
231
+ headers: {
232
+ accept: 'application/json, application/vnd.oai.openapi+json',
233
+ },
234
+ });
235
+ } catch {
236
+ return { attempt: { url: candidate, outcome: 'network-error' } };
237
+ }
238
+
239
+ if (!isSuccessfulStatus(response.status)) {
240
+ return {
241
+ attempt: { url: candidate, outcome: 'http-error', status: response.status },
242
+ };
243
+ }
244
+
245
+ const parsed = await parseJsonResponse(response);
246
+ if (parsed === undefined) {
247
+ return {
248
+ attempt: { url: candidate, outcome: 'parse-error', status: response.status },
249
+ };
250
+ }
251
+
252
+ if (!isOpenApiDocument(parsed)) {
253
+ return {
254
+ attempt: { url: candidate, outcome: 'unsupported-document', status: response.status },
255
+ };
256
+ }
257
+
258
+ const result = importOpenApiDocument({
259
+ id: input.id,
260
+ document: parsed,
261
+ baseUrl: input.baseUrl,
262
+ credential: input.credential,
263
+ documentUrl: candidate,
264
+ name: input.name,
265
+ description: input.description,
266
+ metadata: input.metadata,
267
+ });
268
+
269
+ return {
270
+ attempt: {
271
+ url: candidate,
272
+ outcome: result.ok ? 'matched' : 'invalid-document',
273
+ status: response.status,
274
+ },
275
+ result,
276
+ };
277
+ }
278
+
279
+ function parseHttpUrl(rawUrl: string): URL | undefined {
280
+ try {
281
+ const parsed = new URL(rawUrl.trim());
282
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
283
+ if (parsed.username.length > 0 || parsed.password.length > 0) return undefined;
284
+ return parsed;
285
+ } catch {
286
+ return undefined;
287
+ }
288
+ }
289
+
290
+ function normalizeCandidateUrl(url: URL): string {
291
+ const normalized = new URL(url.toString());
292
+ normalized.hash = '';
293
+ return normalized.toString();
294
+ }
295
+
296
+ function isSuccessfulStatus(status: number): boolean {
297
+ return status >= 200 && status < 300;
298
+ }
299
+
300
+ async function parseJsonResponse(response: ExternalApiFetchResponse): Promise<unknown> {
301
+ try {
302
+ return JSON.parse(await response.text()) as unknown;
303
+ } catch {
304
+ return undefined;
305
+ }
306
+ }
307
+
308
+ function isOpenApiDocument(value: unknown): value is OpenApiDocumentObject {
309
+ const record = readRecord(value);
310
+ return (
311
+ record !== undefined &&
312
+ typeof record.openapi === 'string' &&
313
+ readRecord(record.paths) !== undefined
314
+ );
315
+ }
316
+
317
+ function readGraphQlIntrospection(value: unknown): GraphQlIntrospectionResult | undefined {
318
+ const payload = readRecord(value);
319
+ const data = readRecord(payload?.data);
320
+ if (readRecord(data?.__schema) === undefined) return undefined;
321
+ return data;
322
+ }
323
+
324
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
325
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
326
+ }
327
+
328
+ function readRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {
329
+ return isRecord(value) ? value : undefined;
330
+ }
331
+
332
+ function discoveryFailure(
333
+ dataSourceId: string,
334
+ attempts: readonly OpenApiDiscoveryAttempt[],
335
+ message: string,
336
+ ): DiscoverOpenApiDataSourceResult {
337
+ return {
338
+ ok: false,
339
+ attempts,
340
+ diagnostics: [{ code: 'missing-schema', dataSourceId, message, severity: 'error' }],
341
+ };
342
+ }
343
+
344
+ function graphqlFailure(
345
+ dataSourceId: string,
346
+ message: string,
347
+ code: DataSourceDiagnostic['code'] = 'invalid-config',
348
+ ): Extract<IntrospectGraphQlDataSourceResult, { readonly ok: false }> {
349
+ return {
350
+ ok: false,
351
+ diagnostics: [{ code, dataSourceId, message, severity: 'error' }],
352
+ };
353
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { DataSourceConfig, DataSourceKind } from '@ankhorage/contracts/data';
2
2
 
3
+ export * from './discovery';
3
4
  export * from './graphql';
4
5
  export * from './managed-api';
5
6
  export * from './openapi';