File Coverage

blib/lib/JMAP/Tester/WebSocket.pm
Criterion Covered Total %
statement 41 119 34.4
branch 0 22 0.0
condition 0 17 0.0
subroutine 14 22 63.6
pod 1 2 50.0
total 56 182 30.7


line stmt bran cond sub pod time code
1 1     1   330554 use v5.10.0;
  1         3  
2 1     1   6 use warnings;
  1         1  
  1         72  
3              
4             package JMAP::Tester::WebSocket 0.005;
5             # ABSTRACT: a WebSocket JMAP client made for testing JMAP servers
6              
7 1     1   521 use Moo;
  1         6015  
  1         3  
8 1     1   3279 use IO::Async::Loop;
  1         29557  
  1         32  
9 1     1   452 use IO::Async::Timer::Countdown;
  1         15690  
  1         31  
10 1     1   395 use Net::Async::WebSocket::Client 0.13;
  1         57842  
  1         29  
11 1     1   9 use Protocol::WebSocket::Request;
  1         4  
  1         63  
12 1     1   418 use Params::Util qw(_HASH0 _ARRAY0);
  1         3483  
  1         68  
13 1     1   543 use Data::Dumper;
  1         6431  
  1         93  
14 1     1   9 use Scalar::Util qw(weaken);
  1         2  
  1         42  
15 1     1   499 use Try::Tiny;
  1         1061  
  1         52  
16              
17 1     1   400 use JMAP::Tester::WebSocket::Response;
  1         4  
  1         54  
18 1     1   380 use JMAP::Tester::WebSocket::Result::Failure;
  1         5  
  1         72  
