@lemonmade/ucp-schema 0.1.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.
@@ -0,0 +1,481 @@
1
+ import type {
2
+ UcpProfile,
3
+ UcpOperation,
4
+ UcpProfileCapability,
5
+ UcpProfileJsonSchema,
6
+ } from './types.ts';
7
+
8
+ interface UcpProfileCapabilitiesWithResolvedSchemas {
9
+ readonly capability: UcpProfileCapability;
10
+ readonly schema: UcpProfileJsonSchema;
11
+ }
12
+
13
+ interface UcpProfileWithResolvedSchemas {
14
+ readonly capabilities: readonly UcpProfileCapabilitiesWithResolvedSchemas[];
15
+ }
16
+
17
+ export interface UcpProfileSchemaFetcher {
18
+ (url: string): Promise<UcpProfileJsonSchema>;
19
+ }
20
+
21
+ /**
22
+ * Composes operation-aware JSON schemas for UCP objects, based on the capabilities
23
+ * referenced in a UCP profile.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * const composer = await UcpSchemaComposer.fromProfile({
28
+ * ucp: {
29
+ * capabilities: [
30
+ * {
31
+ * name: 'dev.ucp.shopping.checkout',
32
+ * version: '2026-01-11',
33
+ * spec: 'https://ucp.dev/specification/checkout',
34
+ * schema: 'https://ucp.dev/schemas/shopping/checkout.json',
35
+ * },
36
+ * // ... more capabilities ...
37
+ * ],
38
+ * },
39
+ * });
40
+ *
41
+ * const checkoutFile = composer.get('https://ucp.dev/schemas/shopping/checkout.json');
42
+ * const checkoutReadSchema = checkoutFile.composedSchema();
43
+ * const checkoutCreateSchema = checkoutFile.composedSchema({ operation: 'create' });
44
+ * ```
45
+ */
46
+ export class UcpSchemaComposer {
47
+ /**
48
+ * Create a composed schema from a UCP profile. This function will look
49
+ * at the capabilities and payment handlers in the profile, fetch the
50
+ * JSON schemas (and any JSON schemas referenced within), and return an
51
+ * object that allows you to get a composed schema for a specific file
52
+ * and UCP operation.
53
+ */
54
+ static async fromProfile(
55
+ {ucp: {capabilities}}: UcpProfile,
56
+ {
57
+ fetch: fetchSchema = createDefaultSchemaFetcher(),
58
+ }: {
59
+ /**
60
+ * A custom function to fetch schemas based on a URL. By default, `fromProfile()`
61
+ * will use `fetch()` (with default fetch options) for each schema. Use this to
62
+ * customize the logic, or to provide a cache of schema responses.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * const composer = await UcpSchemaComposer.fromProfile({
67
+ * ucp: {
68
+ * capabilities: [
69
+ * // ... more capabilities ...
70
+ * ],
71
+ * },
72
+ * }, {
73
+ * fetch: async (url) => prefetchedSchemaMap.get(url) ?? (await fetch(url).then((response) => response.json()))
74
+ * });
75
+ */
76
+ fetch?: UcpProfileSchemaFetcher;
77
+ } = {},
78
+ ): Promise<UcpSchemaComposer> {
79
+ const resolvedCapabilities = await Promise.all(
80
+ capabilities.map(async (capability) => {
81
+ let json: UcpProfileJsonSchema;
82
+
83
+ const schemaUrl = new URL(capability.schema);
84
+ const reverseDnsForUrl = schemaUrl.hostname
85
+ .split('.')
86
+ .reverse()
87
+ .join('.');
88
+
89
+ if (!capability.name.startsWith(reverseDnsForUrl)) {
90
+ throw new Error(
91
+ `Invalid schema name: ${capability.name} does not match URL ${capability.schema}`,
92
+ );
93
+ }
94
+
95
+ try {
96
+ // TODO: also need to fetch more JSON schemas that may be referenced by the fields in this one
97
+ json = await fetchSchema(capability.schema);
98
+ } catch (error) {
99
+ throw new Error(`Schema not found for URL: ${capability.schema}`);
100
+ }
101
+
102
+ return {
103
+ capability,
104
+ schema: json,
105
+ } satisfies UcpProfileCapabilitiesWithResolvedSchemas;
106
+ }),
107
+ );
108
+
109
+ return new UcpSchemaComposer({
110
+ capabilities: resolvedCapabilities,
111
+ });
112
+ }
113
+
114
+ readonly #profile: UcpProfileWithResolvedSchemas;
115
+ readonly #profileNameToUrlMap = new Map<string, string>();
116
+ readonly #profileUrlToCapabilityMap = new Map<string, UcpProfileCapability>();
117
+ readonly #schemaMapByOperation = new Map<
118
+ UcpOperation,
119
+ ReturnType<typeof createSchemaMap>
120
+ >();
121
+
122
+ constructor(profile: UcpProfileWithResolvedSchemas) {
123
+ this.#profile = profile;
124
+
125
+ for (const {capability} of profile.capabilities) {
126
+ this.#profileNameToUrlMap.set(capability.name, capability.schema);
127
+ this.#profileUrlToCapabilityMap.set(capability.schema, capability);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Get a schema file composer for the specified schema URL.
133
+ *
134
+ * @param schema - The schema URL or capability name
135
+ * @returns A UcpSchemaComposerFile instance, or undefined if not found
136
+ */
137
+ get(schema: string) {
138
+ const url = this.#profileNameToUrlMap.get(schema) ?? schema;
139
+ const capability = this.#profileUrlToCapabilityMap.get(url);
140
+
141
+ // TODO: we should allow any schema URL, not just the ones that were directly referenced
142
+ // in the profile. Right now, we do not traverse the graph of other JSON schemas that may be referenced
143
+ // by the fields in the profile capabilities.
144
+ if (capability == null) return undefined;
145
+
146
+ return new UcpSchemaComposerFile(url, {capability, composer: this});
147
+ }
148
+
149
+ /**
150
+ * Get all schema entries for a specific operation.
151
+ *
152
+ * @param options - Options for getting entries
153
+ * @param options.operation - The operation context
154
+ * @returns An iterator of schema URL and composed schema pairs
155
+ */
156
+ entries({operation = 'read'}: {operation?: UcpOperation} = {}) {
157
+ return this.#schemaMapForOperation(operation).entries();
158
+ }
159
+
160
+ /**
161
+ * Internal method to get the composed schema directly.
162
+ * Used by UcpSchemaComposerFile.
163
+ */
164
+ composedSchema(
165
+ schema: string,
166
+ {operation = 'read'}: {operation?: UcpOperation} = {},
167
+ ): UcpProfileJsonSchema | undefined {
168
+ return this.#schemaMapForOperation(operation).get(schema);
169
+ }
170
+
171
+ #schemaMapForOperation(operation: UcpOperation) {
172
+ let schemaMap = this.#schemaMapByOperation.get(operation);
173
+
174
+ if (schemaMap == null) {
175
+ schemaMap = createSchemaMap(this.#profile, {operation});
176
+ }
177
+
178
+ return schemaMap;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Represents a schema file that can be composed with different operation contexts.
184
+ */
185
+ export class UcpSchemaComposerFile {
186
+ readonly #url: string;
187
+ readonly #composer: UcpSchemaComposer;
188
+ readonly capability?: UcpProfileCapability;
189
+
190
+ constructor(
191
+ url: string,
192
+ {
193
+ composer,
194
+ capability,
195
+ }: {composer: UcpSchemaComposer; capability?: UcpProfileCapability},
196
+ ) {
197
+ this.#url = url;
198
+ this.#composer = composer;
199
+ this.capability = capability;
200
+ }
201
+
202
+ /**
203
+ * Get the composed schema for a specific operation.
204
+ */
205
+ composedSchema(options?: {operation?: UcpOperation}) {
206
+ const schema = this.#composer.composedSchema(this.#url, options);
207
+
208
+ if (schema == null) {
209
+ throw new ReferenceError(`Schema not found for URL: ${this.#url}`);
210
+ }
211
+
212
+ return schema;
213
+ }
214
+ }
215
+
216
+ // Helpers
217
+
218
+ /**
219
+ * Compose schemas by resolving extensions
220
+ */
221
+ function createSchemaMap(
222
+ {capabilities}: UcpProfileWithResolvedSchemas,
223
+ {operation}: {operation: UcpOperation},
224
+ ) {
225
+ const schemaMap = new Map<string, UcpProfileJsonSchema>();
226
+ const schemaNameToUrlMap = new Map<string, string>();
227
+
228
+ for (const {capability, schema} of capabilities) {
229
+ schemaNameToUrlMap.set(capability.name, capability.schema);
230
+
231
+ const clonedSchema = structuredClone(schema);
232
+ processSchemaUcpMetadata(clonedSchema, operation);
233
+ schemaMap.set(capability.schema, clonedSchema);
234
+
235
+ if (capability.extends == null) continue;
236
+
237
+ const defs = clonedSchema.$defs;
238
+ const extendedSchemas = Array.isArray(capability.extends)
239
+ ? capability.extends
240
+ : [capability.extends];
241
+
242
+ for (const extendedSchemaName of extendedSchemas) {
243
+ const extensionDef = defs?.[extendedSchemaName];
244
+ if (extensionDef?.allOf == null) continue;
245
+
246
+ const extendedSchemaUrl = schemaNameToUrlMap.get(extendedSchemaName);
247
+ const extendedSchema = extendedSchemaUrl
248
+ ? schemaMap.get(extendedSchemaUrl)
249
+ : undefined;
250
+ if (extendedSchema == null) continue;
251
+
252
+ // Make the base type ready for extension
253
+ if (extendedSchema.allOf?.[0]?.$ref !== `#/$defs/${extendedSchemaName}`) {
254
+ const newDef = {
255
+ type: 'object',
256
+ title: `${extendedSchema.title ?? extendedSchemaName} (base)`,
257
+ ...(extendedSchema.properties
258
+ ? {properties: extendedSchema.properties}
259
+ : {}),
260
+ ...(extendedSchema.required
261
+ ? {required: extendedSchema.required}
262
+ : {}),
263
+ ...(extendedSchema.items ? {items: extendedSchema.items} : {}),
264
+ ...(extendedSchema.allOf ? {allOf: extendedSchema.allOf} : {}),
265
+ ...(extendedSchema.oneOf ? {oneOf: extendedSchema.oneOf} : {}),
266
+ } satisfies UcpProfileJsonSchema;
267
+
268
+ extendedSchema.$defs ??= {};
269
+ extendedSchema.$defs[extendedSchemaName] = newDef;
270
+ extendedSchema.allOf = [
271
+ {type: 'object', $ref: `#/$defs/${extendedSchemaName}`},
272
+ ];
273
+ delete extendedSchema.properties;
274
+ delete extendedSchema.required;
275
+ delete extendedSchema.items;
276
+ delete extendedSchema.oneOf;
277
+ }
278
+
279
+ const clonedDefs = structuredClone(defs!);
280
+
281
+ // Rewrite the extension def to remove a reference to the base type, we will manually
282
+ // create an `allOf` type for it that includes all schema additions.
283
+ const filtereExtensionDefAllOf = extensionDef.allOf.filter(
284
+ ({$ref}) =>
285
+ $ref == null ||
286
+ new URL($ref, capability.schema).href !== extendedSchemaUrl,
287
+ );
288
+
289
+ if (filtereExtensionDefAllOf.length > 1) {
290
+ clonedDefs[extendedSchemaName] = {
291
+ type: 'object',
292
+ allOf: filtereExtensionDefAllOf,
293
+ } satisfies UcpProfileJsonSchema;
294
+ } else {
295
+ const {allOf, ...rest} = extensionDef;
296
+ clonedDefs[extendedSchemaName] = {
297
+ type: 'object',
298
+ ...rest,
299
+ ...filtereExtensionDefAllOf[0],
300
+ } satisfies UcpProfileJsonSchema;
301
+ }
302
+
303
+ // TODO: turn this into a smarter def pruning algorithm
304
+ for (const otherExtendedSchemaName of extendedSchemas) {
305
+ if (otherExtendedSchemaName === extendedSchemaName) continue;
306
+ delete clonedDefs[otherExtendedSchemaName];
307
+ }
308
+
309
+ Object.assign(
310
+ extendedSchema.$defs!,
311
+ namespaceExtensionDefs(clonedDefs, capability),
312
+ );
313
+ extendedSchema.allOf!.push({
314
+ type: 'object',
315
+ $ref: `#/$defs/${namespaceIdentifier(extendedSchemaName, capability)}`,
316
+ });
317
+ }
318
+ }
319
+
320
+ return {
321
+ get(schema: string) {
322
+ if (schemaNameToUrlMap.has(schema)) {
323
+ return schemaMap.get(schemaNameToUrlMap.get(schema)!);
324
+ }
325
+
326
+ return schemaMap.get(schema);
327
+ },
328
+ entries() {
329
+ return schemaMap.entries();
330
+ },
331
+ };
332
+ }
333
+
334
+ function processSchemaUcpMetadata(
335
+ schema: UcpProfileJsonSchema,
336
+ operation: UcpOperation,
337
+ ): UcpProfileJsonSchema {
338
+ let requiredNeedsUpdate = false;
339
+ const updatedRequired = new Set(schema.required);
340
+
341
+ if (schema.properties != null) {
342
+ for (const [key, value] of Object.entries(schema.properties)) {
343
+ processSchemaUcpMetadata(value, operation);
344
+
345
+ if (value.ucp_request == null) continue;
346
+
347
+ const ucpRequest =
348
+ typeof value.ucp_request === 'string'
349
+ ? value.ucp_request
350
+ : value.ucp_request[operation];
351
+ delete value.ucp_request;
352
+
353
+ if (operation === 'read') continue;
354
+
355
+ switch (ucpRequest) {
356
+ case 'omit':
357
+ delete schema.properties[key];
358
+
359
+ if (updatedRequired.has(key)) {
360
+ requiredNeedsUpdate = true;
361
+ updatedRequired.delete(key);
362
+ }
363
+
364
+ break;
365
+ case 'required':
366
+ if (!updatedRequired.has(key)) {
367
+ requiredNeedsUpdate = true;
368
+ updatedRequired.add(key);
369
+ }
370
+
371
+ break;
372
+ case 'optional':
373
+ if (updatedRequired.has(key)) {
374
+ requiredNeedsUpdate = true;
375
+ updatedRequired.delete(key);
376
+ }
377
+
378
+ break;
379
+ }
380
+ }
381
+ }
382
+
383
+ if (requiredNeedsUpdate) {
384
+ schema.required = Array.from(updatedRequired);
385
+ }
386
+
387
+ if (schema.items != null) {
388
+ if (Array.isArray(schema.items)) {
389
+ for (const item of schema.items) {
390
+ processSchemaUcpMetadata(item, operation);
391
+ }
392
+ } else {
393
+ processSchemaUcpMetadata(schema.items, operation);
394
+ }
395
+ }
396
+
397
+ if (schema.allOf != null) {
398
+ for (const allOfSchema of schema.allOf) {
399
+ processSchemaUcpMetadata(allOfSchema, operation);
400
+ }
401
+ }
402
+
403
+ if (schema.oneOf != null) {
404
+ for (const oneOfSchema of schema.oneOf) {
405
+ processSchemaUcpMetadata(oneOfSchema, operation);
406
+ }
407
+ }
408
+
409
+ if (schema.$defs != null) {
410
+ for (const value of Object.values(schema.$defs)) {
411
+ processSchemaUcpMetadata(value, operation);
412
+ }
413
+ }
414
+
415
+ return schema;
416
+ }
417
+
418
+ function namespaceIdentifier(
419
+ identifier: string,
420
+ capability: Pick<UcpProfileCapability, 'name'>,
421
+ ) {
422
+ return `${capability.name}~${identifier}`;
423
+ }
424
+
425
+ function namespaceExtensionDefs(
426
+ defs: Record<string, UcpProfileJsonSchema>,
427
+ capability: Pick<UcpProfileCapability, 'name'>,
428
+ ) {
429
+ const newDefs: Record<string, UcpProfileJsonSchema> = {};
430
+
431
+ for (const [key, value] of Object.entries(defs)) {
432
+ newDefs[namespaceIdentifier(key, capability)] = updateRefsWithNamespace(
433
+ value,
434
+ capability,
435
+ );
436
+ }
437
+
438
+ return newDefs;
439
+ }
440
+
441
+ function updateRefsWithNamespace(
442
+ obj: any,
443
+ capability: Pick<UcpProfileCapability, 'name'>,
444
+ ): any {
445
+ if (obj === null || typeof obj !== 'object') {
446
+ return obj;
447
+ }
448
+
449
+ if (Array.isArray(obj)) {
450
+ return obj.map((item) => updateRefsWithNamespace(item, capability));
451
+ }
452
+
453
+ const cloned = obj;
454
+ for (const [key, value] of Object.entries(obj)) {
455
+ if (key === '$ref' && typeof value === 'string') {
456
+ // Update internal references like #/$defs/fulfillment_method
457
+ const match = value.match(/^#\/\$defs\/(.+)$/);
458
+ if (match?.[1]) {
459
+ cloned[key] = `#/$defs/${namespaceIdentifier(match[1], capability)}`;
460
+ } else {
461
+ cloned[key] = value;
462
+ }
463
+ } else {
464
+ cloned[key] = updateRefsWithNamespace(value, capability);
465
+ }
466
+ }
467
+ return cloned;
468
+ }
469
+
470
+ function createDefaultSchemaFetcher() {
471
+ const cache = new Map<string, Promise<UcpProfileJsonSchema>>();
472
+ return ((url) => {
473
+ const cached = cache.get(url);
474
+ if (cached) {
475
+ return cached;
476
+ }
477
+ const promise = fetch(url).then((response) => response.json());
478
+ cache.set(url, promise);
479
+ return promise;
480
+ }) satisfies UcpProfileSchemaFetcher;
481
+ }
@@ -0,0 +1,2 @@
1
+ export type * from './types.ts';
2
+ export {UcpSchemaComposer} from './compose.ts';
@@ -0,0 +1,69 @@
1
+ export interface UcpProfile {
2
+ ucp: {
3
+ version: string;
4
+ capabilities: UcpProfileCapability[];
5
+ };
6
+ }
7
+
8
+ export interface UcpProfileCapability {
9
+ name: string;
10
+ version: string;
11
+ spec: string;
12
+ schema: string;
13
+ extends?: string | string[];
14
+ }
15
+
16
+ export interface UcpProfileService {
17
+ version: string;
18
+ spec: string;
19
+ rest?: {
20
+ schema: string;
21
+ endpoint: string;
22
+ };
23
+ mcp?: {
24
+ schema: string;
25
+ endpoint: string;
26
+ };
27
+ }
28
+
29
+ export type UcpOperation =
30
+ | 'read'
31
+ | 'create'
32
+ | 'update'
33
+ | 'complete'
34
+ | (string & {});
35
+
36
+ export interface UcpProfileJsonSchema {
37
+ $id?: string;
38
+
39
+ ucp_request?:
40
+ | 'required'
41
+ | 'optional'
42
+ | 'omit'
43
+ | {
44
+ [K in UcpOperation]?: 'required' | 'optional' | 'omit';
45
+ };
46
+
47
+ $schema?:
48
+ | 'https://json-schema.org/draft/2020-12/schema'
49
+ | 'http://json-schema.org/draft-07/schema#'
50
+ | 'http://json-schema.org/draft-04/schema#';
51
+ $ref?: string;
52
+ $defs?: Record<string, UcpProfileJsonSchema>;
53
+ type?:
54
+ | 'object'
55
+ | 'array'
56
+ | 'string'
57
+ | 'number'
58
+ | 'boolean'
59
+ | 'null'
60
+ | 'integer';
61
+ title?: string;
62
+ description?: string;
63
+ oneOf?: UcpProfileJsonSchema[];
64
+ allOf?: UcpProfileJsonSchema[];
65
+ properties?: Record<string, UcpProfileJsonSchema>;
66
+ items?: UcpProfileJsonSchema | UcpProfileJsonSchema[];
67
+ required?: string[];
68
+ [k: string]: unknown;
69
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "@quilted/typescript/tsconfig.package.json",
3
+ "references": []
4
+ }
package/vite.config.js ADDED
@@ -0,0 +1,6 @@
1
+ import {defineConfig} from 'vite';
2
+ import {quiltPackage} from '@quilted/vite/package';
3
+
4
+ export default defineConfig({
5
+ plugins: [quiltPackage()],
6
+ });