@codexsploitx/schemaapi 1.0.0 → 1.0.2

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +1 -1
  3. package/readme.md +125 -48
  4. package/docs/adapters/deno.md +0 -51
  5. package/docs/adapters/express.md +0 -67
  6. package/docs/adapters/fastify.md +0 -64
  7. package/docs/adapters/hapi.md +0 -67
  8. package/docs/adapters/koa.md +0 -61
  9. package/docs/adapters/nest.md +0 -66
  10. package/docs/adapters/next.md +0 -66
  11. package/docs/adapters/remix.md +0 -72
  12. package/docs/cli.md +0 -18
  13. package/docs/consepts.md +0 -18
  14. package/docs/getting_started.md +0 -149
  15. package/docs/sdk.md +0 -25
  16. package/docs/validation.md +0 -228
  17. package/docs/versioning.md +0 -28
  18. package/eslint.config.mjs +0 -34
  19. package/rollup.config.js +0 -19
  20. package/src/adapters/deno.ts +0 -139
  21. package/src/adapters/express.ts +0 -134
  22. package/src/adapters/fastify.ts +0 -133
  23. package/src/adapters/hapi.ts +0 -140
  24. package/src/adapters/index.ts +0 -9
  25. package/src/adapters/koa.ts +0 -128
  26. package/src/adapters/nest.ts +0 -122
  27. package/src/adapters/next.ts +0 -175
  28. package/src/adapters/remix.ts +0 -145
  29. package/src/adapters/ws.ts +0 -132
  30. package/src/core/client.ts +0 -104
  31. package/src/core/contract.ts +0 -534
  32. package/src/core/versioning.test.ts +0 -174
  33. package/src/docs.ts +0 -535
  34. package/src/index.ts +0 -5
  35. package/src/playground.test.ts +0 -98
  36. package/src/playground.ts +0 -13
  37. package/src/sdk.ts +0 -17
  38. package/tests/adapters.deno.test.ts +0 -70
  39. package/tests/adapters.express.test.ts +0 -67
  40. package/tests/adapters.fastify.test.ts +0 -63
  41. package/tests/adapters.hapi.test.ts +0 -66
  42. package/tests/adapters.koa.test.ts +0 -58
  43. package/tests/adapters.nest.test.ts +0 -85
  44. package/tests/adapters.next.test.ts +0 -39
  45. package/tests/adapters.remix.test.ts +0 -52
  46. package/tests/adapters.ws.test.ts +0 -91
  47. package/tests/cli.test.ts +0 -156
  48. package/tests/client.test.ts +0 -110
  49. package/tests/contract.handle.test.ts +0 -267
  50. package/tests/docs.test.ts +0 -96
  51. package/tests/sdk.test.ts +0 -34
  52. package/tsconfig.json +0 -15
