honertia 0.1.5 → 0.1.6

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
@@ -23,10 +23,15 @@ I wanted to build on Cloudflare Workers whilst retaining the ergonomics of the L
23
23
  bun add honertia
24
24
  ```
25
25
 
26
- ## Quick Start
26
+ ### Demo
27
+
28
+ Deploy the honertia-worker-demo repo to Cloudflare
27
29
 
28
30
  [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/PatrickOgilvie/honertia-worker-demo)
29
31
 
32
+ ## Quick Start
33
+
34
+
30
35
  ### Recommended File Structure
31
36
 
32
37
  ```
@@ -429,6 +434,225 @@ vite.hmrHead() // HMR preamble script tags for React Fast Refresh
429
434
  - **Dependencies**:
430
435
  - `effect` >= 3.12.0
431
436
 
437
+ ## Anatomy of an Action
438
+
439
+ Actions in Honertia are fully composable Effect computations. Instead of using different action factories for different combinations of features, you opt-in to exactly what you need by yielding services and helpers inside your action.
440
+
441
+ This design is inspired by Laravel's [laravel-actions](https://laravelactions.com/) package, where you opt-in to capabilities by adding methods to your action class. In Honertia, you opt-in by yielding services - the order of your `yield*` statements determines the execution order.
442
+
443
+ ### The `action` Wrapper
444
+
445
+ The `action` function is a semantic wrapper that marks an Effect as an action:
446
+
447
+ ```typescript
448
+ import { Effect } from 'effect'
449
+ import { action } from 'honertia/effect'
450
+
451
+ export const myAction = action(
452
+ Effect.gen(function* () {
453
+ // Your action logic here
454
+ return new Response('OK')
455
+ })
456
+ )
457
+ ```
458
+
459
+ It's intentionally minimal - all the power comes from what you yield inside.
460
+
461
+ ### Composable Helpers
462
+
463
+ #### `authorize` - Authentication & Authorization
464
+
465
+ Opt-in to authentication and authorization checks. Returns the authenticated user, fails with `UnauthorizedError` if no user is present, and fails with `ForbiddenError` if the check returns `false`.
466
+
467
+ ```typescript
468
+ import { authorize } from 'honertia/effect'
469
+
470
+ // Just require authentication (any logged-in user)
471
+ const auth = yield* authorize()
472
+
473
+ // Require a specific role
474
+ const auth = yield* authorize((a) => a.user.role === 'admin')
475
+
476
+ // Require resource ownership
477
+ const auth = yield* authorize((a) => a.user.id === project.userId)
478
+ ```
479
+
480
+ If the check function returns `false`, the action fails immediately with a `ForbiddenError`.
481
+
482
+ #### `validateRequest` - Schema Validation
483
+
484
+ Opt-in to request validation using Effect Schema:
485
+
486
+ ```typescript
487
+ import { Schema as S } from 'effect'
488
+ import { validateRequest, requiredString } from 'honertia/effect'
489
+
490
+ const input = yield* validateRequest(
491
+ S.Struct({ name: requiredString, description: S.optional(S.String) }),
492
+ { errorComponent: 'Projects/Create' }
493
+ )
494
+ // input is fully typed: { name: string, description?: string }
495
+ ```
496
+
497
+ On validation failure, re-renders `errorComponent` with field-level errors.
498
+
499
+ #### `DatabaseService` - Database Access
500
+
501
+ Opt-in to database access:
502
+
503
+ ```typescript
504
+ import { DatabaseService } from 'honertia/effect'
505
+
506
+ const db = yield* DatabaseService
507
+ const projects = yield* Effect.tryPromise(() =>
508
+ db.query.projects.findMany()
509
+ )
510
+ ```
511
+
512
+ #### `render` / `redirect` - Responses
513
+
514
+ Return responses from your action:
515
+
516
+ ```typescript
517
+ import { render, redirect } from 'honertia/effect'
518
+
519
+ // Render a page
520
+ return yield* render('Projects/Index', { projects })
521
+
522
+ // Redirect after mutation
523
+ return yield* redirect('/projects')
524
+ ```
525
+
526
+ ### Building an Action
527
+
528
+ Here's how these composables work together:
529
+
530
+ ```typescript
531
+ import { Effect, Schema as S } from 'effect'
532
+ import {
533
+ action,
534
+ authorize,
535
+ validateRequest,
536
+ DatabaseService,
537
+ redirect,
538
+ requiredString,
539
+ } from 'honertia/effect'
540
+
541
+ const CreateProjectSchema = S.Struct({
542
+ name: requiredString,
543
+ description: S.optional(S.String),
544
+ })
545
+
546
+ export const createProject = action(
547
+ Effect.gen(function* () {
548
+ // 1. Authorization - fail fast if not allowed
549
+ const auth = yield* authorize((a) => a.user.role === 'author')
550
+
551
+ // 2. Validation - parse and validate request body
552
+ const input = yield* validateRequest(CreateProjectSchema, {
553
+ errorComponent: 'Projects/Create',
554
+ })
555
+
556
+ // 3. Database - perform the mutation
557
+ const db = yield* DatabaseService
558
+ yield* Effect.tryPromise(() =>
559
+ db.insert(projects).values({
560
+ ...input,
561
+ userId: auth.user.id,
562
+ })
563
+ )
564
+
565
+ // 4. Response - redirect on success
566
+ return yield* redirect('/projects')
567
+ })
568
+ )
569
+ ```
570
+
571
+ ### Execution Order Matters
572
+
573
+ The order you yield services determines when they execute:
574
+
575
+ ```typescript
576
+ // Authorization BEFORE validation (recommended for most actions)
577
+ // Don't waste cycles validating if user can't perform the action
578
+ const auth = yield* authorize((a) => a.user.role === 'admin')
579
+ const input = yield* validateRequest(schema)
580
+
581
+ // Validation BEFORE authorization (when you need to fetch the resource first)
582
+ // Validate the ID format, fetch from DB, then check ownership against the DB record
583
+ const { id } = yield* validateRequest(Schema.Struct({ id: Schema.UUID }))
584
+ const project = yield* db.findProjectById(id)
585
+ const auth = yield* authorize((a) => a.user.id === project.ownerId)
586
+ ```
587
+
588
+ ### Type Safety
589
+
590
+ Effect tracks all service requirements at the type level. Your action's type signature shows exactly what it needs:
591
+
592
+ ```typescript
593
+ // This action requires: RequestService, DatabaseService
594
+ export const createProject: Effect.Effect<
595
+ Response | Redirect,
596
+ ValidationError | UnauthorizedError | ForbiddenError | Error,
597
+ RequestService | DatabaseService
598
+ >
599
+ ```
600
+
601
+ The compiler ensures all required services are provided when the action runs.
602
+ Note: `authorize` uses an optional `AuthUserService`, so it won't appear in the required service list unless you `yield* AuthUserService` directly or provide `RequireAuthLayer` explicitly.
603
+
604
+ ### Minimal Actions
605
+
606
+ Not every action needs all features. Use only what you need:
607
+
608
+ ```typescript
609
+ // Public page - no auth, no validation
610
+ export const showAbout = action(
611
+ Effect.gen(function* () {
612
+ return yield* render('About', {})
613
+ })
614
+ )
615
+
616
+ // Read-only authenticated page
617
+ export const showDashboard = action(
618
+ Effect.gen(function* () {
619
+ const auth = yield* authorize()
620
+ const db = yield* DatabaseService
621
+ const stats = yield* fetchStats(db, auth)
622
+ return yield* render('Dashboard', { stats })
623
+ })
624
+ )
625
+
626
+ // API endpoint with just validation
627
+ export const searchProjects = action(
628
+ Effect.gen(function* () {
629
+ const { query } = yield* validateRequest(S.Struct({ query: S.String }))
630
+ const db = yield* DatabaseService
631
+ const results = yield* search(db, query)
632
+ return yield* json({ results })
633
+ })
634
+ )
635
+ ```
636
+
637
+ ### Helper Utilities
638
+
639
+ #### `dbTransaction` - Database Transactions
640
+
641
+ Run multiple database operations in a transaction with automatic rollback on failure. The database instance is passed explicitly to keep the dependency visible and consistent with other service patterns:
642
+
643
+ ```typescript
644
+ import { DatabaseService, dbTransaction } from 'honertia/effect'
645
+
646
+ const db = yield* DatabaseService
647
+
648
+ yield* dbTransaction(db, async (tx) => {
649
+ await tx.insert(users).values({ name: 'Alice', email: 'alice@example.com' })
650
+ await tx.update(accounts).set({ balance: 100 }).where(eq(accounts.userId, id))
651
+ // If any operation fails, the entire transaction rolls back
652
+ return { success: true }
653
+ })
654
+ ```
655
+
432
656
  ## Core Concepts
