reissbaker/structural

View on GitHub
lib/to-jsonschema.ts

Summary

Maintainability
B
6 hrs
Test Coverage
A
97%
import { Type, Comment, Either, DefaultIntersect, Validation } from "./type";
import { TypeOf } from "./checks/type-of";
import { InstanceOf } from "./checks/instance-of";
import { Value } from "./checks/value";
import { Arr } from "./checks/array";
import { PartialStruct, Struct, MissingKey, OptionalKey, Dict, MergeIntersect } from "./checks/struct";
import { MapType } from "./checks/map";
import { SetType } from "./checks/set";
import { Any } from "./checks/any";
import { Is } from "./checks/is";
import { Never } from "./checks/never";
import { Kind } from "./kind";

export const JSON_SCHEMA_VERSION = "https://json-schema.org/draft/2020-12/schema" as const;
type TopLevel<T> = T & {
  $schema: typeof JSON_SCHEMA_VERSION,
  title: string,
};

// TODO: a title annotation built into structural. description can be transformed from comments.
// you should also have a variant of toJSONSchema that mandates top-level titles by insisting that
// the type passed in is a Title type instead of a Kind type.
type Annotated<T> = T & {
  description?: string,
};

export type JSONValue = null
                      | string
                      | number
                      | boolean
                      | Array<JSONValue>
                      | { [key: string]: JSONValue }
                      ;

export type JSONSchema = Annotated<{ type: "string" }>
                       | Annotated<{ type: "number" }>
                       | Annotated<{ type: "boolean" }>
                       | Annotated<{ type: "null" }>
                       | Annotated<{}> // {} is JSON schema's any type
                       | Annotated<{
                         type: "object",
                         required: string[],
                         properties: {
                           [key: string]: JSONSchema,
                         },
                       }>
                       | Annotated<{
                         type: "object",
                         // Dict<T> type is { properties: {}, additionalProperties: T }
                         properties: {},
                         additionalProperties?: JSONSchema,
                       }>
                       | Annotated<{
                         type: "array",
                         items?: JSONSchema,
                       }>
                       | Annotated<{
                         // intersection
                         allOf: JSONSchema[],
                       }>
                       | Annotated<{
                         // union
                         anyOf: JSONSchema[],
                       }>
                       | Annotated<{
                         // technically this can be represented as anyOf([ ...consts ]), but for
                         // readability if all of a union is const values imo you should output an
                         // enum rather than the more complex type
                         enum: Array<JSONValue>
                       }>
                       | Annotated<{
                         const: JSONValue,
                       }>
                       ;

type Options = {
  errorOnValidations?: boolean,
  errorOnIs?: boolean,
  errorOnNever?: boolean,
};
const defaultOpts: Required<Options> = {
  errorOnValidations: true,
  errorOnIs: true,
  errorOnNever: true,
};

export function toJSONSchema(title: string, type: Kind, opts?: Options): TopLevel<JSONSchema> {
  const options = {
    ...defaultOpts,
    ...opts,
  };

  return addMetadata(title, typeToSchema(type, options));
}

function typeToSchema(type: Kind, options: Required<Options>): JSONSchema {
  if(type instanceof Comment) {
    return {
      description: formatCommentString(type.commentStr),
      ...typeToSchema(type.wrapped, options)
    };
  }
  if(type instanceof Either) return fromEither(type, options);
  if(type instanceof DefaultIntersect) return fromIntersect(type, options);
  if(type instanceof MergeIntersect) return fromIntersect(type, options);
  if(type instanceof Validation) return fromValidation(type, options);
  if(type instanceof TypeOf) return fromTypeof(type);
  if(type instanceof InstanceOf) {
    throw `Structural instanceOf types can't be converted to JSON Schema`;
  }
  if(type instanceof Value) return fromValue(type);
  if(type instanceof Arr) return fromArray(type, options);
  if(type instanceof Struct) return fromStruct(type, options);
  if(type instanceof Dict) return fromDict(type, options);
  if(type instanceof MapType) {
    throw `Structural Map types can't be converted to JSON Schema; consider using a dict`;
  }
  if(type instanceof SetType) {
    throw `Structural Set types can't be converted to JSON Schema; consider using an array`;
  }
  if(type instanceof Any) {
    return {};
  }
  if(type instanceof Is) return fromIs(type, options);
  if(type instanceof Never) return fromNever(type, options);

  return fromPartial(type, options);
}

function areSerializableValues(types: Array<Type<any>>): types is Array<Value<JSONValue>> {
  for(const type of types) {
    if(!(type instanceof Value)) return false;
    if(!isSerializable(type.val)) return false;
  }
  return true;
}

