|
| 1 | +import type { CompileOptions, LintDiagnostic } from "./types.js"; |
| 2 | + |
| 3 | +const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]); |
| 4 | +const SUPPORTED_REQUEST_MEDIA_TYPES = new Set([ |
| 5 | + "application/json", |
| 6 | + "application/*+json", |
| 7 | + "multipart/form-data", |
| 8 | + "application/x-www-form-urlencoded", |
| 9 | + "application/octet-stream", |
| 10 | + "text/plain" |
| 11 | +]); |
| 12 | + |
| 13 | +export function lintOpenApiDocument(doc: Record<string, unknown>, options: CompileOptions = {}): LintDiagnostic[] { |
| 14 | + const diagnostics: LintDiagnostic[] = []; |
| 15 | + const strict = Boolean(options.strict); |
| 16 | + |
| 17 | + const version = String(doc.openapi ?? ""); |
| 18 | + if (!/^3\./.test(version)) { |
| 19 | + diagnostics.push({ |
| 20 | + level: strict ? "error" : "warning", |
| 21 | + code: "OPENAPI_VERSION", |
| 22 | + message: `Expected OpenAPI 3.x, got: ${version || "<missing>"}`, |
| 23 | + location: "openapi" |
| 24 | + }); |
| 25 | + } |
| 26 | + |
| 27 | + const servers = Array.isArray(doc.servers) ? doc.servers : []; |
| 28 | + if (servers.length === 0) { |
| 29 | + diagnostics.push({ |
| 30 | + level: strict ? "error" : "warning", |
| 31 | + code: "SERVERS_MISSING", |
| 32 | + message: "Document has no servers[]; use --server-url override.", |
| 33 | + location: "servers" |
| 34 | + }); |
| 35 | + } else { |
| 36 | + for (let i = 0; i < servers.length; i += 1) { |
| 37 | + const server = servers[i] as Record<string, unknown>; |
| 38 | + if (!isObject(server) || typeof server["url"] !== "string" || !String(server["url"]).trim()) { |
| 39 | + diagnostics.push({ |
| 40 | + level: strict ? "error" : "warning", |
| 41 | + code: "SERVER_URL_INVALID", |
| 42 | + message: "Server entry is missing a valid url.", |
| 43 | + location: `servers[${i}]` |
| 44 | + }); |
| 45 | + } |
| 46 | + } |
| 47 | + } |
| 48 | + |
| 49 | + if (isObject(doc.webhooks) && Object.keys(doc.webhooks).length > 0) { |
| 50 | + diagnostics.push({ |
| 51 | + level: "warning", |
| 52 | + code: "WEBHOOKS_PRESENT", |
| 53 | + message: "webhooks are present; they are not auto-exposed as tools by default.", |
| 54 | + location: "webhooks" |
| 55 | + }); |
| 56 | + } |
| 57 | + |
| 58 | + const seenOperationIds = new Set<string>(); |
| 59 | + const paths = isObject(doc.paths) ? (doc.paths as Record<string, unknown>) : {}; |
| 60 | + for (const [path, rawPathItem] of Object.entries(paths)) { |
| 61 | + if (!isObject(rawPathItem)) continue; |
| 62 | + const pathItem = rawPathItem as Record<string, unknown>; |
| 63 | + |
| 64 | + if (isObject(pathItem.callbacks) && Object.keys(pathItem.callbacks).length > 0) { |
| 65 | + diagnostics.push({ |
| 66 | + level: "warning", |
| 67 | + code: "CALLBACKS_PRESENT", |
| 68 | + message: "Path callbacks are present; callback operations are not auto-exposed as tools.", |
| 69 | + location: `paths.${path}.callbacks` |
| 70 | + }); |
| 71 | + } |
| 72 | + |
| 73 | + for (const [method, rawOperation] of Object.entries(pathItem)) { |
| 74 | + if (!HTTP_METHODS.has(method) || !isObject(rawOperation)) continue; |
| 75 | + const operation = rawOperation as Record<string, unknown>; |
| 76 | + const opLocation = `paths.${path}.${method}`; |
| 77 | + |
| 78 | + if (typeof operation.operationId !== "string" || !operation.operationId.trim()) { |
| 79 | + diagnostics.push({ |
| 80 | + level: strict ? "error" : "warning", |
| 81 | + code: "OPERATION_ID_MISSING", |
| 82 | + message: `Missing operationId for ${method.toUpperCase()} ${path}.`, |
| 83 | + location: opLocation |
| 84 | + }); |
| 85 | + } else { |
| 86 | + const id = operation.operationId.trim(); |
| 87 | + if (seenOperationIds.has(id)) { |
| 88 | + diagnostics.push({ |
| 89 | + level: strict ? "error" : "warning", |
| 90 | + code: "OPERATION_ID_DUPLICATE", |
| 91 | + message: `Duplicate operationId: ${id}`, |
| 92 | + location: `${opLocation}.operationId` |
| 93 | + }); |
| 94 | + } |
| 95 | + seenOperationIds.add(id); |
| 96 | + } |
| 97 | + |
| 98 | + const requestBody = operation.requestBody; |
| 99 | + if (isObject(requestBody) && isObject((requestBody as Record<string, unknown>)["content"])) { |
| 100 | + const content = (requestBody as Record<string, unknown>)["content"] as Record<string, unknown>; |
| 101 | + const mediaTypes = Object.keys(content); |
| 102 | + if (mediaTypes.length > 0 && !mediaTypes.some((x) => SUPPORTED_REQUEST_MEDIA_TYPES.has(x))) { |
| 103 | + diagnostics.push({ |
| 104 | + level: strict ? "error" : "warning", |
| 105 | + code: "REQUEST_MEDIA_TYPE_UNSUPPORTED", |
| 106 | + message: `No supported request media type for ${method.toUpperCase()} ${path}.`, |
| 107 | + location: `${opLocation}.requestBody.content` |
| 108 | + }); |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + const responses = isObject(operation.responses) ? (operation.responses as Record<string, unknown>) : {}; |
| 113 | + for (const [status, response] of Object.entries(responses)) { |
| 114 | + if (isObject(response) && isObject((response as Record<string, unknown>)["links"])) { |
| 115 | + diagnostics.push({ |
| 116 | + level: "warning", |
| 117 | + code: "LINKS_PRESENT", |
| 118 | + message: "Response links are present; link relations are not translated into MCP tool semantics.", |
| 119 | + location: `${opLocation}.responses.${status}.links` |
| 120 | + }); |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + const bodySchema = extractRequestSchema(operation); |
| 125 | + if (isObject(bodySchema) && Array.isArray((bodySchema as Record<string, unknown>)["oneOf"])) { |
| 126 | + const discriminator = (bodySchema as Record<string, unknown>)["discriminator"]; |
| 127 | + if (!isObject(discriminator)) { |
| 128 | + diagnostics.push({ |
| 129 | + level: "warning", |
| 130 | + code: "ONEOF_NO_DISCRIMINATOR", |
| 131 | + message: "oneOf schema without discriminator can be ambiguous for runtime validation.", |
| 132 | + location: `${opLocation}.requestBody` |
| 133 | + }); |
| 134 | + } |
| 135 | + } |
| 136 | + } |
| 137 | + } |
| 138 | + |
| 139 | + return diagnostics; |
| 140 | +} |
| 141 | + |
| 142 | +function isObject(value: unknown): value is object { |
| 143 | + return value !== null && typeof value === "object"; |
| 144 | +} |
| 145 | + |
| 146 | +function extractRequestSchema(operation: Record<string, unknown>): unknown { |
| 147 | + const requestBody = operation["requestBody"]; |
| 148 | + if (!isObject(requestBody)) return undefined; |
| 149 | + const content = (requestBody as Record<string, unknown>)["content"]; |
| 150 | + if (!isObject(content)) return undefined; |
| 151 | + const first = Object.values(content as Record<string, unknown>)[0]; |
| 152 | + if (!isObject(first)) return undefined; |
| 153 | + return (first as Record<string, unknown>)["schema"]; |
| 154 | +} |
0 commit comments