Skip to content

Commit dc5dcec

Browse files
committed
Support for enum error messages
1 parent d00327a commit dc5dcec

File tree

7 files changed

+118
-16
lines changed

7 files changed

+118
-16
lines changed

src/Data/ResolvedValidationSet.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,23 +70,31 @@ public static function make(
7070
*/
7171
public function hasValidation(string $rule): bool
7272
{
73-
return $this->validations->toCollection()->first(fn (ResolvedValidation $v) => $v->rule === $rule) !== null;
73+
return $this->getValidation($rule) !== null;
7474
}
7575

7676
/**
7777
* Get a specific validation by rule name
7878
*/
7979
public function getValidation(string $rule): ?ResolvedValidation
8080
{
81-
return $this->validations->toCollection()->first(fn (ResolvedValidation $v) => $v->rule === $rule);
81+
$normalizedRule = strtolower($rule);
82+
83+
return $this->validations
84+
->toCollection()
85+
->first(fn (ResolvedValidation $v) => strtolower($v->rule) === $normalizedRule);
8286
}
8387

8488
/**
8589
* Get all validations with a specific rule name (for rules that can appear multiple times)
8690
*/
8791
public function getValidations(string $rule): Collection
8892
{
89-
return $this->validations->toCollection()->filter(fn (ResolvedValidation $v) => $v->rule === $rule);
93+
$normalizedRule = strtolower($rule);
94+
95+
return $this->validations
96+
->toCollection()
97+
->filter(fn (ResolvedValidation $v) => strtolower($v->rule) === $normalizedRule);
9098
}
9199

92100
/**

src/Services/MessageResolutionService.php

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,10 @@ public function resolveCustomMessage(): string
6868
throw new \InvalidArgumentException('The field, rule name, and validator must be set using with() prior to calling resolveCustomMessage.');
6969
}
7070

71-
// Check for custom message first
72-
$customMessageKey = $this->field.'.'.lcfirst($this->ruleName);
71+
$customMessage = $this->findCustomMessageForRule();
7372

74-
if (isset($this->validator->customMessages[$customMessageKey])) {
75-
return $this->validator->customMessages[$customMessageKey];
73+
if ($customMessage !== null) {
74+
return $customMessage;
7675
}
7776

7877
// Fall back to default Laravel message
@@ -252,6 +251,47 @@ private function applyMessageReplacements(string $message, array $parameters): s
252251
return $this->ensureDisplayableAttribute($replaced);
253252
}
254253

254+
/**
255+
* Locate a custom message for the current rule, including known aliases.
256+
*/
257+
private function findCustomMessageForRule(): ?string
258+
{
259+
foreach ($this->possibleCustomMessageKeys() as $customMessageKey) {
260+
if (isset($this->validator->customMessages[$customMessageKey])) {
261+
return $this->validator->customMessages[$customMessageKey];
262+
}
263+
}
264+
265+
return null;
266+
}
267+
268+
/**
269+
* Build the list of possible custom message keys to check.
270+
*/
271+
private function possibleCustomMessageKeys(): array
272+
{
273+
$baseRule = lcfirst($this->ruleName);
274+
$keys = [$this->field.'.'.$baseRule];
275+
276+
foreach ($this->getRuleMessageAliases($baseRule) as $alias) {
277+
$keys[] = $this->field.'.'.$alias;
278+
}
279+
280+
return array_values(array_unique($keys));
281+
}
282+
283+
/**
284+
* Return aliases for a validation rule where Laravel normalizes the rule name.
285+
*/
286+
private function getRuleMessageAliases(string $ruleName): array
287+
{
288+
return match (strtolower($ruleName)) {
289+
'in' => ['enum'],
290+
'enum' => ['in'],
291+
default => [],
292+
};
293+
}
294+
255295
private function ensureDisplayableAttribute(string $message): string
256296
{
257297
$displayable = $this->getDisplayableAttribute();

src/TypeHandlers/EnumTypeHandler.php

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,18 @@ public function handle(SchemaPropertyData $property): BuilderInterface
5757
}
5858

5959
// Check for enum error messages
60-
if (str_starts_with($type, 'enum:') && $validations->getMessage('enum')) {
61-
$builder->message($validations->getMessage('enum'));
62-
} elseif ($validations->hasValidation('in') && $validations->getMessage('in')) {
63-
$builder->message($validations->getMessage('in'));
60+
$enumMessage = null;
61+
62+
if (str_starts_with($type, 'enum:')) {
63+
$enumMessage = $validations->getMessage('Enum');
64+
}
65+
66+
if ($enumMessage === null && $validations->hasValidation('In')) {
67+
$enumMessage = $validations->getMessage('In');
68+
}
69+
70+
if ($enumMessage !== null) {
71+
$builder->message($enumMessage);
6472
}
6573

6674
// Handle nullable

tests/Feature/EnumZodGenerationTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use PHPUnit\Framework\Attributes\Test;
66
use RomegaSoftware\LaravelSchemaGenerator\Extractors\RequestClassExtractor;
77
use RomegaSoftware\LaravelSchemaGenerator\Generators\ValidationSchemaGenerator;
8+
use RomegaSoftware\LaravelSchemaGenerator\Tests\Fixtures\FormRequests\EnumWithCustomMessageRequest;
89
use RomegaSoftware\LaravelSchemaGenerator\Tests\Fixtures\FormRequests\UnifiedValidationRequest;
910
use RomegaSoftware\LaravelSchemaGenerator\Tests\TestCase;
1011
use RomegaSoftware\LaravelSchemaGenerator\Tests\Traits\InteractsWithExtractors;
@@ -35,4 +36,18 @@ public function it_generates_z_enum_for_laravel_enum_rule(): void
3536
$this->assertStringNotContainsString('status: z.enum(["pending", "approved", "rejected"], { message: "The metadata.status field is required." }).optional()', $schema,
3637
'Required status field should not be optional');
3738
}
39+
40+
#[Test]
41+
public function it_uses_custom_messages_for_enum_rules(): void
42+
{
43+
$reflection = new \ReflectionClass(EnumWithCustomMessageRequest::class);
44+
$extracted = $this->getRequestExtractor()->extract($reflection);
45+
$schema = $this->generator->generate($extracted);
46+
47+
$this->assertStringContainsString(
48+
'status: z.enum(["pending", "active", "inactive", "deleted"], { message: "Please choose a valid state from the available options." }).nullable().optional()',
49+
$schema,
50+
'Enum fields should include custom messages defined on the form request'
51+
);
52+
}
3853
}

