spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. import { fromSchemaOpenApi3_0, fromSchemaOpenApi3_1 } from "effect/JsonSchema"
  2. import type { JsonSchema } from "../tool.js"
  3. import { isBlockedMember } from "../tool-runtime.js"
  4. import type {
  5. Body,
  6. Document,
  7. InputField,
  8. OperationInput,
  9. Parsed,
  10. SecurityRequirement,
  11. SecurityScheme,
  12. } from "./types.js"
  13. export const methods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"])
  14. const parameterLocations = ["path", "query", "header"] as const
  15. const ignoredHeaderParameters = new Set(["accept", "content-type", "authorization"])
  16. export const isRecord = (value: unknown): value is Record<string, unknown> =>
  17. typeof value === "object" && value !== null && !Array.isArray(value)
  18. const asArray = (value: unknown): ReadonlyArray<unknown> => (Array.isArray(value) ? value : [])
  19. export const nonEmptyString = (value: unknown): string | undefined =>
  20. typeof value === "string" && value !== "" ? value : undefined
  21. // Guards record lookups keyed by spec- or model-controlled names against
  22. // prototype-inherited values (e.g. a parameter named `toString`).
  23. export const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
  24. Object.hasOwn(record, key) ? record[key] : undefined
  25. export const resolve = (document: Document, value: unknown): unknown => {
  26. const next = (current: unknown, seen: ReadonlySet<string>): unknown => {
  27. if (!isRecord(current)) return current
  28. const ref = nonEmptyString(current.$ref)
  29. if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current
  30. const target = ref
  31. .slice(2)
  32. .split("/")
  33. .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
  34. .reduce<unknown>((item, segment) => (isRecord(item) ? own(item, segment) : undefined), document)
  35. return target === undefined ? current : next(target, new Set([...seen, ref]))
  36. }
  37. return next(value, new Set())
  38. }
  39. const projectSchema = (document: Document, value: unknown): JsonSchema => {
  40. if (!isRecord(value)) return {}
  41. const normalized = nonEmptyString(document.openapi)?.startsWith("3.0")
  42. ? fromSchemaOpenApi3_0(value)
  43. : fromSchemaOpenApi3_1(value)
  44. return Object.keys(normalized.definitions).length === 0
  45. ? normalized.schema
  46. : { ...normalized.schema, $defs: normalized.definitions }
  47. }
  48. export const componentDefinitions = (document: Document): Readonly<Record<string, JsonSchema>> => {
  49. const components = isRecord(document.components) ? document.components : {}
  50. const schemas = isRecord(components.schemas) ? components.schemas : {}
  51. return Object.fromEntries(Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value)]))
  52. }
  53. const withDefinitions = (schema: JsonSchema, definitions: Readonly<Record<string, JsonSchema>>): JsonSchema => {
  54. if (Object.keys(definitions).length === 0) return schema
  55. const local = isRecord(schema.$defs) ? schema.$defs : {}
  56. return { ...schema, $defs: { ...definitions, ...local } }
  57. }
  58. const isJsonMediaType = (mediaType: string): boolean => {
  59. const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""
  60. return normalized === "application/json" || normalized.endsWith("+json")
  61. }
  62. const isBinaryMediaType = (document: Document, mediaType: string, value: unknown): boolean => {
  63. const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""
  64. if (!isJsonMediaType(normalized) && !normalized.startsWith("text/")) return true
  65. if (!isRecord(value)) return false
  66. const schema = resolve(document, value.schema)
  67. return isRecord(schema) && schema.format === "binary"
  68. }
  69. const jsonContent = (
  70. content: Record<string, unknown>,
  71. ): { readonly mediaType: string; readonly schema: unknown } | undefined => {
  72. const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType))
  73. return entry !== undefined && isRecord(entry[1]) ? { mediaType: entry[0], schema: entry[1].schema } : undefined
  74. }
  75. const isFlattenableObjectBody = (
  76. schema: unknown,
  77. requestRequired: boolean,
  78. ): schema is Record<string, unknown> & { readonly properties: Record<string, unknown> } =>
  79. isRecord(schema) &&
  80. requestRequired &&
  81. schema.type === "object" &&
  82. isRecord(schema.properties) &&
  83. schema.additionalProperties === false &&
  84. schema.nullable !== true &&
  85. schema.allOf === undefined &&
  86. schema.anyOf === undefined &&
  87. schema.oneOf === undefined
  88. type PlannedField = Omit<InputField, "inputName">
  89. const operationParameters = (
  90. document: Document,
  91. pathItem: Record<string, unknown>,
  92. operation: Record<string, unknown>,
  93. ): Parsed<ReadonlyArray<PlannedField>> => {
  94. // Operation-level parameters override path-level ones sharing (location, name).
  95. const declared = new Map<
  96. string,
  97. { readonly name: string; readonly location: string; readonly parameter: Record<string, unknown> }
  98. >()
  99. for (const raw of [...asArray(pathItem.parameters), ...asArray(operation.parameters)]) {
  100. const resolved = resolve(document, raw)
  101. if (!isRecord(resolved)) return { ok: false, reason: "parameter declaration is invalid or unresolved" }
  102. const name = nonEmptyString(resolved.name)
  103. const location = nonEmptyString(resolved.in)
  104. if (name === undefined || location === undefined)
  105. return { ok: false, reason: "parameter declaration is missing name or location" }
  106. declared.set(`${location}:${name}`, { name, location, parameter: resolved })
  107. }
  108. const unordered: Array<PlannedField> = []
  109. for (const item of declared.values()) {
  110. const name = item.name
  111. const location = item.location
  112. const resolved = item.parameter
  113. if (location === "cookie") return { ok: false, reason: `cookie parameter '${name}' is not supported` }
  114. if (location !== "path" && location !== "query" && location !== "header") {
  115. return { ok: false, reason: `parameter '${name}' uses unsupported location '${location}'` }
  116. }
  117. if (location === "header" && ignoredHeaderParameters.has(name.toLowerCase())) continue
  118. if (resolved.schema === undefined && resolved.content === undefined) {
  119. return { ok: false, reason: `parameter '${name}' declares neither schema nor content` }
  120. }
  121. if (resolved.content !== undefined)
  122. return { ok: false, reason: `parameter '${name}' uses unsupported content encoding` }
  123. if (resolved.style !== undefined && nonEmptyString(resolved.style) === undefined) {
  124. return { ok: false, reason: `parameter '${name}' has an invalid style` }
  125. }
  126. if (resolved.explode !== undefined && typeof resolved.explode !== "boolean") {
  127. return { ok: false, reason: `parameter '${name}' has an invalid explode value` }
  128. }
  129. if (resolved.allowReserved !== undefined && typeof resolved.allowReserved !== "boolean") {
  130. return { ok: false, reason: `parameter '${name}' has an invalid allowReserved value` }
  131. }
  132. if (resolved.allowReserved === true)
  133. return { ok: false, reason: `parameter '${name}' uses unsupported allowReserved encoding` }
  134. const declaredStyle = nonEmptyString(resolved.style) ?? (location === "query" ? "form" : "simple")
  135. if (location === "query" && declaredStyle !== "form" && declaredStyle !== "deepObject") {
  136. return { ok: false, reason: `query parameter '${name}' uses unsupported style '${declaredStyle}'` }
  137. }
  138. if (location !== "query" && declaredStyle !== "simple") {
  139. return { ok: false, reason: `${location} parameter '${name}' uses unsupported style '${declaredStyle}'` }
  140. }
  141. const style = declaredStyle === "deepObject" ? "deepObject" : declaredStyle === "form" ? "form" : "simple"
  142. const explode = typeof resolved.explode === "boolean" ? resolved.explode : style === "form"
  143. if (style === "deepObject" && !explode) {
  144. return { ok: false, reason: `query parameter '${name}' uses deepObject with explode=false` }
  145. }
  146. const base = projectSchema(document, resolved.schema)
  147. const description = nonEmptyString(resolved.description)
  148. unordered.push({
  149. name,
  150. location,
  151. required: resolved.required === true || location === "path",
  152. style,
  153. explode,
  154. schema: {
  155. ...base,
  156. ...(base.description === undefined && description !== undefined ? { description } : {}),
  157. },
  158. })
  159. }
  160. return {
  161. ok: true,
  162. value: parameterLocations.flatMap((location) => unordered.filter((field) => field.location === location)),
  163. }
  164. }
  165. const operationBody = (
  166. document: Document,
  167. operation: Record<string, unknown>,
  168. ): Parsed<{ readonly fields: ReadonlyArray<PlannedField>; readonly body: Body | undefined }> => {
  169. const resolved = resolve(document, operation.requestBody)
  170. if (!isRecord(resolved)) return { ok: true, value: { fields: [], body: undefined } }
  171. const content = isRecord(resolved.content) ? resolved.content : {}
  172. const selected = jsonContent(content)
  173. if (selected === undefined) {
  174. return {
  175. ok: false,
  176. reason: `request body has no JSON content (declared: ${Object.keys(content).join(", ") || "none"})`,
  177. }
  178. }
  179. const schema = resolve(document, selected.schema)
  180. const required = resolved.required === true
  181. if (!isFlattenableObjectBody(schema, required)) {
  182. return {
  183. ok: true,
  184. value: {
  185. fields: [
  186. {
  187. name: "body",
  188. location: "body",
  189. required,
  190. schema: projectSchema(document, selected.schema),
  191. style: undefined,
  192. explode: undefined,
  193. },
  194. ],
  195. body: { required, mode: "value", mediaType: selected.mediaType },
  196. },
  197. }
  198. }
  199. const requiredProperties = new Set(
  200. Array.isArray(schema.required) ? schema.required.filter((item): item is string => typeof item === "string") : [],
  201. )
  202. return {
  203. ok: true,
  204. value: {
  205. fields: Object.entries(schema.properties).map(([name, value]) => ({
  206. name,
  207. location: "body" as const,
  208. required: required && requiredProperties.has(name),
  209. schema: projectSchema(document, value),
  210. style: undefined,
  211. explode: undefined,
  212. })),
  213. body: { required, mode: "object", mediaType: selected.mediaType },
  214. },
  215. }
  216. }
  217. export const operationInput = (
  218. document: Document,
  219. pathItem: Record<string, unknown>,
  220. operation: Record<string, unknown>,
  221. ): Parsed<OperationInput> => {
  222. const parameters = operationParameters(document, pathItem, operation)
  223. if (!parameters.ok) return parameters
  224. const requestBody = operationBody(document, operation)
  225. if (!requestBody.ok) return requestBody
  226. const fields = [...parameters.value, ...requestBody.value.fields]
  227. const conflicts = new Set(
  228. [...Map.groupBy(fields, (field) => field.name)]
  229. .filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1)
  230. .map(([name]) => name),
  231. )
  232. const used = new Set<string>()
  233. return {
  234. ok: true,
  235. value: {
  236. fields: fields.map((field) => {
  237. const visibleName = isBlockedMember(field.name) ? `${field.name}_2` : field.name
  238. const base = conflicts.has(field.name) ? `${field.location}_${visibleName}` : visibleName
  239. const next = (index: number): string => {
  240. const candidate = index === 1 ? base : `${base}_${index}`
  241. return used.has(candidate) ? next(index + 1) : candidate
  242. }
  243. const inputName = next(1)
  244. used.add(inputName)
  245. return { ...field, inputName }
  246. }),
  247. body: requestBody.value.body,
  248. },
  249. }
  250. }
  251. export const inputSchema = (
  252. fields: ReadonlyArray<InputField>,
  253. definitions: Readonly<Record<string, JsonSchema>>,
  254. ): JsonSchema => {
  255. const required = fields.filter((field) => field.required).map((field) => field.inputName)
  256. return withDefinitions(
  257. {
  258. type: "object",
  259. properties: Object.fromEntries(fields.map((field) => [field.inputName, field.schema])),
  260. ...(required.length === 0 ? {} : { required }),
  261. },
  262. definitions,
  263. )
  264. }
  265. const successfulResponses = (
  266. document: Document,
  267. operation: Record<string, unknown>,
  268. ): Parsed<ReadonlyArray<Record<string, unknown>>> => {
  269. if (!isRecord(operation.responses)) return { ok: true, value: [] }
  270. const entries = Object.entries(operation.responses)
  271. const selected = [
  272. ...entries.filter(([status]) => /^2\d\d$/.test(status)).sort(([a], [b]) => a.localeCompare(b)),
  273. ...entries.filter(([status]) => status.toUpperCase() === "2XX"),
  274. ]
  275. const responses: Array<Record<string, unknown>> = []
  276. for (const [, value] of selected) {
  277. const resolved = resolve(document, value)
  278. if (!isRecord(resolved) || nonEmptyString(resolved.$ref) !== undefined) {
  279. return { ok: false, reason: "successful response declaration is invalid or unresolved" }
  280. }
  281. responses.push(resolved)
  282. }
  283. return { ok: true, value: responses }
  284. }
  285. export const operationOutput = (
  286. document: Document,
  287. operation: Record<string, unknown>,
  288. definitions: Readonly<Record<string, JsonSchema>>,
  289. ): Parsed<JsonSchema | undefined> => {
  290. if (operation["x-websocket"] === true) return { ok: false, reason: "WebSocket operations are not supported" }
  291. const responses = successfulResponses(document, operation)
  292. if (!responses.ok) return responses
  293. const streams = responses.value.some(
  294. (response) =>
  295. isRecord(response.content) &&
  296. Object.keys(response.content).some(
  297. (mediaType) => mediaType.split(";")[0]?.trim().toLowerCase() === "text/event-stream",
  298. ),
  299. )
  300. if (streams) return { ok: false, reason: "SSE operations are not supported" }
  301. const binary = responses.value.some(
  302. (response) =>
  303. isRecord(response.content) &&
  304. Object.entries(response.content).some(([mediaType, value]) => isBinaryMediaType(document, mediaType, value)),
  305. )
  306. if (binary) return { ok: false, reason: "binary responses are not supported" }
  307. const outcomes: Array<JsonSchema> = []
  308. for (const response of responses.value) {
  309. if (response.content !== undefined && !isRecord(response.content)) return { ok: true, value: undefined }
  310. const content = isRecord(response.content) ? response.content : {}
  311. if (Object.keys(content).length === 0) {
  312. outcomes.push({ type: "null" })
  313. continue
  314. }
  315. for (const [mediaType, value] of Object.entries(content)) {
  316. if (!isJsonMediaType(mediaType)) {
  317. outcomes.push({ type: "string" })
  318. continue
  319. }
  320. if (!isRecord(value) || value.schema === undefined) return { ok: true, value: undefined }
  321. outcomes.push(projectSchema(document, value.schema))
  322. }
  323. }
  324. if (outcomes.length === 0) return { ok: true, value: undefined }
  325. return {
  326. ok: true,
  327. value: withDefinitions(outcomes.length === 1 ? (outcomes[0] ?? {}) : { anyOf: outcomes }, definitions),
  328. }
  329. }
  330. const sanitizeOperationSegment = (raw: string): string => {
  331. const base =
  332. raw
  333. .replaceAll(/[^A-Za-z0-9_$]+/g, "_")
  334. .replace(/^_+|_+$/g, "")
  335. .replace(/^([0-9])/, "_$1") || "operation"
  336. return isBlockedMember(base) ? `${base}_2` : base
  337. }
  338. const fallbackOperationId = (method: string, path: string): string =>
  339. [
  340. method,
  341. ...path
  342. .split("/")
  343. .filter((part) => part !== "")
  344. .flatMap((part) => (part.startsWith("{") && part.endsWith("}") ? ["by", part.slice(1, -1)] : [part]))
  345. .flatMap((part) => part.split(/[^A-Za-z0-9]+/).filter((word) => word !== "")),
  346. ]
  347. .map((word, index) => {
  348. const lower = word.toLowerCase()
  349. return index === 0 ? lower : `${lower.charAt(0).toUpperCase()}${lower.slice(1)}`
  350. })
  351. .join("")
  352. export const operationPath = (
  353. method: string,
  354. path: string,
  355. operation: Record<string, unknown>,
  356. used: ReadonlySet<string>,
  357. namespaces: ReadonlySet<string>,
  358. ): ReadonlyArray<string> => {
  359. const raw = nonEmptyString(operation.operationId)
  360. const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(
  361. sanitizeOperationSegment,
  362. )
  363. if (isOperationPathAvailable(segments, used, namespaces)) return segments
  364. const conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join(".")))
  365. if (conflict >= 0 && conflict + 1 < segments.length) {
  366. const collapsed = segments.flatMap((segment, index) => {
  367. if (index === conflict) {
  368. const next = segments[index + 1] ?? ""
  369. return [`${segment}${next.charAt(0).toUpperCase()}${next.slice(1)}`]
  370. }
  371. return index === conflict + 1 ? [] : [segment]
  372. })
  373. if (isOperationPathAvailable(collapsed, used, namespaces)) return collapsed
  374. }
  375. const fallback = segments.join("_")
  376. const next = (index: number): string => {
  377. const candidate = `${fallback}_${index}`
  378. return isOperationPathAvailable([candidate], used, namespaces) ? candidate : next(index + 1)
  379. }
  380. return [next(2)]
  381. }
  382. const isOperationPathAvailable = (
  383. segments: ReadonlyArray<string>,
  384. used: ReadonlySet<string>,
  385. namespaces: ReadonlySet<string>,
  386. ): boolean => {
  387. const key = segments.join(".")
  388. if (used.has(key) || namespaces.has(key)) return false
  389. return segments.slice(0, -1).every((_, index) => !used.has(segments.slice(0, index + 1).join(".")))
  390. }
  391. export const specServerUrl = (source: Record<string, unknown>): Parsed<string> => {
  392. const server = asArray(source.servers).find(isRecord)
  393. const url = server === undefined ? undefined : nonEmptyString(server.url)
  394. if (url === undefined) return { ok: false, reason: "spec declares no servers; pass baseUrl" }
  395. if (/\{[^{}]+\}/.test(url)) {
  396. return { ok: false, reason: `server URL '${url}' is not an absolute URL; pass baseUrl` }
  397. }
  398. return validateBaseUrl(url)
  399. }
  400. export const validateBaseUrl = (value: string): Parsed<string> => {
  401. if (!/^https?:\/\//i.test(value)) return { ok: false, reason: `server URL '${value}' is not an absolute HTTP(S) URL` }
  402. const url = URL.parse(value)
  403. if (url === null || (url.protocol !== "http:" && url.protocol !== "https:")) {
  404. return { ok: false, reason: `server URL '${value}' is not an absolute HTTP(S) URL` }
  405. }
  406. if (url.search !== "" || url.hash !== "") {
  407. return { ok: false, reason: `server URL '${value}' contains an unsupported query string or fragment` }
  408. }
  409. return { ok: true, value }
  410. }
  411. export const securityRequirements = (value: unknown): Parsed<ReadonlyArray<SecurityRequirement>> => {
  412. if (value === undefined) return { ok: true, value: [] }
  413. if (!Array.isArray(value)) return { ok: false, reason: "security declaration is not an array" }
  414. const requirements: Array<SecurityRequirement> = []
  415. for (const item of value) {
  416. if (!isRecord(item)) return { ok: false, reason: "security requirement is not an object" }
  417. const requirement = Object.create(null) as Record<string, ReadonlyArray<string>>
  418. for (const [name, scopes] of Object.entries(item)) {
  419. if (!Array.isArray(scopes)) return { ok: false, reason: "security requirement scopes are not string arrays" }
  420. const parsed = scopes.filter((scope): scope is string => typeof scope === "string")
  421. if (parsed.length !== scopes.length) {
  422. return { ok: false, reason: "security requirement scopes are not string arrays" }
  423. }
  424. requirement[name] = parsed
  425. }
  426. requirements.push(requirement)
  427. }
  428. return { ok: true, value: requirements }
  429. }
  430. export const operationSecurityRequirements = (
  431. value: unknown,
  432. defaults: Parsed<ReadonlyArray<SecurityRequirement>>,
  433. schemes: Readonly<Record<string, SecurityScheme>>,
  434. ): Parsed<ReadonlyArray<SecurityRequirement>> => {
  435. const parsed = value === undefined ? defaults : securityRequirements(value)
  436. if (!parsed.ok) return parsed
  437. const supported = parsed.value.filter((requirement) =>
  438. Object.keys(requirement).every((name) => {
  439. const scheme = own(schemes, name)
  440. return scheme !== undefined && !(scheme.type === "apiKey" && scheme.in === "cookie")
  441. }),
  442. )
  443. if (parsed.value.length === 0 || supported.length > 0) return { ok: true, value: supported }
  444. const names = [...new Set(parsed.value.flatMap((requirement) => Object.keys(requirement)))]
  445. const cookieScheme = names.find((name) => {
  446. const definition = own(schemes, name)
  447. return definition?.type === "apiKey" && definition.in === "cookie"
  448. })
  449. return {
  450. ok: false,
  451. reason:
  452. cookieScheme === undefined
  453. ? `security requirement references missing or malformed scheme: ${names.join(", ")}`
  454. : `cookie authentication '${cookieScheme}' is not supported`,
  455. }
  456. }
  457. export const securitySchemes = (document: Document): Readonly<Record<string, SecurityScheme>> => {
  458. const components = isRecord(document.components) ? document.components : {}
  459. const declared = isRecord(components.securitySchemes) ? components.securitySchemes : {}
  460. return Object.fromEntries(
  461. Object.entries(declared).flatMap<readonly [string, SecurityScheme]>(([name, value]) => {
  462. const resolved = resolve(document, value)
  463. if (!isRecord(resolved)) return []
  464. const type = nonEmptyString(resolved.type)
  465. if (type === "apiKey") {
  466. const carrier = nonEmptyString(resolved.in)
  467. const parameter = nonEmptyString(resolved.name)
  468. if (parameter === undefined || (carrier !== "header" && carrier !== "query" && carrier !== "cookie")) return []
  469. return [[name, { type, name: parameter, in: carrier }] as const]
  470. }
  471. if (type === "http") {
  472. const scheme = nonEmptyString(resolved.scheme)?.toLowerCase()
  473. return scheme === undefined ? [] : [[name, { type, scheme }] as const]
  474. }
  475. if (type === "oauth2" || type === "openIdConnect") return [[name, { type }] as const]
  476. return []
  477. }),
  478. )
  479. }