File Coverage

blib/lib/PAGI/Middleware/JSONBody.pm
Criterion Covered Total %
statement 71 77 92.2
branch 17 24 70.8
condition 7 13 53.8
subroutine 11 11 100.0
pod 1 1 100.0
total 107 126 84.9


line stmt bran cond sub pod time code
1             package PAGI::Middleware::JSONBody;
2             $PAGI::Middleware::JSONBody::VERSION = '0.002000';
3 1     1   438164 use strict;
  1         3  
  1         62  
4 1     1   7 use warnings;
  1         7  
  1         103  
5 1     1   560 use parent 'PAGI::Middleware';
  1         582  
  1         7  
6 1     1   81 use Future::AsyncAwait;
  1         2  
  1         6  
7 1     1   60 use JSON::MaybeXS ();
  1         2  
  1         2260  
8              
9             =head1 NAME
10              
11             PAGI::Middleware::JSONBody - JSON request body parsing middleware
12              
13             =head1 SYNOPSIS
14              
15             use PAGI::Middleware::Builder;
16              
17             my $app = builder {
18             enable 'JSONBody';
19             $my_app;
20             };
21              
22             # In your app:
23             async sub app {
24             my ($scope, $receive, $send) = @_;
25              
26             my $json_data = $scope->{pagi.parsed_body};
27             # $json_data is a hashref/arrayref from JSON
28             }
29              
30             =head1 DESCRIPTION
31              
32             PAGI::Middleware::JSONBody parses JSON request bodies and makes the
33             parsed data available in C<< $scope->{'pagi.parsed_body'} >>.
34              
35             =head1 CONFIGURATION
36              
37             =over 4
38              
39             =item * max_size (default: 1MB)
40              
41             Maximum body size to parse (in bytes).
42              
43             =item * content_types (default: application/json)
44              
45             Content-Type patterns to parse.
46              
47             =back
48              
49             =cut
50              
51             sub _init {
52 5     5   20 my ($self, $config) = @_;
53              
54 5   100     49 $self->{max_size} = $config->{max_size} // 1024 * 1024; # 1MB
55             $self->{content_types} = $config->{content_types} // [
56 5   50     56 'application/json',
57             'application/json; charset=utf-8',
58             ];
59             }
60              
61             sub wrap {
62 5     5 1 75 my ($self, $app) = @_;
63              
64 5     5   273 return async sub {
65 5         16 my ($scope, $receive, $send) = @_;
66 5 50       34 if ($scope->{type} ne 'http') {
67 0         0 await $app->($scope, $receive, $send);
68 0         0 return;
69             }
70              
71             # Check content type
72 5   50     26 my $content_type = $self->_get_header($scope, 'content-type') // '';
73 5         28 my $is_json = $self->_is_json_content_type($content_type);
74              
75 5 100       25 unless ($is_json) {
76 1         5 await $app->($scope, $receive, $send);
77 1         196 return;
78             }
79              
80             # Read and parse body
81 4         10 my $body = '';
82 4         9 my $too_large = 0;
83              
84 4         11 while (1) {
85 4         18 my $event = await $receive->();
86 4 50 33     501 last unless $event && $event->{type};
87              
88 4 50       23 if ($event->{type} eq 'http.request') {
    0          
89 4   50     19 $body .= $event->{body} // '';
90 4 100       20 if (length($body) > $self->{max_size}) {
91 1         4 $too_large = 1;
92 1         4 last;
93             }
94 3 50       19 last unless $event->{more};
95             }
96             elsif ($event->{type} eq 'http.disconnect') {
97 0         0 last;
98             }
99             }
100              
101 4 100       15 if ($too_large) {
102 1         6 await $self->_send_error($send, 413, 'Request body too large');
103 1         75 return;
104             }
105              
106             # Parse JSON
107 3         7 my $parsed;
108 3         8 eval {
109 3         56 $parsed = JSON::MaybeXS::decode_json($body);
110             };
111 3 100       12 if ($@) {
112 1         10 await $self->_send_error($send, 400, 'Invalid JSON: ' . $@);
113 1         113 return;
114             }
115              
116             # Create modified scope with parsed body
117 2         24 my $new_scope = $self->modify_scope($scope, {
118             'pagi.parsed_body' => $parsed,
119             'pagi.raw_body' => $body,
120             });
121              
122             # Create a receive that returns empty (body already consumed)
123 0         0 my $empty_receive = async sub {
124 0         0 return { type => 'http.request', body => '', more => 0 };
125 2         13 };
126              
127 2         53 await $app->($new_scope, $empty_receive, $send);
128 5         72 };
129             }
130              
131             sub _is_json_content_type {
132 5     5   12 my ($self, $content_type) = @_;
133              
134 5         45 $content_type = lc($content_type);
135 5         33 $content_type =~ s/\s+//g; # Remove whitespace
136              
137 5         11 for my $pattern (@{$self->{content_types}}) {
  5         17  
138 7         35 my $lc_pattern = lc($pattern);
139 7         36 $lc_pattern =~ s/\s+//g;
140 7 100       43 return 1 if index($content_type, $lc_pattern) == 0;
141             }
142              
143             # Also match any application/XXX+json
144 2 100       19 return 1 if $content_type =~ m{^application/[^;]+\+json};
145              
146 1         4 return 0;
147             }
148              
149             sub _get_header {
150 5     5   20 my ($self, $scope, $name) = @_;
151              
152 5         24 $name = lc($name);
153 5   50     11 for my $h (@{$scope->{headers} // []}) {
  5         32  
154 5 50       42 return $h->[1] if lc($h->[0]) eq $name;
155             }
156 0         0 return;
157             }
158              
159 2     2   6 async sub _send_error {
160 2         9 my ($self, $send, $status, $message) = @_;
161              
162 2         95 my $body = JSON::MaybeXS::encode_json({ error => $message });
163              
164 2         23 await $send->({
165             type => 'http.response.start',
166             status => $status,
167             headers => [
168             ['Content-Type', 'application/json'],
169             ['Content-Length', length($body)],
170             ],
171             });
172 2         118 await $send->({
173             type => 'http.response.body',
174             body => $body,
175             more => 0,
176             });
177             }
178              
179             1;
180              
181             __END__