@extk/expressive 0.7.1 → 0.8.0

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/README.md CHANGED
@@ -16,6 +16,29 @@
16
16
 
17
17
  ---
18
18
 
19
+ ## Table of Contents
20
+
21
+ - [What is this?](#what-is-this)
22
+ - [Install](#install)
23
+ - [Quick Start](#quick-start)
24
+ - [Error Handling](#error-handling)
25
+ - [OpenAPI / Swagger](#openapi--swagger)
26
+ - [File uploads](#file-uploads)
27
+ - [Using Zod schemas for OpenAPI](#using-zod-schemas-for-openapi)
28
+ - [Middleware](#middleware)
29
+ - [getApiErrorHandlerMiddleware](#getapierrorhandlermiddleware)
30
+ - [getApiNotFoundMiddleware](#getapinotfoundmiddleware)
31
+ - [getGlobalNotFoundMiddleware](#getglobalnotfoundmiddleware)
32
+ - [getGlobalErrorHandlerMiddleware](#getglobalerrorhandlermiddleware)
33
+ - [getBasicAuthMiddleware](#getbasicauthmiddleware)
34
+ - [silently](#silently)
35
+ - [Logging](#logging)
36
+ - [Utilities](#utilities)
37
+ - [API Response Format](#api-response-format)
38
+ - [License](#license)
39
+
40
+ ---
41
+
19
42
  ## What is this?
20
43
 
21
44
  `@extk/expressive` is an opinionated toolkit for Express 5 that wires up the things every API needs but nobody wants to set up from scratch:
@@ -171,6 +194,38 @@ addRoute(
171
194
  );
172
195
  ```
173
196
 
197
+ ### File uploads
198
+
199
+ Use `SWG.singleFileSchema` for a single file field, or `SWG.formDataSchema` for a custom multipart body:
200
+
201
+ ```ts
202
+ // single file — field name defaults to 'file', required defaults to true
203
+ addRoute({
204
+ method: 'post',
205
+ path: '/upload',
206
+ oapi: {
207
+ requestBody: SWG.singleFileSchema(),
208
+ // requestBody: SWG.singleFileSchema('avatar', true),
209
+ },
210
+ }, handler);
211
+
212
+ // custom multipart schema with multiple fields
213
+ addRoute({
214
+ method: 'post',
215
+ path: '/upload/rich',
216
+ oapi: {
217
+ requestBody: SWG.formDataSchema({
218
+ type: 'object',
219
+ properties: {
220
+ file: { type: 'string', format: 'binary' },
221
+ title: { type: 'string' },
222
+ },
223
+ required: ['file'],
224
+ }),
225
+ },
226
+ }, handler);
227
+ ```
228
+
174
229
  Configure security schemes via the swagger builder:
175
230
 
176
231
  ```ts
@@ -261,6 +316,55 @@ addRoute({
261
316
 
262
317
  This way your Zod schemas serve as the single source of truth for both runtime validation and API documentation.
263
318
 
319
+ ## Middleware
320
+
321
+ All middleware factories are returned from `bootstrap()`.
322
+
323
+ ### `getApiErrorHandlerMiddleware(errorMapper?)`
324
+
325
+ Express error handler for API routes. Catches `ApiError` subclasses, handles malformed JSON, and falls back to `InternalError` for unknown errors. Pass an optional `errorMapper` to map third-party errors (e.g. Zod, Multer) to typed `ApiError` instances.
326
+
327
+ ```ts
328
+ app.use(getApiErrorHandlerMiddleware((err) => {
329
+ if (err.name === 'ZodError') return new SchemaValidationError('Validation failed').setData(err.issues);
330
+ return null;
331
+ }));
332
+ ```
333
+
334
+ ### `getApiNotFoundMiddleware()`
335
+
336
+ Returns a JSON `404` response for unmatched API routes.
337
+
338
+ ```ts
339
+ app.use(getApiNotFoundMiddleware());
340
+ // { status: 'error', message: 'GET /unknown not found', errorCode: 'NOT_FOUND' }
341
+ ```
342
+
343
+ ### `getGlobalNotFoundMiddleware(content?)`
344
+
345
+ Returns a plain-text `404`. Useful as the last catch-all for non-API routes. Defaults to `¯\_(ツ)_/¯`.
346
+
347
+ ```ts
348
+ app.use(getGlobalNotFoundMiddleware());
349
+ app.use(getGlobalNotFoundMiddleware('Not found'));
350
+ ```
351
+
352
+ ### `getGlobalErrorHandlerMiddleware()`
353
+
354
+ Minimal error handler that logs and responds with a plain-text `500`. Use this outside of API route groups where JSON responses aren't expected.
355
+
356
+ ### `getBasicAuthMiddleware(basicAuthBase64, realm?)`
357
+
358
+ Protects a route or the Swagger UI with HTTP Basic auth. Accepts a pre-encoded base64 `user:password` string.
359
+
360
+ ```ts
361
+ expressiveServer()
362
+ .withSwagger(
363
+ { config: swaggerDoc },
364
+ getBasicAuthMiddleware(process.env.SWAGGER_AUTH!, 'API Docs'),
365
+ )
366
+ ```
367
+
264
368
  ## silently
265
369
 
266
370
  `silently` runs a function — sync or async — and suppresses any errors it throws. Errors are forwarded to `alertHandler` (if configured) or logged via the container logger.
package/dist/index.d.mts CHANGED
@@ -33,61 +33,85 @@ type Container = {
33
33
  alertHandler?: AlertHandler;
34
34
  };
35
35
 
36
+ type Nullable<T extends string> = T | [T, 'null'] | ['null', T];
36
37
  type NumericConfigs = {
37
38
  minimum?: number;
38
39
  maximum?: number;
39
- exclusiveMinimum?: boolean;
40
- exclusiveMaximum?: boolean;
40
+ exclusiveMinimum?: number;
41
+ exclusiveMaximum?: number;
41
42
  multipleOf?: number;
42
43
  };
43
44
  type NumberSchema = {
44
- type: 'number';
45
+ type: Nullable<'number'>;
45
46
  format?: 'float' | 'double';
46
47
  } & NumericConfigs;
47
48
  type IntegerSchema = {
48
- type: 'integer';
49
+ type: Nullable<'integer'>;
49
50
  format?: 'int32' | 'int64';
50
51
  } & NumericConfigs;
51
52
  type StringSchema = {
52
- type: 'string';
53
+ type: Nullable<'string'>;
53
54
  minLength?: number;
54
55
  maxLength?: number;
55
56
  format?: 'date' | 'date-time' | 'password' | 'byte' | 'binary' | 'email' | 'uuid' | 'uri' | 'hostname' | 'ipv4' | 'ipv6' | OtherString;
56
57
  pattern?: string;
57
58
  };
58
59
  type BooleanSchema = {
59
- type: 'boolean';
60
+ type: Nullable<'boolean'>;
61
+ };
62
+ type NullSchema = {
63
+ type: 'null';
60
64
  };
61
65
  type ArraySchema = {
62
- type: 'array';
63
- items: Partial<Schema>;
66
+ type: Nullable<'array'>;
67
+ items?: Schema;
64
68
  minItems?: number;
65
69
  maxItems?: number;
66
70
  uniqueItems?: boolean;
67
71
  };
68
72
  type ObjectSchema = {
69
- type: 'object';
73
+ type: Nullable<'object'>;
70
74
  properties?: Record<string, Schema>;
71
75
  required?: string[];
72
76
  additionalProperties?: boolean | Schema;
73
77
  minProperties?: number;
74
78
  maxProperties?: number;
75
79
  };
76
- type BaseSchema = ({
77
- nullable?: boolean;
78
- enum?: unknown[];
80
+ type Discriminator = {
81
+ propertyName: string;
82
+ mapping?: Record<string, string>;
83
+ };
84
+ type CommonSchemaProps = {
85
+ title?: string;
79
86
  description?: string;
87
+ example?: unknown;
80
88
  default?: unknown;
81
- } & (StringSchema | NumberSchema | IntegerSchema | BooleanSchema | ArraySchema | ObjectSchema)) | {
82
- $ref: string;
89
+ enum?: unknown[];
90
+ const?: unknown;
91
+ readOnly?: boolean;
92
+ writeOnly?: boolean;
93
+ deprecated?: boolean;
83
94
  };
84
- type Schema = BaseSchema | {
95
+ type BaseSchema = CommonSchemaProps & (StringSchema | NumberSchema | IntegerSchema | BooleanSchema | NullSchema | ArraySchema | ObjectSchema | {
96
+ $ref: string;
97
+ });
98
+ type Schema = // circular
99
+ BaseSchema | (CommonSchemaProps & {
85
100
  allOf: Schema[];
86
- } | {
101
+ discriminator?: Discriminator;
102
+ }) | (CommonSchemaProps & {
87
103
  anyOf: Schema[];
88
- } | {
104
+ discriminator?: Discriminator;
105
+ }) | (CommonSchemaProps & {
89
106
  oneOf: Schema[];
90
- } | OtherUnknown;
107
+ discriminator?: Discriminator;
108
+ }) | (CommonSchemaProps & {
109
+ not: Schema;
110
+ }) | (CommonSchemaProps & {
111
+ if: Schema;
112
+ then?: Schema;
113
+ else?: Schema;
114
+ }) | OtherUnknown;
91
115
  type Content = {
92
116
  description?: string;
93
117
  content?: Partial<Record<ContentType, {
@@ -95,7 +119,7 @@ type Content = {
95
119
  }>>;
96
120
  };
97
121
  type Param = {
98
- in: 'path' | 'query' | 'headers';
122
+ in: 'path' | 'query' | 'header' | 'cookie';
99
123
  name: string;
100
124
  description: string;
101
125
  required: boolean;
@@ -107,6 +131,7 @@ type Servers = {
107
131
  }[];
108
132
  type PathItem = {
109
133
  summary?: string;
134
+ description?: string;
110
135
  requestBody?: Content;
111
136
  parameters?: Param[];
112
137
  servers?: Servers;
@@ -116,13 +141,14 @@ type PathItem = {
116
141
  deprecated?: boolean;
117
142
  security?: AuthMethod[];
118
143
  };
144
+ /** { BearerAuth: [] } | { OAuth2: ['read', 'write'] } */
119
145
  type AuthMethod = Record<string, string[]>;
120
146
  type SecurityScheme = {
121
147
  type: 'http';
122
148
  scheme: 'basic' | 'bearer';
123
149
  } | {
124
150
  type: 'apiKey';
125
- in: 'header';
151
+ in: 'header' | 'query' | 'cookie';
126
152
  name: string;
127
153
  } | {
128
154
  type: 'openIdConnect';
@@ -234,8 +260,9 @@ declare class SwaggerBuilder {
234
260
  withSecuritySchemes(schemes: Record<string, SecurityScheme>): this;
235
261
  withSchemas(schemas: Record<string, Schema>): this;
236
262
  withDefaultSecurity(globalAuthMethods: AuthMethod[]): this;
237
- get(): SwaggerConfig;
263
+ build(): SwaggerConfig;
238
264
  }
265
+ declare function singleFileSchema(field?: string, required?: boolean): Content;
239
266
  declare function formDataSchema(schema: Schema): Content;
240
267
  declare function jsonSchema(schema: Schema): Content;
241
268
  declare function jsonSchemaRef(name: string): Content;
@@ -251,6 +278,7 @@ declare const SWG: {
251
278
  formDataSchema: typeof formDataSchema;
252
279
  jsonSchema: typeof jsonSchema;
253
280
  jsonSchemaRef: typeof jsonSchemaRef;
281
+ singleFileSchema: typeof singleFileSchema;
254
282
  security: (name: string) => AuthMethod;
255
283
  securitySchemes: {
256
284
  readonly BasicAuth: () => SecurityScheme;
package/dist/index.d.ts CHANGED
@@ -33,61 +33,85 @@ type Container = {
33
33
  alertHandler?: AlertHandler;
34
34
  };
35
35
 
36
+ type Nullable<T extends string> = T | [T, 'null'] | ['null', T];
36
37
  type NumericConfigs = {
37
38
  minimum?: number;
38
39
  maximum?: number;
39
- exclusiveMinimum?: boolean;
40
- exclusiveMaximum?: boolean;
40
+ exclusiveMinimum?: number;
41
+ exclusiveMaximum?: number;
41
42
  multipleOf?: number;
42
43
  };
43
44
  type NumberSchema = {
44
- type: 'number';
45
+ type: Nullable<'number'>;
45
46
  format?: 'float' | 'double';
46
47
  } & NumericConfigs;
47
48
  type IntegerSchema = {
48
- type: 'integer';
49
+ type: Nullable<'integer'>;
49
50
  format?: 'int32' | 'int64';
50
51
  } & NumericConfigs;
51
52
  type StringSchema = {
52
- type: 'string';
53
+ type: Nullable<'string'>;
53
54
  minLength?: number;
54
55
  maxLength?: number;
55
56
  format?: 'date' | 'date-time' | 'password' | 'byte' | 'binary' | 'email' | 'uuid' | 'uri' | 'hostname' | 'ipv4' | 'ipv6' | OtherString;
56
57
  pattern?: string;
57
58
  };
58
59
  type BooleanSchema = {
59
- type: 'boolean';
60
+ type: Nullable<'boolean'>;
61
+ };
62
+ type NullSchema = {
63
+ type: 'null';
60
64
  };
61
65
  type ArraySchema = {
62
- type: 'array';
63
- items: Partial<Schema>;
66
+ type: Nullable<'array'>;
67
+ items?: Schema;
64
68
  minItems?: number;
65
69
  maxItems?: number;
66
70
  uniqueItems?: boolean;
67
71
  };
68
72
  type ObjectSchema = {
69
- type: 'object';
73
+ type: Nullable<'object'>;
70
74
  properties?: Record<string, Schema>;
71
75
  required?: string[];
72
76
  additionalProperties?: boolean | Schema;
73
77
  minProperties?: number;
74
78
  maxProperties?: number;
75
79
  };
76
- type BaseSchema = ({
77
- nullable?: boolean;
78
- enum?: unknown[];
80
+ type Discriminator = {
81
+ propertyName: string;
82
+ mapping?: Record<string, string>;
83
+ };
84
+ type CommonSchemaProps = {
85
+ title?: string;
79
86
  description?: string;
87
+ example?: unknown;
80
88
  default?: unknown;
81
- } & (StringSchema | NumberSchema | IntegerSchema | BooleanSchema | ArraySchema | ObjectSchema)) | {
82
- $ref: string;
89
+ enum?: unknown[];
90
+ const?: unknown;
91
+ readOnly?: boolean;
92
+ writeOnly?: boolean;
93
+ deprecated?: boolean;
83
94
  };
84
- type Schema = BaseSchema | {
95
+ type BaseSchema = CommonSchemaProps & (StringSchema | NumberSchema | IntegerSchema | BooleanSchema | NullSchema | ArraySchema | ObjectSchema | {
96
+ $ref: string;
97
+ });
98
+ type Schema = // circular
99
+ BaseSchema | (CommonSchemaProps & {
85
100
  allOf: Schema[];
86
- } | {
101
+ discriminator?: Discriminator;
102
+ }) | (CommonSchemaProps & {
87
103
  anyOf: Schema[];
88
- } | {
104
+ discriminator?: Discriminator;
105
+ }) | (CommonSchemaProps & {
89
106
  oneOf: Schema[];
90
- } | OtherUnknown;
107
+ discriminator?: Discriminator;
108
+ }) | (CommonSchemaProps & {
109
+ not: Schema;
110
+ }) | (CommonSchemaProps & {
111
+ if: Schema;
112
+ then?: Schema;
113
+ else?: Schema;
114
+ }) | OtherUnknown;
91
115
  type Content = {
92
116
  description?: string;
93
117
  content?: Partial<Record<ContentType, {
@@ -95,7 +119,7 @@ type Content = {
95
119
  }>>;
96
120
  };
97
121
  type Param = {
98
- in: 'path' | 'query' | 'headers';
122
+ in: 'path' | 'query' | 'header' | 'cookie';
99
123
  name: string;
100
124
  description: string;
101
125
  required: boolean;
@@ -107,6 +131,7 @@ type Servers = {
107
131
  }[];
108
132
  type PathItem = {
109
133
  summary?: string;
134
+ description?: string;
110
135
  requestBody?: Content;
111
136
  parameters?: Param[];
112
137
  servers?: Servers;
@@ -116,13 +141,14 @@ type PathItem = {
116
141
  deprecated?: boolean;
117
142
  security?: AuthMethod[];
118
143
  };
144
+ /** { BearerAuth: [] } | { OAuth2: ['read', 'write'] } */
119
145
  type AuthMethod = Record<string, string[]>;
120
146
  type SecurityScheme = {
121
147
  type: 'http';
122
148
  scheme: 'basic' | 'bearer';
123
149
  } | {
124
150
  type: 'apiKey';
125
- in: 'header';
151
+ in: 'header' | 'query' | 'cookie';
126
152
  name: string;
127
153
  } | {
128
154
  type: 'openIdConnect';
@@ -234,8 +260,9 @@ declare class SwaggerBuilder {
234
260
  withSecuritySchemes(schemes: Record<string, SecurityScheme>): this;
235
261
  withSchemas(schemas: Record<string, Schema>): this;
236
262
  withDefaultSecurity(globalAuthMethods: AuthMethod[]): this;
237
- get(): SwaggerConfig;
263
+ build(): SwaggerConfig;
238
264
  }
265
+ declare function singleFileSchema(field?: string, required?: boolean): Content;
239
266
  declare function formDataSchema(schema: Schema): Content;
240
267
  declare function jsonSchema(schema: Schema): Content;
241
268
  declare function jsonSchemaRef(name: string): Content;
@@ -251,6 +278,7 @@ declare const SWG: {
251
278
  formDataSchema: typeof formDataSchema;
252
279
  jsonSchema: typeof jsonSchema;
253
280
  jsonSchemaRef: typeof jsonSchemaRef;
281
+ singleFileSchema: typeof singleFileSchema;
254
282
  security: (name: string) => AuthMethod;
255
283
  securitySchemes: {
256
284
  readonly BasicAuth: () => SecurityScheme;
package/dist/index.js CHANGED
@@ -91,7 +91,7 @@ var SwaggerBuilder = class {
91
91
  this.swaggerDoc.security = globalAuthMethods;
92
92
  return this;
93
93
  }
94
- get() {
94
+ build() {
95
95
  return this.swaggerDoc;
96
96
  }
97
97
  };
@@ -131,6 +131,22 @@ var security = (name) => {
131
131
  }
132
132
  return securityRegistry[name];
133
133
  };
134
+ function singleFileSchema(field = "file", required = true) {
135
+ const schema = {
136
+ type: "object",
137
+ properties: {
138
+ [field]: { type: "string", format: "binary" }
139
+ },
140
+ ...required && { required: [field] }
141
+ };
142
+ return {
143
+ content: {
144
+ "multipart/form-data": {
145
+ schema
146
+ }
147
+ }
148
+ };
149
+ }
134
150
  function formDataSchema(schema) {
135
151
  return {
136
152
  content: {
@@ -168,7 +184,7 @@ function queryParam(id, schema, required = true, description = "", name) {
168
184
  return param("query", id, schema, required, description, name);
169
185
  }
170
186
  function headerParam(id, schema, required = true, description = "", name) {
171
- return param("headers", id, schema, required, description, name);
187
+ return param("header", id, schema, required, description, name);
172
188
  }
173
189
  function convertExpressPath(path2) {
174
190
  return path2.replace(/:([a-zA-Z0-9_*]+)/g, "{$1}");
@@ -188,6 +204,7 @@ var SWG = {
188
204
  formDataSchema,
189
205
  jsonSchema,
190
206
  jsonSchemaRef,
207
+ singleFileSchema,
191
208
  security,
192
209
  securitySchemes
193
210
  };
package/dist/index.mjs CHANGED
@@ -30,7 +30,7 @@ var SwaggerBuilder = class {
30
30
  this.swaggerDoc.security = globalAuthMethods;
31
31
  return this;
32
32
  }
33
- get() {
33
+ build() {
34
34
  return this.swaggerDoc;
35
35
  }
36
36
  };
@@ -70,6 +70,22 @@ var security = (name) => {
70
70
  }
71
71
  return securityRegistry[name];
72
72
  };
73
+ function singleFileSchema(field = "file", required = true) {
74
+ const schema = {
75
+ type: "object",
76
+ properties: {
77
+ [field]: { type: "string", format: "binary" }
78
+ },
79
+ ...required && { required: [field] }
80
+ };
81
+ return {
82
+ content: {
83
+ "multipart/form-data": {
84
+ schema
85
+ }
86
+ }
87
+ };
88
+ }
73
89
  function formDataSchema(schema) {
74
90
  return {
75
91
  content: {
@@ -107,7 +123,7 @@ function queryParam(id, schema, required = true, description = "", name) {
107
123
  return param("query", id, schema, required, description, name);
108
124
  }
109
125
  function headerParam(id, schema, required = true, description = "", name) {
110
- return param("headers", id, schema, required, description, name);
126
+ return param("header", id, schema, required, description, name);
111
127
  }
112
128
  function convertExpressPath(path2) {
113
129
  return path2.replace(/:([a-zA-Z0-9_*]+)/g, "{$1}");
@@ -127,6 +143,7 @@ var SWG = {
127
143
  formDataSchema,
128
144
  jsonSchema,
129
145
  jsonSchemaRef,
146
+ singleFileSchema,
130
147
  security,
131
148
  securitySchemes
132
149
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@extk/expressive",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "type": "commonjs",
5
5
  "publishConfig": {
6
6
  "access": "public"