UNPKG

@scalar/oas-utils

Version:

Open API spec and Yaml handling utilities

314 lines (311 loc) 11.8 kB
import { schemaModel } from '../../helpers/schema-model.js'; import { getServerVariableExamples } from '../../spec-getters/get-server-variable-examples.js'; import { nanoidSchema } from '@scalar/types/utils'; import { z } from 'zod'; import { getRequestBodyFromOperation } from '../../spec-getters/get-request-body-from-operation.js'; // --------------------------------------------------------------------------- // Example Parameters /** * TODO: Deprecate this. * * The request schema should be stored in the request and any * parameters should be validated against that */ const requestExampleParametersSchema = z .object({ key: z.string().default(''), value: z.coerce.string().default(''), enabled: z.boolean().default(true), file: z.any().optional(), description: z.string().optional(), required: z.boolean().optional(), enum: z.array(z.string()).optional(), examples: z.array(z.string()).optional(), type: z .union([ // 'string' z.string(), // ['string', 'null'] z.array(z.string()), ]) .optional(), format: z.string().optional(), minimum: z.number().optional(), maximum: z.number().optional(), default: z.any().optional(), nullable: z.boolean().optional(), }) // set nullable: to true if type is ['string', 'null'] .transform((_data) => { const data = { ..._data }; // type: ['string', 'null'] -> nullable: true if (Array.isArray(data.type) && data.type.includes('null')) { data.nullable = true; } // Hey, if it’s just one value and 'null', we can make it a string and ditch the 'null'. if (Array.isArray(data.type) && data.type.length === 2 && data.type.includes('null')) { data.type = data.type.find((item) => item !== 'null'); } return data; }); const xScalarFileValueSchema = z .object({ url: z.string(), base64: z.string().optional(), }) .nullable(); /** * Schema for the OAS serialization of request example parameters * * File values can be optionally fetched on import OR inserted as a base64 encoded string */ z.union([ z.object({ type: z.literal('string'), value: z.string(), }), z.object({ type: z.literal('file'), file: xScalarFileValueSchema, }), ]); // --------------------------------------------------------------------------- // Example Body /** * Possible encodings for example request bodies when using text formats * * TODO: This list may not be comprehensive enough */ const exampleRequestBodyEncoding = ['json', 'text', 'html', 'javascript', 'xml', 'yaml', 'edn']; const exampleBodyMime = [ 'application/json', 'text/plain', 'text/html', 'application/javascript', 'application/xml', 'application/yaml', 'application/edn', 'application/octet-stream', 'application/x-www-form-urlencoded', 'multipart/form-data', /** Used for direct files */ 'binary', ]; /** * TODO: Migrate away from this layout to the format used in the extension * * If a user changes the encoding of the body we expect the content to change as well */ const exampleRequestBodySchema = z.object({ raw: z .object({ encoding: z.enum(exampleRequestBodyEncoding), value: z.string().default(''), mimeType: z.string().optional(), }) .optional(), formData: z .object({ encoding: z.union([z.literal('form-data'), z.literal('urlencoded')]).default('form-data'), value: requestExampleParametersSchema.array().default([]), }) .optional(), binary: z.instanceof(Blob).optional(), activeBody: z.union([z.literal('raw'), z.literal('formData'), z.literal('binary')]).default('raw'), }); /** Schema for the OAS serialization of request example bodies */ const xScalarExampleBodySchema = z.object({ encoding: z.enum(exampleBodyMime), /** * Body content as an object with a separately specified encoding or a simple pre-encoded string value * * Ideally we would convert any objects into the proper encoding on import */ content: z.union([z.record(z.string(), z.any()), z.string()]), /** When the encoding is `binary` this will be used to link to the file */ file: xScalarFileValueSchema.optional(), }); // --------------------------------------------------------------------------- // Example Schema const requestExampleSchema = z.object({ uid: nanoidSchema.brand(), type: z.literal('requestExample').optional().default('requestExample'), requestUid: z.string().brand().optional(), name: z.string().optional().default('Name'), body: exampleRequestBodySchema.optional().default({}), parameters: z .object({ path: requestExampleParametersSchema.array().default([]), query: requestExampleParametersSchema.array().default([]), headers: requestExampleParametersSchema.array().default([{ key: 'Accept', value: '*/*', enabled: true }]), cookies: requestExampleParametersSchema.array().default([]), }) .optional() .default({}), /** TODO: Should this be deprecated? */ serverVariables: z.record(z.string(), z.array(z.string())).optional(), }); /** For OAS serialization we just store the simple key/value pairs */ const xScalarExampleParameterSchema = z.record(z.string(), z.string()).optional(); /** Schema for the OAS serialization of request examples */ const xScalarExampleSchema = z.object({ /** TODO: Should this be required? */ name: z.string().optional(), body: xScalarExampleBodySchema.optional(), parameters: z.object({ path: xScalarExampleParameterSchema, query: xScalarExampleParameterSchema, headers: xScalarExampleParameterSchema, cookies: xScalarExampleParameterSchema, }), }); // --------------------------------------------------------------------------- // Example Helpers /** Create new instance parameter from a request parameter */ function createParamInstance(param) { const schema = param.schema; const keys = Object.keys(param?.examples ?? {}); const firstExample = (() => { if (keys.length && !Array.isArray(param.examples)) { return param.examples?.[keys[0]]; } if (Array.isArray(param.examples) && param.examples.length > 0) { return { value: param.examples[0] }; } return null; })(); /** * TODO: * - Need better value defaulting here * - Need to handle non-string parameters much better * - Need to handle unions/array values for schema */ const value = String(schema?.default ?? schema?.examples?.[0] ?? schema?.example ?? firstExample?.value ?? param.example ?? ''); // Handle non-string enums and enums within items for array types const parseEnum = (() => { if (schema?.enum && schema?.type !== 'string') { return schema.enum?.map(String); } if (schema?.items?.enum && schema?.type === 'array') { return schema.items.enum.map(String); } return schema?.enum; })(); // Handle non-string examples const parseExamples = schema?.examples && schema?.type !== 'string' ? schema.examples?.map(String) : schema?.examples; // safe parse the example const example = schemaModel({ ...schema, key: param.name, value, description: param.description, required: param.required, /** Initialized all required properties to enabled */ enabled: !!param.required, enum: parseEnum, examples: parseExamples, }, requestExampleParametersSchema, false); if (!example) { console.warn(`Example at ${param.name} is invalid.`); return requestExampleParametersSchema.parse({}); } return example; } /** * Create new request example from a request * Iterates the name of the example if provided */ function createExampleFromRequest(request, name, server) { // --------------------------------------------------------------------------- // Populate all parameters with an example value const parameters = { path: [], query: [], cookie: [], // deprecated TODO: add zod transform to remove header: [], headers: [{ key: 'Accept', value: '*/*', enabled: true }], }; // Populated the separated params request.parameters?.forEach((p) => parameters[p.in].push(createParamInstance(p))); // TODO: add zod transform to remove header and only support headers if (parameters.header.length > 0) { parameters.headers = parameters.header; parameters.header = []; } // Get content type header const contentTypeHeader = parameters.headers.find((h) => h.key.toLowerCase() === 'content-type'); // --------------------------------------------------------------------------- // Handle request body defaulting for various content type encodings const body = { activeBody: 'raw', }; // If we have a request body or a content type header if (request.requestBody || contentTypeHeader?.value) { const requestBody = getRequestBodyFromOperation({ path: request.path, information: { requestBody: request.requestBody, }, }); const contentType = request.requestBody ? requestBody?.mimeType : contentTypeHeader?.value; // Handle JSON and JSON-like mimetypes if (contentType?.includes('/json') || contentType?.endsWith('+json')) { body.activeBody = 'raw'; body.raw = { encoding: 'json', mimeType: contentType, value: requestBody?.text ?? JSON.stringify({}), }; } if (contentType === 'application/xml') { body.activeBody = 'raw'; body.raw = { encoding: 'xml', value: requestBody?.text ?? '', }; } /** * TODO: Are we loading example files from somewhere based on the spec? * How are we handling the body values */ if (contentType === 'application/octet-stream') { body.activeBody = 'binary'; body.binary = undefined; } if (contentType === 'application/x-www-form-urlencoded' || contentType === 'multipart/form-data') { body.activeBody = 'formData'; body.formData = { encoding: contentType === 'application/x-www-form-urlencoded' ? 'urlencoded' : 'form-data', value: (requestBody?.params || []).map((param) => ({ key: param.name, value: param.value || '', enabled: true, })), }; } // Add the content-type header if it doesn't exist if (requestBody?.mimeType && !contentTypeHeader) { parameters.headers.push({ key: 'Content-Type', value: requestBody.mimeType, enabled: true, }); } } const serverVariables = server ? getServerVariableExamples(server) : {}; // safe parse the example const example = schemaModel({ requestUid: request.uid, parameters, name, body, serverVariables, }, requestExampleSchema, false); if (!example) { console.warn(`Example at ${request.uid} is invalid.`); return requestExampleSchema.parse({}); } return example; } export { createExampleFromRequest, createParamInstance, exampleBodyMime, exampleRequestBodyEncoding, exampleRequestBodySchema, requestExampleParametersSchema, requestExampleSchema, xScalarExampleBodySchema, xScalarExampleSchema, xScalarFileValueSchema };