@codexsploitx/schemaapi 1.0.0 → 1.0.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.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/docs/adapters/deno.md +0 -51
  3. package/docs/adapters/express.md +0 -67
  4. package/docs/adapters/fastify.md +0 -64
  5. package/docs/adapters/hapi.md +0 -67
  6. package/docs/adapters/koa.md +0 -61
  7. package/docs/adapters/nest.md +0 -66
  8. package/docs/adapters/next.md +0 -66
  9. package/docs/adapters/remix.md +0 -72
  10. package/docs/cli.md +0 -18
  11. package/docs/consepts.md +0 -18
  12. package/docs/getting_started.md +0 -149
  13. package/docs/sdk.md +0 -25
  14. package/docs/validation.md +0 -228
  15. package/docs/versioning.md +0 -28
  16. package/eslint.config.mjs +0 -34
  17. package/rollup.config.js +0 -19
  18. package/src/adapters/deno.ts +0 -139
  19. package/src/adapters/express.ts +0 -134
  20. package/src/adapters/fastify.ts +0 -133
  21. package/src/adapters/hapi.ts +0 -140
  22. package/src/adapters/index.ts +0 -9
  23. package/src/adapters/koa.ts +0 -128
  24. package/src/adapters/nest.ts +0 -122
  25. package/src/adapters/next.ts +0 -175
  26. package/src/adapters/remix.ts +0 -145
  27. package/src/adapters/ws.ts +0 -132
  28. package/src/core/client.ts +0 -104
  29. package/src/core/contract.ts +0 -534
  30. package/src/core/versioning.test.ts +0 -174
  31. package/src/docs.ts +0 -535
  32. package/src/index.ts +0 -5
  33. package/src/playground.test.ts +0 -98
  34. package/src/playground.ts +0 -13
  35. package/src/sdk.ts +0 -17
  36. package/tests/adapters.deno.test.ts +0 -70
  37. package/tests/adapters.express.test.ts +0 -67
  38. package/tests/adapters.fastify.test.ts +0 -63
  39. package/tests/adapters.hapi.test.ts +0 -66
  40. package/tests/adapters.koa.test.ts +0 -58
  41. package/tests/adapters.nest.test.ts +0 -85
  42. package/tests/adapters.next.test.ts +0 -39
  43. package/tests/adapters.remix.test.ts +0 -52
  44. package/tests/adapters.ws.test.ts +0 -91
  45. package/tests/cli.test.ts +0 -156
  46. package/tests/client.test.ts +0 -110
  47. package/tests/contract.handle.test.ts +0 -267
  48. package/tests/docs.test.ts +0 -96
  49. package/tests/sdk.test.ts +0 -34
  50. package/tsconfig.json +0 -15
