File Coverage

blib/lib/OpenAPI/Client.pm
Criterion Covered Total %
statement 140 145 96.5
branch 47 60 78.3
condition 12 18 66.6
subroutine 29 31 93.5
pod 4 4 100.0
total 232 258 89.9


line stmt bran cond sub pod time code
1             package OpenAPI::Client;
2 11     11   1352736 use Mojo::EventEmitter -base;
  11         4328  
  11         129  
3              
4 9     9   1470 use Carp ();
  9         18  
  9         141  
5 9     9   3725 use JSON::Validator;
  9         1984569  
  9         71  
6 9     9   384 use Mojo::UserAgent;
  9         19  
  9         62  
7 9     9   227 use Mojo::Promise;
  9         18  
  9         109  
8 9     9   214 use Scalar::Util qw(blessed);
  9         17  
  9         491  
9              
10 9   50 9   55 use constant DEBUG => $ENV{OPENAPI_CLIENT_DEBUG} || 0;
  9         18  
  9         21561  
11              
12             our $VERSION = '1.05';
13              
14             has base_url => sub {
15             my $self = shift;
16             my $validator = $self->validator;
17             my $url = $validator->can('base_url') ? $validator->base_url->clone : Mojo::URL->new;
18             $url->scheme('http') unless $url->scheme;
19             $url->host('localhost') unless $url->host;
20             return $url;
21             };
22              
23             has ua => sub { Mojo::UserAgent->new };
24              
25             sub call {
26 7     7 1 14954 my ($self, $op) = (shift, shift);
27 7 100       290 my $code = $self->can($op) or Carp::croak('[OpenAPI::Client] No such operationId');
28 6         31 return $self->$code(@_);
29             }
30              
31             sub call_p {
32 2     2 1 14762 my ($self, $op) = (shift, shift);
33 2 100       35 my $code = $self->can("${op}_p") or return Mojo::Promise->reject('[OpenAPI::Client] No such operationId');
34 1         7 return $self->$code(@_);
35             }
36              
37             sub new {
38 24     24 1 1862096 my ($parent, $specification) = (shift, shift);
39 24 50       114 my $attrs = @_ == 1 ? shift : {@_};
40              
41 24         111 my $class = $parent->_url_to_class($specification);
42 24 100       240 $parent->_generate_class($class, $specification, $attrs) unless $class->isa($parent);
43              
44 24         444 my $self = $class->SUPER::new($attrs);
45 24 100 100     331 $self->base_url(Mojo::URL->new($self->{base_url})) if $self->{base_url} and !blessed $self->{base_url};
46 24 50       234 $self->ua->transactor->name('Mojo-OpenAPI (Perl)') unless $self->{ua};
47              
48 24 100       757 if (my $app = delete $self->{app}) {
49 9         53 $self->base_url->host(undef)->scheme(undef)->port(undef);
50 9         119 $self->ua->server->app($app);
51             }
52              
53 24         530 return $self;
54             }
55              
56 0     0 1 0 sub validator { Carp::confess("validator() is not defined for $_[0]") }
57              
58             sub _generate_class {
59 10     10   32 my ($parent, $class, $specification, $attrs) = @_;
60              
61 10         94 my $jv = JSON::Validator->new;
62 10   50     235 $jv->coerce($attrs->{coerce} // 'booleans,numbers,strings');
63 10 100       516 $jv->store->ua->server->app($attrs->{app}) if $attrs->{app};
64              
65 10         991 my $schema = $jv->schema($specification)->schema;
66 10 50       160081 die "Invalid schema: $specification has the following errors:\n", join "\n", @{$schema->errors} if @{$schema->errors};
  0         0  
  10         51  
67              
68 8 50   8   89 eval <<"HERE" or Carp::confess("package $class: $@");
  8         18  
  8         76  
  10         2118080  
69             package $class;
70             use Mojo::Base '$parent';
71             1;
72             HERE
73              
74 10     69   128 Mojo::Util::monkey_patch($class => validator => sub {$schema});
  69     69   1067  
75 10 100       260 return unless $schema->can('routes'); # In case it is not an OpenAPI spec
76              
77 8         51 for my $route ($schema->routes->each) {
78 13 50       2655 next unless $route->{operation_id};
79 13         23 warn "[$class] Add method $route->{operation_id}() for $route->{method} $route->{path}\n" if DEBUG;
80 13         81 $class->_generate_method_bnb($route->{operation_id} => $route);
81 13         323 $class->_generate_method_p("$route->{operation_id}_p" => $route);
82             }
83             }
84              
85             sub _generate_method_bnb {
86 13     40   32 my ($class, $method_name, $route) = @_;
87              
88             Mojo::Util::monkey_patch $class => $method_name => sub {
89 17 100   17   66526 my $cb = ref $_[-1] eq 'CODE' ? pop : undef;
        9      
90 17         34 my $self = shift;
91 17         89 my $tx = $self->_build_tx($route, @_);
92              
93 17 100       75 if ($tx->error) {
94 9 100       177 return $tx unless $cb;
95 1     1   13 Mojo::IOLoop->next_tick(sub { $self->$cb($tx) });
  1         119  
96 1         90 return $self;
97             }
98              
99 8 50       283 return $self->ua->start($tx) unless $cb;
100 0     0   0 $self->ua->start($tx, sub { $self->$cb($_[1]) });
  0         0  
101 0         0 return $self;
102 13         72 };
103             }
104              
105             sub _generate_method_p {
106 13     21   36 my ($class, $method_name, $route) = @_;
107              
108             Mojo::Util::monkey_patch $class => $method_name => sub {
109 6     12   64605 my $self = shift;
110 6         35 my $tx = $self->_build_tx($route, @_);
111              
112 6 100       21 return $self->ua->start_p($tx) unless my $err = $tx->error;
113 1 50       24 return Mojo::Promise->new->reject($err->{message}) unless $err->{code};
114 1 50 33     5 return Mojo::Promise->new->reject('WebSocket handshake failed') if $tx->req->is_handshake && !$tx->is_websocket;
115 1         54 return Mojo::Promise->new->resolve($tx);
116 13         63 };
117             }
118              
119             sub _build_tx {
120 23     35   78 my ($self, $route, $params, %content) = @_;
121 23         70 my $v = $self->validator;
122 23         92 my $url = $self->base_url->clone;
123 23         1048 my ($tx, %headers);
124              
125 23   100     72 push @{$url->path}, map { local $_ = $_; s,\{([-\w]+)\},{$params->{$1}//''},ge; $_ } grep {length} split '/',
  37         73  
  37         133  
  10         21  
  10         84  
  37         173  
  60         2008  
126 23         42 $route->{path};
127              
128             my @errors = $self->validator->validate_request(
129             [@$route{qw(method path)}],
130             {
131             body => sub {
132 8     18   2179 my ($name, $param) = @_;
133              
134 8 100       28 if (exists $params->{$name}) {
135 4         14 $content{json} = $params->{$name};
136             }
137             else {
138 4         8 for ('body', sort keys %{$self->ua->transactor->generators}) {
  4         15  
139 14 100       114 next unless exists $content{$_};
140 2         5 $params->{$name} = $content{$_};
141 2         4 last;
142             }
143             }
144              
145 8         42 return {exists => $params->{$name}, value => $params->{$name}};
146             },
147             formData => sub {
148 8     18   2007 my ($name, $param) = @_;
149 8         21 my $value = _param_as_array($name => $params);
150 8         25 $content{form}{$name} = $params->{$name};
151 8         29 return {exists => !!@$value, value => $value};
152             },
153             header => sub {
154 2     12   183 my ($name, $param) = @_;
155 2         5 my $value = _param_as_array($name => $params);
156 2         6 $headers{$name} = $value;
157 2         10 return {exists => !!@$value, value => $value};
158             },
159             path => sub {
160 10     20   2042 my ($name, $param) = @_;
161 10         49 return {exists => exists $params->{$name}, value => $params->{$name}};
162             },
163             query => sub {
164 18     28   3431 my ($name, $param) = @_;
165 18         77 my $value = _param_as_array($name => $params);
166 18         71 $url->query->param($name => _coerce_collection_format($value, $param));
167 18         843 return {exists => !!@$value, value => $value};
168             },
169             }
170 23         103 );
171              
172 23 100       4708 if (@errors) {
173 10         16 warn "[@{[ref $self]}] Validation for $route->{method} $url failed: @errors\n" if DEBUG;
174 10         75 $tx = Mojo::Transaction::HTTP->new;
175 10         91 $tx->req->method(uc $route->{method});
176 10         255 $tx->req->url($url);
177 10         136 $tx->res->headers->content_type('application/json');
178 10         713 $tx->res->body(Mojo::JSON::encode_json({errors => \@errors}));
179 10         2110 $tx->res->code(400)->message($tx->res->default_message);
180 10         253 $tx->res->error({message => 'Invalid input', code => 400});
181             }
182             else {
183 13         25 warn "[@{[ref $self]}] Validation for $route->{method} $url was successful\n" if DEBUG;
184 13 50       57 $tx = $self->ua->build_tx($route->{method}, $url, \%headers, defined $content{body} ? $content{body} : %content);
185             }
186              
187 23         4061 $tx->req->env->{operationId} = $route->{operation_id};
188 23         335 $self->emit(after_build_tx => $tx);
189              
190 23         352 return $tx;
191             }
192              
193             sub _coerce_collection_format {
194 18     22   282 my ($value, $param) = @_;
195 18   66     120 my $format = $param->{collectionFormat} || ($param->{type} eq 'array' ? 'csv' : '');
196 18 100 66     115 return $value if !$format or $format eq 'multi';
197 1 50       5 return join "|", @$value if $format eq 'pipes';
198 1 50       5 return join " ", @$value if $format eq 'ssv';
199 1 50       3 return join "\t", @$value if $format eq 'tsv';
200 1         8 return join ",", @$value;
201             }
202              
203             sub _param_as_array {
204 28     32   64 my ($name, $params) = @_;
205 28 100       120 return !exists $params->{$name} ? [] : ref $params->{$name} eq 'ARRAY' ? $params->{$name} : [$params->{$name}];
    100          
206             }
207              
208             sub _url_to_class {
209 24     24   73 my ($self, $package) = @_;
210              
211 24         117 $package =~ s!^\w+?://!!;
212 24         759 $package =~ s!\W!_!g;
213 24 50       773 $package = Mojo::Util::md5_sum($package) if length $package > 110; # 110 is a bit random, but it cannot be too long
214              
215 24         88 return "$self\::$package";
216             }
217              
218             1;
219              
220             =encoding utf8
221              
222             =head1 NAME
223              
224             OpenAPI::Client - A client for talking to an Open API powered server
225              
226             =head1 DESCRIPTION
227              
228             L can generating classes that can talk to an Open API server.
229             This is done by generating a custom class, based on a Open API specification,
230             with methods that transform parameters into a HTTP request.
231              
232             The generated class will perform input validation, so invalid data won't be
233             sent to the server.
234              
235             Note that this implementation is currently EXPERIMENTAL, but unlikely to change!
236             Feedback is appreciated.
237              
238             =head1 SYNOPSIS
239              
240             =head2 Open API specification
241              
242             The specification given to L need to point to a valid OpenAPI document.
243             This document can be OpenAPI v2.x or v3.x, and it can be in either JSON or YAML
244             format. Example:
245              
246             openapi: 3.0.1
247             info:
248             title: Swagger Petstore
249             version: 1.0.0
250             servers:
251             - url: http://petstore.swagger.io/v1
252             paths:
253             /pets:
254             get:
255             operationId: listPets
256             ...
257              
258             C, C and the first item in C will be used to construct
259             L. This can be altered at any time, if you need to send data to a
260             custom endpoint.
261              
262             =head2 Client
263              
264             The OpenAPI API specification will be used to generate a sub-class of
265             L where the "operationId", inside of each path definition, is
266             used to generate methods:
267              
268             use OpenAPI::Client;
269             $client = OpenAPI::Client->new("file:///path/to/api.json");
270              
271             # Blocking
272             $tx = $client->listPets;
273              
274             # Non-blocking
275             $client = $client->listPets(sub { my ($client, $tx) = @_; });
276              
277             # Promises
278             $promise = $client->listPets_p->then(sub { my $tx = shift });
279              
280             # With parameters
281             $tx = $client->listPets({limit => 10});
282              
283             See L for more information about what you can do with the
284             C<$tx> object, but you often just want something like this:
285              
286             # Check for errors
287             die $tx->error->{message} if $tx->error;
288              
289             # Extract data from the JSON responses
290             say $tx->res->json->{pets}[0]{name};
291              
292             Check out L, L and
293             L for some of the most used methods in that class.
294              
295             =head1 CUSTOMIZATION
296              
297             =head2 Custom server URL
298              
299             If you want to request a different server than what is specified in the Open
300             API document, you can change the L:
301              
302             # Pass on a Mojo::URL object to the constructor
303             $base_url = Mojo::URL->new("http://example.com");
304             $client1 = OpenAPI::Client->new("file:///path/to/api.json", base_url => $base_url);
305              
306             # A plain string will be converted to a Mojo::URL object
307             $client2 = OpenAPI::Client->new("file:///path/to/api.json", base_url => "http://example.com");
308              
309             # Change the base_url after the client has been created
310             $client3 = OpenAPI::Client->new("file:///path/to/api.json");
311             $client3->base_url->host("other.example.com");
312              
313             =head2 Custom content
314              
315             You can send XML or any format you like, but this require you to add a new
316             "generator":
317              
318             use Your::XML::Library "to_xml";
319             $client->ua->transactor->add_generator(xml => sub {
320             my ($t, $tx, $data) = @_;
321             $tx->req->body(to_xml $data);
322             return $tx;
323             });
324              
325             $client->addHero({}, xml => {name => "Supergirl"});
326              
327             See L for more details.
328              
329             =head1 EVENTS
330              
331             =head2 after_build_tx
332              
333             $client->on(after_build_tx => sub { my ($client, $tx) = @_ })
334              
335             This event is emitted after a L object has been
336             built, just before it is passed on to the L. Note that all validation has
337             already been run, so alternating the C<$tx> too much, might cause an invalid
338             request on the server side.
339              
340             A special L variable will be set, to reference the
341             operationId:
342              
343             $tx->req->env->{operationId};
344              
345             Note that this usage of C is currently EXPERIMENTAL:
346              
347             =head1 ATTRIBUTES
348              
349             =head2 base_url
350              
351             $base_url = $client->base_url;
352              
353             Returns a L object with the base URL to the API. The default value
354             comes from C, C and C in the OpenAPI v2 specification
355             or from C in the OpenAPI v3 specification.
356              
357             =head2 ua
358              
359             $ua = $client->ua;
360              
361             Returns a L object which is used to execute requests.
362              
363             =head1 METHODS
364              
365             =head2 call
366              
367             $tx = $client->call($operationId => \%params, %content);
368             $client = $client->call($operationId => \%params, %content, sub { my ($client, $tx) = @_; });
369              
370             Used to either call an C<$operationId> that has an "invalid name", such as
371             "list pets" instead of "listPets" or to call an C<$operationId> that you are
372             unsure is supported yet. If it is not, an exception will be thrown,
373             matching text "No such operationId".
374              
375             C<$operationId> is the name of the resource defined in the
376             L.
377              
378             C<$params> is optional, but must be a hash ref, where the keys should match a
379             named parameter in the L.
380              
381             C<%content> is used for the body of the request, where the key need to be
382             either "body" or a matching L. Example:
383              
384             $client->addHero({}, body => "Some data");
385             $client->addHero({}, json => {name => "Supergirl"});
386              
387             C<$tx> is a L object.
388              
389             =head2 call_p
390              
391             $promise = $client->call_p($operationId => $params, %content);
392             $promise->then(sub { my $tx = shift });
393              
394             As L above, but returns a L object.
395              
396             =head2 new
397              
398             $client = OpenAPI::Client->new($specification, \%attributes);
399             $client = OpenAPI::Client->new($specification, %attributes);
400              
401             Returns an object of a generated class, with methods generated from the Open
402             API specification located at C<$specification>. See L
403             for valid versions of C<$specification>.
404              
405             Note that the class is cached by perl, so loading a new specification from the
406             same URL will not generate a new class.
407              
408             Extra C<%attributes>:
409              
410             =over 2
411              
412             =item * app
413              
414             Specifying an C is useful when running against a local L
415             instance.
416              
417             =item * coerce
418              
419             See L. Default to "booleans,numbers,strings".
420              
421             =back
422              
423             =head2 validator
424              
425             $validator = $client->validator;
426             $validator = $class->validator;
427              
428             Returns a L object for a generated class.
429             Note that this is a global variable, so changing the object will affect all
430             instances returned by L.
431              
432             =head1 COPYRIGHT AND LICENSE
433              
434             Copyright (C) 2017-2021, Jan Henning Thorsen
435              
436             This program is free software, you can redistribute it and/or modify it under
437             the terms of the Artistic License version 2.0.
438              
439             =head1 AUTHORS
440              
441             Jan Henning Thorsen - C
442              
443             Ed J - C
444              
445             =cut