package/src/docs.ts DELETED
@@ -1,535 +0,0 @@
1
- import type { ContractDocs, FieldDoc, MethodDoc } from "./core/contract";
2
-
3
- export type DocsFormat = "json" | "html" | "text";
4
-
5
- export type RenderHtmlOptions = {
6
- title?: string;
7
- theme?: "light" | "dark" | "auto";
8
- };
9
-
10
- export function renderDocsJSON(docs: ContractDocs): string {
11
- return JSON.stringify(docs, null, 2);
12
- }
13
-
14
- export function renderDocsText(docs: ContractDocs): string {
15
- const lines: string[] = [];
16
-
17
- lines.push("SchemaApi Contract");
18
- lines.push("================================");
19
-
20
- docs.routes.forEach((route) => {
21
- lines.push("");
22
- lines.push(`${route.method} ${route.path}`);
23
- lines.push("-".repeat((route.method + route.path).length + 1));
24
-
25
- if (route.roles && route.roles.length > 0) {
26
- lines.push(`roles: ${route.roles.join(", ")}`);
27
- }
28
-
29
- if (route.media) {
30
- const mediaParts: string[] = [];
31
- if (route.media.kind) {
32
- mediaParts.push(`kind=${route.media.kind}`);
33
- }
34
- if (route.media.contentTypes && route.media.contentTypes.length > 0) {
35
- mediaParts.push(`types=[${route.media.contentTypes.join(", ")}]`);
36
- }
37
- if (route.media.maxSize) {
38
- mediaParts.push(`maxSize=${route.media.maxSize}`);
39
- }
40
- lines.push(`media: ${mediaParts.join(" ")}`);
41
- }
42
-
43
- const renderSection = (name: string, fields?: FieldDoc[]) => {
44
- if (!fields || fields.length === 0) {
45
- return;
46
- }
47
- lines.push(``);
48
- lines.push(`${name}:`);
49
- fields.forEach((f) => {
50
- const flag = f.optional ? "optional" : "required";
51
- const typeName = f.type ? f.type : "unknown";
52
- lines.push(` - ${f.name}: ${typeName} (${flag})`);
53
- });
54
- };
55
-
56
- renderSection("params", route.params);
57
- renderSection("query", route.query);
58
- renderSection("body", route.body);
59
- renderSection("headers", route.headers);
60
-
61
- if (route.errors && route.errors.length > 0) {
62
- lines.push("");
63
- lines.push("errors:");
64
- route.errors.forEach((e) => {
65
- lines.push(` - ${e.status}: ${e.code}`);
66
- });
67
- }
68
- });
69
-
70
- return lines.join("\n");
71
- }
72
-
73
- function getThemeBackground(theme: RenderHtmlOptions["theme"]): string {
74
- if (theme === "dark") {
75
- return "#020617";
76
- }
77
- if (theme === "light") {
78
- return "#f9fafb";
79
- }
80
- return "#0b1120";
81
- }
82
-
83
- function getThemeForeground(theme: RenderHtmlOptions["theme"]): string {
84
- if (theme === "dark") {
85
- return "#e5e7eb";
86
- }
87
- if (theme === "light") {
88
- return "#111827";
89
- }
90
- return "#e5e7eb";
91
- }
92
-
93
- export function renderDocsHTML(
94
- docs: ContractDocs,
95
- options?: RenderHtmlOptions
96
- ): string {
97
- const title = options?.title ?? "SchemaApi Docs";
98
- const bg = getThemeBackground(options?.theme);
99
- const fg = getThemeForeground(options?.theme);
100
-
101
- const escape = (value: unknown): string => {
102
- if (value === undefined || value === null) {
103
- return "";
104
- }
105
- return String(value)
106
- .replace(/&/g, "&")
107
- .replace(/</g, "&lt;")
108
- .replace(/>/g, "&gt;")
109
- .replace(/"/g, "&quot;");
110
- };
111
-
112
- const renderFieldsTable = (caption: string, fields?: FieldDoc[]): string => {
113
- if (!fields || fields.length === 0) {
114
- return "";
115
- }
116
- const rows = fields
117
- .map((f) => {
118
- const typeName = f.type ? f.type : "unknown";
119
- const badge = f.optional ? "optional" : "required";
120
- return `<tr>
121
- <td class="field-name">${escape(f.name)}</td>
122
- <td class="field-type">${escape(typeName)}</td>
123
- <td class="field-badge ${badge}">${badge}</td>
124
- </tr>`;
125
- })
126
- .join("\n");
127
-
128
- return `<section class="block">
129
- <div class="block-title">${escape(caption)}</div>
130
- <table class="fields">
131
- <thead>
132
- <tr>
133
- <th>name</th>
134
- <th>type</th>
135
- <th>required</th>
136
- </tr>
137
- </thead>
138
- <tbody>
139
- ${rows}
140
- </tbody>
141
- </table>
142
- </section>`;
143
- };
144
-
145
- const renderRoute = (route: MethodDoc): string => {
146
- const method = escape(route.method);
147
- const path = escape(route.path);
148
-
149
- const roles =
150
- route.roles && route.roles.length > 0
151
- ? `<div class="pill-group">
152
- ${route.roles
153
- .map(
154
- (r) => `<span class="pill pill-role">role: ${escape(r)}</span>`
155
- )
156
- .join("\n")}
157
- </div>`
158
- : "";
159
-
160
- const media = route.media
161
- ? (() => {
162
- const parts: string[] = [];
163
- if (route.media?.kind) {
164
- parts.push(`kind=${escape(route.media.kind)}`);
165
- }
166
- if (route.media?.contentTypes && route.media.contentTypes.length) {
167
- parts.push(
168
- `types=[${route.media.contentTypes
169
- .map((t) => escape(t))
170
- .join(", ")}]`
171
- );
172
- }
173
- if (route.media?.maxSize) {
174
- parts.push(`maxSize=${escape(route.media.maxSize)}`);
175
- }
176
- return `<div class="pill-group">
177
- <span class="pill pill-media">media ${parts.join(" · ")}</span>
178
- </div>`;
179
- })()
180
- : "";
181
-
182
- const errors =
183
- route.errors && route.errors.length > 0
184
- ? `<section class="block">
185
- <div class="block-title">errors</div>
186
- <table class="fields">
187
- <thead>
188
- <tr>
189
- <th>status</th>
190
- <th>code</th>
191
- </tr>
192
- </thead>
193
- <tbody>
194
- ${route.errors
195
- .map(
196
- (e) => `<tr>
197
- <td class="field-status">${escape(e.status)}</td>
198
- <td class="field-error-code">${escape(e.code)}</td>
199
- </tr>`
200
- )
201
- .join("\n")}
202
- </tbody>
203
- </table>
204
- </section>`
205
- : "";
206
-
207
- const params = renderFieldsTable("params", route.params);
208
- const query = renderFieldsTable("query", route.query);
209
- const body = renderFieldsTable("body", route.body);
210
- const headers = renderFieldsTable("headers", route.headers);
211
-
212
- return `<article class="route-card route-${method.toLowerCase()}">
213
- <header class="route-header">
214
- <span class="method method-${method.toLowerCase()}">${method}</span>
215
- <span class="path">${path}</span>
216
- </header>
217
- <div class="route-meta">
218
- ${roles}
219
- ${media}
220
- </div>
221
- <div class="route-content">
222
- ${params}
223
- ${query}
224
- ${body}
225
- ${headers}
226
- ${errors}
227
- </div>
228
- </article>`;
229
- };
230
-
231
- const routesHtml = docs.routes.map((r) => renderRoute(r)).join("\n");
232
-
233
- return `<!doctype html>
234
- <html lang="en">
235
- <head>
236
- <meta charset="utf-8" />
237
- <title>${escape(title)}</title>
238
- <meta name="viewport" content="width=device-width, initial-scale=1" />
239
- <style>
240
- :root {
241
- --bg: ${bg};
242
- --fg: ${fg};
243
- --accent: #38bdf8;
244
- --accent-soft: rgba(56, 189, 248, 0.12);
245
- --card-bg: rgba(15, 23, 42, 0.9);
246
- --border-subtle: rgba(148, 163, 184, 0.35);
247
- --badge-get: #22c55e;
248
- --badge-post: #3b82f6;
249
- --badge-put: #eab308;
250
- --badge-delete: #ef4444;
251
- --badge-other: #a855f7;
252
- --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
253
- "Inter", sans-serif;
254
- --font-mono: ui-monospace, Menlo, Monaco, Consolas, "Liberation Mono",
255
- "Courier New", monospace;
256
- }
257
-
258
- * {
259
- box-sizing: border-box;
260
- }
261
-
262
- body {
263
- margin: 0;
264
- padding: 2.5rem 1.5rem 3rem;
265
- background: radial-gradient(circle at top left, #0f172a 0, var(--bg) 45%),
266
- radial-gradient(circle at bottom right, #020617 0, var(--bg) 55%);
267
- color: var(--fg);
268
- font-family: var(--font-sans);
269
- -webkit-font-smoothing: antialiased;
270
- }
271
-
272
- .shell {
273
- max-width: 1120px;
274
- margin: 0 auto;
275
- }
276
-
277
- .masthead {
278
- display: flex;
279
- flex-direction: column;
280
- gap: 0.75rem;
281
- margin-bottom: 2rem;
282
- }
283
-
284
- .brand {
285
- display: inline-flex;
286
- align-items: center;
287
- gap: 0.5rem;
288
- font-family: var(--font-mono);
289
- font-size: 0.9rem;
290
- letter-spacing: 0.12em;
291
- text-transform: uppercase;
292
- color: #9ca3af;
293
- }
294
-
295
- .brand-mark {
296
- width: 0.65rem;
297
- height: 0.65rem;
298
- border-radius: 999px;
299
- background: radial-gradient(circle at 30% 30%, #e5e7eb, #38bdf8);
300
- box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.2);
301
- }
302
-
303
- .title {
304
- font-size: 1.9rem;
305
- font-weight: 600;
306
- letter-spacing: -0.03em;
307
- }
308
-
309
- .subtitle {
310
- font-size: 0.95rem;
311
- color: #9ca3af;
312
- max-width: 520px;
313
- }
314
-
315
- .routes-grid {
316
- display: grid;
317
- grid-template-columns: minmax(0, 1fr);
318
- gap: 1rem;
319
- }
320
-
321
- @media (min-width: 900px) {
322
- .routes-grid {
323
- grid-template-columns: repeat(2, minmax(0, 1fr));
324
- }
325
- }
326
-
327
- .route-card {
328
- border-radius: 1rem;
329
- padding: 1rem 1.1rem 1.1rem;
330
- background: rgba(15, 23, 42, 0.92);
331
- border: 1px solid var(--border-subtle);
332
- box-shadow: 0 18px 55px rgba(15, 23, 42, 0.8);
333
- display: flex;
334
- flex-direction: column;
335
- gap: 0.75rem;
336
- }
337
-
338
- .route-header {
339
- display: flex;
340
- align-items: center;
341
- gap: 0.75rem;
342
- font-family: var(--font-mono);
343
- font-size: 0.85rem;
344
- letter-spacing: 0.06em;
345
- text-transform: uppercase;
346
- }
347
-
348
- .method {
349
- padding: 0.15rem 0.55rem;
350
- border-radius: 999px;
351
- border: 1px solid rgba(148, 163, 184, 0.5);
352
- background: rgba(15, 23, 42, 0.8);
353
- }
354
-
355
- .method-get {
356
- border-color: rgba(34, 197, 94, 0.6);
357
- color: #bbf7d0;
358
- background: rgba(34, 197, 94, 0.08);
359
- }
360
-
361
- .method-post {
362
- border-color: rgba(59, 130, 246, 0.6);
363
- color: #bfdbfe;
364
- background: rgba(59, 130, 246, 0.08);
365
- }
366
-
367
- .method-put {
368
- border-color: rgba(234, 179, 8, 0.6);
369
- color: #fef9c3;
370
- background: rgba(234, 179, 8, 0.08);
371
- }
372
-
373
- .method-delete {
374
- border-color: rgba(239, 68, 68, 0.6);
375
- color: #fee2e2;
376
- background: rgba(239, 68, 68, 0.08);
377
- }
378
-
379
- .method-patch {
380
- border-color: rgba(168, 85, 247, 0.6);
381
- color: #ede9fe;
382
- background: rgba(168, 85, 247, 0.08);
383
- }
384
-
385
- .path {
386
- padding: 0.1rem 0.65rem;
387
- border-radius: 999px;
388
- background: radial-gradient(circle at 0% 0%, #0f172a, #020617);
389
- border: 1px solid rgba(148, 163, 184, 0.5);
390
- color: #e5e7eb;
391
- }
392
-
393
- .route-meta {
394
- display: flex;
395
- flex-direction: column;
396
- gap: 0.25rem;
397
- }
398
-
399
- .pill-group {
400
- display: flex;
401
- flex-wrap: wrap;
402
- gap: 0.25rem;
403
- }
404
-
405
- .pill {
406
- font-family: var(--font-mono);
407
- font-size: 0.7rem;
408
- padding: 0.1rem 0.45rem;
409
- border-radius: 999px;
410
- border: 1px solid rgba(148, 163, 184, 0.4);
411
- background: rgba(15, 23, 42, 0.9);
412
- color: #9ca3af;
413
- }
414
-
415
- .pill-role {
416
- border-color: rgba(52, 211, 153, 0.5);
417
- color: #a7f3d0;
418
- }
419
-
420
- .pill-media {
421
- border-color: rgba(56, 189, 248, 0.6);
422
- color: #bae6fd;
423
- background: rgba(15, 23, 42, 0.9);
424
- }
425
-
426
- .route-content {
427
- display: flex;
428
- flex-direction: column;
429
- gap: 0.6rem;
430
- margin-top: 0.2rem;
431
- }
432
-
433
- .block {
434
- border-radius: 0.75rem;
435
- border: 1px solid rgba(31, 41, 55, 0.9);
436
- background: radial-gradient(circle at top left, #020617, #020617);
437
- padding: 0.6rem 0.7rem 0.55rem;
438
- }
439
-
440
- .block-title {
441
- font-family: var(--font-mono);
442
- font-size: 0.65rem;
443
- letter-spacing: 0.2em;
444
- text-transform: uppercase;
445
- color: #6b7280;
446
- margin-bottom: 0.35rem;
447
- }
448
-
449
- table.fields {
450
- width: 100%;
451
- border-collapse: collapse;
452
- font-size: 0.8rem;
453
- font-family: var(--font-mono);
454
- }
455
-
456
- table.fields th {
457
- text-align: left;
458
- font-weight: 500;
459
- color: #9ca3af;
460
- padding-bottom: 0.2rem;
461
- }
462
-
463
- table.fields td {
464
- padding: 0.12rem 0;
465
- }
466
-
467
- .field-name {
468
- color: #e5e7eb;
469
- }
470
-
471
- .field-type {
472
- color: #a5b4fc;
473
- }
474
-
475
- .field-badge {
476
- font-size: 0.7rem;
477
- padding: 0 0.4rem;
478
- border-radius: 999px;
479
- border: 1px solid rgba(148, 163, 184, 0.4);
480
- color: #9ca3af;
481
- }
482
-
483
- .field-badge.required {
484
- border-color: rgba(248, 113, 113, 0.6);
485
- color: #fecaca;
486
- }
487
-
488
- .field-badge.optional {
489
- border-color: rgba(59, 130, 246, 0.6);
490
- color: #bfdbfe;
491
- }
492
-
493
- .field-status {
494
- color: #f97316;
495
- }
496
-
497
- .field-error-code {
498
- color: #fca5a5;
499
- }
500
- </style>
501
- </head>
502
- <body>
503
- <div class="shell">
504
- <header class="masthead">
505
- <div class="brand">
506
- <span class="brand-mark"></span>
507
- <span>SCHEMAAPI · CONTRACT DOCS</span>
508
- </div>
509
- <h1 class="title">${escape(title)}</h1>
510
- <p class="subtitle">
511
- Contratos de API descritos directamente desde el runtime. Sin Swagger, sin
512
- YAML. Solo SchemaApi como única fuente de verdad.
513
- </p>
514
- </header>
515
- <main class="routes-grid">
516
- ${routesHtml}
517
- </main>
518
- </div>
519
- </body>
520
- </html>`;
521
- }
522
-
523
- export function renderDocs(
524
- docs: ContractDocs,
525
- options: { format: DocsFormat } & Partial<RenderHtmlOptions>
526
- ): string {
527
- if (options.format === "json") {
528
- return renderDocsJSON(docs);
529
- }
530
- if (options.format === "html") {
531
- return renderDocsHTML(docs, options);
532
- }
533
- return renderDocsText(docs);
534
- }
535
-
package/src/index.ts DELETED
@@ -1,5 +0,0 @@
1
- export * from "./core/contract";
2
- export * from "./core/client";
3
- export { generateSDK } from "./sdk";
4
- export * as adapters from "./adapters";
5
- export * from "./docs";
@@ -1,98 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { z } from "zod";
3
- import { contract } from "./playground";
4
- import { createContract, SchemaApiError } from "./core/contract";
5
-
6
- describe("Playground Contract", () => {
7
- it("should be defined", () => {
8
- expect(contract).toBeDefined();
9
- expect(contract.schema).toBeDefined();
10
- });
11
-
12
- it("should have the correct structure", () => {
13
- type PlaygroundSchema = Record<
14
- string,
15
- {
16
- GET?: {
17
- roles?: string[];
18
- };
19
- }
20
- >;
21
- const schema = contract.schema as PlaygroundSchema;
22
- const route = schema["/users/:id"];
23
- expect(route).toBeDefined();
24
- const getRoute = route.GET;
25
- expect(getRoute).toBeDefined();
26
- expect(getRoute?.roles ?? []).toContain("admin");
27
- });
28
-
29
- it("should fail if dynamic params in route are not declared in params schema", () => {
30
- expect(() =>
31
- createContract({
32
- "/posts/:postId/comments/:id": {
33
- GET: {
34
- params: z.object({ postId: z.string() }),
35
- response: z.object({ ok: z.boolean() }),
36
- },
37
- },
38
- }).handle("GET /posts/:postId/comments/:id", async () => ({ ok: true }))
39
- ).toThrow(
40
- "Dynamic path param :id in /posts/:postId/comments/:id is not defined in params"
41
- );
42
- });
43
-
44
- it("should allow handler errors with status codes defined in contract.errors", async () => {
45
- const c = createContract({
46
- "/users/:id": {
47
- GET: {
48
- params: z.object({ id: z.string().uuid() }),
49
- response: z.object({ ok: z.boolean() }),
50
- errors: {
51
- 404: "USER_NOT_FOUND",
52
- },
53
- },
54
- },
55
- });
56
-
57
- const handler = c.handle("GET /users/:id", async () => {
58
- throw new SchemaApiError(404, "Not Found");
59
- });
60
-
61
- await expect(
62
- handler({ params: { id: "550e8400-e29b-41d4-a716-446655440000" } })
63
- ).rejects.toMatchObject({ code: 404 });
64
- });
65
-
66
- it("should reject handler errors with status codes not defined in contract.errors", async () => {
67
- const c = createContract({
68
- "/users/:id": {
69
- GET: {
70
- params: z.object({ id: z.string().uuid() }),
71
- response: z.object({ ok: z.boolean() }),
72
- errors: {
73
- 404: "USER_NOT_FOUND",
74
- },
75
- },
76
- },
77
- });
78
-
79
- const handler = c.handle("GET /users/:id", async () => {
80
- throw new SchemaApiError(418, "Teapot");
81
- });
82
-
83
- await expect(
84
- handler({ params: { id: "550e8400-e29b-41d4-a716-446655440000" } })
85
- ).rejects.toThrow("418 is not defined in contract.errors");
86
- });
87
-
88
- it("should expose docs derived from the contract schema", () => {
89
- const docs = contract.docs();
90
- expect(docs.routes.length).toBeGreaterThan(0);
91
- const userRoute = docs.routes.find(
92
- (r) => r.path === "/users/:id" && r.method === "GET"
93
- );
94
- expect(userRoute).toBeDefined();
95
- expect(userRoute?.roles).toContain("admin");
96
- expect(userRoute?.params?.some((f) => f.name === "id")).toBe(true);
97
- });
98
- });
package/src/playground.ts DELETED
@@ -1,13 +0,0 @@
1
- import { createContract } from "./index";
2
- import { z } from "zod";
3
-
4
- export const contract = createContract({
5
- "/users/:id": {
6
- GET: {
7
- params: z.object({ id: z.string().uuid() }),
8
- headers: z.object({ authorization: z.string() }),
9
- roles: ["user", "admin"],
10
- response: z.object({ id: z.string(), username: z.string() }),
11
- },
12
- },
13
- });
package/src/sdk.ts DELETED
@@ -1,17 +0,0 @@
1
- import { createClient } from "./core/client";
2
-
3
- type GenerateSDKOptions = {
4
- baseUrl: string;
5
- fetch?: typeof fetch;
6
- };
7
-
8
- export function generateSDK<TContract>(
9
- contract: { schema: TContract },
10
- options: GenerateSDKOptions
11
- ) {
12
- return createClient(contract, {
13
- baseUrl: options.baseUrl,
14
- fetch: options.fetch,
15
- });
16
- }
17
-