File Coverage

blib/lib/OpenAPI/Client.pm
Criterion Covered Total %
statement 140 145 96.5
branch 48 60 80.0
condition 12 18 66.6
subroutine 29 31 93.5
pod 4 4 100.0
total 233 258 90.3


line stmt bran cond sub pod time code
1             package OpenAPI::Client;
2 11     11   1563734 use Mojo::EventEmitter -base;
  11         5110  
  11         106  
3              
4 9     9   1524 use Carp ();
  9         21  
  9         181  
5 9     9   4371 use JSON::Validator;
  9         2311766  
  9         71  
6 9     9   413 use Mojo::UserAgent;
  9         23  
  9         60  
7 9     9   250 use Mojo::Promise;
  9         38  
  9         122  
8 9     9   289 use Scalar::Util qw(blessed);
  9         22  
  9         547  
9              
10 9   50 9   55 use constant DEBUG => $ENV{OPENAPI_CLIENT_DEBUG} || 0;
  9         32  
  9         25690  
11              
12             our $VERSION = '1.07';
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 15565 my ($self, $op) = (shift, shift);
27 7 100       308 my $code = $self->can($op) or Carp::croak('[OpenAPI::Client] No such operationId');
28 6         21 return $self->$code(@_);
29             }
30              
31             sub call_p {
32 2     2 1 14852 my ($self, $op) = (shift, shift);
33 2 100       36 my $code = $self->can("${op}_p") or return Mojo::Promise->reject('[OpenAPI::Client] No such operationId');
34 1         8 return $self->$code(@_);
35             }
36              
37             sub new {
38 24     24 1 2256725 my ($parent, $specification) = (shift, shift);
39 24 50       114 my $attrs = @_ == 1 ? shift : {@_};
40              
41 24         101 my $class = $parent->_url_to_class($specification);
42 24 100       233 $parent->_generate_class($class, $specification, $attrs) unless $class->isa($parent);
43              
44 24         506 my $self = $class->SUPER::new($attrs);
45 24 100 100     362 $self->base_url(Mojo::URL->new($self->{base_url})) if $self->{base_url} and !blessed $self->{base_url};
46 24 100       250 $self->ua->transactor->name('Mojo-OpenAPI (Perl)') unless $self->{ua};
47              
48 24 100       749 if (my $app = delete $self->{app}) {
49 8         46 $self->base_url->host(undef)->scheme(undef)->port(undef);
50 8         132 $self->ua->server->app($app);
51             }
52              
53 24         527 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   42 my ($parent, $class, $specification, $attrs) = @_;
60              
61 10         97 my $jv = JSON::Validator->new;
62 10   50     318 $jv->coerce($attrs->{coerce} // 'booleans,numbers,strings');
63 10 100       509 $jv->store->ua->server->app($attrs->{app}) if $attrs->{app};
64              
65 10         900 my $schema = $jv->schema($specification)->schema;
66 10 50       183016 die "Invalid schema: $specification has the following errors:\n", join "\n", @{$schema->errors} if @{$schema->errors};
  0         0  
  10         55  
67              
68 8 50   8   106 eval <<"HERE" or Carp::confess("package $class: $@");
  8         18  
  8         78  
  10         2561345  
69             package $class;
70             use Mojo::Base '$parent';
71             1;
72             HERE
73              
74 10     69   471 Mojo::Util::monkey_patch($class => validator => sub {$schema});
  69     69   1118  
75 10 100       286 return unless $schema->can('routes'); # In case it is not an OpenAPI spec
76              
77 8         52 for my $route ($schema->routes->each) {
78 13 50       3191 next unless $route->{operation_id};
79 13         27 warn "[$class] Add method $route->{operation_id}() for $route->{method} $route->{path}\n" if DEBUG;
80 13         95 $class->_generate_method_bnb($route->{operation_id} => $route);
81 13         353 $class->_generate_method_p("$route->{operation_id}_p" => $route);
82             }
83             }
84              
85             sub _generate_method_bnb {
86 13     40   38 my ($class, $method_name, $route) = @_;
87              
88             Mojo::Util::monkey_patch $class => $method_name => sub {
89 17 100   17   79093 my $cb = ref $_[-1] eq 'CODE' ? pop : undef;
        9      
90 17         35 my $self = shift;
91 17         89 my $tx = $self->_build_tx($route, @_);
92              
93 17 100       73 if ($tx->error) {
94 9 100       199 return $tx unless $cb;
95 1     1   10 Mojo::IOLoop->next_tick(sub { $self->$cb($tx) });
  1         118  
96 1         73 return $self;
97             }
98              
99 8 50       282 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         81 };
103             }
104              
105             sub _generate_method_p {
106 13     21   45 my ($class, $method_name, $route) = @_;
107              
108             Mojo::Util::monkey_patch $class => $method_name => sub {
109 6     12   70323 my $self = shift;
110 6         29 my $tx = $self->_build_tx($route, @_);
111              
112 6 100       23 return $self->ua->start_p($tx) unless my $err = $tx->error;
113 1 50       20 return Mojo::Promise->new->reject($err->{message}) unless $err->{code};
114 1 50 33     4 return Mojo::Promise->new->reject('WebSocket handshake failed') if $tx->req->is_handshake && !$tx->is_websocket;
115 1         55 return Mojo::Promise->new->resolve($tx);
116 13         73 };
117             }
118              
119             sub _build_tx {
120 23     35   78 my ($self, $route, $params, %content) = @_;
121 23         64 my $v = $self->validator;
122 23         83 my $url = $self->base_url->clone;
123 23         1065 my ($tx, %headers);
124              
125 23   100     69 push @{$url->path}, map { local $_ = $_; s,\{([-\w]+)\},{$params->{$1}//''},ge; $_ } grep {length} split '/',
  37         84  
  37         145  
  10         24  
  10         75  
  37         162  
  60         1772  
126 23         45 $route->{path};
127              
128             my @errors = $self->validator->validate_request(
129             [@$route{qw(method path)}],
130             {
131             body => sub {
132 8     18   2440 my ($name, $param) = @_;
133              
134 8 100       38 if (exists $params->{$name}) {
135 4         15 $content{json} = $params->{$name};
136             }
137             else {
138 4         9 for ('body', sort keys %{$self->ua->transactor->generators}) {
  4         13  
139 14 100       118 next unless exists $content{$_};
140 2         8 $params->{$name} = $content{$_};
141 2         5 last;
142             }
143             }
144              
145 8         40 return {exists => $params->{$name}, value => $params->{$name}};
146             },
147             formData => sub {
148 8     18   2330 my ($name, $param) = @_;
149 8         18 my $value = _param_as_array($name => $params);
150 8         21 $content{form}{$name} = $params->{$name};
151 8         31 return {exists => !!@$value, value => $value};
152             },
153             header => sub {
154 2     12   190 my ($name, $param) = @_;
155 2         5 my $value = _param_as_array($name => $params);
156 2         6 $headers{$name} = $value;
157 2         8 return {exists => !!@$value, value => $value};
158             },
159             path => sub {
160 10     20   2448 my ($name, $param) = @_;
161 10         47 return {exists => exists $params->{$name}, value => $params->{$name}};
162             },
163             query => sub {
164 18     28   3801 my ($name, $param) = @_;
165 18         45 my $value = _param_as_array($name => $params);
166 18         60 $url->query->param($name => _coerce_collection_format($value, $param));
167 18         846 return {exists => !!@$value, value => $value};
168             },
169             }
170 23         102 );
171              
172 23 100       4999 if (@errors) {
173 10         18 warn "[@{[ref $self]}] Validation for $route->{method} $url failed: @errors\n" if DEBUG;
174 10         74 $tx = Mojo::Transaction::HTTP->new;
175 10         90 $tx->req->method(uc $route->{method});
176 10         303 $tx->req->url($url);
177 10         135 $tx->res->headers->content_type('application/json');
178 10         713 $tx->res->body(Mojo::JSON::encode_json({errors => \@errors}));
179 10         2154 $tx->res->code(400)->message($tx->res->default_message);
180 10         257 $tx->res->error({message => 'Invalid input', code => 400});
181             }
182             else {
183 13         28 warn "[@{[ref $self]}] Validation for $route->{method} $url was successful\n" if DEBUG;
184 13 50       56 $tx = $self->ua->build_tx($route->{method}, $url, \%headers, defined $content{body} ? $content{body} : %content);
185             }
186              
187 23         3960 $tx->req->env->{operationId} = $route->{operation_id};
188 23         334 $self->emit(after_build_tx => $tx);
189              
190 23         387 return $tx;
191             }
192              
193             sub _coerce_collection_format {
194 18     22   273 my ($value, $param) = @_;
195 18   66     121 my $format = $param->{collectionFormat} || (+($param->{type} // '') eq 'array' ? 'csv' : '');
196 18 100 66     98 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       4 return join "\t", @$value if $format eq 'tsv';
200 1         7 return join ",", @$value;
201             }
202              
203             sub _param_as_array {
204 28     32   52 my ($name, $params) = @_;
205 28 100       119 return !exists $params->{$name} ? [] : ref $params->{$name} eq 'ARRAY' ? $params->{$name} : [$params->{$name}];
    100          
206             }
207              
208             sub _url_to_class {
209 24     24   72 my ($self, $package) = @_;
210              
211 24         128 $package =~ s!^\w+?://!!;
212 24         564 $package =~ s!\W!_!g;
213 24 50       868 $package = Mojo::Util::md5_sum($package) if length $package > 110; # 110 is a bit random, but it cannot be too long
214              
215 24         104 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