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   1432922 use Mojo::EventEmitter -base;
  11         4647  
  11         106  
3              
4 9     9   1571 use Carp ();
  9         17  
  9         159  
5 9     9   3986 use JSON::Validator;
  9         2061133  
  9         80  
6 9     9   394 use Mojo::UserAgent;
  9         23  
  9         115  
7 9     9   227 use Mojo::Promise;
  9         18  
  9         108  
8 9     9   223 use Scalar::Util qw(blessed);
  9         14  
  9         580  
9              
10 9   50 9   52 use constant DEBUG => $ENV{OPENAPI_CLIENT_DEBUG} || 0;
  9         19  
  9         22100  
11              
12             our $VERSION = '1.06';
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 17780 my ($self, $op) = (shift, shift);
27 7 100       330 my $code = $self->can($op) or Carp::croak('[OpenAPI::Client] No such operationId');
28 6         30 return $self->$code(@_);
29             }
30              
31             sub call_p {
32 2     2 1 17825 my ($self, $op) = (shift, shift);
33 2 100       56 my $code = $self->can("${op}_p") or return Mojo::Promise->reject('[OpenAPI::Client] No such operationId');
34 1         6 return $self->$code(@_);
35             }
36              
37             sub new {
38 24     24 1 1833616 my ($parent, $specification) = (shift, shift);
39 24 50       117 my $attrs = @_ == 1 ? shift : {@_};
40              
41 24         108 my $class = $parent->_url_to_class($specification);
42 24 100       247 $parent->_generate_class($class, $specification, $attrs) unless $class->isa($parent);
43              
44 24         447 my $self = $class->SUPER::new($attrs);
45 24 100 100     352 $self->base_url(Mojo::URL->new($self->{base_url})) if $self->{base_url} and !blessed $self->{base_url};
46 24 50       247 $self->ua->transactor->name('Mojo-OpenAPI (Perl)') unless $self->{ua};
47              
48 24 100       793 if (my $app = delete $self->{app}) {
49 9         52 $self->base_url->host(undef)->scheme(undef)->port(undef);
50 9         125 $self->ua->server->app($app);
51             }
52              
53 24         587 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   36 my ($parent, $class, $specification, $attrs) = @_;
60              
61 10         104 my $jv = JSON::Validator->new;
62 10   50     257 $jv->coerce($attrs->{coerce} // 'booleans,numbers,strings');
63 10 100       432 $jv->store->ua->server->app($attrs->{app}) if $attrs->{app};
64              
65 10         1002 my $schema = $jv->schema($specification)->schema;
66 10 50       157972 die "Invalid schema: $specification has the following errors:\n", join "\n", @{$schema->errors} if @{$schema->errors};
  0         0  
  10         47  
67              
68 8 50   8   97 eval <<"HERE" or Carp::confess("package $class: $@");
  8         16  
  8         72  
  10         2153565  
69             package $class;
70             use Mojo::Base '$parent';
71             1;
72             HERE
73              
74 10     69   109 Mojo::Util::monkey_patch($class => validator => sub {$schema});
  69     69   1205  
75 10 100       242 return unless $schema->can('routes'); # In case it is not an OpenAPI spec
76              
77 8         46 for my $route ($schema->routes->each) {
78 13 50       2755 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         75 $class->_generate_method_bnb($route->{operation_id} => $route);
81 13         263 $class->_generate_method_p("$route->{operation_id}_p" => $route);
82             }
83             }
84              
85             sub _generate_method_bnb {
86 13     40   33 my ($class, $method_name, $route) = @_;
87              
88             Mojo::Util::monkey_patch $class => $method_name => sub {
89 17 100   17   72199 my $cb = ref $_[-1] eq 'CODE' ? pop : undef;
        9      
90 17         40 my $self = shift;
91 17         99 my $tx = $self->_build_tx($route, @_);
92              
93 17 100       90 if ($tx->error) {
94 9 100       187 return $tx unless $cb;
95 1     1   19 Mojo::IOLoop->next_tick(sub { $self->$cb($tx) });
  1         146  
96 1         118 return $self;
97             }
98              
99 8 50       289 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         79 };
103             }
104              
105             sub _generate_method_p {
106 13     21   34 my ($class, $method_name, $route) = @_;
107              
108             Mojo::Util::monkey_patch $class => $method_name => sub {
109 6     12   65613 my $self = shift;
110 6         41 my $tx = $self->_build_tx($route, @_);
111              
112 6 100       31 return $self->ua->start_p($tx) unless my $err = $tx->error;
113 1 50       22 return Mojo::Promise->new->reject($err->{message}) unless $err->{code};
114 1 50 33     3 return Mojo::Promise->new->reject('WebSocket handshake failed') if $tx->req->is_handshake && !$tx->is_websocket;
115 1         63 return Mojo::Promise->new->resolve($tx);
116 13         63 };
117             }
118              
119             sub _build_tx {
120 23     35   82 my ($self, $route, $params, %content) = @_;
121 23         89 my $v = $self->validator;
122 23         119 my $url = $self->base_url->clone;
123 23         1308 my ($tx, %headers);
124              
125 23   100     90 push @{$url->path}, map { local $_ = $_; s,\{([-\w]+)\},{$params->{$1}//''},ge; $_ } grep {length} split '/',
  37         79  
  37         157  
  10         26  
  10         81  
  37         132  
  60         2191  
126 23         53 $route->{path};
127              
128             my @errors = $self->validator->validate_request(
129             [@$route{qw(method path)}],
130             {
131             body => sub {
132 8     18   2212 my ($name, $param) = @_;
133              
134 8 100       31 if (exists $params->{$name}) {
135 4         12 $content{json} = $params->{$name};
136             }
137             else {
138 4         7 for ('body', sort keys %{$self->ua->transactor->generators}) {
  4         13  
139 14 100       124 next unless exists $content{$_};
140 2         7 $params->{$name} = $content{$_};
141 2         5 last;
142             }
143             }
144              
145 8         36 return {exists => $params->{$name}, value => $params->{$name}};
146             },
147             formData => sub {
148 8     18   2263 my ($name, $param) = @_;
149 8         25 my $value = _param_as_array($name => $params);
150 8         25 $content{form}{$name} = $params->{$name};
151 8         35 return {exists => !!@$value, value => $value};
152             },
153             header => sub {
154 2     12   283 my ($name, $param) = @_;
155 2         9 my $value = _param_as_array($name => $params);
156 2         7 $headers{$name} = $value;
157 2         10 return {exists => !!@$value, value => $value};
158             },
159             path => sub {
160 10     20   2108 my ($name, $param) = @_;
161 10         53 return {exists => exists $params->{$name}, value => $params->{$name}};
162             },
163             query => sub {
164 18     28   3971 my ($name, $param) = @_;
165 18         61 my $value = _param_as_array($name => $params);
166 18         87 $url->query->param($name => _coerce_collection_format($value, $param));
167 18         917 return {exists => !!@$value, value => $value};
168             },
169             }
170 23         110 );
171              
172 23 100       4957 if (@errors) {
173 10         18 warn "[@{[ref $self]}] Validation for $route->{method} $url failed: @errors\n" if DEBUG;
174 10         86 $tx = Mojo::Transaction::HTTP->new;
175 10         90 $tx->req->method(uc $route->{method});
176 10         289 $tx->req->url($url);
177 10         139 $tx->res->headers->content_type('application/json');
178 10         802 $tx->res->body(Mojo::JSON::encode_json({errors => \@errors}));
179 10         2255 $tx->res->code(400)->message($tx->res->default_message);
180 10         278 $tx->res->error({message => 'Invalid input', code => 400});
181             }
182             else {
183 13         29 warn "[@{[ref $self]}] Validation for $route->{method} $url was successful\n" if DEBUG;
184 13 50       69 $tx = $self->ua->build_tx($route->{method}, $url, \%headers, defined $content{body} ? $content{body} : %content);
185             }
186              
187 23         4489 $tx->req->env->{operationId} = $route->{operation_id};
188 23         383 $self->emit(after_build_tx => $tx);
189              
190 23         398 return $tx;
191             }
192              
193             sub _coerce_collection_format {
194 18     22   369 my ($value, $param) = @_;
195 18   66     168 my $format = $param->{collectionFormat} || (+($param->{type} // '') eq 'array' ? 'csv' : '');
196 18 100 66     133 return $value if !$format or $format eq 'multi';
197 1 50       5 return join "|", @$value if $format eq 'pipes';
198 1 50       4 return join " ", @$value if $format eq 'ssv';
199 1 50       5 return join "\t", @$value if $format eq 'tsv';
200 1         9 return join ",", @$value;
201             }
202              
203             sub _param_as_array {
204 28     32   76 my ($name, $params) = @_;
205 28 100       138 return !exists $params->{$name} ? [] : ref $params->{$name} eq 'ARRAY' ? $params->{$name} : [$params->{$name}];
    100          
206             }
207              
208             sub _url_to_class {
209 24     24   107 my ($self, $package) = @_;
210              
211 24         124 $package =~ s!^\w+?://!!;
212 24         857 $package =~ s!\W!_!g;
213 24 50       779 $package = Mojo::Util::md5_sum($package) if length $package > 110; # 110 is a bit random, but it cannot be too long
214              
215 24         93 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             =head2 Project Founder
442              
443             Jan Henning Thorsen - C
444              
445             =head2 Contributors
446              
447             =over 2
448              
449              
450             =item * Clive Holloway
451              
452             =item * Ed J
453              
454             =item * Jan Henning Thorsen
455              
456             =item * Jan Henning Thorsen
457              
458             =item * Mohammad S Anwar
459              
460             =item * Reneeb
461              
462             =item * Roy Storey
463              
464             =item * Veesh Goldman
465              
466             =back
467              
468             =cut