Mojolicious-Plugin-Fondation-OpenAPI
view release on metacpan or search on metacpan
lib/Mojolicious/Plugin/Fondation/OpenAPI/Command/openapi.pm view on Meta::CPAN
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};
# ------------------------------------------------------------------
# STEP A -- Build the API Base (canonical schema)
#
# Three-layer cascade, highest priority wins:
# 1. DBIx structure (implicit: data_type, size, is_nullable, ...)
# 2. extra->{openapi} (flat keys declared in Result class)
# 3. Config override (flat keys in myapp.conf)
#
# After the cascade, writeOnly columns are stripped from the
# API Base â they only appear in create/update/patch contexts.
# ------------------------------------------------------------------
my %api_props;
my @api_required;
for my $col (sort keys %$columns_info) {
my $info = $columns_info->{$col};
my $openapi = $info->{extra}{openapi} // {};
my $cfg = $col_configs->{$col} // {};
my %prop;
# --- Level 1: DBIx structure (implicit) ---
$prop{type} = $self->_resolve_type($info);
$prop{maxLength} = int($info->{size})
if $prop{type} eq 'string' && defined $info->{size};
$prop{nullable} = true if $info->{is_nullable};
$prop{default} = $info->{default_value}
if defined $info->{default_value};
# Implicit rules from DBIx structure
$prop{readOnly} = true if $info->{is_auto_increment};
$prop{readOnly} = true if $col eq 'created_at' || $col eq 'updated_at';
$prop{format} = 'date' if $info->{data_type} =~ /^date$/i;
$prop{format} = 'date-time' if $info->{data_type} =~ /datetime|timestamp/i;
$prop{format} = 'float' if $info->{data_type} =~ /^float$/i;
$prop{format} = 'double' if $info->{data_type} =~ /^double$/i;
# --- Level 2: extra->{openapi} flat keys ---
$self->_apply_openapi_flat(\%prop, $openapi);
# --- Level 3: Config override flat keys ---
$self->_apply_openapi_flat(\%prop, $cfg);
# Description fallback
$prop{description} //= ucfirst join(' ', split /[_-]/, $col);
$api_props{$col} = \%prop;
# Required: is_nullable explicitly set to 0, no default_value, AND NOT writeOnly/readOnly
if ((exists $info->{is_nullable} && !$info->{is_nullable})
&& !defined $info->{default_value}
&& !$prop{writeOnly} && !$prop{readOnly}) {
push @api_required, $col;
}
}
# API Base: exclude writeOnly properties
my %api_base_props;
for my $col (sort keys %api_props) {
next if $api_props{$col}{writeOnly};
$api_base_props{$col} = $api_props{$col};
}
my $api_base = {
type => 'object',
title => $resultname,
description => "Schema for $resultname",
properties => \%api_base_props,
required => \@api_required,
};
lib/Mojolicious/Plugin/Fondation/OpenAPI/Command/openapi.pm view on Meta::CPAN
update => { required => 1 } # force required on PUT
update => { required => 0 } # force optional on PUT
read => { required => 1 } # force required on GET item
read => { required => 0 } # force optional on GET item
list => { required => 1 } # force required on GET collection
list => { required => 0 } # force optional on GET collection
=head2 Conditional schema generation
Contextual schemas (C<UserCreate>, C<UserUpdate>, C<UserRead>,
C<UserList>) are generated I<only> when they differ from the API Base.
Comparison considers both property names and the C<required> array.
A simple source like C<Group> with no contextual rules produces a
single canonical schema used everywhere. A complex source like C<User>
with C<password> having C<create.required =E<gt> 1> and
C<update.required =E<gt> 0> produces C<User>, C<UserCreate>, and
C<UserUpdate>.
=head2 writeOnly handling
Fields marked C<writeOnly> are:
=over
=item * Excluded from the API Base C<required> array
=item * Excluded from the API Base C<properties> hash
=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
( run in 0.988 second using v1.01-cache-2.11-cpan-e7c6538aa59 )