@onebun/core 0.1.7 → 0.1.8
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/package.json +1 -1
- package/src/decorators/decorators.test.ts +128 -1
- package/src/decorators/decorators.ts +135 -47
- package/src/index.ts +1 -0
- package/src/types.ts +13 -0
package/package.json
CHANGED
|
@@ -481,7 +481,7 @@ describe('decorators', () => {
|
|
|
481
481
|
expect(route?.params?.[0].type).toBe(ParamType.RESPONSE);
|
|
482
482
|
});
|
|
483
483
|
|
|
484
|
-
test('should handle parameter
|
|
484
|
+
test('should handle parameter with schema (optional by default for Query)', () => {
|
|
485
485
|
@Controller()
|
|
486
486
|
class TestController {
|
|
487
487
|
@Get()
|
|
@@ -490,6 +490,37 @@ describe('decorators', () => {
|
|
|
490
490
|
) {}
|
|
491
491
|
}
|
|
492
492
|
|
|
493
|
+
const metadata = getControllerMetadata(TestController);
|
|
494
|
+
const route = metadata?.routes[0];
|
|
495
|
+
const param = route?.params?.[0];
|
|
496
|
+
expect(param?.isRequired).toBe(false); // Query is optional by default
|
|
497
|
+
expect(param?.schema).toBeDefined();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('should make Query required with { required: true } option', () => {
|
|
501
|
+
@Controller()
|
|
502
|
+
class TestController {
|
|
503
|
+
@Get()
|
|
504
|
+
test(
|
|
505
|
+
@Query('filter', { required: true }) filter: string,
|
|
506
|
+
) {}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const metadata = getControllerMetadata(TestController);
|
|
510
|
+
const route = metadata?.routes[0];
|
|
511
|
+
const param = route?.params?.[0];
|
|
512
|
+
expect(param?.isRequired).toBe(true);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('should make Query with schema required with { required: true } option', () => {
|
|
516
|
+
@Controller()
|
|
517
|
+
class TestController {
|
|
518
|
+
@Get()
|
|
519
|
+
test(
|
|
520
|
+
@Query('filter', type('string'), { required: true }) filter: string,
|
|
521
|
+
) {}
|
|
522
|
+
}
|
|
523
|
+
|
|
493
524
|
const metadata = getControllerMetadata(TestController);
|
|
494
525
|
const route = metadata?.routes[0];
|
|
495
526
|
const param = route?.params?.[0];
|
|
@@ -497,6 +528,102 @@ describe('decorators', () => {
|
|
|
497
528
|
expect(param?.schema).toBeDefined();
|
|
498
529
|
});
|
|
499
530
|
|
|
531
|
+
test('should make Header optional by default', () => {
|
|
532
|
+
@Controller()
|
|
533
|
+
class TestController {
|
|
534
|
+
@Get()
|
|
535
|
+
test(
|
|
536
|
+
@Header('X-Token') token: string,
|
|
537
|
+
) {}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const metadata = getControllerMetadata(TestController);
|
|
541
|
+
const route = metadata?.routes[0];
|
|
542
|
+
const param = route?.params?.[0];
|
|
543
|
+
expect(param?.isRequired).toBe(false);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test('should make Header required with { required: true } option', () => {
|
|
547
|
+
@Controller()
|
|
548
|
+
class TestController {
|
|
549
|
+
@Get()
|
|
550
|
+
test(
|
|
551
|
+
@Header('Authorization', { required: true }) auth: string,
|
|
552
|
+
) {}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const metadata = getControllerMetadata(TestController);
|
|
556
|
+
const route = metadata?.routes[0];
|
|
557
|
+
const param = route?.params?.[0];
|
|
558
|
+
expect(param?.isRequired).toBe(true);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test('should make Param always required (OpenAPI spec)', () => {
|
|
562
|
+
@Controller()
|
|
563
|
+
class TestController {
|
|
564
|
+
@Get(':id')
|
|
565
|
+
test(
|
|
566
|
+
@Param('id') id: string,
|
|
567
|
+
) {}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const metadata = getControllerMetadata(TestController);
|
|
571
|
+
const route = metadata?.routes[0];
|
|
572
|
+
const param = route?.params?.[0];
|
|
573
|
+
expect(param?.isRequired).toBe(true);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test('should determine Body required from schema (not accepting undefined)', () => {
|
|
577
|
+
const bodySchema = type({ name: 'string' });
|
|
578
|
+
|
|
579
|
+
@Controller()
|
|
580
|
+
class TestController {
|
|
581
|
+
@Post()
|
|
582
|
+
test(
|
|
583
|
+
@Body(bodySchema) data: { name: string },
|
|
584
|
+
) {}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const metadata = getControllerMetadata(TestController);
|
|
588
|
+
const route = metadata?.routes[0];
|
|
589
|
+
const param = route?.params?.[0];
|
|
590
|
+
expect(param?.isRequired).toBe(true); // Schema doesn't accept undefined
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test('should determine Body optional from schema (accepting undefined)', () => {
|
|
594
|
+
const bodySchema = type({ name: 'string' }).or(type.undefined);
|
|
595
|
+
|
|
596
|
+
@Controller()
|
|
597
|
+
class TestController {
|
|
598
|
+
@Post()
|
|
599
|
+
test(
|
|
600
|
+
@Body(bodySchema) data: { name: string } | undefined,
|
|
601
|
+
) {}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const metadata = getControllerMetadata(TestController);
|
|
605
|
+
const route = metadata?.routes[0];
|
|
606
|
+
const param = route?.params?.[0];
|
|
607
|
+
expect(param?.isRequired).toBe(false); // Schema accepts undefined
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test('should allow explicit override of Body required', () => {
|
|
611
|
+
const bodySchema = type({ name: 'string' });
|
|
612
|
+
|
|
613
|
+
@Controller()
|
|
614
|
+
class TestController {
|
|
615
|
+
@Post()
|
|
616
|
+
test(
|
|
617
|
+
@Body(bodySchema, { required: false }) data: { name: string },
|
|
618
|
+
) {}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const metadata = getControllerMetadata(TestController);
|
|
622
|
+
const route = metadata?.routes[0];
|
|
623
|
+
const param = route?.params?.[0];
|
|
624
|
+
expect(param?.isRequired).toBe(false); // Explicitly set to optional
|
|
625
|
+
});
|
|
626
|
+
|
|
500
627
|
test('should handle multiple parameters', () => {
|
|
501
628
|
@Controller()
|
|
502
629
|
class TestController {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import './metadata'; // Import polyfill first
|
|
2
|
-
import type
|
|
2
|
+
import { type, type Type } from 'arktype';
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
type ControllerMetadata,
|
|
6
6
|
HttpMethod,
|
|
7
|
+
type ParamDecoratorOptions,
|
|
7
8
|
type ParamMetadata,
|
|
8
9
|
ParamType,
|
|
9
10
|
} from '../types';
|
|
@@ -193,7 +194,7 @@ export function controllerDecorator(basePath: string = '') {
|
|
|
193
194
|
* Usage: constructor(\@Inject(CounterService) private counterService: CounterService)
|
|
194
195
|
*/
|
|
195
196
|
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any
|
|
196
|
-
export function Inject<T>(
|
|
197
|
+
export function Inject<T>(serviceType: new (...args: any[]) => T) {
|
|
197
198
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
|
|
198
199
|
return (target: any, propertyKey: string | symbol | undefined, parameterIndex: number): void => {
|
|
199
200
|
// Get existing dependencies or create new array
|
|
@@ -206,7 +207,7 @@ export function Inject<T>(type: new (...args: any[]) => T) {
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
// Set the explicit type
|
|
209
|
-
existingDeps[parameterIndex] =
|
|
210
|
+
existingDeps[parameterIndex] = serviceType;
|
|
210
211
|
META_CONSTRUCTOR_PARAMS.set(target, existingDeps);
|
|
211
212
|
};
|
|
212
213
|
}
|
|
@@ -301,62 +302,135 @@ function createRouteDecorator(method: HttpMethod) {
|
|
|
301
302
|
};
|
|
302
303
|
}
|
|
303
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Helper function to check if a schema accepts undefined
|
|
307
|
+
* Used to determine if @Body is required by default
|
|
308
|
+
*/
|
|
309
|
+
function schemaAcceptsUndefined(schema: Type<unknown>): boolean {
|
|
310
|
+
return !(schema(undefined) instanceof type.errors);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Helper function to check if value is an arktype schema
|
|
315
|
+
*/
|
|
316
|
+
function isArkTypeSchema(value: unknown): value is Type<unknown> {
|
|
317
|
+
if (!value) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ArkType schemas are functions with 'kind' property
|
|
322
|
+
return (
|
|
323
|
+
typeof value === 'function' &&
|
|
324
|
+
('kind' in value || 'impl' in value || typeof (value as Type<unknown>)({}) !== 'undefined')
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Helper function to check if value is options object
|
|
330
|
+
*/
|
|
331
|
+
function isOptions(value: unknown): value is ParamDecoratorOptions {
|
|
332
|
+
if (!value || typeof value !== 'object') {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
// Options object has 'required' property or is an empty object
|
|
336
|
+
const keys = Object.keys(value);
|
|
337
|
+
|
|
338
|
+
return keys.length === 0 || keys.every((k) => k === 'required');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Determine isRequired based on param type and options
|
|
343
|
+
* - PATH: always true (OpenAPI spec)
|
|
344
|
+
* - BODY: options?.required ?? !schemaAcceptsUndefined(schema) (determined from schema)
|
|
345
|
+
* - QUERY, HEADER: options?.required ?? false (optional by default)
|
|
346
|
+
*/
|
|
347
|
+
function determineIsRequired(
|
|
348
|
+
paramType: ParamType,
|
|
349
|
+
schema: Type<unknown> | undefined,
|
|
350
|
+
options: ParamDecoratorOptions | undefined,
|
|
351
|
+
): boolean {
|
|
352
|
+
// PATH parameters are always required per OpenAPI spec
|
|
353
|
+
if (paramType === ParamType.PATH) {
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// If options explicitly set required, use that
|
|
358
|
+
if (options?.required !== undefined) {
|
|
359
|
+
return options.required;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// For BODY, determine from schema
|
|
363
|
+
if (paramType === ParamType.BODY && schema) {
|
|
364
|
+
return !schemaAcceptsUndefined(schema);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// QUERY, HEADER are optional by default
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
|
|
304
371
|
/**
|
|
305
372
|
* Create parameter decorator factory
|
|
306
|
-
* Supports
|
|
373
|
+
* Supports multiple signatures:
|
|
374
|
+
* - \@Param('id') - path parameter (always required)
|
|
375
|
+
* - \@Query('name') - optional by default
|
|
376
|
+
* - \@Query('name', { required: true }) - explicitly required
|
|
377
|
+
* - \@Query('name', schema) - with validation, optional by default
|
|
378
|
+
* - \@Query('name', schema, { required: true }) - with validation, explicitly required
|
|
379
|
+
* - \@Body(schema) - required determined from schema
|
|
380
|
+
* - \@Body(schema, { required: false }) - explicitly optional
|
|
381
|
+
* - \@Header('X-Token') - optional by default
|
|
382
|
+
* - \@Header('X-Token', { required: true }) - explicitly required
|
|
307
383
|
*/
|
|
308
384
|
function createParamDecorator(paramType: ParamType) {
|
|
309
385
|
return (
|
|
310
|
-
nameOrSchema?: string | Type<unknown
|
|
311
|
-
|
|
386
|
+
nameOrSchema?: string | Type<unknown> | ParamDecoratorOptions,
|
|
387
|
+
schemaOrOptions?: Type<unknown> | ParamDecoratorOptions,
|
|
388
|
+
options?: ParamDecoratorOptions,
|
|
312
389
|
) =>
|
|
313
390
|
(target: object, propertyKey: string, parameterIndex: number) => {
|
|
314
391
|
const params: ParamMetadata[] =
|
|
315
392
|
Reflect.getMetadata(PARAMS_METADATA, target, propertyKey) || [];
|
|
316
393
|
|
|
317
394
|
let metadata: ParamMetadata;
|
|
395
|
+
let name = '';
|
|
396
|
+
let schema: Type<unknown> | undefined;
|
|
397
|
+
let opts: ParamDecoratorOptions | undefined;
|
|
318
398
|
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
399
|
+
// Parse arguments based on their types
|
|
400
|
+
// Case 1: @Body(schema) or @Body(schema, options)
|
|
401
|
+
if (isArkTypeSchema(nameOrSchema)) {
|
|
402
|
+
schema = nameOrSchema;
|
|
403
|
+
if (isOptions(schemaOrOptions)) {
|
|
404
|
+
opts = schemaOrOptions;
|
|
405
|
+
}
|
|
406
|
+
} else if (typeof nameOrSchema === 'string' && isOptions(schemaOrOptions)) {
|
|
407
|
+
// Case 2: @Query('name', options) - name + options, no schema
|
|
408
|
+
name = nameOrSchema;
|
|
409
|
+
opts = schemaOrOptions;
|
|
410
|
+
} else if (typeof nameOrSchema === 'string' && isArkTypeSchema(schemaOrOptions)) {
|
|
411
|
+
// Case 3: @Query('name', schema) or @Query('name', schema, options)
|
|
412
|
+
name = nameOrSchema;
|
|
413
|
+
schema = schemaOrOptions;
|
|
414
|
+
if (isOptions(options)) {
|
|
415
|
+
opts = options;
|
|
323
416
|
}
|
|
417
|
+
} else if (typeof nameOrSchema === 'string') {
|
|
418
|
+
// Case 4: @Query('name') or @Body() - simple case
|
|
419
|
+
name = nameOrSchema;
|
|
420
|
+
} else if (isOptions(nameOrSchema)) {
|
|
421
|
+
// Case 5: @Query(options) - options only (edge case)
|
|
422
|
+
opts = nameOrSchema;
|
|
423
|
+
}
|
|
324
424
|
|
|
325
|
-
|
|
326
|
-
return (
|
|
327
|
-
typeof value === 'function' &&
|
|
328
|
-
('kind' in value || 'impl' in value || typeof (value as Type<unknown>)({}) !== 'undefined')
|
|
329
|
-
);
|
|
330
|
-
};
|
|
425
|
+
const isRequired = determineIsRequired(paramType, schema, opts);
|
|
331
426
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
schema: schemaValue,
|
|
340
|
-
isRequired: true,
|
|
341
|
-
};
|
|
342
|
-
} else if (typeof nameOrSchema === 'string' && isArkTypeSchema(schema)) {
|
|
343
|
-
// @Param('id', schema) or @Query('filter', schema)
|
|
344
|
-
metadata = {
|
|
345
|
-
type: paramType,
|
|
346
|
-
name: nameOrSchema,
|
|
347
|
-
index: parameterIndex,
|
|
348
|
-
schema: schema as Type<unknown>,
|
|
349
|
-
isRequired: true,
|
|
350
|
-
};
|
|
351
|
-
} else {
|
|
352
|
-
// Simple case: @Param('id') or @Query('filter') - no schema, no validation
|
|
353
|
-
const name = typeof nameOrSchema === 'string' ? nameOrSchema : '';
|
|
354
|
-
metadata = {
|
|
355
|
-
type: paramType,
|
|
356
|
-
name,
|
|
357
|
-
index: parameterIndex,
|
|
358
|
-
};
|
|
359
|
-
}
|
|
427
|
+
metadata = {
|
|
428
|
+
type: paramType,
|
|
429
|
+
name,
|
|
430
|
+
index: parameterIndex,
|
|
431
|
+
schema,
|
|
432
|
+
isRequired,
|
|
433
|
+
};
|
|
360
434
|
|
|
361
435
|
params.push(metadata);
|
|
362
436
|
|
|
@@ -366,28 +440,42 @@ function createParamDecorator(paramType: ParamType) {
|
|
|
366
440
|
|
|
367
441
|
/**
|
|
368
442
|
* Path parameter decorator
|
|
443
|
+
* Path parameters are always required per OpenAPI spec
|
|
369
444
|
* @example \@Param('id')
|
|
445
|
+
* @example \@Param('id', idSchema) - with validation
|
|
370
446
|
*/
|
|
371
447
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
372
448
|
export const Param = createParamDecorator(ParamType.PATH);
|
|
373
449
|
|
|
374
450
|
/**
|
|
375
451
|
* Query parameter decorator
|
|
376
|
-
*
|
|
452
|
+
* Optional by default, use { required: true } for required parameters
|
|
453
|
+
* @example \@Query('filter') - optional
|
|
454
|
+
* @example \@Query('filter', { required: true }) - required
|
|
455
|
+
* @example \@Query('filter', schema) - optional with validation
|
|
456
|
+
* @example \@Query('filter', schema, { required: true }) - required with validation
|
|
377
457
|
*/
|
|
378
458
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
379
459
|
export const Query = createParamDecorator(ParamType.QUERY);
|
|
380
460
|
|
|
381
461
|
/**
|
|
382
462
|
* Body parameter decorator
|
|
383
|
-
*
|
|
463
|
+
* Required is determined from schema (accepts undefined = optional)
|
|
464
|
+
* @example \@Body(schema) - required if schema doesn't accept undefined
|
|
465
|
+
* @example \@Body(schema.or(type.undefined)) - optional (schema accepts undefined)
|
|
466
|
+
* @example \@Body(schema, { required: false }) - explicitly optional
|
|
467
|
+
* @example \@Body(schema, { required: true }) - explicitly required
|
|
384
468
|
*/
|
|
385
469
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
386
470
|
export const Body = createParamDecorator(ParamType.BODY);
|
|
387
471
|
|
|
388
472
|
/**
|
|
389
473
|
* Header parameter decorator
|
|
390
|
-
*
|
|
474
|
+
* Optional by default, use { required: true } for required parameters
|
|
475
|
+
* @example \@Header('Authorization') - optional
|
|
476
|
+
* @example \@Header('Authorization', { required: true }) - required
|
|
477
|
+
* @example \@Header('Authorization', schema) - optional with validation
|
|
478
|
+
* @example \@Header('Authorization', schema, { required: true }) - required with validation
|
|
391
479
|
*/
|
|
392
480
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
393
481
|
export const Header = createParamDecorator(ParamType.HEADER);
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -452,6 +452,19 @@ export enum ParamType {
|
|
|
452
452
|
RESPONSE = 'response',
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Options for parameter decorators (@Query, @Header, @Body, etc.)
|
|
457
|
+
*/
|
|
458
|
+
export interface ParamDecoratorOptions {
|
|
459
|
+
/**
|
|
460
|
+
* Whether the parameter is required
|
|
461
|
+
* - @Param: always true (OpenAPI spec requirement)
|
|
462
|
+
* - @Query, @Header: false by default
|
|
463
|
+
* - @Body: determined from schema (accepts undefined = optional)
|
|
464
|
+
*/
|
|
465
|
+
required?: boolean;
|
|
466
|
+
}
|
|
467
|
+
|
|
455
468
|
/**
|
|
456
469
|
* Parameter metadata
|
|
457
470
|
*/
|