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 )