@@ -1,534 +0,0 @@
1
- import { z, ZodObject, ZodTypeAny } from "zod";
2
-
3
- export class SchemaApiError extends Error {
4
- constructor(
5
- public code: number,
6
- message: string
7
- ) {
8
- super(message);
9
- this.name = "SchemaApiError";
10
- }
11
- }
12
-
13
- type RequestContext = {
14
- params?: unknown;
15
- query?: unknown;
16
- body?: unknown;
17
- headers?: unknown;
18
- user?: { role?: string };
19
- [key: string]: unknown;
20
- };
21
-
22
- type MediaConfig = {
23
- kind?: "upload" | "download";
24
- contentTypes?: string[];
25
- maxSize?: number;
26
- };
27
-
28
- export type NormalizedErrorPayload = {
29
- error: string;
30
- message: string;
31
- status: number;
32
- };
33
-
34
- export function buildErrorPayload(error: unknown): NormalizedErrorPayload {
35
- if (error instanceof SchemaApiError) {
36
- return {
37
- error: error.message,
38
- message: error.message,
39
- status: error.code,
40
- };
41
- }
42
- const typedError = error as { message?: string; status?: number; code?: number };
43
- const status =
44
- typeof typedError.status === "number"
45
- ? typedError.status
46
- : typeof typedError.code === "number"
47
- ? typedError.code
48
- : 500;
49
- const message =
50
- typeof typedError.message === "string"
51
- ? typedError.message
52
- : "Internal Server Error";
53
-
54
- return {
55
- error: message,
56
- message,
57
- status,
58
- };
59
- }
60
-
61
- type MethodDefinition = {
62
- params?: ZodTypeAny;
63
- query?: ZodTypeAny;
64
- body?: ZodTypeAny;
65
- headers?: ZodTypeAny;
66
- roles?: string[];
67
- response?: ZodTypeAny;
68
- clientMessages?: ZodTypeAny;
69
- serverMessages?: ZodTypeAny;
70
- media?: MediaConfig;
71
- errors?: Record<string, unknown>;
72
- [key: string]: unknown;
73
- };
74
-
75
- type RouteDefinition = Record<string, MethodDefinition>;
76
-
77
- type ContractDefinition = Record<string, RouteDefinition>;
78
-
79
- type CompareResult = {
80
- breakingChanges: string[];
81
- };
82
-
83
- export type FieldDoc = {
84
- name: string;
85
- type?: string;
86
- optional: boolean;
87
- };
88
-
89
- export type MethodDoc = {
90
- method: string;
91
- path: string;
92
- params?: FieldDoc[];
93
- query?: FieldDoc[];
94
- body?: FieldDoc[];
95
- headers?: FieldDoc[];
96
- roles?: string[];
97
- errors?: { status: string; code: string }[];
98
- media?: MediaConfig;
99
- };
100
-
101
- export type ContractDocs = {
102
- routes: MethodDoc[];
103
- };
104
-
105
- function extractRouteParams(route: string): string[] {
106
- return route
107
- .split("/")
108
- .filter((segment) => segment.startsWith(":"))
109
- .map((segment) => segment.slice(1))
110
- .filter((name) => name.length > 0);
111
- }
112
-
113
- function getBaseSchema(schema: ZodTypeAny): ZodTypeAny {
114
- let current: ZodTypeAny = schema;
115
- while (
116
- (current as { _def?: { innerType?: ZodTypeAny } })._def &&
117
- (current as { _def?: { innerType?: ZodTypeAny } })._def?.innerType
118
- ) {
119
- const def = (current as unknown as { _def: { innerType: ZodTypeAny } })._def;
120
- current = def.innerType;
121
- }
122
- return current;
123
- }
124
-
125
- function getObjectShape(schema: unknown): Record<string, ZodTypeAny> | null {
126
- if (!schema) {
127
- return null;
128
- }
129
- const base = getBaseSchema(schema as ZodTypeAny);
130
- if (base instanceof ZodObject) {
131
- const objectSchema = base as ZodObject<Record<string, ZodTypeAny>>;
132
- return objectSchema.shape;
133
- }
134
- return null;
135
- }
136
-
137
- function getTypeName(schema: ZodTypeAny): string | undefined {
138
- const base = getBaseSchema(schema);
139
- const def = base._def as unknown;
140
- if (!def || typeof def !== "object") {
141
- return undefined;
142
- }
143
- const record = def as Record<string, unknown>;
144
- const value = record["typeName"] || record["type"];
145
- if (typeof value === "string") {
146
- return value;
147
- }
148
- return undefined;
149
- }
150
-
151
- function isOptional(schema: ZodTypeAny): boolean {
152
- return schema.isOptional();
153
- }
154
-
155
- export function createContract<T extends ContractDefinition>(schema: T) {
156
- const api = {
157
- handle(endpoint: string, handler: (ctx: RequestContext) => unknown | Promise<unknown>) {
158
- const [method, route] = endpoint.split(" ");
159
- const routeSchema = (schema as ContractDefinition)[route];
160
-
161
- if (!routeSchema) {
162
- throw new SchemaApiError(404, `Route ${route} not defined in contract`);
163
- }
164
-
165
- const methodSchema = routeSchema[method];
166
-
167
- if (!methodSchema) {
168
- throw new SchemaApiError(
169
- 405,
170
- `Method ${method} not defined for route ${route}`
171
- );
172
- }
173
-
174
- const dynamicParams = extractRouteParams(route);
175
- if (dynamicParams.length > 0) {
176
- const paramsShape = getObjectShape(methodSchema.params);
177
- if (!paramsShape) {
178
- throw new Error(
179
- `Route ${route} has dynamic params but no params schema is defined`
180
- );
181
- }
182
- dynamicParams.forEach((name) => {
183
- if (!(name in paramsShape)) {
184
- throw new Error(
185
- `Dynamic path param :${name} in ${route} is not defined in params`
186
- );
187
- }
188
- });
189
- }
190
-
191
- return async (ctx: RequestContext) => {
192
- const context = ctx;
193
-
194
- const media = (methodSchema as { media?: MediaConfig }).media;
195
- if (media && media.kind === "upload") {
196
- const rawHeaders = (context.headers ??
197
- {}) as Record<string, unknown>;
198
- const headers: Record<string, string> = {};
199
- Object.keys(rawHeaders).forEach((key) => {
200
- const value = rawHeaders[key];
201
- if (typeof value === "string") {
202
- headers[key.toLowerCase()] = value;
203
- }
204
- });
205
-
206
- const contentTypeHeader = headers["content-type"];
207
- if (media.contentTypes && media.contentTypes.length > 0) {
208
- const matches =
209
- !!contentTypeHeader &&
210
- media.contentTypes.some((expected) => {
211
- if (expected.endsWith("/*")) {
212
- const prefix = expected.slice(0, -1);
213
- return contentTypeHeader.startsWith(prefix);
214
- }
215
- return (
216
- contentTypeHeader === expected ||
217
- contentTypeHeader.startsWith(`${expected};`)
218
- );
219
- });
220
- if (!matches) {
221
- throw new SchemaApiError(415, "Unsupported Media Type");
222
- }
223
- }
224
-
225
- if (media.maxSize && media.maxSize > 0) {
226
- const lengthHeader = headers["content-length"];
227
- if (lengthHeader !== undefined) {
228
- const parsed = Number(lengthHeader);
229
- if (!Number.isNaN(parsed) && parsed > media.maxSize) {
230
- throw new SchemaApiError(413, "Payload Too Large");
231
- }
232
- }
233
- }
234
- }
235
-
236
- if (methodSchema.roles && methodSchema.roles.length > 0) {
237
- const userRole = context.user?.role;
238
- if (!userRole || !methodSchema.roles.includes(userRole)) {
239
- throw new SchemaApiError(403, "Forbidden");
240
- }
241
- }
242
-
243
- try {
244
- if (methodSchema.params && context.params !== undefined) {
245
- context.params = methodSchema.params.parse(context.params);
246
- }
247
- if (methodSchema.query && context.query !== undefined) {
248
- context.query = methodSchema.query.parse(context.query);
249
- }
250
- if (methodSchema.body && context.body !== undefined) {
251
- context.body = methodSchema.body.parse(context.body);
252
- }
253
- if (methodSchema.headers && context.headers !== undefined) {
254
- context.headers = methodSchema.headers.parse(context.headers);
255
- }
256
- } catch (error) {
257
- if (error instanceof z.ZodError) {
258
- throw new SchemaApiError(
259
- 400,
260
- `Validation Error: ${JSON.stringify(error.flatten())}`
261
- );
262
- }
263
- throw error;
264
- }
265
-
266
- let result: unknown;
267
-
268
- try {
269
- result = await handler(context);
270
- } catch (error) {
271
- if (error instanceof SchemaApiError) {
272
- const code = error.code;
273
- const errorsConfig = (methodSchema.errors ??
274
- {}) as Record<string, unknown>;
275
- const allowedStatuses = Object.keys(errorsConfig);
276
-
277
- if (code >= 400 && code !== 400 && code !== 500) {
278
- if (!allowedStatuses.includes(String(code))) {
279
- throw new SchemaApiError(
280
- code,
281
- `${code} is not defined in contract.errors`
282
- );
283
- }
284
- }
285
-
286
- throw error;
287
- }
288
-
289
- throw error;
290
- }
291
-
292
- if (methodSchema.response) {
293
- try {
294
- return methodSchema.response.parse(result);
295
- } catch (error) {
296
- if (error instanceof z.ZodError) {
297
- throw new SchemaApiError(
298
- 500,
299
- `Response Validation Error: ${JSON.stringify(error.flatten())}`
300
- );
301
- }
302
- throw error;
303
- }
304
- }
305
-
306
- return result;
307
- };
308
- },
309
- compareWith(other: { schema: ContractDefinition }): CompareResult {
310
- const breaking: string[] = [];
311
- const oldSchema = other.schema as ContractDefinition;
312
- const newSchema = schema as ContractDefinition;
313
-
314
- Object.keys(oldSchema).forEach((route) => {
315
- if (!(route in newSchema)) {
316
- breaking.push(`Route removed: ${route}`);
317
- return;
318
- }
319
-
320
- const oldRoute = oldSchema[route];
321
- const newRoute = newSchema[route];
322
-
323
- Object.keys(oldRoute).forEach((method) => {
324
- const oldMethod = oldRoute[method];
325
- const newMethod = newRoute[method];
326
- const pathId = `${method} ${route}`;
327
-
328
- if (!newMethod) {
329
- breaking.push(`Method removed: ${pathId}`);
330
- return;
331
- }
332
-
333
- // Check Response (Output)
334
- const oldResponseShape = getObjectShape(oldMethod.response);
335
- const newResponseShape = getObjectShape(newMethod.response);
336
-
337
- if (oldResponseShape && newResponseShape) {
338
- Object.keys(oldResponseShape).forEach((field) => {
339
- if (!(field in newResponseShape)) {
340
- breaking.push(`Response field removed: ${field} in ${pathId}`);
341
- return;
342
- }
343
-
344
- const oldField = oldResponseShape[field];
345
- const newField = newResponseShape[field];
346
-
347
- const oldType = getTypeName(oldField);
348
- const newType = getTypeName(newField);
349
-
350
- if (oldType && newType && oldType !== newType) {
351
- breaking.push(
352
- `Response field type changed: ${field} in ${pathId}`
353
- );
354
- }
355
- });
356
- }
357
-
358
- // Check Request Body (Input)
359
- const oldBodyShape = getObjectShape(oldMethod.body);
360
- const newBodyShape = getObjectShape(newMethod.body);
361
-
362
- if (newBodyShape) {
363
- Object.keys(newBodyShape).forEach((field) => {
364
- const newField = newBodyShape[field];
365
- const oldField = oldBodyShape ? oldBodyShape[field] : undefined;
366
-
367
- if (!oldField) {
368
- // New field added. Check if required.
369
- if (!newField.isOptional()) {
370
- breaking.push(`New required request body field: ${field} in ${pathId}`);
371
- }
372
- } else {
373
- // Field exists in both. Check type change.
374
- const oldType = getTypeName(oldField);
375
- const newType = getTypeName(newField);
376
- if (oldType && newType && oldType !== newType) {
377
- breaking.push(`Request body field type changed: ${field} in ${pathId}`);
378
- }
379
-
380
- // Check if became required (was optional)
381
- if (oldField.isOptional() && !newField.isOptional()) {
382
- breaking.push(`Request body field became required: ${field} in ${pathId}`);
383
- }
384
- }
385
- });
386
- }
387
-
388
- // Check Query Params (Input)
389
- const oldQueryShape = getObjectShape(oldMethod.query);
390
- const newQueryShape = getObjectShape(newMethod.query);
391
-
392
- if (newQueryShape) {
393
- Object.keys(newQueryShape).forEach((field) => {
394
- const newField = newQueryShape[field];
395
- const oldField = oldQueryShape ? oldQueryShape[field] : undefined;
396
-
397
- if (!oldField) {
398
- if (!newField.isOptional()) {
399
- breaking.push(`New required query param: ${field} in ${pathId}`);
400
- }
401
- } else {
402
- const oldType = getTypeName(oldField);
403
- const newType = getTypeName(newField);
404
- if (oldType && newType && oldType !== newType) {
405
- breaking.push(`Query param type changed: ${field} in ${pathId}`);
406
- }
407
- }
408
- });
409
- }
410
-
411
- // Check Path Params (Input)
412
- const oldParamsShape = getObjectShape(oldMethod.params);
413
- const newParamsShape = getObjectShape(newMethod.params);
414
-
415
- if (newParamsShape) {
416
- Object.keys(newParamsShape).forEach((field) => {
417
- const newField = newParamsShape[field];
418
- const oldField = oldParamsShape ? oldParamsShape[field] : undefined;
419
-
420
- if (oldField) {
421
- const oldType = getTypeName(oldField);
422
- const newType = getTypeName(newField);
423
- if (oldType && newType && oldType !== newType) {
424
- breaking.push(`Path param type changed: ${field} in ${pathId}`);
425
- }
426
- }
427
- });
428
- }
429
-
430
- const oldErrors = (oldMethod.errors ?? {}) as Record<
431
- string,
432
- unknown
433
- >;
434
- const newErrors = (newMethod.errors ?? {}) as Record<
435
- string,
436
- unknown
437
- >;
438
-
439
- Object.keys(oldErrors).forEach((status) => {
440
- if (!(status in newErrors)) {
441
- breaking.push(`Status code removed: ${status} in ${pathId}`);
442
- } else {
443
- const oldError = oldErrors[status];
444
- const newError = newErrors[status];
445
- if (oldError !== newError) {
446
- breaking.push(
447
- `Error code changed for status ${status} in ${pathId}`
448
- );
449
- }
450
- }
451
- });
452
-
453
- const oldRoles = (oldMethod.roles ?? []) as string[];
454
- const newRoles = (newMethod.roles ?? []) as string[];
455
- oldRoles.forEach((role) => {
456
- if (!newRoles.includes(role)) {
457
- breaking.push(`Role removed: ${role} in ${pathId}`);
458
- }
459
- });
460
- });
461
- });
462
-
463
- return { breakingChanges: breaking };
464
- },
465
- docs(): ContractDocs {
466
- const routesDocs: MethodDoc[] = [];
467
- const current = schema as ContractDefinition;
468
-
469
- Object.keys(current).forEach((route) => {
470
- const routeDef = current[route];
471
- Object.keys(routeDef).forEach((method) => {
472
- const methodDef = routeDef[method];
473
-
474
- const doc: MethodDoc = {
475
- method,
476
- path: route,
477
- roles: methodDef.roles ? [...methodDef.roles] : undefined,
478
- media: methodDef.media,
479
- errors: methodDef.errors
480
- ? Object.keys(methodDef.errors).map((status) => ({
481
- status,
482
- code: String(methodDef.errors?.[status]),
483
- }))
484
- : undefined,
485
- };
486
-
487
- const fillFields = (
488
- source: ZodTypeAny | undefined,
489
- assign: (fields: FieldDoc[]) => void
490
- ) => {
491
- if (!source) {
492
- return;
493
- }
494
- const shape = getObjectShape(source);
495
- if (!shape) {
496
- return;
497
- }
498
- const fields: FieldDoc[] = Object.keys(shape).map((name) => {
499
- const fieldSchema = shape[name];
500
- const typeName = getTypeName(fieldSchema);
501
- const optional = isOptional(fieldSchema);
502
- return {
503
- name,
504
- type: typeName,
505
- optional,
506
- };
507
- });
508
- assign(fields);
509
- };
510
-
511
- fillFields(methodDef.params, (fields) => {
512
- doc.params = fields;
513
- });
514
- fillFields(methodDef.query, (fields) => {
515
- doc.query = fields;
516
- });
517
- fillFields(methodDef.body, (fields) => {
518
- doc.body = fields;
519
- });
520
- fillFields(methodDef.headers, (fields) => {
521
- doc.headers = fields;
522
- });
523
-
524
- routesDocs.push(doc);
525
- });
526
- });
527
-
528
- return { routes: routesDocs };
529
- },
530
- schema,
531
- };
532
-
533
- return api;
534
- }
@@ -1,174 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { z } from "zod";
3
- import { createContract } from "./contract";
4
-
5
- describe("Versioning - compareWith", () => {
6
- it("should detect removed routes", () => {
7
- const v1 = createContract({
8
- "/users": {
9
- GET: { response: z.object({ id: z.string() }) },
10
- },
11
- });
12
- const v2 = createContract({});
13
-
14
- const result = v2.compareWith(v1);
15
- expect(result.breakingChanges).toContain("Route removed: /users");
16
- });
17
-
18
- it("should detect removed methods", () => {
19
- const v1 = createContract({
20
- "/users": {
21
- GET: { response: z.object({ id: z.string() }) },
22
- POST: { response: z.object({ id: z.string() }) },
23
- },
24
- });
25
- const v2 = createContract({
26
- "/users": {
27
- GET: { response: z.object({ id: z.string() }) },
28
- },
29
- });
30
-
31
- const result = v2.compareWith(v1);
32
- expect(result.breakingChanges).toContain("Method removed: POST /users");
33
- });
34
-
35
- it("should detect removed response fields", () => {
36
- const v1 = createContract({
37
- "/users": {
38
- GET: {
39
- response: z.object({
40
- id: z.string(),
41
- username: z.string(),
42
- }),
43
- },
44
- },
45
- });
46
- const v2 = createContract({
47
- "/users": {
48
- GET: {
49
- response: z.object({
50
- id: z.string(),
51
- }),
52
- },
53
- },
54
- });
55
-
56
- const result = v2.compareWith(v1);
57
- expect(result.breakingChanges).toContain(
58
- "Response field removed: username in GET /users"
59
- );
60
- });
61
-
62
- it("should detect changed response field types", () => {
63
- const v1 = createContract({
64
- "/users": {
65
- GET: {
66
- response: z.object({
67
- id: z.string(),
68
- }),
69
- },
70
- },
71
- });
72
- const v2 = createContract({
73
- "/users": {
74
- GET: {
75
- response: z.object({
76
- id: z.number(),
77
- }),
78
- },
79
- },
80
- });
81
-
82
- const result = v2.compareWith(v1);
83
- expect(result.breakingChanges).toContain(
84
- "Response field type changed: id in GET /users"
85
- );
86
- });
87
-
88
- it("should detect removed status codes", () => {
89
- const v1 = createContract({
90
- "/users": {
91
- GET: {
92
- response: z.object({ id: z.string() }),
93
- errors: {
94
- 404: "Not Found",
95
- 500: "Server Error",
96
- },
97
- },
98
- },
99
- });
100
- const v2 = createContract({
101
- "/users": {
102
- GET: {
103
- response: z.object({ id: z.string() }),
104
- errors: {
105
- 500: "Server Error",
106
- },
107
- },
108
- },
109
- });
110
-
111
- const result = v2.compareWith(v1);
112
- expect(result.breakingChanges).toContain(
113
- "Status code removed: 404 in GET /users"
114
- );
115
- });
116
-
117
- // New tests for Request validations (currently expected to fail or need implementation)
118
- it("should detect changed request body field types", () => {
119
- const v1 = createContract({
120
- "/users": {
121
- POST: {
122
- body: z.object({
123
- age: z.string(),
124
- }),
125
- response: z.object({ id: z.string() }),
126
- },
127
- },
128
- });
129
- const v2 = createContract({
130
- "/users": {
131
- POST: {
132
- body: z.object({
133
- age: z.number(),
134
- }),
135
- response: z.object({ id: z.string() }),
136
- },
137
- },
138
- });
139
-
140
- const result = v2.compareWith(v1);
141
- expect(result.breakingChanges).toContain(
142
- "Request body field type changed: age in POST /users"
143
- );
144
- });
145
-
146
- it("should detect new required fields in request body", () => {
147
- const v1 = createContract({
148
- "/users": {
149
- POST: {
150
- body: z.object({
151
- name: z.string(),
152
- }),
153
- response: z.object({ id: z.string() }),
154
- },
155
- },
156
- });
157
- const v2 = createContract({
158
- "/users": {
159
- POST: {
160
- body: z.object({
161
- name: z.string(),
162
- age: z.number(), // New required field
163
- }),
164
- response: z.object({ id: z.string() }),
165
- },
166
- },
167
- });
168
-
169
- const result = v2.compareWith(v1);
170
- expect(result.breakingChanges).toContain(
171
- "New required request body field: age in POST /users"
172
- );
173
- });
174
- });