diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 51634d91ba6a..622fcaa574e8 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -108,7 +108,9 @@ const runtime = CodeMode.make({ tools: { opencode: api.tools } }) It is synchronous and returns `{ tools, skipped }`: operations with unsupported encodings, non-JSON bodies, binary responses, or streaming land in `skipped` instead of producing broken tools. Auth is resolved host-side and never -model-visible; generated tools require `HttpClient.HttpClient` in the environment. See the option docstrings in +model-visible; generated tools require `HttpClient.HttpClient` in the environment. `readOnly` properties are omitted +from request signatures and `writeOnly` properties from response signatures. These JSON Schemas are model-facing, not +runtime filters: nested value bodies and server responses pass through unchanged. See the option docstrings in `src/openapi/types.ts` for full semantics. ## Outputs diff --git a/packages/codemode/src/openapi/TODO.md b/packages/codemode/src/openapi/TODO.md index cbcfe81a68c8..05558ace07f4 100644 --- a/packages/codemode/src/openapi/TODO.md +++ b/packages/codemode/src/openapi/TODO.md @@ -5,11 +5,15 @@ The initial adapter intentionally skips operations it cannot execute correctly. - Cookie parameters, authentication, and cookie-header merging. - Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization. - External references and complete nested `$defs` support. +- `$anchor` and nested `$id` resource resolution in directional (`readOnly`/`writeOnly`) projection. +- Use-site cleanup for `allOf` branches that reference shared component schemas: per-direction component definitions are projected globally, so a directional annotation declared only at one use site cannot remove the property from a referenced component's definition. +- Hidden-name cleanup inside `then`/`else`/`dependentSchemas`/`dependentRequired`, which constrain the same instance as `allOf`; a hidden property may remain named in those keywords. +- Projection inside `not`/`if`/`contains`, whose semantics would invert or shift if constraints were removed; those subschemas pass through unchanged, and a `$ref` from such a context to a projected `$defs` or component definition still observes hiding. +- Iterative traversal for pathologically deep schema nesting: the directional scan and projection recurse per level and overflow the stack around ten thousand levels, below the pre-existing converter limit of roughly fifty thousand; `fromSpec` throws a catchable `RangeError` either way. - Relative or templated server URLs and server variables. - Base URLs containing query strings or fragments. - Runtime response-schema validation and full content negotiation. - Binary response values and explicit byte-oriented return types. -- Request/response projection for `readOnly` and `writeOnly` properties. - SSE, WebSocket, and other streaming transports. - Recovery of responses rejected by a status-filtering `HttpClient`. - Configurable request and response size limits. diff --git a/packages/codemode/src/openapi/index.ts b/packages/codemode/src/openapi/index.ts index e1f2c64166f9..5ea688129e1d 100644 --- a/packages/codemode/src/openapi/index.ts +++ b/packages/codemode/src/openapi/index.ts @@ -3,6 +3,7 @@ import { make, type Definition } from "../tool.js" import { invoke } from "./runtime.js" import { componentDefinitions, + hasDirectionalSchemas, inputSchema, isRecord, methods, @@ -38,7 +39,10 @@ export const fromSpec = (options: Options): Result => { const document = options.spec const schemes = securitySchemes(document) const defaultSecurity = securityRequirements(document.security) - const definitions = componentDefinitions(document) + const requestDefinitions = componentDefinitions(document, "request") + const responseDefinitions = hasDirectionalSchemas(document) + ? componentDefinitions(document, "response") + : requestDefinitions const paths = isRecord(document.paths) ? document.paths : {} const used = new Set() const namespaces = new Set() @@ -57,7 +61,7 @@ export const fromSpec = (options: Options): Result => { summary: nonEmptyString(operationValue.summary), description: nonEmptyString(operationValue.description), } - const output = operationOutput(document, operationValue, definitions) + const output = operationOutput(document, operationValue, responseDefinitions) if (!output.ok) { skipped.push({ method: operation.method, path, reason: output.reason }) continue @@ -102,7 +106,7 @@ export const fromSpec = (options: Options): Result => { segments, make({ description: operation.description ?? operation.summary ?? `${operation.method} ${path}`, - input: inputSchema(input.fields, definitions), + input: inputSchema(input.fields, requestDefinitions), output: output.value, run: (input) => invoke(plan, input), }), diff --git a/packages/codemode/src/openapi/spec.ts b/packages/codemode/src/openapi/spec.ts index b74b3e69f209..c2443d03236c 100644 --- a/packages/codemode/src/openapi/spec.ts +++ b/packages/codemode/src/openapi/spec.ts @@ -27,22 +27,225 @@ export const nonEmptyString = (value: unknown): string | undefined => export const own = (record: Readonly>, key: string): T | undefined => Object.hasOwn(record, key) ? record[key] : undefined +const resolvePointer = (root: unknown, ref: string): unknown => + ref + .slice(2) + .split("/") + .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")) + .reduce((item, segment) => (isRecord(item) ? own(item, segment) : undefined), root) + export const resolve = (document: Document, value: unknown): unknown => { const next = (current: unknown, seen: ReadonlySet): unknown => { if (!isRecord(current)) return current - const ref = nonEmptyString(current.$ref) + const ref = nonEmptyString(own(current, "$ref")) if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current - const target = ref - .slice(2) - .split("/") - .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")) - .reduce((item, segment) => (isRecord(item) ? own(item, segment) : undefined), document) + const target = resolvePointer(document, ref) return target === undefined ? current : next(target, new Set([...seen, ref])) } return next(value, new Set()) } -const projectSchema = (document: Document, value: unknown): JsonSchema => { +// Model-facing directional projection: request schemas omit `readOnly` properties, +// response schemas omit `writeOnly` properties, and `required` stays consistent. +// Runtime values pass through unchanged. +type SchemaDirection = "request" | "response" +type SchemaResource = { readonly value: unknown; readonly root: unknown } + +const hiddenKeyword = { request: "readOnly", response: "writeOnly" } as const + +// Resolves one `$ref` hop so every link of a chain has its own sibling declarations +// inspected; cycles terminate in the callers' cycle solver. Local `$defs`/`definitions` +// pointers resolve against the schema being projected, other pointers rebase onto the target. +const resolveResource = (document: Document, resource: SchemaResource): SchemaResource => { + if (!isRecord(resource.value)) return resource + const ref = nonEmptyString(own(resource.value, "$ref")) + if (ref === undefined || !ref.startsWith("#/")) return resource + const local = ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/") + const target = resolvePointer(local ? resource.root : document, ref) + if (target === undefined) return resource + return { value: target, root: local ? resource.root : target } +} + +// Hidden-ness and hidden names are memoized per schema object and direction so +// diamond-shaped reference graphs stay linear. Documents are assumed immutable once +// projected; a schema reachable under multiple resolution roots reuses the first result. +type Solver = { + readonly values: Map + // Discovery index per schema whose strongly connected component is unresolved. + readonly pending: Map + readonly stack: Array +} +type DirectionCache = { + readonly hidden: Solver + readonly names: Solver> +} + +const emptyCache = (): DirectionCache => ({ + hidden: { values: new Map(), pending: new Map(), stack: [] }, + names: { values: new Map(), pending: new Map(), stack: [] }, +}) + +const projectionCaches = new WeakMap>() + +const projectionCache = (document: Document, direction: SchemaDirection): DirectionCache => { + const existing = projectionCaches.get(document) + if (existing !== undefined) return existing[direction] + const created = { request: emptyCache(), response: emptyCache() } + projectionCaches.set(document, created) + return created[direction] +} + +// Tarjan's strongly connected components: cycle members all reach the same +// declarations, so the component root's value is final for every member. Only resolved +// components are cached, keeping results independent of traversal order. +type CycleScope = { lowlink: number } + +const solveCycles = ( + solver: Solver, + key: unknown, + provisional: T, + scope: CycleScope, + compute: (inner: CycleScope) => T, +): T => { + const cached = solver.values.get(key) + if (cached !== undefined) return cached + const pending = solver.pending.get(key) + if (pending !== undefined) { + scope.lowlink = Math.min(scope.lowlink, pending) + return provisional + } + // Components pop as contiguous stack suffixes, so pending indices stay 0..size-1. + const index = solver.pending.size + const base = solver.stack.length + solver.pending.set(key, index) + solver.stack.push(key) + const inner: CycleScope = { lowlink: Infinity } + const value = compute(inner) + if (inner.lowlink < index) { + scope.lowlink = Math.min(scope.lowlink, inner.lowlink) + return value + } + for (const member of solver.stack.splice(base)) { + solver.pending.delete(member) + solver.values.set(member, value) + } + return value +} + +// Most documents have no directional keywords; one cached scan skips projection entirely. +const directionalDocuments = new WeakMap() + +export const hasDirectionalSchemas = (document: Document): boolean => { + const cached = directionalDocuments.get(document) + if (cached !== undefined) return cached + const contains = (value: unknown): boolean => { + if (Array.isArray(value)) return value.some(contains) + if (!isRecord(value)) return false + if (own(value, "readOnly") === true || own(value, "writeOnly") === true) return true + return Object.values(value).some(contains) + } + const result = contains(document) + directionalDocuments.set(document, result) + return result +} + +// OpenAPI 3.1 allows keywords as siblings of `$ref`, so a schema's own declarations +// are inspected before following the reference. +const isHidden = ( + document: Document, + resource: SchemaResource, + direction: SchemaDirection, + scope: CycleScope = { lowlink: Infinity }, +): boolean => { + const value = resource.value + if (!isRecord(value)) return false + if (own(value, hiddenKeyword[direction]) === true) return true + return solveCycles(projectionCache(document, direction).hidden, value, false, scope, (inner) => { + const target = resolveResource(document, resource) + return ( + asArray(own(value, "allOf")).some((item) => isHidden(document, { ...resource, value: item }, direction, inner)) || + (target.value !== value && isHidden(document, target, direction, inner)) + ) + }) +} + +// Hidden property names declared by a schema itself or inherited through `$ref` and +// `allOf` composition, so sibling `required` lists stay consistent after projection. +const hiddenNames = ( + document: Document, + resource: SchemaResource, + direction: SchemaDirection, + scope: CycleScope = { lowlink: Infinity }, +): ReadonlySet => { + const value = resource.value + if (!isRecord(value)) return new Set() + return solveCycles(projectionCache(document, direction).names, value, new Set(), scope, (inner) => { + const properties = own(value, "properties") + const declared = isRecord(properties) + ? Object.entries(properties) + .filter(([, property]) => isHidden(document, { ...resource, value: property }, direction)) + .map(([name]) => name) + : [] + const composed = asArray(own(value, "allOf")).flatMap((item) => [ + ...hiddenNames(document, { ...resource, value: item }, direction, inner), + ]) + const target = resolveResource(document, resource) + const referenced = target.value === value ? [] : hiddenNames(document, target, direction, inner) + return new Set([...declared, ...composed, ...referenced]) + }) +} + +// `not`/`if`/`contains` subschemas pass through unprojected: they negate or select +// rather than assert, so removing hidden properties would invert their semantics. +const nestedSchemas = new Set([ + "items", + "additionalProperties", + "unevaluatedProperties", + "propertyNames", + "then", + "else", +]) +const nestedSchemaLists = new Set(["anyOf", "oneOf", "prefixItems"]) +const nestedSchemaMaps = new Set(["patternProperties", "dependentSchemas", "$defs", "definitions"]) + +const directionalSchema = ( + document: Document, + resource: SchemaResource, + direction: SchemaDirection, + excluded: ReadonlySet = new Set(), +): unknown => { + if (!isRecord(resource.value)) return resource.value + const hidden = new Set([...excluded, ...hiddenNames(document, resource, direction)]) + const project = (item: unknown, inherited: ReadonlySet = new Set()): unknown => + directionalSchema(document, { ...resource, value: item }, direction, inherited) + return Object.fromEntries( + Object.entries(resource.value).map(([key, item]) => { + if (key === "properties" && isRecord(item)) { + return [ + key, + Object.fromEntries( + Object.entries(item) + .filter(([name]) => !hidden.has(name)) + .map(([name, property]) => [name, project(property)]), + ), + ] + } + if (key === "required" && Array.isArray(item)) { + return [key, item.filter((name) => typeof name !== "string" || !hidden.has(name))] + } + // allOf branches share one object; hidden names apply across every branch. + if (key === "allOf" && Array.isArray(item)) return [key, item.map((entry) => project(entry, hidden))] + if (nestedSchemas.has(key)) return [key, project(item)] + if (nestedSchemaLists.has(key) && Array.isArray(item)) return [key, item.map((entry) => project(entry))] + if (nestedSchemaMaps.has(key) && isRecord(item)) { + return [key, Object.fromEntries(Object.entries(item).map(([name, entry]) => [name, project(entry)]))] + } + return [key, item] + }), + ) +} + +const normalizeSchema = (document: Document, value: unknown): JsonSchema => { if (!isRecord(value)) return {} const normalized = nonEmptyString(document.openapi)?.startsWith("3.0") ? fromSchemaOpenApi3_0(value) @@ -52,10 +255,21 @@ const projectSchema = (document: Document, value: unknown): JsonSchema => { : { ...normalized.schema, $defs: normalized.definitions } } -export const componentDefinitions = (document: Document): Readonly> => { +const projectSchema = (document: Document, value: unknown, direction: SchemaDirection): JsonSchema => + normalizeSchema( + document, + hasDirectionalSchemas(document) ? directionalSchema(document, { value, root: value }, direction) : value, + ) + +export const componentDefinitions = ( + document: Document, + direction: SchemaDirection, +): Readonly> => { const components = isRecord(document.components) ? document.components : {} const schemas = isRecord(components.schemas) ? components.schemas : {} - return Object.fromEntries(Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value)])) + return Object.fromEntries( + Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value, direction)]), + ) } const withDefinitions = (schema: JsonSchema, definitions: Readonly>): JsonSchema => { @@ -157,7 +371,7 @@ const operationParameters = ( if (style === "deepObject" && !explode) { return { ok: false, reason: `query parameter '${name}' uses deepObject with explode=false` } } - const base = projectSchema(document, resolved.schema) + const base = projectSchema(document, resolved.schema, "request") const description = nonEmptyString(resolved.description) unordered.push({ name, @@ -191,7 +405,10 @@ const operationBody = ( reason: `request body has no JSON content (declared: ${Object.keys(content).join(", ") || "none"})`, } } - const schema = resolve(document, selected.schema) + const resolvedSchema = resolve(document, selected.schema) + const schema = hasDirectionalSchemas(document) + ? directionalSchema(document, { value: resolvedSchema, root: resolvedSchema }, "request") + : resolvedSchema const required = resolved.required === true if (!isFlattenableObjectBody(schema, required)) { return { @@ -202,7 +419,7 @@ const operationBody = ( name: "body", location: "body", required, - schema: projectSchema(document, selected.schema), + schema: projectSchema(document, selected.schema, "request"), style: undefined, explode: undefined, }, @@ -217,11 +434,13 @@ const operationBody = ( return { ok: true, value: { + // Field schemas were already projected with the body as resolution root; a second + // directional pass rooted at the field would misresolve shadowed local $defs. fields: Object.entries(schema.properties).map(([name, value]) => ({ name, location: "body" as const, required: required && requiredProperties.has(name), - schema: projectSchema(document, value), + schema: normalizeSchema(document, value), style: undefined, explode: undefined, })), @@ -339,7 +558,7 @@ export const operationOutput = ( continue } if (!isRecord(value) || value.schema === undefined) return { ok: true, value: undefined } - outcomes.push(projectSchema(document, value.schema)) + outcomes.push(projectSchema(document, value.schema, "response")) } } if (outcomes.length === 0) return { ok: true, value: undefined } diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index 3901ea8836d9..f4da5c31e0d8 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -59,6 +59,53 @@ const singleOperation = (operation: Record, method = "get"): Do }, }) +const directionalSpec = (openapi: string): Document => ({ + openapi, + paths: { + "/users": { + post: { + operationId: "users.create", + requestBody: { + required: true, + content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } }, + }, + responses: { + 200: { + description: "Created", + content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } }, + }, + }, + }, + }, + }, + components: { + schemas: { + ReadOnlyID: { type: "string", readOnly: true }, + User: { + type: "object", + additionalProperties: false, + required: ["id", "name", "password", "profile", "generated"], + properties: { + id: { type: "string", readOnly: true }, + name: { type: "string" }, + password: { type: "string", writeOnly: true }, + profile: { + type: "object", + additionalProperties: false, + required: ["createdAt", "secret", "label"], + properties: { + createdAt: { type: "string", readOnly: true }, + secret: { type: "string", writeOnly: true }, + label: { type: "string" }, + }, + }, + generated: { $ref: "#/components/schemas/ReadOnlyID" }, + }, + }, + }, + }, +}) + describe("OpenAPI.fromSpec", () => { test("covers a representative API from generation through execution", async () => { const resolutions: Array = [] @@ -354,6 +401,550 @@ describe("OpenAPI.fromSpec", () => { expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } }) }) + test("projects read-only and write-only properties by schema direction", () => { + for (const version of ["3.0.3", "3.1.0"]) { + const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec(version) }).tools, "users.create") + if (!Tool.isDefinition(tool) || !isRecord(tool.input) || !isRecord(tool.output)) { + throw new Error(`users.create was not generated for OpenAPI ${version}`) + } + + expect(inputTypeScript(tool)).toBe( + "{ name: string; password: string; profile: { secret: string; label: string } }", + ) + expect(outputTypeScript(tool)).toBe( + "{ id: string; name: string; profile: { createdAt: string; label: string }; generated: string }", + ) + + const requestDefinitions = isRecord(tool.input.$defs) ? tool.input.$defs : {} + const responseDefinitions = isRecord(tool.output.$defs) ? tool.output.$defs : {} + const requestUser = isRecord(requestDefinitions.User) ? requestDefinitions.User : {} + const responseUser = isRecord(responseDefinitions.User) ? responseDefinitions.User : {} + expect(Object.keys(isRecord(requestUser.properties) ? requestUser.properties : {})).toEqual([ + "name", + "password", + "profile", + ]) + expect(requestUser.required).toEqual(["name", "password", "profile"]) + expect(Object.keys(isRecord(responseUser.properties) ? responseUser.properties : {})).toEqual([ + "id", + "name", + "profile", + "generated", + ]) + expect(responseUser.required).toEqual(["id", "name", "profile", "generated"]) + } + }) + + test("projects directional annotations through local refs and allOf composition", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + required: ["local", "composed", "name"], + properties: { + local: { $ref: "#/$defs/ReadOnlyValue" }, + composed: { allOf: [{ $ref: "#/$defs/ReadOnlyValue" }] }, + name: { type: "string" }, + }, + $defs: { + ReadOnlyValue: { type: "string", readOnly: true }, + }, + }, + }, + }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + expect(inputTypeScript(tool)).toBe("{ name: string }") + }) + + test("honors declarations that are siblings of a $ref", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/test": { + post: { + operationId: "test", + responses: { 200: { description: "Success" } }, + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + required: ["record"], + properties: { + record: { + $ref: "#/components/schemas/Base", + properties: { extra: { type: "string", readOnly: true }, note: { type: "string" } }, + required: ["extra", "note", "id"], + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Base: { + type: "object", + required: ["id", "name"], + properties: { id: { type: "string", readOnly: true }, name: { type: "string" } }, + }, + }, + }, + }, + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const properties = isRecord(tool.input.properties) ? tool.input.properties : {} + const record = isRecord(properties.record) ? properties.record : {} + const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {} + const base = isRecord(definitions.Base) ? definitions.Base : {} + + expect(Object.keys(isRecord(record.properties) ? record.properties : {})).toEqual(["note"]) + expect(record.required).toEqual(["note"]) + expect(Object.keys(isRecord(base.properties) ? base.properties : {})).toEqual(["name"]) + expect(base.required).toEqual(["name"]) + }) + + test("honors directional declarations on intermediate reference hops", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { + ...singleOperation( + { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + required: ["secret", "name"], + properties: { + // Hidden only by the sibling declaration on the middle hop. + secret: { $ref: "#/components/schemas/Middle" }, + name: { type: "string" }, + }, + }, + }, + }, + }, + }, + "post", + ), + components: { + schemas: { + Middle: { $ref: "#/components/schemas/Plain", readOnly: true }, + Plain: { type: "string" }, + }, + }, + }, + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + expect(inputTypeScript(tool)).toBe("{ name: string }") + }) + + test("projects cyclic component references without hanging", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/test": { + post: { + operationId: "test", + responses: { 200: { description: "Success" } }, + requestBody: { + required: true, + content: { "application/json": { schema: { $ref: "#/components/schemas/Node" } } }, + }, + }, + }, + }, + components: { + schemas: { + Node: { + type: "object", + required: ["id", "name", "child"], + properties: { + id: { type: "string", readOnly: true }, + name: { type: "string" }, + child: { $ref: "#/components/schemas/Node" }, + }, + }, + }, + }, + }, + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {} + const node = isRecord(definitions.Node) ? definitions.Node : {} + + expect(Object.keys(isRecord(node.properties) ? node.properties : {})).toEqual(["name", "child"]) + expect(node.required).toEqual(["name", "child"]) + }) + + test("projects diamond-shaped reference graphs in linear time", () => { + // Each component references the next twice; without memoized hidden-ness this is 2^30 work. + const depth = 30 + const schemas = Object.fromEntries( + Array.from({ length: depth }, (_, index) => [ + `C${index}`, + index === depth - 1 + ? { type: "object", properties: { id: { type: "string", readOnly: true }, name: { type: "string" } } } + : { allOf: [{ $ref: `#/components/schemas/C${index + 1}` }, { $ref: `#/components/schemas/C${index + 1}` }] }, + ]), + ) + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { + openapi: "3.1.0", + paths: { + "/test": { + post: { + operationId: "test", + responses: { 200: { description: "Success" } }, + requestBody: { + required: true, + content: { "application/json": { schema: { $ref: "#/components/schemas/C0" } } }, + }, + }, + }, + }, + components: { schemas }, + }, + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {} + const leaf = isRecord(definitions[`C${depth - 1}`]) ? definitions[`C${depth - 1}`] : {} + + expect(Object.keys(isRecord(leaf.properties) ? leaf.properties : {})).toEqual(["name"]) + }) + + test("resolves hiding through reference cycles regardless of evaluation order", () => { + // `Wrap` is hidden only through the cycle member `Loop`; evaluating a property that + // enters the cycle at `Loop` first must not freeze a provisional result for `Wrap`. + const schemas = { + Wrap: { allOf: [{ $ref: "#/components/schemas/Loop" }] }, + Loop: { allOf: [{ $ref: "#/components/schemas/Wrap" }, { readOnly: true }] }, + } + const body = (properties: Record) => ({ + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + required: [...Object.keys(properties), "name"], + properties: { ...properties, name: { type: "string" } }, + }, + }, + }, + }) + for (const properties of [ + { a: { $ref: "#/components/schemas/Loop" }, b: { $ref: "#/components/schemas/Wrap" } }, + { a: { $ref: "#/components/schemas/Wrap" }, b: { $ref: "#/components/schemas/Loop" } }, + ]) { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: { ...singleOperation({ requestBody: body(properties) }, "post"), components: { schemas } }, + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + expect(inputTypeScript(tool)).toBe("{ name: string }") + } + }) + + test("keeps not, if, and contains subschemas unprojected", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + required: ["record"], + properties: { + record: { + type: "object", + // Removing `secret` here would turn `not` unsatisfiable and + // flip which branch of `if` applies; both must pass through. + not: { required: ["secret"], properties: { secret: { type: "string", readOnly: true } } }, + if: { required: ["kind"], properties: { kind: { type: "string", readOnly: true } } }, + }, + }, + }, + }, + }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const properties = isRecord(tool.input.properties) ? tool.input.properties : {} + const record: Record = isRecord(properties.record) ? properties.record : {} + + expect(record.not).toEqual({ required: ["secret"], properties: { secret: { type: "string", readOnly: true } } }) + expect(record.if).toEqual({ required: ["kind"], properties: { kind: { type: "string", readOnly: true } } }) + }) + + test("does not hide properties whose direction is declared only in anyOf or oneOf alternatives", () => { + // Deliberate scope bound: alternatives may apply, so a directional declaration on + // one alternative does not hide the property; the annotation is preserved as-is. + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + required: ["choice", "pick"], + properties: { + choice: { anyOf: [{ type: "string", readOnly: true }, { type: "number" }] }, + pick: { oneOf: [{ type: "string", readOnly: true }, { type: "number" }] }, + }, + }, + }, + }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const properties = isRecord(tool.input.properties) ? tool.input.properties : {} + const choice: Record = isRecord(properties.choice) ? properties.choice : {} + const pick: Record = isRecord(properties.pick) ? properties.pick : {} + + expect(Object.keys(properties)).toEqual(["choice", "pick"]) + expect(choice.anyOf).toEqual([{ type: "string", readOnly: true }, { type: "number" }]) + expect(pick.oneOf).toEqual([{ type: "string", readOnly: true }, { type: "number" }]) + }) + + test("does not misresolve shadowed local $defs when flattening body fields", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + required: ["record"], + $defs: { Value: { type: "string" } }, + properties: { + record: { + type: "object", + required: ["x"], + properties: { x: { $ref: "#/$defs/Value" } }, + // Shadows the body-level Value; must not affect the body-rooted projection. + $defs: { Value: { type: "string", readOnly: true } }, + }, + }, + }, + }, + }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const properties = isRecord(tool.input.properties) ? tool.input.properties : {} + const record = isRecord(properties.record) ? properties.record : {} + + expect(Object.keys(isRecord(record.properties) ? record.properties : {})).toEqual(["x"]) + expect(record.required).toEqual(["x"]) + }) + + test("projects directional annotations inside parameter schemas", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation({ + parameters: [ + { + name: "filter", + in: "query", + required: true, + schema: { + type: "object", + required: ["state", "id"], + properties: { state: { type: "string" }, id: { type: "string", readOnly: true } }, + }, + }, + ], + }), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + expect(inputTypeScript(tool)).toBe("{ filter: { state: string } }") + }) + + test("ignores inherited directional annotations", () => { + const inherited: Record = { type: "string" } + Object.setPrototypeOf(inherited, { readOnly: true }) + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation({ + parameters: [ + { + name: "filter", + in: "query", + required: true, + schema: { + type: "object", + // The own annotation on `id` keeps projection active for the document, + // so `value` pins that prototype-inherited annotations are not read. + properties: { value: inherited, id: { type: "string", readOnly: true } }, + required: ["value", "id"], + }, + }, + ], + }), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + + expect(inputTypeScript(tool)).toBe("{ filter: { value: string } }") + }) + + test("cleans required properties across allOf branches", () => { + const tool = toolAt( + OpenAPI.fromSpec({ + baseUrl, + spec: singleOperation( + { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["id", "name"], + allOf: [ + { + type: "object", + required: ["id", "name"], + properties: { id: { type: "string", readOnly: true }, name: { type: "string" } }, + }, + ], + }, + }, + }, + }, + }, + "post", + ), + }).tools, + "test", + ) + if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + const properties = isRecord(tool.input.properties) ? tool.input.properties : {} + const body = isRecord(properties.body) ? properties.body : {} + const allOf = Array.isArray(body.allOf) ? body.allOf : [] + const branch = isRecord(allOf[0]) ? allOf[0] : {} + + expect(body.required).toEqual(["name"]) + expect(branch.required).toEqual(["name"]) + expect(Object.keys(isRecord(branch.properties) ? branch.properties : {})).toEqual(["name"]) + }) + + test("keeps directional schemas model-facing while preserving runtime pass-through", async () => { + const client = recordingClient(() => + json({ + id: "server-id", + name: "Ada", + password: "returned-by-server", + profile: { createdAt: "today", secret: "returned-secret", label: "primary" }, + generated: "generated-id", + }), + ) + const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec("3.1.0") }).tools, "users.create") + if (!Tool.isDefinition(tool)) throw new Error("users.create was not generated") + + const result = await Effect.runPromise( + tool + .run({ + id: "ignored-top-level", + generated: "ignored-generated", + name: "Ada", + password: "request-secret", + profile: { createdAt: "sent-nested", secret: "nested-secret", label: "primary" }, + }) + .pipe(Effect.provide(client.layer)), + ) + + expect(client.requests[0]?.body).toEqual({ + name: "Ada", + password: "request-secret", + profile: { createdAt: "sent-nested", secret: "nested-secret", label: "primary" }, + }) + expect(result).toMatchObject({ password: "returned-by-server", profile: { secret: "returned-secret" } }) + }) + test("documents that the opencode fixture is unauthenticated", async () => { const spec = await opencodeSpec() const components = isRecord(spec.components) ? spec.components : {} @@ -525,9 +1116,9 @@ describe("OpenAPI.fromSpec", () => { expect(client.requests[0]?.url).toBe( `${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`, ) - await expect( - Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer))), - ).rejects.toThrow("Parameter 'tags' contains an unsupported nested value.") + await expect(Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow( + "Parameter 'tags' contains an unsupported nested value.", + ) await expect( Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))), ).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.")