433
657
 
434
658
  ### Effect-Based Handlers
@@ -655,6 +879,45 @@ export const createProject = Effect.gen(function* () {
655
879
  })
656
880
  ```
657
881
 
882
+ ### Validation Options
883
+
884
+ `validateRequest` accepts an options object with:
885
+
886
+ ```typescript
887
+ const input = yield* validateRequest(schema, {
888
+ // Re-render this component with errors on validation failure
889
+ // If not set, redirects back to the previous page
890
+ errorComponent: 'Projects/Create',
891
+
892
+ // Override default error messages per field
893
+ messages: {
894
+ name: 'Please enter a project name',
895
+ email: 'That email address is not valid',
896
+ },
897
+
898
+ // Human-readable field names for the :attribute placeholder
899
+ // Use with messages like 'The :attribute field is required'
900
+ attributes: {
901
+ name: 'project name',
902
+ email: 'email address',
903
+ },
904
+ })
905
+ ```
906
+
907
+ **Example with `:attribute` placeholder:**
908
+
909
+ ```typescript
910
+ const schema = S.Struct({
911
+ email: S.String.pipe(S.minLength(1, { message: () => 'The :attribute field is required' })),
912
+ })
913
+
914
+ const input = yield* validateRequest(schema, {
915
+ attributes: { email: 'email address' },
916
+ errorComponent: 'Auth/Register',
917
+ })
918
+ // Error: "The email address field is required"
919
+ ```
920
+
658
921
  ### Available Validators
659
922
 
660
923
  #### Strings
@@ -972,223 +1235,6 @@ export const logoutUser = betterAuthLogoutAction({
972
1235
  2. **better-auth returns an error** → Calls your `errorMapper`, then re-renders `errorComponent` with those errors
973
1236
  3. **Success** → Sets session cookies from better-auth's response headers, then 303 redirects to `redirectTo`
974
1237
 
975
- ## Anatomy of an Action
976
-
977
- Actions in Honertia are fully composable Effect computations. Instead of using different action factories for different combinations of features, you opt-in to exactly what you need by yielding services and helpers inside your action.
978
-
979
- This design is inspired by Laravel's [laravel-actions](https://laravelactions.com/) package, where you opt-in to capabilities by adding methods to your action class. In Honertia, you opt-in by yielding services - the order of your `yield*` statements determines the execution order.
980
-
981
- ### The `action` Wrapper
982
-
983
- The `action` function is a semantic wrapper that marks an Effect as an action:
984
-
985
- ```typescript
986
- import { Effect } from 'effect'
987
- import { action } from 'honertia/effect'
988
-
989
- export const myAction = action(
990
- Effect.gen(function* () {
991
- // Your action logic here
992
- return new Response('OK')
993
- })
994
- )
995
- ```
996
-
997
- It's intentionally minimal - all the power comes from what you yield inside.
998
-
999
- ### Composable Helpers
1000
-
1001
- #### `authorize` - Authentication & Authorization
1002
-
1003
- Opt-in to authentication and authorization checks. Returns the authenticated user, fails with `UnauthorizedError` if no user is present, and fails with `ForbiddenError` if the check returns `false`.
1004
-
1005
- ```typescript
1006
- import { authorize } from 'honertia/effect'
1007
-
1008
- // Just require authentication (any logged-in user)
1009
- const auth = yield* authorize()
1010
-
1011
- // Require a specific role
1012
- const auth = yield* authorize((a) => a.user.role === 'admin')
1013
-
1014
- // Require resource ownership
1015
- const auth = yield* authorize((a) => a.user.id === project.userId)
1016
- ```
1017
-
1018
- If the check function returns `false`, the action fails immediately with a `ForbiddenError`.
1019
-
1020
- #### `validateRequest` - Schema Validation
1021
-
1022
- Opt-in to request validation using Effect Schema:
1023
-
1024
- ```typescript
1025
- import { Schema as S } from 'effect'
1026
- import { validateRequest, requiredString } from 'honertia/effect'
1027
-
1028
- const input = yield* validateRequest(
1029
- S.Struct({ name: requiredString, description: S.optional(S.String) }),
1030
- { errorComponent: 'Projects/Create' }
1031
- )
1032
- // input is fully typed: { name: string, description?: string }
1033
- ```
1034
-
1035
- On validation failure, re-renders `errorComponent` with field-level errors.
1036
-
1037
- #### `DatabaseService` - Database Access
1038
-
1039
- Opt-in to database access:
1040
-
1041
- ```typescript
1042
- import { DatabaseService } from 'honertia/effect'
1043
-
1044
- const db = yield* DatabaseService
1045
- const projects = yield* Effect.tryPromise(() =>
1046
- db.query.projects.findMany()
1047
- )
1048
- ```
1049
-
1050
- #### `render` / `redirect` - Responses
1051
-
1052
- Return responses from your action:
1053
-
1054
- ```typescript
1055
- import { render, redirect } from 'honertia/effect'
1056
-
1057
- // Render a page
1058
- return yield* render('Projects/Index', { projects })
1059
-
1060
- // Redirect after mutation
1061
- return yield* redirect('/projects')
1062
- ```
1063
-
1064
- ### Building an Action
1065
-
1066
- Here's how these composables work together:
1067
-
1068
- ```typescript
1069
- import { Effect, Schema as S } from 'effect'
1070
- import {
1071
- action,
1072
- authorize,
1073
- validateRequest,
1074
- DatabaseService,
1075
- redirect,
1076
- requiredString,
1077
- } from 'honertia/effect'
1078
-
1079
- const CreateProjectSchema = S.Struct({
1080
- name: requiredString,
1081
- description: S.optional(S.String),
1082
- })
1083
-
1084
- export const createProject = action(
1085
- Effect.gen(function* () {
1086
- // 1. Authorization - fail fast if not allowed
1087
- const auth = yield* authorize((a) => a.user.role === 'author')
1088
-
1089
- // 2. Validation - parse and validate request body
1090
- const input = yield* validateRequest(CreateProjectSchema, {
1091
- errorComponent: 'Projects/Create',
1092
- })
1093
-
1094
- // 3. Database - perform the mutation
1095
- const db = yield* DatabaseService
1096
- yield* Effect.tryPromise(() =>
1097
- db.insert(projects).values({
1098
- ...input,
1099
- userId: auth.user.id,
1100
- })
1101
- )
1102
-
1103
- // 4. Response - redirect on success
1104
- return yield* redirect('/projects')
1105
- })
1106
- )
1107
- ```
1108
-
1109
- ### Execution Order Matters
1110
-
1111
- The order you yield services determines when they execute:
1112
-
1113
- ```typescript
1114
- // Authorization BEFORE validation (recommended for mutations)
1115
- // Don't waste cycles validating if user can't perform the action
1116
- const auth = yield* authorize((a) => a.user.role === 'admin')
1117
- const input = yield* validateRequest(schema)
1118
-
1119
- // Validation BEFORE authorization (when you need input for auth check)
1120
- const input = yield* validateRequest(schema)
1121
- const auth = yield* authorize((a) => a.user.id === input.ownerId)
1122
- ```
1123
-
1124
- ### Type Safety
1125
-
1126
- Effect tracks all service requirements at the type level. Your action's type signature shows exactly what it needs:
1127
-
1128
- ```typescript
1129
- // This action requires: RequestService, DatabaseService
1130
- export const createProject: Effect.Effect<
1131
- Response | Redirect,
1132
- ValidationError | UnauthorizedError | ForbiddenError | Error,
1133
- RequestService | DatabaseService
1134
- >
1135
- ```
1136
-
1137
- The compiler ensures all required services are provided when the action runs.
1138
- Note: `authorize` uses an optional `AuthUserService`, so it won't appear in the required service list unless you `yield* AuthUserService` directly or provide `RequireAuthLayer` explicitly.
1139
-
1140
- ### Minimal Actions
1141
-
1142
- Not every action needs all features. Use only what you need:
1143
-
1144
- ```typescript
1145
- // Public page - no auth, no validation
1146
- export const showAbout = action(
1147
- Effect.gen(function* () {
1148
- return yield* render('About', {})
1149
- })
1150
- )
1151
-
1152
- // Read-only authenticated page
1153
- export const showDashboard = action(
1154
- Effect.gen(function* () {
1155
- const auth = yield* authorize()
1156
- const db = yield* DatabaseService
1157
- const stats = yield* fetchStats(db, auth)
1158
- return yield* render('Dashboard', { stats })
1159
- })
1160
- )
1161
-
1162
- // API endpoint with just validation
1163
- export const searchProjects = action(
1164
- Effect.gen(function* () {
1165
- const { query } = yield* validateRequest(S.Struct({ query: S.String }))
1166
- const db = yield* DatabaseService
1167
- const results = yield* search(db, query)
1168
- return yield* json({ results })
1169
- })
1170
- )
1171
- ```
1172
-
1173
- ### Helper Utilities
1174
-
1175
- #### `dbTransaction` - Database Transactions
1176
-
1177
- Run multiple database operations in a transaction with automatic rollback on failure. The database instance is passed explicitly to keep the dependency visible and consistent with other service patterns:
1178
-
1179
- ```typescript
1180
- import { DatabaseService, dbTransaction } from 'honertia/effect'
1181
-
1182
- const db = yield* DatabaseService
1183
-
1184
- yield* dbTransaction(db, async (tx) => {
1185
- await tx.insert(users).values({ name: 'Alice', email: 'alice@example.com' })
1186
- await tx.update(accounts).set({ balance: 100 }).where(eq(accounts.userId, id))
1187
- // If any operation fails, the entire transaction rolls back
1188
- return { success: true }
1189
- })
1190
- ```
1191
-
1192
1238
  ## React Integration
1193
1239
 
1194
1240
  ### Page Component Type
@@ -8,31 +8,53 @@ import { RequestService } from './services.js';
8
8
  import { ValidationError } from './errors.js';
9
9
  /**
10
10
  * Extract validation data from the request.
11
- * Merges route params, query params, and body.
11
+ * Merges route params, query params, and body (body takes precedence).
12
12
  */
13
- export declare const getValidationData: Effect.Effect<{
14
- [x: string]: unknown;
15
- }, never, RequestService>;
13
+ export declare const getValidationData: Effect.Effect<Record<string, unknown>, ValidationError, RequestService>;
16
14
  /**
17
15
  * Format Effect Schema parse errors into field-level validation errors.
18
16
  */
19
17
  export declare function formatSchemaErrors(error: ParseResult.ParseError, messages?: Record<string, string>, attributes?: Record<string, string>): Record<string, string>;
20
18
  /**
21
- * Validate data against a schema.
22
- * Returns validated data or fails with ValidationError.
19
+ * Options for validation functions.
23
20
  */
24
- export declare const validate: <A, I>(schema: S.Schema<A, I>, options?: {
21
+ export interface ValidateOptions {
22
+ /**
23
+ * Custom error messages keyed by field name.
24
+ * Overrides the default messages from Effect Schema.
25
+ *
26
+ * @example
27
+ * { email: 'Please enter a valid email address' }
28
+ */
25
29
  messages?: Record<string, string>;
30
+ /**
31
+ * Human-readable names for fields, used with the `:attribute` placeholder.
32
+ * If a message contains `:attribute`, it will be replaced with the value here.
33
+ *
34
+ * @example
35
+ * // With attributes: { email: 'email address' }
36
+ * // And message: 'The :attribute field is required'
37
+ * // Produces: 'The email address field is required'
38
+ */
26
39
  attributes?: Record<string, string>;
40
+ /**
41
+ * The Inertia component to re-render when validation fails.
42
+ * If set, a ValidationError will trigger a re-render of this component
43
+ * with the errors passed as props. If not set, redirects back.
44
+ *
45
+ * @example
46
+ * 'Projects/Create'
47
+ */
27
48
  errorComponent?: string;
28
- }) => (data: unknown) => Effect.Effect<A, ValidationError, never>;
49
+ }
50
+ /**
51
+ * Validate data against a schema.
52
+ * Returns validated data or fails with ValidationError.
53
+ */
54
+ export declare function validate<A, I>(schema: S.Schema<A, I>, data: unknown, options?: ValidateOptions): Effect.Effect<A, ValidationError, never>;
29
55
  /**
30
56
  * Validate request data against a schema.
31
57
  * Extracts data from request and validates in one step.
32
58
  */
33
- export declare const validateRequest: <A, I>(schema: S.Schema<A, I>, options?: {
34
- messages?: Record<string, string>;
35
- attributes?: Record<string, string>;
36
- errorComponent?: string;
37
- }) => Effect.Effect<A, ValidationError, RequestService>;
59
+ export declare function validateRequest<A, I>(schema: S.Schema<A, I>, options?: ValidateOptions): Effect.Effect<A, ValidationError, RequestService>;
38
60
  //# sourceMappingURL=validation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/effect/validation.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,IAAI,CAAC,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAA;AACzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAC9C,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAE7C;;;GAGG;AACH,eAAO,MAAM,iBAAiB;;yBAuB5B,CAAA;AAEF;;GAEG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,WAAW,CAAC,UAAU,EAC7B,QAAQ,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,EACrC,UAAU,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GACtC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAqBxB;AAED;;;GAGG;AACH,eAAO,MAAM,QAAQ,GAAI,CAAC,EAAE,CAAC,EAC3B,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,EACtB,UAAU;IACR,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACjC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACnC,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,MAEA,MAAM,OAAO,KAAG,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,eAAe,EAAE,KAAK,CAYrD,CAAA;AAEL;;;GAGG;AACH,eAAO,MAAM,eAAe,GAAI,CAAC,EAAE,CAAC,EAClC,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,EACtB,UAAU;IACR,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACjC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACnC,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,KACA,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,eAAe,EAAE,cAAc,CAI/C,CAAA"}
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/effect/validation.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,IAAI,CAAC,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAA;AACzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAC9C,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAE7C;;;GAGG;AACH,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,MAAM,CAC3C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACvB,eAAe,EACf,cAAc,CA0Bd,CAAA;AAEF;;GAEG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,WAAW,CAAC,UAAU,EAC7B,QAAQ,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,EACrC,UAAU,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GACtC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAgBxB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAEjC;;;;;;;;OAQG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAEnC;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EAAE,CAAC,EAC3B,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,EACtB,IAAI,EAAE,OAAO,EACb,OAAO,GAAE,eAAoB,GAC5B,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,eAAe,EAAE,KAAK,CAAC,CAS1C;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,CAAC,EAClC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,EACtB,OAAO,GAAE,eAAoB,GAC5B,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,eAAe,EAAE,cAAc,CAAC,CAKnD"}
@@ -8,46 +8,36 @@ import { RequestService } from './services.js';
8
8
  import { ValidationError } from './errors.js';
9
9
  /**
10
10
  * Extract validation data from the request.
11
- * Merges route params, query params, and body.
11
+ * Merges route params, query params, and body (body takes precedence).
12
12
  */
13
13
  export const getValidationData = Effect.gen(function* () {
14
14
  const request = yield* RequestService;
15
15
  const routeParams = request.params();
16
16
  const queryParams = request.query();
17
- let body = {};
18
- if (!['GET', 'HEAD'].includes(request.method.toUpperCase())) {
19
- const contentType = request.header('Content-Type') || '';
20
- const bodyResult = yield* Effect.tryPromise({
21
- try: () => contentType.includes('application/json')
22
- ? request.json()
23
- : request.parseBody(),
24
- catch: () => ({}),
25
- }).pipe(Effect.catchAll(() => Effect.succeed({})));
26
- body = bodyResult;
17
+ // Only parse body for methods that typically have one
18
+ if (['GET', 'HEAD'].includes(request.method.toUpperCase())) {
19
+ return { ...routeParams, ...queryParams };
27
20
  }
28
- return {
29
- ...routeParams,
30
- ...queryParams,
31
- ...body,
32
- };
21
+ const contentType = request.header('Content-Type') ?? '';
22
+ const isJson = contentType.includes('application/json');
23
+ const body = yield* Effect.tryPromise(() => isJson ? request.json() : request.parseBody()).pipe(Effect.mapError(() => new ValidationError({
24
+ errors: { form: isJson ? 'Invalid JSON body' : 'Could not parse request body' },
25
+ })));
26
+ return { ...routeParams, ...queryParams, ...body };
33
27
  });
34
28
  /**
35
29
  * Format Effect Schema parse errors into field-level validation errors.
36
30
  */
37
31
  export function formatSchemaErrors(error, messages = {}, attributes = {}) {
38
32
  const errors = {};
39
- // Use ArrayFormatter to get structured errors
40
- const formattedErrors = ParseResult.ArrayFormatter.formatErrorSync(error);
41
- for (const issue of formattedErrors) {
42
- const pathStr = issue.path.length > 0
43
- ? issue.path.map(p => typeof p === 'object' && p !== null && 'key' in p ? p.key : String(p)).join('.')
44
- : 'form';
45
- if (errors[pathStr])
33
+ const issues = ParseResult.ArrayFormatter.formatErrorSync(error);
34
+ for (const issue of issues) {
35
+ const field = issue.path.length > 0 ? issue.path.map(String).join('.') : 'form';
36
+ if (errors[field])
46
37
  continue; // First error wins
47
- const attribute = attributes[pathStr] ?? pathStr;
48
- const messageKey = messages[pathStr];
49
- const message = messageKey ?? issue.message;
50
- errors[pathStr] = message.replace(/:attribute/g, attribute);
38
+ const attribute = attributes[field] ?? field;
39
+ const message = messages[field] ?? issue.message;
40
+ errors[field] = message.replace(/:attribute/g, attribute);
51
41
  }
52
42
  return errors;
53
43
  }
@@ -55,15 +45,19 @@ export function formatSchemaErrors(error, messages = {}, attributes = {}) {
55
45
  * Validate data against a schema.
56
46
  * Returns validated data or fails with ValidationError.
57
47
  */
58
- export const validate = (schema, options) => (data) => S.decodeUnknown(schema)(data).pipe(Effect.mapError((error) => new ValidationError({
59
- errors: formatSchemaErrors(error, options?.messages ?? {}, options?.attributes ?? {}),
60
- component: options?.errorComponent,
61
- })));
48
+ export function validate(schema, data, options = {}) {
49
+ return S.decodeUnknown(schema)(data).pipe(Effect.mapError((error) => new ValidationError({
50
+ errors: formatSchemaErrors(error, options.messages, options.attributes),
51
+ component: options.errorComponent,
52
+ })));
53
+ }
62
54
  /**
63
55
  * Validate request data against a schema.
64
56
  * Extracts data from request and validates in one step.
65
57
  */
66
- export const validateRequest = (schema, options) => Effect.gen(function* () {
67
- const data = yield* getValidationData;
68
- return yield* validate(schema, options)(data);
69
- });
58
+ export function validateRequest(schema, options = {}) {
59
+ return Effect.gen(function* () {
60
+ const data = yield* getValidationData;
61
+ return yield* validate(schema, data, options);
62
+ });
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "honertia",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Inertia.js-style server-driven SPA adapter for Hono",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",