@contractkit/openapi-to-ck 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$ci.log +40 -0
- package/.turbo/turbo-build.log +20 -0
- package/.turbo/turbo-format.log +36 -0
- package/.turbo/turbo-test$colon$ci.log +77 -0
- package/.turbo/turbo-test.log +19 -0
- package/CHANGELOG.md +108 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +878 -0
- package/coverage/coverage-final.json +10 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +131 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/ast-to-ck.ts.html +1336 -0
- package/coverage/src/circular-refs.ts.html +298 -0
- package/coverage/src/convert.ts.html +535 -0
- package/coverage/src/index.html +221 -0
- package/coverage/src/normalize.ts.html +1162 -0
- package/coverage/src/paths-to-ast.ts.html +1165 -0
- package/coverage/src/schema-to-ast.ts.html +1378 -0
- package/coverage/src/tag-splitter.ts.html +685 -0
- package/coverage/src/warnings.ts.html +154 -0
- package/coverage/tests/helpers.ts.html +559 -0
- package/coverage/tests/index.html +116 -0
- package/dist/ast-to-ck.d.ts +8 -0
- package/dist/ast-to-ck.d.ts.map +1 -0
- package/dist/chunk-CQGFQKBJ.js +1681 -0
- package/dist/chunk-CQGFQKBJ.js.map +1 -0
- package/dist/chunk-FNUU2DWY.js +1779 -0
- package/dist/chunk-FNUU2DWY.js.map +1 -0
- package/dist/chunk-LD2HAFZG.js +1681 -0
- package/dist/chunk-LD2HAFZG.js.map +1 -0
- package/dist/chunk-LQ2B3EJG.js +1777 -0
- package/dist/chunk-LQ2B3EJG.js.map +1 -0
- package/dist/chunk-M6JA6WY2.js +1714 -0
- package/dist/chunk-M6JA6WY2.js.map +1 -0
- package/dist/chunk-MQTKLXTN.js +1681 -0
- package/dist/chunk-MQTKLXTN.js.map +1 -0
- package/dist/chunk-NV7RGUUS.js +1699 -0
- package/dist/chunk-NV7RGUUS.js.map +1 -0
- package/dist/chunk-REM25FDE.js +1779 -0
- package/dist/chunk-REM25FDE.js.map +1 -0
- package/dist/chunk-SNW7GJOM.js +1681 -0
- package/dist/chunk-SNW7GJOM.js.map +1 -0
- package/dist/chunk-UWOSCBRG.js +1681 -0
- package/dist/chunk-UWOSCBRG.js.map +1 -0
- package/dist/chunk-YP7FWA4H.js +1779 -0
- package/dist/chunk-YP7FWA4H.js.map +1 -0
- package/dist/circular-refs.d.ts +12 -0
- package/dist/circular-refs.d.ts.map +1 -0
- package/dist/convert.d.ts +6 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/normalize.d.ts +12 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/paths-to-ast.d.ts +22 -0
- package/dist/paths-to-ast.d.ts.map +1 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +86 -0
- package/dist/plugin.js.map +1 -0
- package/dist/schema-to-ast.d.ts +39 -0
- package/dist/schema-to-ast.d.ts.map +1 -0
- package/dist/tag-splitter.d.ts +16 -0
- package/dist/tag-splitter.d.ts.map +1 -0
- package/dist/types.d.ts +137 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/warnings.d.ts +10 -0
- package/dist/warnings.d.ts.map +1 -0
- package/eslint.config.js +6 -0
- package/package.json +46 -0
- package/src/ast-to-ck.ts +417 -0
- package/src/circular-refs.ts +71 -0
- package/src/convert.ts +150 -0
- package/src/index.ts +8 -0
- package/src/normalize.ts +359 -0
- package/src/paths-to-ast.ts +360 -0
- package/src/plugin.ts +89 -0
- package/src/schema-to-ast.ts +431 -0
- package/src/tag-splitter.ts +200 -0
- package/src/types.ts +133 -0
- package/src/warnings.ts +23 -0
- package/tests/ast-to-ck.test.ts +425 -0
- package/tests/circular-refs.test.ts +71 -0
- package/tests/convert.test.ts +330 -0
- package/tests/fixtures/petstore-3.1.json +187 -0
- package/tests/helpers.ts +158 -0
- package/tests/normalize.test.ts +114 -0
- package/tests/schema-to-ast.test.ts +341 -0
- package/tests/tag-splitter.test.ts +75 -0
- package/tsconfig.json +9 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// ─── Public API types ─────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface ConvertOptions {
|
|
4
|
+
/** File path, JSON/YAML string, or pre-parsed OpenAPI document object. */
|
|
5
|
+
input: string | Record<string, unknown>;
|
|
6
|
+
/** Output mode: single .ck file or split by OpenAPI tag. Default: 'by-tag'. */
|
|
7
|
+
split?: 'single' | 'by-tag';
|
|
8
|
+
/** Emit OpenAPI descriptions as # comments. Default: true. */
|
|
9
|
+
includeComments?: boolean;
|
|
10
|
+
/** Called for each warning during conversion. */
|
|
11
|
+
onWarning?: (warning: Warning) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ConvertResult {
|
|
15
|
+
/** Map of filename -> .ck source text. */
|
|
16
|
+
files: Map<string, string>;
|
|
17
|
+
/** All warnings collected during conversion. */
|
|
18
|
+
warnings: Warning[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Warning {
|
|
22
|
+
/** JSON pointer path into the OpenAPI spec (e.g. "#/components/schemas/Foo"). */
|
|
23
|
+
path: string;
|
|
24
|
+
/** Human-readable description of the issue. */
|
|
25
|
+
message: string;
|
|
26
|
+
/** Severity level. */
|
|
27
|
+
severity: 'info' | 'warn';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Internal types ───────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** Normalized OpenAPI 3.1 schema object (subset of fields we use). */
|
|
33
|
+
export interface NormalizedSchema {
|
|
34
|
+
type?: string | string[];
|
|
35
|
+
format?: string;
|
|
36
|
+
items?: NormalizedSchema;
|
|
37
|
+
prefixItems?: NormalizedSchema[];
|
|
38
|
+
properties?: Record<string, NormalizedSchema>;
|
|
39
|
+
additionalProperties?: boolean | NormalizedSchema;
|
|
40
|
+
required?: string[];
|
|
41
|
+
enum?: unknown[];
|
|
42
|
+
const?: unknown;
|
|
43
|
+
oneOf?: NormalizedSchema[];
|
|
44
|
+
anyOf?: NormalizedSchema[];
|
|
45
|
+
allOf?: NormalizedSchema[];
|
|
46
|
+
$ref?: string;
|
|
47
|
+
nullable?: boolean;
|
|
48
|
+
readOnly?: boolean;
|
|
49
|
+
writeOnly?: boolean;
|
|
50
|
+
deprecated?: boolean;
|
|
51
|
+
default?: unknown;
|
|
52
|
+
description?: string;
|
|
53
|
+
minLength?: number;
|
|
54
|
+
maxLength?: number;
|
|
55
|
+
pattern?: string;
|
|
56
|
+
minimum?: number;
|
|
57
|
+
maximum?: number;
|
|
58
|
+
minItems?: number;
|
|
59
|
+
maxItems?: number;
|
|
60
|
+
discriminator?: { propertyName?: string; mapping?: Record<string, string> };
|
|
61
|
+
xml?: unknown;
|
|
62
|
+
externalDocs?: unknown;
|
|
63
|
+
not?: unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Normalized OpenAPI 3.1 document (post-upgrade, post-dereference). */
|
|
67
|
+
export interface NormalizedDocument {
|
|
68
|
+
openapi: string;
|
|
69
|
+
info: { title: string; version: string; description?: string };
|
|
70
|
+
paths?: Record<string, NormalizedPathItem>;
|
|
71
|
+
components?: {
|
|
72
|
+
schemas?: Record<string, NormalizedSchema>;
|
|
73
|
+
securitySchemes?: Record<string, unknown>;
|
|
74
|
+
};
|
|
75
|
+
security?: Record<string, string[]>[];
|
|
76
|
+
servers?: { url: string; description?: string }[];
|
|
77
|
+
tags?: { name: string; description?: string }[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface NormalizedPathItem {
|
|
81
|
+
summary?: string;
|
|
82
|
+
description?: string;
|
|
83
|
+
parameters?: NormalizedParameter[];
|
|
84
|
+
get?: NormalizedOperation;
|
|
85
|
+
post?: NormalizedOperation;
|
|
86
|
+
put?: NormalizedOperation;
|
|
87
|
+
patch?: NormalizedOperation;
|
|
88
|
+
delete?: NormalizedOperation;
|
|
89
|
+
head?: NormalizedOperation;
|
|
90
|
+
options?: NormalizedOperation;
|
|
91
|
+
trace?: NormalizedOperation;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface NormalizedOperation {
|
|
95
|
+
operationId?: string;
|
|
96
|
+
summary?: string;
|
|
97
|
+
description?: string;
|
|
98
|
+
tags?: string[];
|
|
99
|
+
parameters?: NormalizedParameter[];
|
|
100
|
+
requestBody?: NormalizedRequestBody;
|
|
101
|
+
responses?: Record<string, NormalizedResponse>;
|
|
102
|
+
security?: Record<string, string[]>[];
|
|
103
|
+
deprecated?: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface NormalizedParameter {
|
|
107
|
+
name: string;
|
|
108
|
+
in: 'path' | 'query' | 'header' | 'cookie';
|
|
109
|
+
required?: boolean;
|
|
110
|
+
description?: string;
|
|
111
|
+
deprecated?: boolean;
|
|
112
|
+
schema?: NormalizedSchema;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface NormalizedRequestBody {
|
|
116
|
+
description?: string;
|
|
117
|
+
required?: boolean;
|
|
118
|
+
content?: Record<string, { schema?: NormalizedSchema }>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface NormalizedResponse {
|
|
122
|
+
description?: string;
|
|
123
|
+
content?: Record<string, { schema?: NormalizedSchema }>;
|
|
124
|
+
/** OpenAPI Header Objects keyed by header name (case-insensitive on the wire). */
|
|
125
|
+
headers?: Record<string, NormalizedHeader>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface NormalizedHeader {
|
|
129
|
+
description?: string;
|
|
130
|
+
required?: boolean;
|
|
131
|
+
deprecated?: boolean;
|
|
132
|
+
schema?: NormalizedSchema;
|
|
133
|
+
}
|
package/src/warnings.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Warning } from './types.js';
|
|
2
|
+
|
|
3
|
+
export class WarningCollector {
|
|
4
|
+
readonly warnings: Warning[] = [];
|
|
5
|
+
private onWarning?: (w: Warning) => void;
|
|
6
|
+
|
|
7
|
+
constructor(onWarning?: (w: Warning) => void) {
|
|
8
|
+
this.onWarning = onWarning;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
warn(path: string, message: string): void {
|
|
12
|
+
this.add({ path, message, severity: 'warn' });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
info(path: string, message: string): void {
|
|
16
|
+
this.add({ path, message, severity: 'info' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private add(warning: Warning): void {
|
|
20
|
+
this.warnings.push(warning);
|
|
21
|
+
this.onWarning?.(warning);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { astToCk, serializeType } from '../src/ast-to-ck.js';
|
|
3
|
+
import {
|
|
4
|
+
ckRoot,
|
|
5
|
+
model,
|
|
6
|
+
field,
|
|
7
|
+
scalarType,
|
|
8
|
+
arrayType,
|
|
9
|
+
tupleType,
|
|
10
|
+
recordType,
|
|
11
|
+
enumType,
|
|
12
|
+
literalType,
|
|
13
|
+
unionType,
|
|
14
|
+
intersectionType,
|
|
15
|
+
refType,
|
|
16
|
+
inlineObjectType,
|
|
17
|
+
lazyType,
|
|
18
|
+
opRoute,
|
|
19
|
+
opOperation,
|
|
20
|
+
opParam,
|
|
21
|
+
opRequest,
|
|
22
|
+
opResponse,
|
|
23
|
+
} from './helpers.js';
|
|
24
|
+
|
|
25
|
+
// ─── Type Serialization ───────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe('serializeType', () => {
|
|
28
|
+
it('serializes scalar types', () => {
|
|
29
|
+
expect(serializeType(scalarType('string'))).toBe('string');
|
|
30
|
+
expect(serializeType(scalarType('int'))).toBe('int');
|
|
31
|
+
expect(serializeType(scalarType('boolean'))).toBe('boolean');
|
|
32
|
+
expect(serializeType(scalarType('uuid'))).toBe('uuid');
|
|
33
|
+
expect(serializeType(scalarType('email'))).toBe('email');
|
|
34
|
+
expect(serializeType(scalarType('datetime'))).toBe('datetime');
|
|
35
|
+
expect(serializeType(scalarType('binary'))).toBe('binary');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('serializes scalar types with constraints', () => {
|
|
39
|
+
expect(serializeType(scalarType('string', { min: 1, max: 100 }))).toBe('string(min=1, max=100)');
|
|
40
|
+
expect(serializeType(scalarType('string', { len: 3 }))).toBe('string(length=3)');
|
|
41
|
+
expect(serializeType(scalarType('string', { regex: '/^[a-z]+$/' }))).toBe('string(regex=/^[a-z]+$/)');
|
|
42
|
+
expect(serializeType(scalarType('int', { min: 0 }))).toBe('int(min=0)');
|
|
43
|
+
expect(serializeType(scalarType('int', { min: 0, max: 100 }))).toBe('int(min=0, max=100)');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('serializes array types', () => {
|
|
47
|
+
expect(serializeType(arrayType(scalarType('string')))).toBe('array(string)');
|
|
48
|
+
expect(serializeType(arrayType(refType('User'), { min: 1 }))).toBe('array(User, min=1)');
|
|
49
|
+
expect(serializeType(arrayType(scalarType('int'), { min: 0, max: 10 }))).toBe('array(int, min=0, max=10)');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('serializes tuple types', () => {
|
|
53
|
+
expect(serializeType(tupleType(scalarType('string'), scalarType('int')))).toBe('tuple(string, int)');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('serializes record types', () => {
|
|
57
|
+
expect(serializeType(recordType(scalarType('string'), scalarType('int')))).toBe('record(string, int)');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('serializes enum types', () => {
|
|
61
|
+
expect(serializeType(enumType('asc', 'desc'))).toBe('enum(asc, desc)');
|
|
62
|
+
expect(serializeType(enumType('credit', 'debit'))).toBe('enum(credit, debit)');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('serializes literal types', () => {
|
|
66
|
+
expect(serializeType(literalType('hello'))).toBe('literal("hello")');
|
|
67
|
+
expect(serializeType(literalType(42))).toBe('literal(42)');
|
|
68
|
+
expect(serializeType(literalType(true))).toBe('literal(true)');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('serializes union types', () => {
|
|
72
|
+
expect(serializeType(unionType(scalarType('string'), scalarType('int')))).toBe('string | int');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('serializes intersection types', () => {
|
|
76
|
+
expect(serializeType(intersectionType(refType('A'), refType('B')))).toBe('A & B');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('serializes ref types', () => {
|
|
80
|
+
expect(serializeType(refType('User'))).toBe('User');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('serializes lazy types', () => {
|
|
84
|
+
expect(serializeType(lazyType(refType('Category')))).toBe('lazy(Category)');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('serializes inline object types', () => {
|
|
88
|
+
const type = inlineObjectType([field('name', scalarType('string')), field('age', scalarType('int'))]);
|
|
89
|
+
const result = serializeType(type);
|
|
90
|
+
expect(result).toContain('{');
|
|
91
|
+
expect(result).toContain('name: string');
|
|
92
|
+
expect(result).toContain('age: int');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ─── Model Serialization ──────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe('model serialization', () => {
|
|
99
|
+
it('serializes a simple model with fields', () => {
|
|
100
|
+
const root = ckRoot({
|
|
101
|
+
models: [
|
|
102
|
+
model('User', [
|
|
103
|
+
field('id', scalarType('uuid'), { visibility: 'readonly' }),
|
|
104
|
+
field('name', scalarType('string', { min: 3, max: 100 })),
|
|
105
|
+
field('email', scalarType('email')),
|
|
106
|
+
field('bio', scalarType('string'), { optional: true }),
|
|
107
|
+
]),
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
const result = astToCk(root);
|
|
111
|
+
expect(result).toContain('contract User: {');
|
|
112
|
+
expect(result).toContain(' id: readonly uuid');
|
|
113
|
+
expect(result).toContain(' name: string(min=3, max=100)');
|
|
114
|
+
expect(result).toContain(' email: email');
|
|
115
|
+
expect(result).toContain(' bio?: string');
|
|
116
|
+
expect(result).toContain('}');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('serializes model with descriptions as comments', () => {
|
|
120
|
+
const root = ckRoot({
|
|
121
|
+
models: [model('User', [field('id', scalarType('uuid'), { description: 'The user ID' })], { description: 'A user' })],
|
|
122
|
+
});
|
|
123
|
+
const result = astToCk(root);
|
|
124
|
+
expect(result).toContain('contract User: { # A user');
|
|
125
|
+
expect(result).toContain(' id: uuid # The user ID');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('omits comments when includeComments is false', () => {
|
|
129
|
+
const root = ckRoot({
|
|
130
|
+
models: [model('User', [field('id', scalarType('uuid'), { description: 'The user ID' })], { description: 'A user' })],
|
|
131
|
+
});
|
|
132
|
+
const result = astToCk(root, { includeComments: false });
|
|
133
|
+
expect(result).not.toContain('#');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('serializes model with default values', () => {
|
|
137
|
+
const root = ckRoot({
|
|
138
|
+
models: [
|
|
139
|
+
model('Pagination', [
|
|
140
|
+
field('page', scalarType('int', { min: 0 }), { default: 0 }),
|
|
141
|
+
field('pageSize', scalarType('int', { min: 1, max: 100 }), { default: 25 }),
|
|
142
|
+
field('sort', enumType('asc', 'desc'), { default: 'desc' }),
|
|
143
|
+
]),
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
const result = astToCk(root);
|
|
147
|
+
expect(result).toContain(' page: int(min=0) = 0');
|
|
148
|
+
expect(result).toContain(' pageSize: int(min=1, max=100) = 25');
|
|
149
|
+
expect(result).toContain(' sort: enum(asc, desc) = desc');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('serializes model with inheritance', () => {
|
|
153
|
+
const root = ckRoot({
|
|
154
|
+
models: [model('Admin', [field('role', enumType('admin', 'superadmin'))], { bases: ['User'] })],
|
|
155
|
+
});
|
|
156
|
+
const result = astToCk(root);
|
|
157
|
+
expect(result).toContain('contract Admin: User & {');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('serializes model type alias', () => {
|
|
161
|
+
const root = ckRoot({
|
|
162
|
+
models: [model('UserId', [], { type: scalarType('uuid') })],
|
|
163
|
+
});
|
|
164
|
+
const result = astToCk(root);
|
|
165
|
+
expect(result).toContain('contract UserId: uuid');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('serializes model with mode and inputCase modifiers', () => {
|
|
169
|
+
const root = ckRoot({
|
|
170
|
+
models: [model('WebhookData', [field('event', scalarType('string'))], { mode: 'loose', inputCase: 'snake' })],
|
|
171
|
+
});
|
|
172
|
+
const result = astToCk(root);
|
|
173
|
+
expect(result).toContain('contract format(input=snake) mode(loose) WebhookData: {');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('serializes nullable fields', () => {
|
|
177
|
+
const root = ckRoot({
|
|
178
|
+
models: [model('Item', [field('parentId', scalarType('uuid'), { nullable: true, optional: true })])],
|
|
179
|
+
});
|
|
180
|
+
const result = astToCk(root);
|
|
181
|
+
expect(result).toContain(' parentId?: uuid | null');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('serializes deprecated models', () => {
|
|
185
|
+
const root = ckRoot({
|
|
186
|
+
models: [model('OldUser', [field('id', scalarType('uuid'))], { deprecated: true })],
|
|
187
|
+
});
|
|
188
|
+
const result = astToCk(root);
|
|
189
|
+
expect(result).toContain('contract deprecated OldUser: {');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ─── Options Block ────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
describe('options block', () => {
|
|
196
|
+
it('serializes options with keys', () => {
|
|
197
|
+
const root = ckRoot({ meta: { area: 'ledger' } });
|
|
198
|
+
const result = astToCk(root);
|
|
199
|
+
expect(result).toContain('options {');
|
|
200
|
+
expect(result).toContain(' keys: {');
|
|
201
|
+
expect(result).toContain(' area: ledger');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('serializes options with services', () => {
|
|
205
|
+
const root = ckRoot({
|
|
206
|
+
services: { LedgerService: '#src/modules/ledger/ledger.service.js' },
|
|
207
|
+
});
|
|
208
|
+
const result = astToCk(root);
|
|
209
|
+
expect(result).toContain(' services: {');
|
|
210
|
+
expect(result).toContain(' LedgerService: "#src/modules/ledger/ledger.service.js"');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('omits options block when empty', () => {
|
|
214
|
+
const root = ckRoot();
|
|
215
|
+
const result = astToCk(root);
|
|
216
|
+
expect(result).not.toContain('options');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ─── Route Serialization ──────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
describe('route serialization', () => {
|
|
223
|
+
it('serializes a simple GET route', () => {
|
|
224
|
+
const root = ckRoot({
|
|
225
|
+
routes: [
|
|
226
|
+
opRoute('/users', [
|
|
227
|
+
opOperation('get', {
|
|
228
|
+
responses: [opResponse(200, refType('User'), 'application/json')],
|
|
229
|
+
}),
|
|
230
|
+
]),
|
|
231
|
+
],
|
|
232
|
+
});
|
|
233
|
+
const result = astToCk(root);
|
|
234
|
+
expect(result).toContain('operation /users: {');
|
|
235
|
+
expect(result).toContain(' get: {');
|
|
236
|
+
expect(result).toContain(' response: {');
|
|
237
|
+
expect(result).toContain(' 200: {');
|
|
238
|
+
expect(result).toContain(' application/json: User');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('serializes route with params', () => {
|
|
242
|
+
const root = ckRoot({
|
|
243
|
+
routes: [
|
|
244
|
+
opRoute(
|
|
245
|
+
'/users/{id}',
|
|
246
|
+
[
|
|
247
|
+
opOperation('get', {
|
|
248
|
+
responses: [opResponse(200, refType('User'), 'application/json')],
|
|
249
|
+
}),
|
|
250
|
+
],
|
|
251
|
+
{
|
|
252
|
+
params: [opParam('id', scalarType('uuid'))],
|
|
253
|
+
},
|
|
254
|
+
),
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
const result = astToCk(root);
|
|
258
|
+
expect(result).toContain(' params: {');
|
|
259
|
+
expect(result).toContain(' id: uuid');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('serializes route with query as type ref', () => {
|
|
263
|
+
const root = ckRoot({
|
|
264
|
+
routes: [
|
|
265
|
+
opRoute('/users', [
|
|
266
|
+
opOperation('get', {
|
|
267
|
+
query: 'Pagination',
|
|
268
|
+
responses: [opResponse(200, arrayType(refType('User')), 'application/json')],
|
|
269
|
+
}),
|
|
270
|
+
]),
|
|
271
|
+
],
|
|
272
|
+
});
|
|
273
|
+
const result = astToCk(root);
|
|
274
|
+
expect(result).toContain(' query: Pagination');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('serializes request body', () => {
|
|
278
|
+
const root = ckRoot({
|
|
279
|
+
routes: [
|
|
280
|
+
opRoute('/users', [
|
|
281
|
+
opOperation('post', {
|
|
282
|
+
request: opRequest(refType('CreateUserInput')),
|
|
283
|
+
responses: [opResponse(201, refType('User'), 'application/json')],
|
|
284
|
+
}),
|
|
285
|
+
]),
|
|
286
|
+
],
|
|
287
|
+
});
|
|
288
|
+
const result = astToCk(root);
|
|
289
|
+
expect(result).toContain(' request: {');
|
|
290
|
+
expect(result).toContain(' application/json: CreateUserInput');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('serializes empty response (204)', () => {
|
|
294
|
+
const root = ckRoot({
|
|
295
|
+
routes: [
|
|
296
|
+
opRoute('/users/{id}', [
|
|
297
|
+
opOperation('delete', {
|
|
298
|
+
responses: [opResponse(204)],
|
|
299
|
+
}),
|
|
300
|
+
]),
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
const result = astToCk(root);
|
|
304
|
+
expect(result).toContain(' 204:');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('serializes route with internal modifier', () => {
|
|
308
|
+
const root = ckRoot({
|
|
309
|
+
routes: [opRoute('/webhooks/stripe', [opOperation('post', { responses: [opResponse(204)] })], { modifiers: ['internal'] })],
|
|
310
|
+
});
|
|
311
|
+
const result = astToCk(root);
|
|
312
|
+
expect(result).toContain('operation(internal) /webhooks/stripe: {');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('serializes security: none', () => {
|
|
316
|
+
const root = ckRoot({
|
|
317
|
+
routes: [
|
|
318
|
+
opRoute('/public', [
|
|
319
|
+
opOperation('get', {
|
|
320
|
+
security: 'none',
|
|
321
|
+
responses: [opResponse(200, refType('Data'), 'application/json')],
|
|
322
|
+
}),
|
|
323
|
+
]),
|
|
324
|
+
],
|
|
325
|
+
});
|
|
326
|
+
const result = astToCk(root);
|
|
327
|
+
expect(result).toContain(' security: none');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('serializes security with roles', () => {
|
|
331
|
+
const root = ckRoot({
|
|
332
|
+
routes: [
|
|
333
|
+
opRoute('/admin', [
|
|
334
|
+
opOperation('get', {
|
|
335
|
+
security: { roles: ['admin', 'superadmin'], loc: { file: 'test.ck', line: 1 } },
|
|
336
|
+
responses: [opResponse(200, refType('Data'), 'application/json')],
|
|
337
|
+
}),
|
|
338
|
+
]),
|
|
339
|
+
],
|
|
340
|
+
});
|
|
341
|
+
const result = astToCk(root);
|
|
342
|
+
expect(result).toContain(' security: {');
|
|
343
|
+
expect(result).toContain(' roles: [admin, superadmin]');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('serializes service and sdk fields', () => {
|
|
347
|
+
const root = ckRoot({
|
|
348
|
+
routes: [
|
|
349
|
+
opRoute('/users', [
|
|
350
|
+
opOperation('get', {
|
|
351
|
+
service: 'UserService.listUsers',
|
|
352
|
+
sdk: 'listUsers',
|
|
353
|
+
responses: [opResponse(200, arrayType(refType('User')), 'application/json')],
|
|
354
|
+
}),
|
|
355
|
+
]),
|
|
356
|
+
],
|
|
357
|
+
});
|
|
358
|
+
const result = astToCk(root);
|
|
359
|
+
expect(result).toContain(' service: UserService.listUsers');
|
|
360
|
+
expect(result).toContain(' sdk: listUsers');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('serializes multiple operations on one route', () => {
|
|
364
|
+
const root = ckRoot({
|
|
365
|
+
routes: [
|
|
366
|
+
opRoute('/users', [
|
|
367
|
+
opOperation('get', {
|
|
368
|
+
responses: [opResponse(200, arrayType(refType('User')), 'application/json')],
|
|
369
|
+
}),
|
|
370
|
+
opOperation('post', {
|
|
371
|
+
request: opRequest(refType('CreateUserInput')),
|
|
372
|
+
responses: [opResponse(201, refType('User'), 'application/json')],
|
|
373
|
+
}),
|
|
374
|
+
]),
|
|
375
|
+
],
|
|
376
|
+
});
|
|
377
|
+
const result = astToCk(root);
|
|
378
|
+
expect(result).toContain(' get: {');
|
|
379
|
+
expect(result).toContain(' post: {');
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ─── Full Document ────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
describe('full document', () => {
|
|
386
|
+
it('serializes a complete document with options, models, and routes', () => {
|
|
387
|
+
const root = ckRoot({
|
|
388
|
+
meta: { area: 'ledger' },
|
|
389
|
+
services: { LedgerService: '#src/modules/ledger/ledger.service.js' },
|
|
390
|
+
models: [
|
|
391
|
+
model(
|
|
392
|
+
'LedgerAccount',
|
|
393
|
+
[
|
|
394
|
+
field('id', scalarType('uuid'), { visibility: 'readonly', description: 'The account ID' }),
|
|
395
|
+
field('name', scalarType('string', { min: 3, max: 100 })),
|
|
396
|
+
],
|
|
397
|
+
{ description: 'A ledger account' },
|
|
398
|
+
),
|
|
399
|
+
],
|
|
400
|
+
routes: [
|
|
401
|
+
opRoute('/ledger/accounts', [
|
|
402
|
+
opOperation('get', {
|
|
403
|
+
service: 'LedgerService.listAccounts',
|
|
404
|
+
responses: [opResponse(200, arrayType(refType('LedgerAccount')), 'application/json')],
|
|
405
|
+
}),
|
|
406
|
+
]),
|
|
407
|
+
],
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const result = astToCk(root);
|
|
411
|
+
|
|
412
|
+
// Options
|
|
413
|
+
expect(result).toContain('options {');
|
|
414
|
+
expect(result).toContain('area: ledger');
|
|
415
|
+
expect(result).toContain('LedgerService: "#src/modules/ledger/ledger.service.js"');
|
|
416
|
+
|
|
417
|
+
// Model
|
|
418
|
+
expect(result).toContain('contract LedgerAccount: { # A ledger account');
|
|
419
|
+
expect(result).toContain(' id: readonly uuid # The account ID');
|
|
420
|
+
|
|
421
|
+
// Route
|
|
422
|
+
expect(result).toContain('operation /ledger/accounts: {');
|
|
423
|
+
expect(result).toContain(' service: LedgerService.listAccounts');
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { detectCircularRefs, extractRefName } from '../src/circular-refs.js';
|
|
3
|
+
|
|
4
|
+
describe('detectCircularRefs', () => {
|
|
5
|
+
it('detects no cycles in acyclic schemas', () => {
|
|
6
|
+
const schemas = {
|
|
7
|
+
User: { type: 'object', properties: { address: { $ref: '#/components/schemas/Address' } } },
|
|
8
|
+
Address: { type: 'object', properties: { city: { type: 'string' } } },
|
|
9
|
+
};
|
|
10
|
+
const result = detectCircularRefs(schemas);
|
|
11
|
+
expect(result.size).toBe(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('detects direct self-reference', () => {
|
|
15
|
+
const schemas = {
|
|
16
|
+
TreeNode: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
children: { type: 'array', items: { $ref: '#/components/schemas/TreeNode' } },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
const result = detectCircularRefs(schemas);
|
|
24
|
+
expect(result.has('TreeNode')).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('detects indirect cycle (A → B → A)', () => {
|
|
28
|
+
const schemas = {
|
|
29
|
+
A: { type: 'object', properties: { b: { $ref: '#/components/schemas/B' } } },
|
|
30
|
+
B: { type: 'object', properties: { a: { $ref: '#/components/schemas/A' } } },
|
|
31
|
+
};
|
|
32
|
+
const result = detectCircularRefs(schemas);
|
|
33
|
+
expect(result.has('A')).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('detects cycles in allOf', () => {
|
|
37
|
+
const schemas = {
|
|
38
|
+
Parent: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: { child: { $ref: '#/components/schemas/Child' } },
|
|
41
|
+
},
|
|
42
|
+
Child: {
|
|
43
|
+
allOf: [{ $ref: '#/components/schemas/Parent' }, { type: 'object', properties: { extra: { type: 'string' } } }],
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const result = detectCircularRefs(schemas);
|
|
47
|
+
expect(result.size).toBeGreaterThan(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('handles schemas with no refs', () => {
|
|
51
|
+
const schemas = {
|
|
52
|
+
Simple: { type: 'string' },
|
|
53
|
+
};
|
|
54
|
+
const result = detectCircularRefs(schemas);
|
|
55
|
+
expect(result.size).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('extractRefName', () => {
|
|
60
|
+
it('extracts from components/schemas ref', () => {
|
|
61
|
+
expect(extractRefName('#/components/schemas/User')).toBe('User');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('extracts from definitions ref', () => {
|
|
65
|
+
expect(extractRefName('#/definitions/User')).toBe('User');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns undefined for other refs', () => {
|
|
69
|
+
expect(extractRefName('#/components/parameters/id')).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
});
|