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 )