function isSerializable(value: any): value is JSONValue {
  try {
    JSON.parse(JSON.stringify(value));
  } catch {
    return false;
  }
  return true;
}

function fromEither(type: Either<any, any>, options: Required<Options>): JSONSchema {
  // While the Either type could theoretically just map to recursive anyOf schemas, in practice it's
  // much more readable to have it attempt to generate an enum list of all values (if it's a bunch
  // of values unioned together, AND the values are all serializable to JSON), or a flat anyOf list
  // otherwise. So, we walk the tree and collect all the types, and do that.
  const allTypes = flatTypes(Either, type);

  if(areSerializableValues(allTypes)) {
    return {
      enum: allTypes.map(t => t.val),
    };
  }

  return {
    anyOf: allTypes.map(type => typeToSchema(type, options)),
  };
}

function fromIntersect(
  type: DefaultIntersect<any, any> | MergeIntersect<any, any, any, any>,
  options: Required<Options>,
): JSONSchema {
  if(type.r instanceof Validation) {
    if(options.errorOnValidations) {
      throw `Structural type contains a validation, but errorOnValidations was set to true`;
    }
    return {
      description: type.r.desc,
      ...typeToSchema(type.l, options),
    };
  }
  return {
    allOf: flatTypes(
      type instanceof DefaultIntersect ? DefaultIntersect : MergeIntersect,
      type,
    ).map(t => typeToSchema(t, options)),
  };
}

function fromValidation(type: Validation<any>, options: Required<Options>): JSONSchema {
  if(options.errorOnValidations) {
    throw `Structural type contains a validation, but errorOnValidations was set to true`;
  }

  return {
    description: type.desc
  };
}

function fromTypeof(type: TypeOf<any>): JSONSchema {
  switch(type.typestring) {
    case "string": return { type: "string" };
    case "number": return { type: "number" };
    case "boolean": return { type: "boolean" };
    case "object": return { type: "object", properties: {} };
  }
  throw `Can't convert Structural TypeOf<${type.typestring}> checks into JSON Schema`
}

function fromValue(type: Value<any>): JSONSchema {
  if(!isSerializable(type.val)) {
    throw `Value type expects ${type.val}, but it isn't serializable to JSON`;
  }

  if(type.val === null) return { type: "null" };

  return {
    const: type.val,
  };
}

function fromArray(type: Arr<any>, options: Required<Options>): JSONSchema {
  return {
    type: 'array',
    items: typeToSchema(type.elementType, options),
  };
}

function fromStruct(type: Struct<any>, options: Required<Options>): JSONSchema {
  const properties: { [key: string]: JSONSchema } = {};
  const required: string[] = [];
  const keys = Object.keys(type.definition);
  for(const key of keys) {
    const val = type.definition[key];
    if(val instanceof MissingKey || val instanceof OptionalKey) {
      properties[key] = typeToSchema(val.type, options);
    }
    else {
      required.push(key);
      properties[key] = typeToSchema(val, options);
    }
  }

  return {
    type: 'object',
    required, properties,
  };
}

function fromDict(type: Dict<any>, options: Required<Options>): JSONSchema {
  return {
    type: 'object',
    properties: {},
    additionalProperties: typeToSchema(type.valueType, options),
  };
}

function fromIs(type: Is<any>, options: Required<Options>) {
  if(options.errorOnIs) {
    throw `Structural type contains an Is check, but errorOnIs was set to true`;
  }

  return {
    description: type.name,
  };
}

function fromNever(_: Never, options: Required<Options>): JSONSchema {
  if(options.errorOnNever) {
    throw `Structural type contains a Never check, but errorOnNever was set to true`;
  }

  return {
    allOf: [ { type: "string" }, { type: "number" } ],
  };
}

function fromPartial(type: PartialStruct<any>, options: Required<Options>): JSONSchema {
  // Since Partial<T> only makes top-level keys optional, we just modify the required array
  const schema = fromStruct(type.struct, options);
  if('required' in schema) schema.required = [];
  return schema;
}

function flatTypes(
  klass: {
    new(...args: any): Either<any, any>
                     | DefaultIntersect<any, any>
                     | MergeIntersect<any, any, any, any>
  },
  node: Type<any>
): Array<Type<any>> {
  if(node instanceof klass) {
    return flatTypes(klass, node.l).concat(flatTypes(klass, node.r));
  }
  return [ node ];
}

function formatCommentString(commentStr: string) {
  const commentLines = commentStr.split("\n").map(line => {
    return line.trim();
  }).filter(line => line !== "");
  return commentLines.join("\n");
}

function addMetadata(title: string, schema: JSONSchema): TopLevel<JSONSchema> {
  return {
    $schema: JSON_SCHEMA_VERSION,
    title,
    ...schema,
  };
}