api-contract-check
Размер (builder): 3.94 KBРазмер (checker): 0.8 KB
Эта библиотека входит в @arfa-tools/generator и строит сериализуемые схемы валидации из TypeScript-типов. Поддерживаются TS alias для разрешения импортов, а также in-memory файлы (еще не сохраненные на диск) для длинных цепочек преобразований.
Библиотека ориентирована на JSON-совместимые контракты. Неподдерживаемые типы деградируют до any, который принимает любое значение. Деградации можно логировать.
Для большинства сценариев достаточно использовать PluginValidators, который уже содержит эффективную схему использования библиотеки. Однако если генератор использовать не получится (например, у вас свой пайплайн генерации или нужна кастомная логика), можно использовать API-интерфейс напрямую.
Сгенерированные схемы валидации подходят для проверки запросов и ответов API, формирования документации, генерации типов для других языков, преобразования в схемы валидации сторонних библиотек и т.п.
Поддерживаемые типы
any,number,object,boolean,string,null, строковые, числовые и boolean literals;- type aliases, interfaces, inline-объекты, обязательные и optional-свойства;
- наследование от базовых interfaces без generic-аргументов: свойства объединяются;
- массивы (
T[],Array<T>), фиксированные tuple иRecord<string, T>; - подстановка параметров generic type aliases, например
type Box<T> = { value?: T }; - indexed access по конкретному ключу объекта (
User['id']) или элементу массива (UserList[number]); - unions и пересечения объектов с совместимыми повторяющимися свойствами;
- самоссылки объекта внутри массивов, включая union и tuple:
children: Array<Node | string>; - enum с literal-инициализаторами и числовой автонумерацией, ссылки
Enum.Member; - именованные импорты, re-export,
export *и path aliases изtsconfig.json.
Этого достаточно для описания практически всего, что может прийти из внешнего API.
Пример поддерживаемых типов и сгенерированной схемы валидации
enum TypeStatus { Draft = 1, Published, Archived = 7 }
enum TypeChannel { Ready = 'READY', Waiting = 'WAITING' }
enum TypeOwner { Owner = "O'Hara" }
type TypeId = number;
type TypePair = object & { left: string; right?: TypeId }
type TypeTuple = [string, TypeId];
type TypeScores = Record<string, TypeId>;
type TypeVariant = { kind: 'named'; title: string } | { kind: 'sized'; size: TypeId };;
type TypeTreeNode = { name: string; children: Array<TypeTreeNode> };
interface TypeBaseDetails { base: string }
interface TypeInheritedDetails extends TypeBaseDetails { own: TypeId }
type TypeDetails = {
raw: any;
tags: Array<string>;
pair: TypePair;
exact: 'leaf';
count: number;
maybe: number | null;
mixed: TypeVariant | boolean;
tuple: TypeTuple;
labels: Array<'x' | 'y'>;
choice: string | number;
record: TypeScores;
inline: { note: string; marker?: 'hot' };
active?: boolean;
enumState: TypeStatus;
inherited: TypeInheritedDetails;
recursive: TypeTreeNode;
metaObject: object;
enumMember: TypeChannel.Ready;
escapedEnum: TypeOwner.Owner;
escapedLiteral: "can't";
};
type TypeDetailsAlias = TypeDetails;
type TypeItem = {
id: TypeId;
mode: TypeChannel;
details: TypeDetailsAlias
};
type TypeCollection = {
title: string;
level4: TypeItem;
variants: Array<TypeVariant>;
groups: Array<{
name: string;
level4Items: Array<{ code: string; detail: { note: string } }>;
}>;
};
type TypeData = {
flag: boolean;
state: TypeStatus;
level3: TypeCollection;
nullable: null;
literalCombo: 'open' | 3;
};
type TypePayload = {
requestId: string;
objectHint: object;
context: {
channel: TypeChannel;
batch: Array<TypeId>;
level2: TypeData
};
};
type TypeRequest = TypePayload;
type TypeResponse = TypePayload;const positiveRequestSchema = {
requestId: 'string',
objectHint: 'object',
context: {
channel: ['union', ['literal', 'READY'], ['literal', 'WAITING']],
batch: ['array', 'number'],
level2: {
flag: 'boolean',
state: ['union', ['literal', 1], ['literal', 2], ['literal', 7]],
literalCombo: ['union', ['literal', 'open'], ['literal', 3]],
nullable: ['literal', null],
level3: {
title: 'string',
variants: [
'array',
['union', { kind: ['literal', 'named'], title: 'string' }, { kind: ['literal', 'sized'], size: 'number' }],
],
groups: [
'array',
{
name: 'string',
level4Items: ['array', { code: 'string', detail: { note: 'string' } }],
},
],
level4: {
id: 'number',
mode: ['union', ['literal', 'READY'], ['literal', 'WAITING']],
details: {
exact: ['literal', 'leaf'],
escapedLiteral: ['literal', "can't"],
count: 'number',
raw: 'any',
metaObject: 'object',
active: ['optional', 'boolean'],
enumState: ['union', ['literal', 1], ['literal', 2], ['literal', 7]],
enumMember: ['literal', 'READY'],
escapedEnum: ['literal', "O'Hara"],
maybe: ['union', 'number', ['literal', null]],
tags: ['array', 'string'],
labels: ['array', ['union', ['literal', 'x'], ['literal', 'y']]],
choice: ['union', 'string', 'number'],
mixed: [
'union',
{ kind: ['literal', 'named'], title: 'string' },
{ kind: ['literal', 'sized'], size: 'number' },
'boolean',
],
pair: { left: 'string', right: ['optional', 'number'] },
record: ['record', 'number'],
tuple: ['tuple', 'string', 'number'],
inherited: { base: 'string', own: 'number' },
inline: { note: 'string', marker: ['optional', ['literal', 'hot']] },
recursive: ['recursiveArray', { name: 'string', children: ['array', ['self']] }],
},
},
},
},
},
} satisfies TypeSchema;
const positiveSchema = {
request: positiveRequestSchema,
response: positiveRequestSchema, // same as request
} satisfies TypeRootSchema;Неподдерживаемые (деградирующие до any) типы
Promise<T>,Map<K, V>и другие неизвестные generic-контейнеры, generic interfaces иextends Base<T>;Record<K, V>, где ключ не задан непосредственно какstring;- dynamic или отсутствующие ключи indexed access, indexed access для tuple;
- mapped, conditional и function types, computed property names, вычисляемые enum-инициализаторы;
- optional- и rest-элементы tuple, несовместимые пересечения;
- прямые цикличные ссылки вне массивов, циклы aliases и рекурсивные корни, не являющиеся объектами;
- references через default/namespace imports; эти импорты учитываются только как файловые зависимости.
Деградации можно отслеживать через getDegraded. В union наличие ветки any превращает весь union в any.
Пример неподдерживаемых типов и сгенерированной схемы валидации
type UnsupportedMapped = { [K in 'a' | 'b']: number };
type UnsupportedPromise = Promise<string>;
type UnsupportedFunction = (value: string) => number;
type UnsupportedGenericBox = Set<boolean>;
type UnsupportedConditional = string extends number ? 1 : 2;
type UnsupportedVariadicTuple = [string, ...number[]];
interface UnsupportedMethodObject {
run(input: string): number;
}
interface UnsupportedIndexObject {
[key: string]: number;
}
interface UnsupportedPayload {
fn: UnsupportedFunction;
mapped: UnsupportedMapped;
promise: UnsupportedPromise;
indexed: UnsupportedIndexObject;
voidValue: void;
genericBox: UnsupportedGenericBox;
neverValue: never;
conditional: UnsupportedConditional;
symbolValue: symbol;
unknownValue: unknown;
variadicTuple: UnsupportedVariadicTuple;
undefinedValue: undefined;
methods: UnsupportedMethodObject;
}
type TypeRequest = UnsupportedPayload;
type TypeResponse = UnsupportedPromise;const fallbackSchema = {
request: {
fn: 'any',
mapped: 'any',
promise: 'any',
indexed: 'any',
voidValue: 'any',
genericBox: 'any',
neverValue: 'any',
conditional: 'any',
symbolValue: 'any',
unknownValue: 'any',
variadicTuple: 'any',
undefinedValue: 'any',
methods: { run: 'any' },
},
response: 'any',
} satisfies TypeRootSchema;