package GraphQL::Plugin::Convert::OpenAPI;
use 5.008001;
use strict;
use warnings;
use GraphQL::Schema;
use GraphQL::Plugin::Type::DateTime;
use GraphQL::Debug qw(_debug);
use JSON::Validator;
use OpenAPI::Client;
our $VERSION = "0.22";
use constant DEBUG => $ENV{GRAPHQL_DEBUG};
my %TYPEMAP = (
string => 'String',
date => 'DateTime',
integer => 'Int',
number => 'Float',
boolean => 'Boolean',
file => 'String',
);
my %TYPE2SCALAR = map { ($_ => 1) } qw(ID String Int Float Boolean DateTime);
my %METHOD2MUTATION = map { ($_ => 1) } qw(post put patch delete);
my @METHODS = (keys %METHOD2MUTATION, qw(get options head));
my %KIND2SIMPLE = (scalar => 1, enum => 1);
sub _apply_modifier {
my ($modifier, $typespec) = @_;
return $typespec if !$modifier;
if (ref $modifier eq 'ARRAY') {
# several at once! order is innermost first
$typespec = _apply_modifier($_, $typespec) for @$modifier;
return $typespec;
}
return $typespec if $modifier eq 'non_null'
and ref $typespec eq 'ARRAY'
and $typespec->[0] eq 'non_null'; # no double-non_null
[ $modifier, { type => $typespec } ];
}
sub _remove_modifiers {
my ($typespec) = @_;
return _remove_modifiers($typespec->{type}) if ref $typespec eq 'HASH';
return $typespec if ref $typespec ne 'ARRAY';
_remove_modifiers($typespec->[1]);
}
sub _map_args {
my ($type, $args, $type2info) = @_;
DEBUG and _debug('OpenAPI._map_args', $type, $args, $type2info);
die "Undefined type" if !defined $type;
return $args if $TYPE2SCALAR{$type} or ($type2info->{$type}||{})->{is_enum};
if (ref $type eq 'ARRAY') {
# type modifiers
my ($mod, $typespec) = @$type;
return _map_args($typespec->{type}, @_[1..3]) if $mod eq 'non_null';
die "Invalid typespec @$type" if $mod ne 'list';
return [ map _map_args($typespec->{type}, $_, @_[2..3]), @$args ];
}
my $field2prop = $type2info->{$type}{field2prop};
my $field2type = $type2info->{$type}{field2type};
my $field2is_hashpair = $type2info->{$type}{field2is_hashpair};
+{ map {
my $value;
if ($field2is_hashpair->{$_}) {
my $pairtype = _remove_modifiers($field2type->{$_});
my $value_type = $type2info->{$pairtype}{field2type}{value};
my $pairs = $args->{$_};
my %hashval;
for my $pair (@$pairs) {
$hashval{$pair->{key}} = _map_args(
$value_type, $pair->{value}, $type2info,
);
}
DEBUG and _debug('OpenAPI._map_args(hashpair)', $type, $pairtype, $pairs, $value_type, \%hashval);
$value = \%hashval;
} else {
$value = _map_args(
$field2type->{$_}, $args->{$_}, $type2info,
);
}
($field2prop->{$_} => $value)
} keys %$args };
}
sub make_field_resolver {
my ($type2info) = @_;
DEBUG and _debug('OpenAPI.make_field_resolver', $type2info);
sub {
my ($root_value, $args, $context, $info) = @_;
my $field_name = $info->{field_name};
my $parent_type = $info->{parent_type}->to_string;
my $pseudo_type = join '.', $parent_type, $field_name;
DEBUG and _debug('OpenAPI.resolver', $root_value, $field_name, $pseudo_type, $args);
if (
ref($root_value) eq 'HASH' and
$type2info->{$parent_type} and
my $prop = $type2info->{$parent_type}{field2prop}{$field_name}
) {
return $root_value->{$prop};
}
my $property = ref($root_value) eq 'HASH'
? $root_value->{$field_name}
: $root_value;
my $result = eval {
return $property->($args, $context, $info) if ref $property eq 'CODE';
return $property if ref $root_value eq 'HASH';
if (!UNIVERSAL::isa($root_value, 'OpenAPI::Client')) {
return $property // die "OpenAPI.resolver could not resolve '$field_name'\n"
if !$root_value->can($field_name);
return $root_value->$field_name($args, $context, $info);
}
# call OAC method
my $operationId = $type2info->{$parent_type}{field2operationId}{$field_name};
my $mapped_args = _map_args(
$pseudo_type,
$args,
$type2info,
);
DEBUG and _debug('OpenAPI.resolver(c)', $operationId, $args, $mapped_args);
my $got = $root_value->call_p($operationId => $mapped_args)->then(
sub {
my $res = shift->res;
DEBUG and _debug('OpenAPI.resolver(res)', $res);
die $res->body."\n" if $res->is_error;
my $json = $res->json;
DEBUG and _debug('OpenAPI.resolver(got)', $json);
my $return_type = $info->{return_type};
$return_type = $return_type->of while $return_type->can('of');
if ($type2info->{$return_type->to_string}{is_hashpair}) {
$json = [ map {
+{ key => $_, value => $json->{$_} }
} sort keys %{$json || {}} ];
}
DEBUG and _debug('OpenAPI.resolver(rettype)', $return_type->to_string, $json);
$json;
}, sub {
DEBUG and _debug('OpenAPI.resolver(error)', shift->res->body);
die shift->res->body . "\n";
}
);
};
die $@ if $@;
$result;
};
}
sub _trim_name {
my ($name) = @_;
return if !defined $name;
$name =~ s#[^a-zA-Z0-9_]+#_#g;
$name;
}
sub _get_type {
my ($info, $maybe_name, $name2type, $type2info) = @_;
DEBUG and _debug("_get_type($maybe_name)", $info);
return 'String' if !$info or !%$info; # bodge but unavoidable
# ignore definitions that are an array as not GQL-idiomatic, deal as array
if ($info->{'$ref'} and ($info->{type}//'') ne 'array') {
DEBUG and _debug("_get_type($maybe_name) ref");
my $rawtype = $info->{'$ref'};
$rawtype =~ s:^#/definitions/::;
return $rawtype;
}
if (
$info->{additionalProperties}
or (($info->{type}//'') eq 'object' and !$info->{properties})
) {
my $type = _get_type(
{
type => 'array',
items => {
type => 'object',
properties => {
key => { type => 'string' },
value => $info->{additionalProperties} // { type => 'string' },
},
},
},
$maybe_name,
$name2type,
$type2info,
);
DEBUG and _debug("_get_type($maybe_name) aP", $type);
$type2info->{$maybe_name}{is_hashpair} = 1;
return $type;
}
if ($info->{properties} or $info->{allOf} or $info->{enum}) {
DEBUG and _debug("_get_type($maybe_name) p");
return _get_spec_from_info(
$maybe_name, $info,
$name2type,
$type2info,
);
}
if ($info->{type} eq 'array') {
DEBUG and _debug("_get_type($maybe_name) a");
return _apply_modifier(
'list',
_get_type(
$info->{items}, $maybe_name,
$name2type,
$type2info,
)
);
}
return 'DateTime'
if ($info->{type}//'') eq 'string'
and ($info->{format}//'') eq 'date-time';
DEBUG and _debug("_get_type($maybe_name) simple");
$TYPEMAP{$info->{type}}
// die "'$maybe_name' unknown data type: @{[$info->{type}]}\n";
}
sub _refinfo2fields {
my ($name, $refinfo, $name2type, $type2info) = @_;
my %fields;
my $properties = $refinfo->{properties};
my %required = map { ($_ => 1) } @{$refinfo->{required} || []};
for my $prop (keys %$properties) {
my $info = $properties->{$prop};
my $field = _trim_name($prop);
$type2info->{$name}{field2prop}{$field} = $prop;
DEBUG and _debug("_refinfo2fields($name) $prop/$field", $info, $type2info->{$name});
my $rawtype = _get_type(
$info, $name.ucfirst($field),
$name2type,
$type2info,
);
my $fulltype = _apply_modifier(
$required{$prop} && 'non_null',
$rawtype,
);
$type2info->{$name}{field2type}{$field} = $fulltype;
$fields{$field} = +{ type => $fulltype };
$fields{$field}->{description} = $info->{description}
if $info->{description};
}
\%fields;
}
sub _merge_fields {
my ($f1, $f2) = @_;
my %merged = %$f1;
for my $k (keys %$f2) {
if (exists $merged{$k}) {
$merged{$k} = $f2->{$k} if ref $f2->{$k}{type}; # ie modified ie non-null
} else {
$merged{$k} = $f2->{$k};
}
}
\%merged;
}
sub _get_spec_from_info {
my (
$name, $refinfo,
$name2type,
$type2info,
) = @_;
DEBUG and _debug("_get_spec_from_info($name)", $refinfo);
my %implements;
my $fields = {};
if ($refinfo->{allOf}) {
for my $schema (@{$refinfo->{allOf}}) {
DEBUG and _debug("_get_spec_from_info($name)(allOf)", $schema);
if ($schema->{'$ref'}) {
my $othertype = _get_type($schema, '$ref', $name2type, $type2info);
my $othertypedef = $name2type->{$othertype};
push @{$implements{interfaces}}, $othertype
if $othertypedef->{kind} eq 'interface';
$fields = _merge_fields($fields, $othertypedef->{fields});
} else {
$fields = _merge_fields($fields, _refinfo2fields(
$name, $schema,
$name2type,
$type2info,
));
}
}
} elsif (my $values = $refinfo->{enum}) {
my (%enum2value, %trimmed2suffix);
for my $uniqvalue (sort keys %{{ @$values, reverse @$values }}) {
my $trimmed = _trim_name($uniqvalue);
$trimmed = 'EMPTY' if !length $trimmed;
$trimmed .= $trimmed2suffix{$trimmed}++ || '';
$enum2value{$trimmed} = { value => $uniqvalue };
}
DEBUG and _debug("_get_spec_from_info($name)(enum)", $values, \%enum2value);
my $spec = +{
kind => 'enum',
name => $name,
values => \%enum2value,
};
$spec->{description} = $refinfo->{title} if $refinfo->{title};
$spec->{description} = $refinfo->{description}
if $refinfo->{description};
$name2type->{$name} = $spec;
$type2info->{$name}{is_enum} = 1;
return $name;
} else {
%$fields = (%$fields, %{_refinfo2fields(
$name, $refinfo,
$name2type,
$type2info,
)});
}
my $spec = +{
kind => $refinfo->{discriminator} ? 'interface' : 'type',
name => $name,
fields => $fields,
%implements,
};
$spec->{description} = $refinfo->{title} if $refinfo->{title};
$spec->{description} = $refinfo->{description}
if $refinfo->{description};
$name2type->{$name} = $spec;
$name;
}
sub _make_union {
my ($types, $name2type) = @_;
my %seen;
my $types2 = [ sort grep !$seen{$_}++, map _remove_modifiers($_), @$types ];
return $types->[0] if @$types2 == 1; # no need for a union
my $typename = join '', @$types2, 'Union';
DEBUG and _debug("_make_union", $types, $types2, $typename);
$name2type->{$typename} ||= {
name => $typename,
kind => 'union',
types => $types2,
};
$typename;
}
sub _make_input {
my ($type, $name2type, $type2info) = @_;
DEBUG and _debug("_make_input", $type);
$type = $type->{type} if ref $type eq 'HASH';
if (ref $type eq 'ARRAY') {
# modifiers, recurse
return _apply_modifier(
$type->[0],
_make_input(
$type->[1],
$name2type,
$type2info,
),
)
}
return $type
if $TYPE2SCALAR{$type}
or $name2type->{$type}{kind} eq 'enum'
or $name2type->{$type}{kind} eq 'input';
# not deal with unions for now
# is an output "type"
my $input_name = $type.'Input';
my $typedef = $name2type->{$type};
my $inputdef = $name2type->{$input_name} ||= {
name => $input_name,
kind => 'input',
$typedef->{description} ? (description => $typedef->{description}) : (),
fields => +{
map {
my $fielddef = $typedef->{fields}{$_};
($_ => +{
%$fielddef, type => _make_input(
$fielddef->{type},
$name2type,
$type2info,
),
})
} keys %{$typedef->{fields}}
},
};
my $inputdef_fields = $inputdef->{fields};
$type2info->{$input_name}{field2prop} = $type2info->{$type}{field2prop};
$type2info->{$input_name}{field2type} = +{
map {
($_ => $inputdef_fields->{$_}{type})
} keys %$inputdef_fields
};
DEBUG and _debug("_make_input(object)($input_name)", $typedef, $type2info->{$input_name}, $type2info->{$type}, $name2type, $type2info);
$input_name;
}
sub _resolve_schema_ref {
my ($obj, $schema) = @_;
my $ref = $obj->{'$ref'};
return $obj if !$ref;
$ref =~ s{^#}{};
$schema->get($ref);
}
sub _kind2name2endpoint {
my ($paths, $schema, $name2type, $type2info) = @_;
my %kind2name2endpoint;
for my $path (keys %$paths) {
for my $method (grep $paths->{$path}{$_}, @METHODS) {
my $info = $paths->{$path}{$method};
my $op_id = $info->{operationId} || $method.'_'._trim_name($path);
my $fieldname = _trim_name($op_id);
my $kind = $METHOD2MUTATION{$method} ? 'mutation' : 'query';
$type2info->{ucfirst $kind}{field2operationId}{$fieldname} = $op_id;
my @successresponses = map _resolve_schema_ref($_, $schema),
map $info->{responses}{$_},
grep /^2/, keys %{$info->{responses}};
DEBUG and _debug("_kind2name2endpoint($path)($method)($fieldname)($op_id)", $info->{responses}, \@successresponses);
my @responsetypes = map _get_type(
$_->{schema}, $fieldname.'Return',
$name2type,
$type2info,
), @successresponses;
@responsetypes = ('String') if !@responsetypes; # void return
my $union = _make_union(
\@responsetypes,
$name2type,
);
my @parameters = map _resolve_schema_ref($_, $schema),
@{ $info->{parameters} };
my $pseudo_type = join '.', ucfirst($kind), $fieldname;
my %args = map {
my $argprop = $_->{name};
my $argfield = _trim_name($argprop);
$type2info->{$pseudo_type}{field2prop}{$argfield} = $argprop;
my $type = _get_type(
$_->{schema} ? $_->{schema} : $_, "${fieldname}_$argfield",
$name2type,
$type2info,
);
my $typename = _remove_modifiers($type);
my $is_hashpair = ($type2info->{$typename}||{})->{is_hashpair};
$type = _make_input(
$type,
$name2type,
$type2info,
);
$type2info->{$pseudo_type}{field2is_hashpair}{$argfield} = $is_hashpair
if $is_hashpair;
$type2info->{$pseudo_type}{field2type}{$argfield} = $type;
($argfield => {
type => _apply_modifier($_->{required} && 'non_null', $type),
$_->{description} ? (description => $_->{description}) : (),
})
} @parameters;
DEBUG and _debug("_kind2name2endpoint($fieldname) params", \%args);
my $description = $info->{summary} || $info->{description};
$kind2name2endpoint{$kind}->{$fieldname} = +{
type => $union,
$description ? (description => $description) : (),
%args ? (args => \%args) : (),
};
}
}
(\%kind2name2endpoint);
}
# possible "kind"s: scalar enum type input union interface
# mutates %$name2typeused - is boolean
sub _walk_type {
my ($name, $name2typeused, $name2type) = @_;
DEBUG and _debug("OpenAPI._walk_type", $name, $name2typeused);#, $name2type
return if $name2typeused->{$name}; # seen - stop
return if $TYPE2SCALAR{$name}; # builtin scalar - stop
$name2typeused->{$name} = 1;
my $type = $name2type->{$name};
return if $KIND2SIMPLE{ $type->{kind} }; # no sub-fields, types, etc - stop
if ($type->{kind} eq 'union') {
DEBUG and _debug("OpenAPI._walk_type(union)");
_walk_type($_, $name2typeused, $name2type) for @{$type->{types}};
return;
}
if ($type->{kind} eq 'interface') {
DEBUG and _debug("OpenAPI._walk_type(interface)");
for my $maybe_type (values %$name2type) {
next if $maybe_type->{kind} ne 'type' or !$maybe_type->{interfaces};
next if !grep $_ eq $name, @{$maybe_type->{interfaces}};
_walk_type($maybe_type->{name}, $name2typeused, $name2type);
}
# continue to pick up the fields' types too
}
# now only input and output object remain (but still interfaces too)
for my $fieldname (keys %{ $type->{fields} }) {
my $field_def = $type->{fields}{$fieldname};
DEBUG and _debug("OpenAPI._walk_type($name)(*object)", $field_def);
_walk_type(_remove_modifiers($field_def->{type}), $name2typeused, $name2type);
next if !%{ $field_def->{args} || {} };
for my $argname (keys %{ $field_def->{args} }) {
DEBUG and _debug("OpenAPI._walk_type(arg)($argname)");
my $arg_def = $field_def->{args}{$argname};
_walk_type(_remove_modifiers($arg_def->{type}), $name2typeused, $name2type);
}
}
}
sub to_graphql {
my ($class, $spec, $app) = @_;
my %appargs = (app => $app) if $app;
my $openapi_schema = JSON::Validator->new->schema($spec)->schema;
DEBUG and _debug('OpenAPI.schema', $openapi_schema);
my $defs = $openapi_schema->get("/definitions");
my @ast;
my (
%name2type,
%type2info,
);
# all non-interface-consumers first
# also drop defs that are an array as not GQL-idiomatic - treat as that array
for my $name (
grep !$defs->{$_}{allOf} && ($defs->{$_}{type}//'') ne 'array', keys %$defs
) {
_get_spec_from_info(
_trim_name($name), $defs->{$name},
\%name2type,
\%type2info,
);
}
# now interface-consumers and can now put in interface fields too
for my $name (grep $defs->{$_}{allOf}, keys %$defs) {
_get_spec_from_info(
_trim_name($name), $defs->{$name},
\%name2type,
\%type2info,
);
}
my ($kind2name2endpoint) = _kind2name2endpoint(
$openapi_schema->get("/paths"), $openapi_schema,
\%name2type,
\%type2info,
);
for my $kind (keys %$kind2name2endpoint) {
$name2type{ucfirst $kind} = +{
kind => 'type',
name => ucfirst $kind,
fields => { %{ $kind2name2endpoint->{$kind} } },
};
}
my %name2typeused;
_walk_type(ucfirst $_, \%name2typeused, \%name2type)
for keys %$kind2name2endpoint;
push @ast, map $name2type{$_}, keys %name2typeused;
+{
schema => GraphQL::Schema->from_ast(\@ast),
root_value => OpenAPI::Client->new($openapi_schema->data, %appargs),
resolver => make_field_resolver(\%type2info),
};
}
=encoding utf-8
=head1 NAME
GraphQL::Plugin::Convert::OpenAPI - convert OpenAPI schema to GraphQL schema
=begin markdown
# PROJECT STATUS
| OS | Build status |
|:-------:|--------------:|
| Linux | [](https://travis-ci.org/graphql-perl/GraphQL-Plugin-Convert-OpenAPI) |
[](https://metacpan.org/pod/GraphQL::Plugin::Convert::OpenAPI) [](https://coveralls.io/github/graphql-perl/GraphQL-Plugin-Convert-OpenAPI?branch=master)
=end markdown
=head1 SYNOPSIS
use GraphQL::Plugin::Convert::OpenAPI;
my $converted = GraphQL::Plugin::Convert::OpenAPI->to_graphql(
'file-containing-spec.json',
);
print $converted->{schema}->to_doc;
=head1 DESCRIPTION
This module implements the L<GraphQL::Plugin::Convert> API to convert
a L<JSON::Validator::OpenAPI::Mojolicious> specification to L<GraphQL::Schema> etc.
It uses, from the given API spec:
=over
=item * the given "definitions" as output types
=item * the given "definitions" as input types when required for an
input parameter
=item * the given operations as fields of either C<Query> if a C<GET>,
or C<Mutation> otherwise
=back
If an output type has C<additionalProperties> (effectively a hash whose
values are of a specified type), this poses a problem for GraphQL which
does not have such a concept. It will be treated as being made up of a
list of pairs of objects (i.e. hashes) with two keys: C<key> and C<value>.
The queries will be run against the spec's server. If the spec starts
with a C</>, and a L<Mojolicious> app is supplied (see below), that
server will instead be the given app.
=head1 ARGUMENTS
To the C<to_graphql> method: a URL to a specification, or a filename
containing a JSON specification, or a data structure, of an OpenAPI v2.
Optionally, a L<Mojolicious> app can be given as the second argument. In
this case, with a L<Mojolicious::Lite> app, do:
my $api = plugin OpenAPI => {spec => 'data://main/api.yaml'};
plugin(GraphQL => {convert => [ 'OpenAPI', $api->validator->bundle, app ]});
with the usual mapping in the case of a full app. For this to work you
need L<Mojolicious::Plugin::OpenAPI> version 1.25+, which returns itself
on C<register>.
=head1 PACKAGE FUNCTIONS
=head2 make_field_resolver
This is available as C<\&GraphQL::Plugin::Convert::OpenAPI::make_field_resolver>
in case it is wanted for use outside of the "bundle" of the C<to_graphql>
method. It takes arguments:
=over
=item
a hash-ref mapping from a GraphQL type-name to another hash-ref with
information about that type. There are addition pseudo-types with stored
information, named eg C<TypeName.fieldName>, for the obvious
purpose. The use of C<.> avoids clashing with real types. This will only
have information about input types.
Valid keys:
=over
=item is_hashpair
True value if that type needs transforming from a hash into pairs.
=item field2operationId
Hash-ref mapping from a GraphQL operation field-name (which will
only be done on the C<Query> or C<Mutation> types, for obvious reasons)
to an C<operationId>.
=item field2type
Hash-ref mapping from a GraphQL type's field-name to hash-ref mapping
its arguments, if any, to the corresponding GraphQL type-name.
=item field2prop
Hash-ref mapping from a GraphQL type's field-name to the corresponding
OpenAPI property-name.
=item is_enum
Boolean value indicating whether the type is a L<GraphQL::Type::Enum>.
=back
=back
and returns a closure that can be used as a field resolver.
=head1 DEBUGGING
To debug, set environment variable C<GRAPHQL_DEBUG> to a true value.
=head1 AUTHOR
Ed J, C<< <etj at cpan.org> >>
Parts based on L<https://github.com/yarax/swagger-to-graphql>
=head1 LICENSE
Copyright (C) Ed J
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=cut
1;