@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/core",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -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 options', () => {
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 { Type } from 'arktype';
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>(type: new (...args: any[]) => 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] = type;
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 \@Body(schema), \@Query(schema), \@Param('id', schema) or \@Param('id') for simple cases
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
- schema?: Type<unknown>,
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
- // Helper function to check if value is an arktype schema
320
- const isArkTypeSchema = (value: unknown): value is Type<unknown> => {
321
- if (!value) {
322
- return false;
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
- // ArkType schemas are functions with 'kind' property
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
- // @Body(schema) or @Query(schema) - first argument is a schema
333
- if (isArkTypeSchema(nameOrSchema)) {
334
- const schemaValue = nameOrSchema as Type<unknown>;
335
- metadata = {
336
- type: paramType,
337
- name: '',
338
- index: parameterIndex,
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
- * @example \@Query('filter')
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
- * @example \@Body()
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
- * @example \@Header('Authorization')
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
@@ -25,6 +25,7 @@ export {
25
25
  type ModuleInstance,
26
26
  type TypedEnvSchema,
27
27
  type ApplicationOptions,
28
+ type ParamDecoratorOptions,
28
29
  type ParamMetadata,
29
30
  type ResponseSchemaMetadata,
30
31
  type RouteMetadata,
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
  */