@scalar/oas-utils
Version:
Open API spec and Yaml handling utilities
314 lines (311 loc) • 11.8 kB
JavaScript
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 };