[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/devforth/adminforth/main/adminforth/servers/openapiDocument.ts [Back]  [Original]

import type { AnySchemaObject } from 'ajv';
import type { IRegisteredApiSchema } from '../types/Back.js';

const COMPONENT_REF_PREFIX = '#/components/schemas/';
const JSON_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema';
const CONDITIONAL_NOTE = 'Runtime applies additional conditional validation rules that are documented in descriptions instead of nested schema conditionals.';
const SINGLE_OR_ARRAY_NOTE = 'Runtime also accepts a single object here, but the documented shape uses the array form because it is more stable for API reference UIs and generated clients.';

type SchemaKind = 'request' | 'response';

type BuildOpenApiDocumentOptions = {
  title: string;
  version: string;
  description: string;
  routes: IRegisteredApiSchema[];
};

type NormalizeOptions = {
  componentName: string;
  requestMode: boolean;
  localDefs: Map;
};

type TransformState = {
  components: Record;
  componentNames: WeakMap;
  usedNames: Set;
};

function isSchemaObject(value: unknown): value is AnySchemaObject {
  return !!value && typeof value === 'object' && !Array.isArray(value);
}

function toPascalCase(value: string): string {
  return value
    .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
    .split(/[^a-zA-Z0-9]+/)
    .filter(Boolean)
    .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
    .join('');
}

function sanitizeComponentName(value: string): string {
  const normalized = toPascalCase(value);
  if (!normalized) {
    return 'SchemaComponent';
  }
  return /^[A-Za-z_]/.test(normalized) ? normalized : `Schema${normalized}`;
}

function createRouteComponentName(route: IRegisteredApiSchema, kind: SchemaKind): string {
  return sanitizeComponentName(`${route.method} ${route.path} ${kind}`);
}

function appendDescription(existing: string | undefined, note: string): string {
  return existing ? `${existing}\n\n${note}` : note;
}

function extractLocalRefKey(ref: string): string | undefined {
  if (!ref.startsWith('#/$defs/')) {
    return undefined;
  }

  return ref.slice('#/$defs/'.length);
}

function hasCompositeKeywords(schema: AnySchemaObject): boolean {
  return Array.isArray(schema.anyOf)
    || Array.isArray(schema.oneOf)
    || Array.isArray(schema.allOf)
    || Array.isArray(schema.prefixItems)
    || '$defs' in schema
    || 'if' in schema
    || 'then' in schema
    || 'else' in schema;
}

function shouldHoistSchema(schema: AnySchemaObject): boolean {
  if (typeof schema.$ref === 'string' && Object.keys(schema).length === 1) {
    return false;
  }

  if (typeof schema.title === 'string' && schema.title.trim().length > 0) {
    return true;
  }

  if (hasCompositeKeywords(schema)) {
    return true;
  }

  return schema.type === 'object'
    && isSchemaObject(schema.properties)
    && Object.keys(schema.properties).length > 1;
}

function createSchemaSignature(schema: AnySchemaObject): unknown {
  return Object.fromEntries(
    Object.entries(schema)
      .filter(([key]) => key !== 'description' && key !== 'examples' && key !== 'title')
      .map(([key, value]) => {
        if (isSchemaObject(value)) {
          return [key, createSchemaSignature(value)];
        }

        if (Array.isArray(value)) {
          return [
            key,
            value.map((item) => isSchemaObject(item) ? createSchemaSignature(item) : item),
          ];
        }

        return [key, value];
      }),
  );
}

function schemasMatch(left: AnySchemaObject, right: AnySchemaObject): boolean {
  if (typeof left.$ref === 'string' || typeof right.$ref === 'string') {
    return left.$ref === right.$ref;
  }

  return JSON.stringify(createSchemaSignature(left)) === JSON.stringify(createSchemaSignature(right));
}

function simplifyRequestSchema(schema: AnySchemaObject): { schema: AnySchemaObject; notes: string[] } {
  const simplifiedSchema: AnySchemaObject = { ...schema };
  const notes: string[] = [];
  const variants = Array.isArray(simplifiedSchema.oneOf)
    ? simplifiedSchema.oneOf
    : Array.isArray(simplifiedSchema.anyOf)
      ? simplifiedSchema.anyOf
      : null;

  if (variants && variants.length === 2) {
    const arrayVariant = variants.find((option) => isSchemaObject(option) && option.type === 'array' && isSchemaObject(option.items));
    const singleVariant = variants.find((option) => option !== arrayVariant);

    if (
      arrayVariant
      && singleVariant
      && isSchemaObject(singleVariant)
      && isSchemaObject(arrayVariant.items)
      && schemasMatch(arrayVariant.items as AnySchemaObject, singleVariant)
    ) {
      const { anyOf, oneOf, ...rest } = simplifiedSchema;
      Object.assign(simplifiedSchema, rest, {
        type: 'array',
        items: arrayVariant.items,
      });
      delete simplifiedSchema.anyOf;
      delete simplifiedSchema.oneOf;
      notes.push(SINGLE_OR_ARRAY_NOTE);
    }
  }

  if ('if' in simplifiedSchema || 'then' in simplifiedSchema || 'else' in simplifiedSchema) {
    delete simplifiedSchema.if;
    delete simplifiedSchema.then;
    delete simplifiedSchema.else;
    notes.push(CONDITIONAL_NOTE);
  }

  if (Array.isArray(simplifiedSchema.allOf)) {
    const remainingSchemas = simplifiedSchema.allOf.filter((option) => {
      return !(isSchemaObject(option) && ('if' in option || 'then' in option || 'else' in option));
    });

    if (remainingSchemas.length !== simplifiedSchema.allOf.length) {
      if (remainingSchemas.length > 0) {
        simplifiedSchema.allOf = remainingSchemas;
      } else {
        delete simplifiedSchema.allOf;
      }
      notes.push(CONDITIONAL_NOTE);
    }
  }

  return { schema: simplifiedSchema, notes };
}

function createChildOptions(options: NormalizeOptions, suffix: string): NormalizeOptions {
  return {
    ...options,
    componentName: `${options.componentName}${toPascalCase(suffix)}`,
  };
}

function resolveComponentName(state: TransformState, schema: AnySchemaObject, preferredName: string): string {
  const existingName = state.componentNames.get(schema as object);
  if (existingName) {
    return existingName;
  }

  const titledName = typeof schema.title === 'string' && schema.title.trim().length > 0
    ? schema.title
    : preferredName;
  const baseName = sanitizeComponentName(titledName);
  let candidate = baseName;
  let suffix = 2;

  while (state.usedNames.has(candidate)) {
    candidate = `${baseName}${suffix}`;
    suffix += 1;
  }

  state.usedNames.add(candidate);
  state.componentNames.set(schema as object, candidate);
  return candidate;
}

function registerComponent(state: TransformState, schema: AnySchemaObject, options: NormalizeOptions): AnySchemaObject {
  const componentName = resolveComponentName(state, schema, options.componentName);
  if (!state.components[componentName]) {
    state.components[componentName] = {};
    state.components[componentName] = normalizeSchema(state, schema, {
      ...options,
      componentName,
    });
  }

  return { $ref: `${COMPONENT_REF_PREFIX}${componentName}` };
}

function hoistLocalDefinitions(state: TransformState, schema: AnySchemaObject, options: NormalizeOptions): Map {
  const localDefs = new Map(options.localDefs);
  const definitions = isSchemaObject(schema.$defs) ? schema.$defs as Record : undefined;

  if (!definitions) {
    return localDefs;
  }

  const entries = Object.entries(definitions).filter(([, definitionSchema]) => isSchemaObject(definitionSchema));

  entries.forEach(([definitionKey, definitionSchema]) => {
    const preferredName = typeof definitionSchema.title === 'string' && definitionSchema.title.trim().length > 0
      ? definitionSchema.title
      : `${options.componentName}${toPascalCase(definitionKey)}`;
    const componentName = resolveComponentName(state, definitionSchema, preferredName);
    localDefs.set(definitionKey, componentName);
  });

  entries.forEach(([, definitionSchema]) => {
    const componentName = state.componentNames.get(definitionSchema as object);
    if (!componentName || state.components[componentName]) {
      return;
    }

    state.components[componentName] = {};
    state.components[componentName] = normalizeSchema(state, definitionSchema, {
      ...options,
      componentName,
      localDefs,
    });
  });

  return localDefs;
}

function normalizeNestedSchema(state: TransformState, schema: AnySchemaObject, options: NormalizeOptions): AnySchemaObject {
  if (typeof schema.$ref === 'string' && Object.keys(schema).length === 1) {
    return normalizeSchema(state, schema, options);
  }

  if (shouldHoistSchema(schema)) {
    return registerComponent(state, schema, options);
  }

  return normalizeSchema(state, schema, options);
}

function normalizeSchema(state: TransformState, schema: AnySchemaObject, options: NormalizeOptions): AnySchemaObject {
  const localDefs = hoistLocalDefinitions(state, schema, options);
  const { schema: simplifiedSchema, notes } = options.requestMode
    ? simplifyRequestSchema(schema)
    : { schema, notes: [] as string[] };
  const normalized: AnySchemaObject = {};

  Object.entries(simplifiedSchema).forEach(([key, value]) => {
    if (key === '$defs' || key === '$schema' || key === '$id') {
      return;
    }

    if (key === '$ref' && typeof value === 'string') {
      const localRefKey = extractLocalRefKey(value);
      normalized.$ref = localRefKey && localDefs.has(localRefKey)
        ? `${COMPONENT_REF_PREFIX}${localDefs.get(localRefKey)}`
        : value;
      return;
    }

    if ((key === 'properties' || key === 'patternProperties' || key === 'dependentSchemas') && isSchemaObject(value)) {
      normalized[key] = Object.fromEntries(
        Object.entries(value).map(([propertyName, propertySchema]) => {
          if (!isSchemaObject(propertySchema)) {
            return [propertyName, propertySchema];
          }

          return [
            propertyName,
            normalizeNestedSchema(state, propertySchema, {
              ...createChildOptions(options, propertyName),
              localDefs,
            }),
          ];
        }),
      );
      return;
    }

    if ((key === 'items' || key === 'contains' || key === 'additionalProperties' || key === 'propertyNames' || key === 'not' || key === 'unevaluatedProperties' || key === 'unevaluatedItems' || key === 'contentSchema') && isSchemaObject(value)) {
      normalized[key] = normalizeNestedSchema(state, value, {
        ...createChildOptions(options, key === 'additionalProperties' ? 'value' : key === 'propertyNames' ? 'propertyName' : key),
        localDefs,
      });
      return;
    }

    if ((key === 'anyOf' || key === 'oneOf' || key === 'allOf' || key === 'prefixItems') && Array.isArray(value)) {
      normalized[key] = value.map((option, index) => {
        if (!isSchemaObject(option)) {
          return option;
        }

        return normalizeNestedSchema(state, option, {
          ...createChildOptions(options, `${key}Option${index + 1}`),
          localDefs,
        });
      });
      return;
    }

    normalized[key] = value;
  });

  if (notes.length > 0) {
    normalized.description = appendDescription(
      typeof normalized.description === 'string' ? normalized.description : undefined,
      notes.join(' '),
    );
  }

  return normalized;
}

export function buildOpenApiDocument(options: BuildOpenApiDocumentOptions): {[key: string]: any} {
  const state: TransformState = {
    components: {},
    componentNames: new WeakMap(),
    usedNames: new Set(),
  };
  const paths: Record = {};

  options.routes.forEach((route) => {
    if (!route.request_schema && !route.response_schema) {
      return;
    }

    if (!paths[route.path]) {
      paths[route.path] = {};
    }

    paths[route.path][route.method] = {
      ...(route.description ? {
        description: route.description,
      } : {}),
      ...(route.request_schema ? {
        requestBody: {
          required: true,
          content: {
            'application/json': {
              schema: registerComponent(state, route.request_schema, {
                componentName: createRouteComponentName(route, 'request'),
                requestMode: true,
                localDefs: new Map(),
              }),
            },
          },
        },
      } : {}),
      responses: {
        200: route.response_schema ? {
          description: 'Successful response',
          content: {
            'application/json': {
              schema: registerComponent(state, route.response_schema, {
                componentName: createRouteComponentName(route, 'response'),
                requestMode: false,
                localDefs: new Map(),
              }),
            },
          },
        } : {
          description: 'Successful response',
        },
      },
    };
  });

  return {
    openapi: '3.1.0',
    jsonSchemaDialect: JSON_SCHEMA_DIALECT,
    info: {
      title: options.title,
      version: options.version,
      description: options.description,
    },
    paths,
    components: {
      schemas: state.components,
    },
  };
}

Web Proxy Viewer  |  New URL  |  Original Page