Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/LaravelSchemaGeneratorServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
use RomegaSoftware\LaravelSchemaGenerator\TypeHandlers\TypeHandlerRegistry;
use RomegaSoftware\LaravelSchemaGenerator\TypeHandlers\UniversalTypeHandler;
use RomegaSoftware\LaravelSchemaGenerator\Writers\ZodTypeScriptWriter;
use Spatie\LaravelData\Resolvers\DataMorphClassResolver;
use Spatie\LaravelData\Resolvers\DataValidationMessagesAndAttributesResolver;
use Spatie\LaravelData\Resolvers\DataValidationRulesResolver;
use Spatie\LaravelData\Support\DataConfig;
Expand Down Expand Up @@ -83,7 +82,7 @@ protected function registerCoreServices(): void
$app->make(DataConfig::class),
$app->make(RuleNormalizer::class),
$app->make(RuleDenormalizer::class),
$app->make(DataMorphClassResolver::class),
$this->resolveDataMorphClassResolver($app),
);
});
$this->app->singleton(DataValidationMessagesAndAttributesResolver::class, function ($app) {
Expand Down Expand Up @@ -193,6 +192,24 @@ protected function spatieDataAvailable(): bool
return class_exists(\Spatie\LaravelData\Resolvers\DataValidatorResolver::class);
}

/**
* Resolve the legacy morph class resolver if the installed Spatie version requires it.
*/
protected function resolveDataMorphClassResolver($app): ?object
{
if (! InheritingDataValidationRulesResolver::requiresDataMorphClassResolver()) {
return null;
}

$resolverClass = 'Spatie\\LaravelData\\Resolvers\\DataMorphClassResolver';

if (! class_exists($resolverClass)) {
return null;
}

return $app->make($resolverClass);
}

/**
* Bootstrap services
*/
Expand Down
31 changes: 26 additions & 5 deletions src/Resolvers/InheritingDataValidationRulesResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
namespace RomegaSoftware\LaravelSchemaGenerator\Resolvers;

use ReflectionClass;
use ReflectionMethod;
use RomegaSoftware\LaravelSchemaGenerator\Attributes\InheritValidationFrom;
use Spatie\LaravelData\Resolvers\DataMorphClassResolver;
use Spatie\LaravelData\Resolvers\DataValidationRulesResolver as BaseResolver;
use Spatie\LaravelData\Support\DataConfig;
use Spatie\LaravelData\Support\Validation\DataRules;
Expand All @@ -22,14 +22,23 @@ public function __construct(
DataConfig $dataConfig,
RuleNormalizer $ruleAttributesResolver,
RuleDenormalizer $ruleDenormalizer,
DataMorphClassResolver $dataMorphClassResolver,
?object $dataMorphClassResolver = null,
) {
parent::__construct(
$parameters = [
$dataConfig,
$ruleAttributesResolver,
$ruleDenormalizer,
$dataMorphClassResolver
);
];

if (self::requiresDataMorphClassResolver()) {
if ($dataMorphClassResolver === null) {
throw new \RuntimeException('Spatie Laravel Data morph resolver is required but was not provided.');
}

$parameters[] = $dataMorphClassResolver;
}

parent::__construct(...$parameters);
}

public function execute(
Expand Down Expand Up @@ -176,4 +185,16 @@ protected function mergeRuleSet(array $existing, array $incoming): array

return $existing;
}

public static function requiresDataMorphClassResolver(): bool
{
static $requires;

if ($requires === null) {
$constructor = new ReflectionMethod(BaseResolver::class, '__construct');
$requires = $constructor->getNumberOfParameters() === 4;
}

return $requires;
}
}
2 changes: 1 addition & 1 deletion tests/Fixtures/Expected/unified-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const UnifiedValidationRequestSchema = z.object({
export type UnifiedValidationRequestSchemaType = z.infer<typeof UnifiedValidationRequestSchema>;

export const UnifiedDataSchema = z.object({
account_details: z.object({ name: z.string({ error: 'The account details.name field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.name field is required.' }).min(1, 'The account details.name field is required.').max(120, 'The account details.name field must not be greater than 120 characters.'), email: z.string().max(255, 'The account details.email field must not be greater than 255 characters.').trim().nullable().optional(), timezone: z.string().max(50, 'The account details.timezone field must not be greater than 50 characters.').trim().nullable().optional(), status: z.enum(["pending", "active", "inactive", "deleted"], { message: "The account details.status field is required." }), priority: z.number({ error: 'The account details.priority field must be an integer.' }).refine((val) => val != undefined && val != null, { error: 'The account details.priority field is required.' }).min(1, 'The account details.priority field must be at least 1.').max(10, 'The account details.priority field must not be greater than 10.'), address: z.object({ street: z.string({ error: 'The account details.address.street field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.street field is required.' }).min(1, 'The account details.address.street field is required.').max(120, 'The account details.address.street field must not be greater than 120 characters.'), city: z.string({ error: 'The account details.address.city field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.city field is required.' }).min(1, 'The account details.address.city field is required.').max(80, 'The account details.address.city field must not be greater than 80 characters.'), state: z.string({ error: 'The account details.address.state field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.state field is required.' }).min(1, 'The account details.address.state field is required.').max(2, 'The account details.address.state field must not be greater than 2 characters.'), postal_code: z.string({ error: 'The account details.address.postal code field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.postal code field is required.' }).min(1, 'The account details.address.postal code field is required.').regex(/^[0-9]{5}$/, 'The account details.address.postal code field format is invalid.'), country: z.string({ error: 'The account details.address.country field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.country field is required.' }).min(1, 'The account details.address.country field is required.').length(2, 'The account details.address.country field must be 2 characters.') }), preferences: z.object({ marketing_opt_in: z.preprocess((val) => { if (typeof val === "string") { const normalized = val.toLowerCase(); if (normalized === "true" || normalized === "1" || normalized === "on" || normalized === "yes") { return true; } if (normalized === "false" || normalized === "0" || normalized === "off" || normalized === "no") { return false; } } if (val === 1) { return true; } if (val === 0) { return false; } return val; }, z.boolean().optional()), contacts: z.array(z.object({ label: z.enum(["primary", "backup"], { message: "The account details.preferences.contacts.*.label field is required." }), email: z.email({ error: 'The account details.preferences.contacts.*.email field must be a valid email address.' }).trim().min(1, 'The account details.preferences.contacts.*.email field is required.'), phone: z.string().max(20, 'The account details.preferences.contacts.*.phone field must not be greater than 20 characters.').trim().nullable().optional() })).nullable() }).nullable().optional() }),
account_details: z.object({ name: z.string({ error: 'The account details.name field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.name field is required.' }).min(1, 'The account details.name field is required.').max(120, 'The account details.name field must not be greater than 120 characters.'), email: z.string().max(255, 'The account details.email field must not be greater than 255 characters.').trim().nullable().optional(), timezone: z.string().max(50, 'The account details.timezone field must not be greater than 50 characters.').trim().nullable().optional(), status: z.enum(["pending", "active", "inactive", "deleted"], { message: "The account details.status field is required." }), priority: z.number({ error: 'The account details.priority field must be an integer.' }).refine((val) => val != undefined && val != null, { error: 'The account details.priority field is required.' }).min(1, 'The account details.priority field must be at least 1.').max(10, 'The account details.priority field must not be greater than 10.'), address: z.object({ street: z.string({ error: 'The account details.address.street field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.street field is required.' }).min(1, 'The account details.address.street field is required.').max(120, 'The account details.address.street field must not be greater than 120 characters.'), city: z.string({ error: 'The account details.address.city field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.city field is required.' }).min(1, 'The account details.address.city field is required.').max(80, 'The account details.address.city field must not be greater than 80 characters.'), state: z.string({ error: 'The account details.address.state field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.state field is required.' }).min(1, 'The account details.address.state field is required.').max(2, 'The account details.address.state field must not be greater than 2 characters.'), postal_code: z.string({ error: 'The account details.address.postal code field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.postal code field is required.' }).min(1, 'The account details.address.postal code field is required.').regex(/^[0-9]{5}$/, 'The account details.address.postal code field format is invalid.'), country: z.string({ error: 'The account details.address.country field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.country field is required.' }).min(1, 'The account details.address.country field is required.').length(2, 'The account details.address.country field must be 2 characters.') }), preferences: z.object({ marketing_opt_in: z.preprocess((val) => { if (typeof val === "string") { const normalized = val.toLowerCase(); if (normalized === "true" || normalized === "1" || normalized === "on" || normalized === "yes") { return true; } if (normalized === "false" || normalized === "0" || normalized === "off" || normalized === "no") { return false; } } if (val === 1) { return true; } if (val === 0) { return false; } return val; }, z.boolean().optional()), contacts: z.array(z.object({ label: z.enum(["primary", "backup"], { message: "The account details.preferences.contacts.*.label field is required." }), email: z.email({ error: 'The account details.preferences.contacts.*.email field must be a valid email address.' }).trim().min(1, 'The account details.preferences.contacts.*.email field is required.'), phone: z.string().max(20, 'The account details.preferences.contacts.*.phone field must not be greater than 20 characters.').trim().nullable().optional() })).nullable().optional() }).nullable().optional() }),
projects: z.array(z.object({ title: z.string({ error: 'The projects.*.title field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The projects.*.title field is required.' }).min(1, 'The projects.*.title field is required.').max(150, 'The projects.*.title field must not be greater than 150 characters.'), summary: z.string().max(500, 'The projects.*.summary field must not be greater than 500 characters.').trim().nullable().optional(), status_state: z.enum(["pending", "active", "inactive", "deleted"], { message: "The projects.*.status state field is required." }), metrics: z.array(z.object({ key: z.string({ error: 'The projects.*.metrics.*.key field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The projects.*.metrics.*.key field is required.' }).min(1, 'The projects.*.metrics.*.key field is required.'), value: z.number({ error: 'The projects.*.metrics.*.value field is required.' }).refine((val) => val != undefined && val != null, { error: 'The projects.*.metrics.*.value field is required.' }).min(0, 'The projects.*.metrics.*.value field must be at least 0.'), trend: z.enum(["up", "down", "flat"]).nullable().optional() })).min(1, 'The projects.*.metrics field must have at least 1 items.'), schedule: z.object({ starts_at: z.string({ error: 'The projects.*.schedule.*.starts at field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The projects.*.schedule.*.starts at field is required.' }).min(1, 'The projects.*.schedule.*.starts at field is required.').refine((val) => { if (val === undefined || val === null) { return true; } if (typeof val !== "string") { return false; } const timestamp = Date.parse(val); return !Number.isNaN(timestamp); }, { message: 'The projects.*.schedule.*.starts at field must be a valid date.' }), ends_at: z.string().refine((val) => { if (val === undefined || val === null) { return true; } if (typeof val !== "string") { return false; } const timestamp = Date.parse(val); return !Number.isNaN(timestamp); }, { message: 'The projects.*.schedule.*.ends at field must be a valid date.' }).trim().nullable().optional() }).optional() })).min(1, 'The projects field must have at least 1 items.'),
notes: z.string().max(500, 'The notes field must not be greater than 500 characters.').trim().nullable().optional(),
});
Expand Down