Mojolicious-Plugin-Fondation-OpenAPI
view release on metacpan or search on metacpan
lib/Mojolicious/Plugin/Fondation/OpenAPI/Command/openapi.pm view on Meta::CPAN
}
warn "[sync-permissions] Assigned $assigned permission(s) to 'admin' group.\n";
}
# ---------------------------------------------------------------------------
# Build client-side validators.js from the OpenAPI spec
# ---------------------------------------------------------------------------
sub _build_validators_js ($self, $spec, $permissive = 0) {
my $schemas = $spec->{components}{schemas};
my $schemas_js = '';
for my $name (sort keys %$schemas) {
my $schema = $schemas->{$name};
my $props = $schema->{properties} // {};
$schemas_js .= "FondationSchemas['$name'] = {\n";
$schemas_js .= " properties: {\n";
for my $prop (sort keys %$props) {
my $def = $props->{$prop};
my @rules;
push @rules, "required: true"
if grep { $_ eq $prop } @{ $schema->{required} // [] };
push @rules, "type: '" . $def->{type} . "'"
if $def->{type};
push @rules, "minLength: " . $def->{minLength}
if $def->{minLength};
push @rules, "maxLength: " . $def->{maxLength}
if $def->{maxLength};
if ($def->{pattern}) {
# Patterns MUST be authored in ECMA-262 dialect (JSON Schema requirement):
# valid for both JSON::Validator (Perl) and new RegExp() client-side.
# We only escape the regex for the JS string literal -- no dialect change.
(my $escaped_pattern = $def->{pattern}) =~ s/\\/\\\\/g;
$escaped_pattern =~ s/'/\\'/g;
push @rules, "pattern: '$escaped_pattern'";
}
push @rules, "format: '" . $def->{format} . "'"
if $def->{format};
push @rules, "nullable: true"
if $def->{nullable};
push @rules, "readOnly: true"
if $def->{readOnly};
push @rules, "writeOnly: true"
if $def->{writeOnly};
$schemas_js .= " '$prop': { " . join(', ', @rules) . " },\n";
}
$schemas_js .= " }\n";
$schemas_js .= "};\n\n";
}
my $validators = <<'VALIDATORS';
var FondationSchemas = {};
SCHEMAS_PLACEHOLDER
window.FondationValidators = {
validate: function(schemaName, data) {
var schema = FondationSchemas[schemaName];
if (!schema) return { valid: false, errors: ['Schema not found: ' + schemaName] };
var errors = [];
for (var prop in schema.properties) {
var rules = schema.properties[prop];
var val = data[prop];
// Skip readOnly fields (server-managed, not in forms)
if (rules.readOnly) continue;
// Required check (skip readOnly -- server-managed)
if (rules.required && !rules.readOnly) {
if (val === undefined || val === null || val === '') {
errors.push(prop + ' is required');
continue;
}
}
// Skip further checks if value is empty and not required
if (val === undefined || val === null || val === '') {
if (rules.nullable) continue;
continue;
}
// Type check
if (rules.type === 'integer') {
var n = Number(val);
if (isNaN(n) || !Number.isInteger(n)) {
errors.push(prop + ' must be an integer');
continue;
}
}
// Format check
if (rules.format === 'email') {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) {
errors.push(prop + ' must be a valid email');
}
}
// Min length
if (rules.minLength && typeof val === 'string' && val.length < rules.minLength) {
errors.push(prop + ' must be at least ' + rules.minLength + ' characters');
}
// Max length
if (rules.maxLength && typeof val === 'string' && val.length > rules.maxLength) {
errors.push(prop + ' must be at most ' + rules.maxLength + ' characters');
}
// Pattern check (ECMA-262 regex, authored in the Result class)
if (rules.pattern && typeof val === 'string' && !new RegExp(rules.pattern).test(val)) {
errors.push(prop + ' does not match the required pattern');
}
// Enum check
if (rules.enum) {
var match = false;
for (var i = 0; i < rules.enum.length; i++) {
if (rules.type === 'integer') {
if (Number(val) === rules.enum[i]) { match = true; break; }
} else {
if (val === String(rules.enum[i])) { match = true; break; }
}
}
if (!match) {
errors.push(prop + ' must be one of: ' + rules.enum.join(', '));
}
}
// Password format
if (rules.format === 'password') {
if (typeof val === 'string' && val.length > 0 && val.length < (rules.minLength || 8)) {
errors.push(prop + ' must be at least ' + (rules.minLength || 8) + ' characters');
}
}
}
return {
valid: errors.length === 0,
errors: errors
};
}
};
VALIDATORS
$validators =~ s/SCHEMAS_PLACEHOLDER/$schemas_js/;
# no_validator_js: replace the whole client-side validation logic with an
# accept-everything stub, so only server-side OpenAPI validation applies.
if ($permissive) {
my $start_marker = "validate: function(schemaName, data) {\n";
my $end_marker = "\n }\n};";
my $s = index($validators, $start_marker);
my $e = index($validators, $end_marker, $s);
if ($s >= 0 && $e > $s) {
substr($validators, $s + length($start_marker),
$e - $s - length($start_marker),
" return { valid: true, errors: [] };\n");
}
}
return $validators;
}
# ---------------------------------------------------------------------------
# Build complete OpenAPI spec from DBIx::Class sources
# ---------------------------------------------------------------------------
sub _build_spec ($self, $schema_class, $app, $config) {
my $spec = {
openapi => '3.0.3',
info => {
title => 'Fondation API',
version => '1.0',
description => 'AUTO-GENERATED -- Do not modify manually',
},
servers => [{url => '/api'}],
paths => {},
components => {schemas => {}},
};
# Config overrides: schemas => { Source => { columns => { Col => {...} } } }
my $schemas_config = $config->{schemas} // {};
# Build lookup: table_name â Result class from all plugins' registry
my %result_classes;
for my $entry (values %{$app->fondation->registry}) {
next unless $entry->{dbic} && $entry->{dbic}{result_classes};
%result_classes = (%result_classes, %{$entry->{dbic}{result_classes}});
}
# Collect openapi_exclude from all plugins' config
my %openapi_exclude;
for my $entry (values %{$app->fondation->registry}) {
my $excl = $entry->{config}{openapi_exclude} // [];
$openapi_exclude{$_} = 1 for @$excl;
}
for my $table_name ($schema_class->sources) {
my $source = $schema_class->source($table_name);
my $columns_info = $source->columns_info;
my $resultname = $self->_extract_name($result_classes{$table_name});
my $src_config = $schemas_config->{$table_name}
// $schemas_config->{$resultname} // {};
my $col_configs = $src_config->{columns} // {};
# Skip sources excluded by plugins
next if $openapi_exclude{$table_name};
# ------------------------------------------------------------------
lib/Mojolicious/Plugin/Fondation/OpenAPI/Command/openapi.pm view on Meta::CPAN
=item * Added back into C<create> and C<update> projection properties
=back
This means GET responses never contain writeOnly fields and the
OpenAPI validator does not expect them.
=head2 Config override
Any column property can be overridden via the plugin configuration
without modifying DBIx classes:
'Fondation::OpenAPI' => {
backend => 'main',
schemas => {
User => {
columns => {
name => {
maxLength => 100, # override DBIx size
},
password => {
writeOnly => 1,
create => { required => 1 },
update => { required => 0 },
},
},
},
},
},
Config keys follow the same flat + contextual structure as
C<extra-E<gt>{openapi}> and take the highest priority in the cascade:
1. Structure DBIx (implicit)
2. extra->{openapi} flat keys (Result class)
3. Config flat keys (myapp.conf)
4. extra->{openapi} contextual (Result class)
5. Config contextual (myapp.conf)
=head1 SUBCOMMANDS
=head2 generate
myapp.pl openapi generate
myapp.pl openapi generate -y
myapp.pl openapi generate --output custom.json
Iterates all DBIx sources (monikers), builds the API Base for each,
applies contextual projections, and writes two files:
=over
=item C<share/openapi.json>
OpenAPI 3.0.3 specification with schemas and CRUD paths. Loaded at
runtime by L<Mojolicious::Plugin::OpenAPI> for request validation.
=item C<public/js/validators.js>
Client-side validation (C<FondationValidators.validate()>) consumed
by L<Fondation::Asset> bundles.
=back
Options:
--output FILE Output path relative to $app->home (default: share/openapi.json)
-y Overwrite without confirmation prompt
=head1 CRUD PATHS
Each source generates five endpoints with C<x-mojo-to> routing and
automatic C<x-auth> permission annotations:
GET /{moniker} -> {Moniker}#list x-auth: {moniker_lc}_list
POST /{moniker} -> {Moniker}#create x-auth: {moniker_lc}_create
GET /{moniker}/{id} -> {Moniker}#read x-auth: {moniker_lc}_read
PUT /{moniker}/{id} -> {Moniker}#update x-auth: {moniker_lc}_update
DELETE /{moniker}/{id} -> {Moniker}#delete x-auth: {moniker_lc}_delete
The C<x-auth> default can be overridden via the plugin config
(C<schemas.{Source}.x_auth.{operation}>). See L<Mojolicious::Plugin::Fondation::OpenAPI>
for details. Enforcement is handled at runtime by
L<Mojolicious::Plugin::Fondation::OpenAPI::Security>.
=head1 SEE ALSO
L<Mojolicious::Plugin::Fondation::OpenAPI>,
L<Fondation::Model::DBIx::Async>,
L<Mojolicious::Plugin::OpenAPI>
=head1 AUTHOR
Daniel Brosseau <dab@cpan.org>
=head1 COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Daniel Brosseau.
This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.
=cut
( run in 0.552 second using v1.01-cache-2.11-cpan-ad19def0cd9 )