/** * @since 1.0.0 */ import * as Data from 'effect/Data' import * as Effect from 'effect/Effect' import * as YAML from 'yaml' /** * @since 1.0.0 * @category Models */ export interface OpenApiSpec { readonly openapi: string readonly info: InfoObject readonly servers?: ReadonlyArray readonly paths: PathsObject readonly components?: ComponentsObject readonly security?: ReadonlyArray>> } /** * @since 1.0.0 * @category Models */ export interface InfoObject { readonly title: string readonly version: string readonly description?: string } /** * @since 1.0.0 * @category Models */ export interface ServerObject { readonly url: string readonly description?: string } /** * @since 1.0.0 * @category Models */ export interface PathsObject { readonly [path: string]: PathItemObject } /** * @since 1.0.0 * @category Models */ export interface PathItemObject { readonly get?: OperationObject readonly post?: OperationObject readonly put?: OperationObject readonly patch?: OperationObject readonly delete?: OperationObject } /** * @since 1.0.0 * @category Models */ export interface OperationObject { readonly operationId: string readonly summary?: string readonly description?: string readonly tags?: ReadonlyArray readonly parameters?: ReadonlyArray readonly requestBody?: RequestBodyObject readonly responses: ResponsesObject readonly security?: ReadonlyArray>> readonly deprecated?: boolean } /** * @since 1.0.0 * @category Models */ export interface ParameterObject { readonly name: string readonly in: 'query' | 'header' | 'path' | 'cookie' readonly required?: boolean readonly schema?: SchemaObject } /** * @since 1.0.0 * @category Models */ export interface RequestBodyObject { readonly content: Record readonly required?: boolean } /** * @since 1.0.0 * @category Models */ export interface MediaTypeObject { readonly schema?: SchemaObject } /** * @since 1.0.0 * @category Models */ export interface ResponsesObject { readonly [statusCode: string]: ResponseObject } /** * @since 1.0.0 * @category Models */ export interface ResponseObject { readonly description: string readonly content?: Record } /** * @since 1.0.0 * @category Models */ export interface SchemaObject { readonly type?: string | ReadonlyArray readonly properties?: Record readonly required?: ReadonlyArray readonly items?: SchemaObject readonly $ref?: string readonly description?: string readonly enum?: ReadonlyArray readonly format?: string readonly nullable?: boolean readonly const?: unknown // String validation readonly minLength?: number readonly maxLength?: number readonly pattern?: string // Number validation readonly minimum?: number readonly maximum?: number readonly multipleOf?: number readonly exclusiveMinimum?: boolean | number readonly exclusiveMaximum?: boolean | number // Array validation readonly minItems?: number readonly maxItems?: number readonly uniqueItems?: boolean // Object additional properties readonly additionalProperties?: boolean | SchemaObject // Schema combinators readonly allOf?: ReadonlyArray readonly oneOf?: ReadonlyArray readonly anyOf?: ReadonlyArray // Custom extensions readonly 'x-circular'?: ReadonlyArray readonly deprecated?: boolean } /** * Raw OpenAPI security scheme objects (before parsing into SecurityScheme types) * * @since 1.0.0 * @category Models */ export interface RawApiKeyScheme { readonly type: 'apiKey' readonly name: string readonly in: 'query' | 'header' | 'cookie' readonly description?: string } /** * @since 1.0.0 * @category Models */ export interface RawHttpScheme { readonly type: 'http' readonly scheme: string readonly bearerFormat?: string readonly description?: string } /** * @since 1.0.0 * @category Models */ export interface RawOAuth2Scheme { readonly type: 'oauth2' readonly flows: { readonly implicit?: { readonly authorizationUrl: string readonly scopes: Record readonly refreshUrl?: string } readonly password?: { readonly tokenUrl: string readonly scopes: Record readonly refreshUrl?: string } readonly clientCredentials?: { readonly tokenUrl: string readonly scopes: Record readonly refreshUrl?: string } readonly authorizationCode?: { readonly authorizationUrl: string readonly tokenUrl: string readonly scopes: Record readonly refreshUrl?: string } } readonly description?: string } /** * @since 1.0.0 * @category Models */ export interface RawOpenIdConnectScheme { readonly type: 'openIdConnect' readonly openIdConnectUrl: string readonly description?: string } /** * @since 1.0.0 * @category Models */ export type RawSecurityScheme = RawApiKeyScheme | RawHttpScheme | RawOAuth2Scheme | RawOpenIdConnectScheme /** * @since 1.0.0 * @category Models */ export interface ParameterRef { readonly $ref: string } /** * @since 1.0.0 * @category Models */ export interface ComponentsObject { readonly schemas?: Record readonly securitySchemes?: Record readonly parameters?: Record } /** * @since 1.0.0 * @category Errors */ export class ParseError extends Data.TaggedError('ParseError')<{ readonly message: string }> {} /** * Parse an OpenAPI 3.1 specification from a JSON or YAML string * * @since 1.0.0 * @category Parsing */ export const parse = (content: string): Effect.Effect => Effect.gen(function* () { // Try to parse as JSON or YAML const spec = yield* Effect.try({ try: () => { try { return JSON.parse(content) } catch { return YAML.parse(content) } }, catch: (error) => new ParseError({ message: `Failed to parse spec: ${String(error)}` }), }) // Validate the spec structure if (typeof spec !== 'object' || spec === null) { return yield* new ParseError({ message: 'Spec must be an object' }) } const obj = spec as Record // Validate openapi version if (typeof obj.openapi !== 'string') { return yield* new ParseError({ message: 'Missing required field: openapi' }) } if (!obj.openapi.startsWith('3.')) { return yield* new ParseError({ message: `Unsupported OpenAPI version: ${obj.openapi}. Only OpenAPI 3.x is supported.`, }) } // Validate info object if (typeof obj.info !== 'object' || obj.info === null) { return yield* new ParseError({ message: 'Missing or invalid required field: info' }) } const info = obj.info as Record if (typeof info.title !== 'string') { return yield* new ParseError({ message: 'Missing required field: info.title' }) } if (typeof info.version !== 'string') { return yield* new ParseError({ message: 'Missing required field: info.version' }) } // Validate paths object if (typeof obj.paths !== 'object' || obj.paths === null) { return yield* new ParseError({ message: 'Missing or invalid required field: paths' }) } // Validate operationId for all operations and check for duplicates const paths = obj.paths as Record const operationIds = new Set() for (const [path, pathItem] of Object.entries(paths)) { if (typeof pathItem !== 'object' || pathItem === null) { continue } const operations = pathItem as Record const httpMethods = ['get', 'post', 'put', 'patch', 'delete'] for (const method of httpMethods) { const operation = operations[method] if (typeof operation === 'object' && operation !== null) { const op = operation as Record if (typeof op.operationId !== 'string') { return yield* new ParseError({ message: `Missing required field operationId for operation: ${method.toUpperCase()} ${path}`, }) } // Check for duplicate operationId if (operationIds.has(op.operationId)) { return yield* new ParseError({ message: `Duplicate operationId '${op.operationId}' found. Each operation must have a unique operationId.`, }) } operationIds.add(op.operationId) } } } return spec as OpenApiSpec })