tests/Fixtures/Expected/unified-schemas.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { z } from 'zod';
22

33
export const UnifiedValidationRequestSchema = z.object({
4-
auth_type: z.enum(["password", "otp"]),
4+
auth_type: z.enum(["password", "otp"], { message: "The selected auth type is invalid." }),
55
attachments: z.array(z.object({ file: z.file().mime(['image/jpeg', 'image/png', 'application/pdf'], 'The attachments.*.file field must be a file of type: jpg, png, pdf, attachments.*.file.').max(5242880, 'The attachments.*.file field must not be greater than 5120 kilobytes.'), description: z.string().max(255, 'The attachments.*.description field must not be greater than 255 characters.').trim().nullable().optional() })).nullable().optional(),
66
credentials: z.object({ email: z.email({ error: 'The credentials.email field must be a valid email address.' }).trim().min(1, 'Email is required.').max(255, 'The credentials.email field must not be greater than 255 characters.'), password: z.string().min(8, 'The credentials.password field must be at least 8 characters.').trim().nullable().optional(), otp: z.number().refine((val) => {const str = String(Math.abs(Math.floor(val))); return str.length === 6; }, { message: 'The credentials.otp field must be 6 digits.' }).nullable().optional() }).optional(),
77
profile: z.object({ name: z.string({ error: 'The profile.name field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The profile.name field is required.' }).min(1, 'The profile.name field is required.').max(150, 'The profile.name field must not be greater than 150 characters.'), bio: z.string().max(500, 'The profile.bio field must not be greater than 500 characters.').trim().nullable().optional(), website: z.preprocess((val) => (val === '' ? undefined : val), z.url({ error: 'The profile.website field must be a valid URL.' }).nullable().optional()), timezone: z.string({ error: 'The profile.timezone field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The profile.timezone field is required.' }).min(1, 'The profile.timezone field is required.'), preferences: z.object({ accepted_terms: z.string().refine((val) => { if (val === undefined || val === null) { return false; } if (typeof val === "string") { const normalized = val.toLowerCase(); if (normalized === "yes" || normalized === "on" || normalized === "true" || normalized === "1") { return true; } } return val === true || val === 1; }, { message: 'You must accept the terms.' }).trim().optional(), tags: z.array(z.enum(["news", "updates", "offers"])).min(1, 'The profile.preferences.tags field must have at least 1 items.').max(5, 'The profile.preferences.tags field must not have more than 5 items.').refine((values) => { if (!Array.isArray(values)) { return true; } const ignoreCase = false; const strict = false; for (let i = 0; i < values.length; i++) { for (let j = i + 1; j < values.length; j++) { const left = values[i]; const right = values[j]; if (ignoreCase && typeof left === "string" && typeof right === "string") { if (left.localeCompare(right, undefined, { sensitivity: "accent" }) === 0) { return false; } continue; } if (strict ? left === right : left == right) { return false; } } } return true; }, { message: 'The profile.preferences.tags field has a duplicate value.' }).optional() }).optional(), contacts: z.array(z.object({ email: z.email({ error: 'The profile.contacts.*.email field must be a valid email address.' }).trim().min(1, 'The profile.contacts.*.email field is required.'), phone: z.string().trim().nullable().optional(), label: z.enum(["primary", "backup"], { message: "The profile.contacts.*.label field is required." }) })).min(1, 'The profile.contacts field must have at least 1 items.').optional(), address: z.object({ street: z.string({ error: 'The profile.address.street field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The profile.address.street field is required.' }).min(1, 'The profile.address.street field is required.'), city: z.string({ error: 'The profile.address.city field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The profile.address.city field is required.' }).min(1, 'The profile.address.city field is required.'), postal_code: z.string({ error: 'The profile.address.postal code field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The profile.address.postal code field is required.' }).min(1, 'The profile.address.postal code field is required.').regex(/^[0-9]{5}$/, 'Postal code must be exactly 5 digits.'), country: z.string({ error: 'The profile.address.country field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The profile.address.country field is required.' }).min(1, 'The profile.address.country field is required.').length(2, 'The profile.address.country field must be 2 characters.') }).optional() }).optional(),
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace RomegaSoftware\LaravelSchemaGenerator\Tests\Fixtures\FormRequests;
4+
5+
use Illuminate\Foundation\Http\FormRequest;
6+
use Illuminate\Validation\Rule;
7+
use RomegaSoftware\LaravelSchemaGenerator\Attributes\ValidationSchema;
8+
use RomegaSoftware\LaravelSchemaGenerator\Tests\Fixtures\Enums\TestStatusEnum;
9+
10+
#[ValidationSchema(name: 'EnumWithCustomMessageRequestSchema')]
11+
class EnumWithCustomMessageRequest extends FormRequest
12+
{
13+
public function rules(): array
14+
{
15+
return [
16+
'status' => ['nullable', Rule::enum(TestStatusEnum::class)],
17+
];
18+
}
19+
20+
public function messages(): array
21+
{
22+
return [
23+
'status.enum' => 'Please choose a valid state from the available options.',
24+
];
25+
}
26+
27+
public function authorize(): bool
28+
{
29+
return true;
30+
}
31+
}

tests/Integration/EnumValidationIntegrationTest.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,10 @@ public function test_enum_validation_consistency_between_root_and_nested()
4141
$schema = $this->generator->generate($extracted, 'TestRequestWithRootEnumSchema');
4242

4343
// Root level enum should be properly formatted (using double quotes as per Zod standard)
44-
$this->assertStringContainsString('payment_method: z.enum(["credit_card", "paypal", "bank_transfer"])', $schema);
44+
$this->assertStringContainsString('payment_method: z.enum(["credit_card", "paypal", "bank_transfer"], { message: "The selected payment method is invalid." })', $schema);
4545

4646
// Nested enum should also be properly formatted
47-
$this->assertStringContainsString('component: z.enum(["base", "tax", "discount"]', $schema);
47+
$this->assertStringContainsString('component: z.enum(["base", "tax", "discount"], { message: "The items.*.pricing.*.component field is required." })', $schema);
4848

4949
// Should NOT contain malformed enum
5050
$this->assertStringNotContainsString('z.enum(App.', $schema);
@@ -72,7 +72,7 @@ public function rules(): array
7272
$schema = $this->generator->generate($extracted, 'StatusEnumSchema');
7373

7474
// Should generate enum correctly even without required|string
75-
$this->assertStringContainsString('status: z.enum(["active", "inactive", "pending"])', $schema);
75+
$this->assertStringContainsString('status: z.enum(["active", "inactive", "pending"], { message: "The selected status is invalid." })', $schema);
7676
}
7777

7878
#[Test]
@@ -93,6 +93,6 @@ public function rules(): array
9393
$schema = $this->generator->generate($extracted, 'PriorityEnumSchema');
9494

9595
// Should generate enum correctly with required
96-
$this->assertStringContainsString('priority: z.enum(["low", "medium", "high"])', $schema);
96+
$this->assertStringContainsString('priority: z.enum(["low", "medium", "high"], { message: "The selected priority is invalid." })', $schema);
9797
}
9898
}

0 commit comments

Comments
 (0)