mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-17 16:15:22 +00:00
486 lines
16 KiB
TypeScript
486 lines
16 KiB
TypeScript
import type { AnyObject, OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser';
|
|
import type { OpenAPIUniversalContext } from './context';
|
|
import { stringifyOpenAPI } from './stringifyOpenAPI';
|
|
import { tString } from './translate';
|
|
import type {
|
|
OpenAPICustomSecurityScheme,
|
|
OpenAPIOperationData,
|
|
OpenAPISecurityScope,
|
|
} from './types';
|
|
|
|
export function checkIsReference(input: unknown): input is OpenAPIV3.ReferenceObject {
|
|
return typeof input === 'object' && !!input && '$ref' in input;
|
|
}
|
|
|
|
export function createStateKey(key: string, scope?: string) {
|
|
return scope ? `${scope}_${key}` : key;
|
|
}
|
|
|
|
/**
|
|
* Read the `x-gitbook-mcp-url` extension from any spec object, treating an empty string as unset
|
|
* so it falls through to the next level when resolving the operation > path > root cascade.
|
|
*/
|
|
export function readMcpUrl(obj: unknown): string | undefined {
|
|
if (obj && typeof obj === 'object' && 'x-gitbook-mcp-url' in obj) {
|
|
const value = obj['x-gitbook-mcp-url'];
|
|
return typeof value === 'string' && value ? value : undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Check if an object has a description. Either at the root level or in items.
|
|
*/
|
|
function hasDescription(object: AnyObject) {
|
|
return 'description' in object || 'x-gitbook-description-html' in object;
|
|
}
|
|
|
|
/**
|
|
* Resolve the description of an object.
|
|
*/
|
|
export function resolveDescription(object: OpenAPIV3.SchemaObject | AnyObject) {
|
|
// Resolve description from the object first
|
|
if (hasDescription(object)) {
|
|
return 'x-gitbook-description-html' in object &&
|
|
typeof object['x-gitbook-description-html'] === 'string'
|
|
? object['x-gitbook-description-html'].trim()
|
|
: typeof object.description === 'string'
|
|
? object.description.trim()
|
|
: undefined;
|
|
}
|
|
|
|
// If the object has no description, try to resolve it from the items
|
|
if ('items' in object && typeof object.items === 'object' && hasDescription(object.items)) {
|
|
return resolveDescription(object.items);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Extract descriptions from an object.
|
|
*/
|
|
export function extractDescriptions(object: AnyObject) {
|
|
return {
|
|
description: object.description,
|
|
'x-gitbook-description-html':
|
|
'x-gitbook-description-html' in object
|
|
? object['x-gitbook-description-html']
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve the first example from an object.
|
|
*/
|
|
export function resolveFirstExample(object: AnyObject): string | undefined {
|
|
if ('examples' in object && typeof object.examples === 'object' && object.examples) {
|
|
const keys = Object.keys(object.examples);
|
|
const firstKey = keys[0];
|
|
if (firstKey && object.examples[firstKey]) {
|
|
return formatExample(object.examples[firstKey]);
|
|
}
|
|
}
|
|
|
|
// Resolve top level example first
|
|
if (shouldDisplayExample(object)) {
|
|
return formatExample(object.example);
|
|
}
|
|
|
|
// Resolve example from items if it exists
|
|
if (object.items && typeof object.items === 'object') {
|
|
return formatExample(object.items.example);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Resolve the schema of a parameter.
|
|
* Extract the description, example and deprecated from parameter.
|
|
*/
|
|
export function resolveParameterSchema(
|
|
parameter: OpenAPIV3.ParameterBaseObject
|
|
): OpenAPIV3.SchemaObject {
|
|
const schema = checkIsReference(parameter.schema) ? undefined : parameter.schema;
|
|
return {
|
|
// Description of the parameter is defined at the parameter level
|
|
// we use display it if the schema doesn't override it
|
|
...extractDescriptions(parameter),
|
|
example: resolveFirstExample(parameter),
|
|
// Deprecated can be defined at the parameter level
|
|
deprecated: parameter.deprecated,
|
|
...schema,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Transform a parameter object to a property object.
|
|
*/
|
|
export function parameterToProperty(
|
|
parameter: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject | OpenAPIV3_1.ReferenceObject
|
|
): {
|
|
propertyName: string | undefined;
|
|
schema: OpenAPIV3.SchemaObject;
|
|
required: boolean | undefined;
|
|
} {
|
|
if (checkIsReference(parameter)) {
|
|
return {
|
|
propertyName: parameter.$ref ?? 'Unknown ref',
|
|
schema: {},
|
|
required: undefined,
|
|
};
|
|
}
|
|
return {
|
|
propertyName: parameter.name,
|
|
schema: resolveParameterSchema(parameter),
|
|
required: parameter.required,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Format the example of a schema.
|
|
*/
|
|
function formatExample(example: unknown): string {
|
|
if (typeof example === 'string') {
|
|
return example
|
|
.replace(/\n/g, ' ') // Replace newlines with spaces
|
|
.replace(/\s+/g, ' ') // Collapse multiple spaces/newlines into a single space
|
|
.replace(/([\{\}:,])\s+/g, '$1 ') // Ensure a space after {, }, :, and ,
|
|
.replace(/\s+([\{\}:,])/g, ' $1') // Ensure a space before {, }, :, and ,
|
|
.trim();
|
|
}
|
|
|
|
return stringifyOpenAPI(example);
|
|
}
|
|
|
|
/**
|
|
* Check if an example should be displayed.
|
|
*/
|
|
function shouldDisplayExample(schema: OpenAPIV3.SchemaObject): boolean {
|
|
return (
|
|
(typeof schema.example === 'string' && !!schema.example) ||
|
|
typeof schema.example === 'number' ||
|
|
typeof schema.example === 'boolean' ||
|
|
(Array.isArray(schema.example) && schema.example.length > 0) ||
|
|
(typeof schema.example === 'object' &&
|
|
schema.example !== null &&
|
|
Object.keys(schema.example).length > 0)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the class name for a status code.
|
|
* 1xx: informational
|
|
* 2xx: success
|
|
* 3xx: redirect
|
|
* 4xx, 5xx: error
|
|
*/
|
|
export function getStatusCodeClassName(statusCode: number | string): string {
|
|
const category = getStatusCodeCategory(statusCode);
|
|
switch (category) {
|
|
case 1:
|
|
return 'informational';
|
|
case 2:
|
|
return 'success';
|
|
case 3:
|
|
return 'redirect';
|
|
case 4:
|
|
case 5:
|
|
return 'error';
|
|
default:
|
|
return 'unknown';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a default label for a status code.
|
|
* This is used when there is no label provided in the OpenAPI spec.
|
|
* 1xx: Information
|
|
* 2xx: Success
|
|
* 3xx: Redirect
|
|
* 4xx, 5xx: Error
|
|
*/
|
|
export function getStatusCodeDefaultLabel(
|
|
statusCode: number | string,
|
|
context: OpenAPIUniversalContext
|
|
): string {
|
|
const category = getStatusCodeCategory(statusCode);
|
|
switch (category) {
|
|
case 1:
|
|
return tString(context.translation, 'information');
|
|
case 2:
|
|
return tString(context.translation, 'success');
|
|
case 3:
|
|
return tString(context.translation, 'redirect');
|
|
case 4:
|
|
case 5:
|
|
return tString(context.translation, 'error');
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function getStatusCodeCategory(statusCode: number | string): number | string {
|
|
const code = typeof statusCode === 'string' ? Number.parseInt(statusCode, 10) : statusCode;
|
|
|
|
if (Number.isNaN(code) || code < 100 || code >= 600) {
|
|
return 'unknown';
|
|
}
|
|
|
|
// Determine the category of the status code based on the first digit
|
|
const category = Math.floor(code / 100);
|
|
|
|
return category;
|
|
}
|
|
|
|
/**
|
|
* Extract non-null types from a union type array.
|
|
* Returns the types excluding 'null' and whether null was present.
|
|
*/
|
|
export function extractNonNullTypes(types: string[]): { nonNullTypes: string[]; hasNull: boolean } {
|
|
const nonNullTypes = types.filter((t) => t !== 'null');
|
|
const hasNull = nonNullTypes.length < types.length;
|
|
return { nonNullTypes, hasNull };
|
|
}
|
|
|
|
/**
|
|
* Get the effective array type from a schema, handling union types.
|
|
* Returns the array type if present, or null if not an array.
|
|
* Handles both OpenAPI 3.0 (type: 'array') and OpenAPI 3.1 (type: ['null', 'array']) nullable arrays.
|
|
*/
|
|
export function getEffectiveArrayType(schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject): {
|
|
isArray: boolean;
|
|
hasNull: boolean;
|
|
items?: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject;
|
|
} {
|
|
if (Array.isArray(schema.type)) {
|
|
const { nonNullTypes, hasNull } = extractNonNullTypes(schema.type);
|
|
if (nonNullTypes.includes('array')) {
|
|
return { isArray: true, hasNull, items: schema.items };
|
|
}
|
|
return { isArray: false, hasNull };
|
|
}
|
|
|
|
if (schema.type === 'array') {
|
|
return { isArray: true, hasNull: false, items: schema.items };
|
|
}
|
|
|
|
return { isArray: false, hasNull: false };
|
|
}
|
|
|
|
/**
|
|
* Check if a schema only describes the `null` type, i.e. it is used as a nullability
|
|
* marker inside an `anyOf`/`oneOf` (OpenAPI 3.1+).
|
|
*/
|
|
function isNullSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject): boolean {
|
|
if (checkIsReference(schema)) {
|
|
return false;
|
|
}
|
|
// `null` is only a valid type in OpenAPI 3.1+, hence the widening.
|
|
const type = schema.type as string | string[] | undefined;
|
|
if (Array.isArray(type)) {
|
|
return type.length > 0 && type.every((t) => t === 'null');
|
|
}
|
|
return type === 'null';
|
|
}
|
|
|
|
/**
|
|
* Normalize the OpenAPI 3.1+ idiom of expressing nullability through `anyOf`/`oneOf`
|
|
* (e.g. `anyOf: [{ type: 'string' }, { type: 'null' }]`) into a regular nullable schema,
|
|
* mirroring how `type: ['string', 'null']` is handled.
|
|
*
|
|
* - Single non-null member: collapse into that member with `nullable: true`.
|
|
* - Multiple non-null members (or a single `$ref` member): drop the null member and keep
|
|
* the union, flagged `nullable: true`.
|
|
* - No null member: returned unchanged (preserves identity for circular-ref tracking).
|
|
*/
|
|
export function normalizeNullableUnion(
|
|
schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject
|
|
): OpenAPIV3.SchemaObject {
|
|
const typed = schema as OpenAPIV3.SchemaObject;
|
|
|
|
const isAnyOf = Array.isArray(typed.anyOf);
|
|
const isOneOf = !isAnyOf && Array.isArray(typed.oneOf);
|
|
if (!isAnyOf && !isOneOf) {
|
|
return typed;
|
|
}
|
|
|
|
const unionKey = isAnyOf ? 'anyOf' : 'oneOf';
|
|
const union = (isAnyOf ? typed.anyOf : typed.oneOf) as (
|
|
| OpenAPIV3.SchemaObject
|
|
| OpenAPIV3.ReferenceObject
|
|
)[];
|
|
|
|
const nonNullMembers = union.filter((member) => !isNullSchema(member));
|
|
if (nonNullMembers.length === union.length) {
|
|
// No null member, nothing to normalize.
|
|
return typed;
|
|
}
|
|
|
|
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = typed;
|
|
|
|
const single = nonNullMembers.length === 1 ? nonNullMembers[0] : undefined;
|
|
if (single && !checkIsReference(single)) {
|
|
// Outer-level metadata (description, title, …) takes precedence over the member's.
|
|
return { ...single, ...rest, nullable: true };
|
|
}
|
|
|
|
return { ...rest, [unionKey]: nonNullMembers, nullable: true };
|
|
}
|
|
|
|
export function getSchemaTitle(
|
|
schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject,
|
|
options?: { ignoreAlternatives?: boolean }
|
|
): string {
|
|
// Otherwise try to infer a nice title
|
|
let type = 'any';
|
|
|
|
if (schema.enum || schema['x-enumDescriptions'] || schema['x-gitbook-enum']) {
|
|
type = `${schema.type} · enum`;
|
|
} else {
|
|
// Handle union types (OpenAPI 3.1 nullable)
|
|
const arrayInfo = getEffectiveArrayType(schema);
|
|
|
|
if (arrayInfo.isArray && !!schema.items) {
|
|
// Handle array type (nullable is handled separately in getAdditionalItems)
|
|
let itemsTitle: string;
|
|
if (checkIsReference(schema.items)) {
|
|
// Extract schema name from $ref (e.g., #/components/schemas/ProductPayoutSplitDto -> ProductPayoutSplitDto)
|
|
const refPath = schema.items.$ref;
|
|
const schemaName = refPath?.split('/').pop() ?? 'object';
|
|
itemsTitle = schemaName;
|
|
} else {
|
|
itemsTitle = getSchemaTitle(schema.items, options);
|
|
}
|
|
type = `${itemsTitle}[]`;
|
|
} else if (Array.isArray(schema.type)) {
|
|
// Handle other union types (non-array)
|
|
// For nullable union types, we show the non-null types only
|
|
// since nullable is handled separately in getAdditionalItems
|
|
const { nonNullTypes } = extractNonNullTypes(schema.type);
|
|
if (nonNullTypes.length === 1) {
|
|
// Single non-null type - show just that type
|
|
type = nonNullTypes[0] ?? 'any';
|
|
} else if (nonNullTypes.length > 1) {
|
|
// Multiple non-null types - join them (excluding null)
|
|
type = nonNullTypes.join(' | ');
|
|
} else {
|
|
// Only null types - fallback to 'any'
|
|
type = 'any';
|
|
}
|
|
} else if (schema.type || schema.properties) {
|
|
type = schema.type ?? 'object';
|
|
|
|
if (schema.format) {
|
|
type += ` · ${schema.format}`;
|
|
}
|
|
|
|
// Only add the title if it's an object (no need for the title of a string, number, etc.)
|
|
if (type === 'object' && schema.title) {
|
|
type += ` · ${schema.title}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Skip alternative type labels if ignoreAlternatives is true (useful when rendering alternatives)
|
|
if (!options?.ignoreAlternatives) {
|
|
if ('anyOf' in schema) {
|
|
type = 'any of';
|
|
} else if ('oneOf' in schema) {
|
|
type = 'one of';
|
|
} else if ('allOf' in schema) {
|
|
type = 'all of';
|
|
} else if ('not' in schema) {
|
|
type = 'not';
|
|
}
|
|
}
|
|
|
|
return type;
|
|
}
|
|
|
|
export type OperationSecurityInfo = {
|
|
key: string;
|
|
label: string;
|
|
schemes: OpenAPICustomSecurityScheme[];
|
|
scopeAlternatives: OpenAPISecurityScope[][];
|
|
};
|
|
|
|
/**
|
|
* Extract security information for an operation based on its security requirements and the spec security schemes.
|
|
*/
|
|
export function extractOperationSecurityInfo(args: {
|
|
securityRequirement: OpenAPIV3.OperationObject['security'];
|
|
securities: OpenAPIOperationData['securities'];
|
|
}): OperationSecurityInfo[] {
|
|
const { securityRequirement, securities } = args;
|
|
const securitiesMap = new Map(securities);
|
|
|
|
// When no security requirement include every schemes
|
|
if (!securityRequirement || securityRequirement.length === 0) {
|
|
return securities.map(([key, security]) => ({
|
|
key,
|
|
label: key,
|
|
schemes: [security],
|
|
scopeAlternatives: security.scopes?.length ? [security.scopes] : [],
|
|
}));
|
|
}
|
|
|
|
const grouped = new Map<string, OperationSecurityInfo>();
|
|
|
|
securityRequirement.forEach((requirement) => {
|
|
const schemeKeys = Object.keys(requirement).sort();
|
|
if (schemeKeys.length === 0) {
|
|
return;
|
|
}
|
|
const label = schemeKeys.join(' & ');
|
|
const existing = grouped.get(label);
|
|
const schemes = schemeKeys
|
|
.map((schemeKey) => securitiesMap.get(schemeKey))
|
|
.filter((s): s is OpenAPICustomSecurityScheme => s !== undefined);
|
|
const scopesForRequirement = schemeKeys.flatMap((schemeKey) =>
|
|
resolveRequiredScopesForScheme(securitiesMap.get(schemeKey), requirement[schemeKey])
|
|
);
|
|
|
|
if (existing) {
|
|
existing.scopeAlternatives.push(scopesForRequirement);
|
|
if (!existing.schemes.length && schemes.length) {
|
|
existing.schemes = schemes;
|
|
}
|
|
} else {
|
|
grouped.set(label, {
|
|
key: `security-${grouped.size}`,
|
|
label,
|
|
schemes,
|
|
scopeAlternatives: [scopesForRequirement],
|
|
});
|
|
}
|
|
});
|
|
|
|
return Array.from(grouped.values());
|
|
}
|
|
|
|
function resolveRequiredScopesForScheme(
|
|
security: OpenAPICustomSecurityScheme | undefined,
|
|
operationScopes: string[] | undefined
|
|
): OpenAPISecurityScope[] {
|
|
if (!security || !operationScopes?.length) {
|
|
return [];
|
|
}
|
|
|
|
if (security.type === 'oauth2') {
|
|
const flows = security.flows ? Object.entries(security.flows) : [];
|
|
const resolved = flows.flatMap(([_, flow]) => {
|
|
return Object.entries(flow.scopes ?? {}).filter(([scope]) =>
|
|
operationScopes.includes(scope)
|
|
);
|
|
});
|
|
|
|
if (resolved.length) {
|
|
return resolved;
|
|
}
|
|
}
|
|
|
|
return operationScopes.map((scope) => [scope, undefined]);
|
|
}
|