Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc1fdb45bd | |||
| b14a88227f | |||
| c430288d42 | |||
| 957886186c |
@@ -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
|
||||
|
||||
@@ -5,11 +5,13 @@ 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.
|
||||
- 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.
|
||||
|
||||
@@ -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<string>()
|
||||
const namespaces = new Set<string>()
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -27,22 +27,182 @@ export const nonEmptyString = (value: unknown): string | undefined =>
|
||||
export const own = <T>(record: Readonly<Record<string, T>>, 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<unknown>((item, segment) => (isRecord(item) ? own(item, segment) : undefined), root)
|
||||
|
||||
export const resolve = (document: Document, value: unknown): unknown => {
|
||||
const next = (current: unknown, seen: ReadonlySet<string>): 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<unknown>((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: `readOnly` properties are omitted from request
|
||||
// schemas and `writeOnly` properties from response schemas, with `required` kept
|
||||
// consistent. Runtime values pass through unchanged. Reference support is deliberately
|
||||
// bounded to JSON pointers (`#/...`); `$anchor` and nested `$id` resource scoping are
|
||||
// out of scope for an advisory schema.
|
||||
type SchemaDirection = "request" | "response"
|
||||
type SchemaResource = { readonly value: unknown; readonly root: unknown }
|
||||
|
||||
const hiddenKeyword = { request: "readOnly", response: "writeOnly" } as const
|
||||
|
||||
// Local `$defs`/`definitions` pointers resolve against the schema being projected;
|
||||
// other pointers resolve against the document and rebase local resolution onto the target.
|
||||
const resolveResource = (document: Document, resource: SchemaResource): SchemaResource => {
|
||||
const next = (current: SchemaResource, seen: ReadonlySet<string>): SchemaResource => {
|
||||
if (!isRecord(current.value)) return current
|
||||
const ref = nonEmptyString(own(current.value, "$ref"))
|
||||
if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current
|
||||
const local = ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/")
|
||||
const target = resolvePointer(local ? current.root : document, ref)
|
||||
if (target === undefined) return current
|
||||
return next({ value: target, root: local ? current.root : target }, new Set([...seen, ref]))
|
||||
}
|
||||
return next(resource, new Set())
|
||||
}
|
||||
|
||||
// Hidden-ness is memoized per schema object and direction so that diamond-shaped
|
||||
// reference graphs (the same component referenced from many sites) stay linear;
|
||||
// path-scoped visited sets would re-traverse shared subtrees exponentially. Entries
|
||||
// are seeded before recursion so reference cycles terminate as not hidden. A schema
|
||||
// reachable under multiple resolution roots reuses the first root's result.
|
||||
type DirectionCache = {
|
||||
readonly hidden: Map<unknown, boolean>
|
||||
readonly names: Map<unknown, ReadonlySet<string>>
|
||||
}
|
||||
const projectionCaches = new WeakMap<Document, Record<SchemaDirection, DirectionCache>>()
|
||||
|
||||
const projectionCache = (document: Document, direction: SchemaDirection): DirectionCache => {
|
||||
const existing = projectionCaches.get(document)
|
||||
if (existing !== undefined) return existing[direction]
|
||||
const created = {
|
||||
request: { hidden: new Map<unknown, boolean>(), names: new Map<unknown, ReadonlySet<string>>() },
|
||||
response: { hidden: new Map<unknown, boolean>(), names: new Map<unknown, ReadonlySet<string>>() },
|
||||
}
|
||||
projectionCaches.set(document, created)
|
||||
return created[direction]
|
||||
}
|
||||
|
||||
// Most documents never use directional keywords; one cached linear scan lets
|
||||
// projection and the doubled per-direction component normalization be skipped entirely.
|
||||
const directionalDocuments = new WeakMap<Document, boolean>()
|
||||
|
||||
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): boolean => {
|
||||
if (!isRecord(resource.value)) return false
|
||||
if (own(resource.value, hiddenKeyword[direction]) === true) return true
|
||||
const cache = projectionCache(document, direction).hidden
|
||||
const cached = cache.get(resource.value)
|
||||
if (cached !== undefined) return cached
|
||||
cache.set(resource.value, false)
|
||||
const target = resolveResource(document, resource)
|
||||
const result =
|
||||
asArray(own(resource.value, "allOf")).some((item) => isHidden(document, { ...resource, value: item }, direction)) ||
|
||||
(target.value !== resource.value && isHidden(document, target, direction))
|
||||
cache.set(resource.value, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// 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): ReadonlySet<string> => {
|
||||
if (!isRecord(resource.value)) return new Set()
|
||||
const cache = projectionCache(document, direction).names
|
||||
const cached = cache.get(resource.value)
|
||||
if (cached !== undefined) return cached
|
||||
cache.set(resource.value, new Set())
|
||||
const properties = own(resource.value, "properties")
|
||||
const declared = isRecord(properties)
|
||||
? Object.entries(properties)
|
||||
.filter(([, property]) => isHidden(document, { ...resource, value: property }, direction))
|
||||
.map(([name]) => name)
|
||||
: []
|
||||
const composed = asArray(own(resource.value, "allOf")).flatMap((item) => [
|
||||
...hiddenNames(document, { ...resource, value: item }, direction),
|
||||
])
|
||||
const target = resolveResource(document, resource)
|
||||
const referenced = target.value === resource.value ? [] : hiddenNames(document, target, direction)
|
||||
const result = new Set([...declared, ...composed, ...referenced])
|
||||
cache.set(resource.value, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const nestedSchemas = new Set([
|
||||
"items",
|
||||
"contains",
|
||||
"additionalProperties",
|
||||
"unevaluatedProperties",
|
||||
"propertyNames",
|
||||
"not",
|
||||
"if",
|
||||
"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<string> = 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<string> = 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 +212,21 @@ const projectSchema = (document: Document, value: unknown): JsonSchema => {
|
||||
: { ...normalized.schema, $defs: normalized.definitions }
|
||||
}
|
||||
|
||||
export const componentDefinitions = (document: Document): Readonly<Record<string, JsonSchema>> => {
|
||||
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<Record<string, JsonSchema>> => {
|
||||
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<Record<string, JsonSchema>>): JsonSchema => {
|
||||
@@ -157,7 +328,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 +362,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 +376,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 +391,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 +515,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 }
|
||||
|
||||
@@ -59,6 +59,53 @@ const singleOperation = (operation: Record<string, unknown>, 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<string> = []
|
||||
@@ -354,6 +401,384 @@ 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("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("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<string, unknown> = { 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", properties: { value: inherited }, required: ["value"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}).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 +950,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.")
|
||||
|
||||
Reference in New Issue
Block a user