19              
20             extends qw(JMAP::Tester);
21              
22             {
23             package
24             JMAP::Tester::WebSocket::LogEvent;
25              
26 1     1   9 use Moo;
  1         2  
  1         5  
27              
28             has as_string => (
29             init_arg => 'payload',
30             required => 1,
31             is => 'ro',
32             );
33             }
34              
35             has +json_codec => (
36             is => 'bare',
37             handles => {
38             json_encode => 'encode',
39             json_decode => 'decode',
40             },
41             default => sub {
42             require JSON;
43              
44             # Not ->utf8-> or we die decoding things with "wide character"...
45             # Maybe to be fixed in Protocol::WebSocket? Or IO::Async is doing this
46             # for us?
47             return JSON->new->convert_blessed;
48             },
49             );
50              
51             has 'timeout' => (
52             is => 'rw',
53             default => 30,
54             );
55              
56             has 'ws_api_uri' => (
57             is => 'rw',
58             required => 1,
59             );
60              
61             has cache_connection => (
62             is => 'ro',
63             default => 0,
64             );
65              
66             has 'authorization' => (
67             is => 'rw',
68             predicate => 'has_authorization',
69             );
70              
71             has _cached_client => (
72             is => 'rw',
73             );
74              
75             has loop => (
76             is => 'rw',
77             default => sub { IO::Async::Loop->new },
78             );
79              
80             sub request {
81 0     0 1   my ($self, $input_request) = @_;
82              
83 0           state $ident = 'a';
84 0           my %seen;
85             my @suffixed;
86              
87 0           my %default_args = %{ $self->default_arguments };
  0            
88              
89 0 0         my $request = _ARRAY0($input_request)
90             ? { methodCalls => $input_request }
91             : { %$input_request };
92              
93 0           for my $call (@{ $request->{methodCalls} }) {
  0            
94 0           my $copy = [ @$call ];
95 0 0         if (defined $copy->[2]) {
96 0           $seen{$call->[2]}++;
97             } else {
98 0           my $next;
99 0           do { $next = $ident++ } until ! $seen{$ident}++;
  0            
100 0           $copy->[2] = $next;
101             }
102              
103             my %arg = (
104             %default_args,
105 0   0       %{ $copy->[1] // {} },
  0            
106             );
107              
108 0           for my $key (keys %arg) {
109 0 0 0       if ( ref $arg{$key}
      0        
110             && ref $arg{$key} eq 'SCALAR'
111 0           && ! defined ${ $arg{$key} }
112             ) {
113 0           delete $arg{$key};
114             }
115             }
116              
117 0           $copy->[1] = \%arg;
118              
119 0           push @suffixed, $copy;
120             }
121              
122 0           $request->{methodCalls} = \@suffixed;
123              
124             $request = $request->{methodCalls}
125 0 0 0       if $ENV{JMAP_TESTER_NO_WRAPPER} && _ARRAY0($input_request);
126              
127 0 0 0       if ($self->_has_default_using && ! exists $request->{using}) {
128 0           $request->{using} = $self->default_using;
129             }
130              
131             # Required by RFC 8887
132 0           $request->{'@type'} = 'Request';
133              
134 0           my $json = $self->json_encode($request);
135              
136 0   0       my $client = $self->_cached_client || $self->connect_ws;
137              
138 0           $self->_logger->log_jmap_request($self, {
139             http_request => JMAP::Tester::WebSocket::LogEvent->new({ payload => $json }),
140             });
141              
142 0           $client->send_text_frame($json);
143              
144             my $watchdog = IO::Async::Timer::Countdown->new(
145             delay => $self->timeout,
146             remove_on_expire => 1,
147             on_expire => sub {
148 0     0     my $seconds = $self->timeout;
149              
150 0           require Carp;
151 0           Carp::confess(
152             "JMAP::Tester::WebSocket->request() timed out after $seconds seconds"
153             );
154             },
155 0           )->start;
156              
157 0           $self->loop->add($watchdog);
158              
159 0           my $res = $self->loop->run;
160              
161 0           $watchdog->stop;
162 0           $self->loop->remove($watchdog);
163              
164 0 0         unless ($self->_cached_client) {
165 0           $self->loop->remove($client);
166             }
167              
168 0           $self->_logger->log_jmap_response($self, {
169             http_response => JMAP::Tester::WebSocket::LogEvent->new({ payload => $res }),
170             });
171              
172 0           return $self->_jresponse_from_wsresponse($res);
173             }
174              
175             sub connect_ws {
176 0     0 0   my ($self) = @_;
177              
178 0           my $loop = $self->loop;
179              
180 0           weaken($loop);
181              
182             my $client = Net::Async::WebSocket::Client->new(
183             on_text_frame => sub {
184 0     0     my ($c, $f) = @_;
185              
186 0           $loop->stop($f);
187             },
188 0           );
189              
190 0           $client->{framebuffer} = Protocol::WebSocket::Frame->new(
191             max_payload_size => 0
192             );
193              
194 0           $self->loop->add($client);
195              
196             my $watchdog = IO::Async::Timer::Countdown->new(
197             delay => $self->timeout,
198             remove_on_expire => 1,
199             on_expire => sub {
200 0     0     my $seconds = $self->timeout;
201              
202 0           require Carp;
203 0           Carp::confess(
204             "JMAP::Tester::WebSocket->connect_ws() timed out after $seconds seconds"
205             );
206             },
207 0           )->start;
208              
209 0           $self->loop->add($watchdog);
210              
211 0 0         $client->connect(
212             url => $self->ws_api_uri,
213             req => Protocol::WebSocket::Request->new(
214             headers => [
215             ( $self->authorization
216             ? ( Authorization => $self->authorization )
217             : ()
218             ),
219             ],
220             subprotocol => 'jmap',
221             ),
222             )->get;
223              
224 0           $watchdog->stop;
225 0           $self->loop->remove($watchdog);
226              
227 0 0         if ($self->cache_connection) {
228 0           $self->_cached_client($client);
229             }
230              
231 0           return $client;
232             }
233              
234             sub _jresponse_from_wsresponse {
235 0     0     my ($self, $ws_res) = @_;
236              
237 0           my ($data, $error);
238              
239             try {
240 0     0     $data = $self->apply_json_types($self->json_decode( $ws_res ));
241             } catch {
242 0     0     $error = $_;
243 0           };
244              
245 0 0         if (defined $error) {
246 0           return JMAP::Tester::WebSocket::Result::Failure->new(
247             ws_response => $ws_res,
248             ident => $error,
249             );
250             }
251              
252 0           my ($items, $props);
253 0 0         if (_HASH0($data)) {
    0          
254 0           $props = $data;
255 0           $items = $props->{methodResponses};
256             } elsif (_ARRAY0($data)) {
257 0           $props = {};
258 0           $items = $data;
259             } else {
260 0           abort("illegal response to JMAP request: $data");
261             }
262              
263 0           return JMAP::Tester::WebSocket::Response->new({
264             items => $items,
265             ws_response => $ws_res,
266             wrapper_properties => $props,
267             });
268             }
269              
270             1;
271              
272             =pod
273              
274             =encoding UTF-8
275              
276             =head1 NAME
277              
278             JMAP::Tester::WebSocket - a WebSocket JMAP client made for testing JMAP servers
279              
280             =head1 VERSION
281              
282             version 0.005
283              
284             =head1 SYNOPSIS
285              
286             use JMAP::Tester::WebSocket;
287              
288             my $jtest = JMAP::Tester::WebSocket->new({
289             ws_api_uri => 'ws://jmap.local/account/123',
290             });
291              
292             my $response = $jtest->request([
293             [ getMailboxes => {} ],
294             [ getMessageUpdates => { sinceState => "123" } ],
295             ]);
296              
297             =head1 DESCRIPTION
298              
299             This module provides a WebSockets wrapper around L.
300              
301             See L for more information.
302              
303             =head1 SEE ALSO
304              
305             L - a JMAP client made for testing JMAP servers
306              
307             =head1 AUTHOR
308              
309             Matthew Horsfall
310              
311             =head1 COPYRIGHT AND LICENSE
312              
313             This software is copyright (c) 2018 by FastMail, Ltd.
314              
315             This is free software; you can redistribute it and/or modify it under
316             the same terms as the Perl 5 programming language system itself.
317              
318             =cut
319              
320             __END__