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              
3 1     1   206303 use strict;
  1         1  
  1         35  
4 1     1   5 use warnings;
  1         2  
  1         47  
5 1     1   329 use parent 'PAGI::Middleware';
  1         279  
  1         4  
6 1     1   53 use Future::AsyncAwait;
  1         2  
  1         4  
7 1     1   38 use JSON::MaybeXS ();
  1         2  
  1         1043  
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   10 my ($self, $config) = @_;
53              
54 5   100     21 $self->{max_size} = $config->{max_size} // 1024 * 1024; # 1MB
55             $self->{content_types} = $config->{content_types} // [
56 5   50     23 'application/json',
57             'application/json; charset=utf-8',
58             ];
59             }
60              
61             sub wrap {
62 5     5 1 40 my ($self, $app) = @_;
63              
64 5     5   178 return async sub {
65 5         9 my ($scope, $receive, $send) = @_;
66 5 50       15 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     10 my $content_type = $self->_get_header($scope, 'content-type') // '';
73 5         11 my $is_json = $self->_is_json_content_type($content_type);
74              
75 5 100       8 unless ($is_json) {
76 1         2 await $app->($scope, $receive, $send);
77 1         102 return;
78             }
79              
80             # Read and parse body
81 4         6 my $body = '';
82 4         5 my $too_large = 0;
83              
84 4         5 while (1) {
85 4         15 my $event = await $receive->();
86 4 50 33     226 last unless $event && $event->{type};
87              
88 4 50       10 if ($event->{type} eq 'http.request') {
    0          
89 4   50     33 $body .= $event->{body} // '';
90 4 100       10 if (length($body) > $self->{max_size}) {
91 1         2 $too_large = 1;
92 1         2 last;
93             }
94 3 50       11 last unless $event->{more};
95             }
96             elsif ($event->{type} eq 'http.disconnect') {
97 0         0 last;
98             }
99             }
100              
101 4 100       9 if ($too_large) {
102 1         3 await $self->_send_error($send, 413, 'Request body too large');
103 1         43 return;
104             }
105              
106             # Parse JSON
107 3         5 my $parsed;
108 3         4 eval {
109 3         30 $parsed = JSON::MaybeXS::decode_json($body);
110             };
111 3 100       7 if ($@) {
112 1         5 await $self->_send_error($send, 400, 'Invalid JSON: ' . $@);
113 1         44 return;
114             }
115              
116             # Create modified scope with parsed body
117 2         14 my $new_scope = {
118             %$scope,
119             'pagi.parsed_body' => $parsed,
120             'pagi.raw_body' => $body,
121             };
122              
123             # Create a receive that returns empty (body already consumed)
124 0         0 my $empty_receive = async sub {
125 0         0 return { type => 'http.request', body => '', more => 0 };
126 2         7 };
127              
128 2         6 await $app->($new_scope, $empty_receive, $send);
129 5         24 };
130             }
131              
132             sub _is_json_content_type {
133 5     5   6 my ($self, $content_type) = @_;
134              
135 5         8 $content_type = lc($content_type);
136 5         14 $content_type =~ s/\s+//g; # Remove whitespace
137              
138 5         6 for my $pattern (@{$self->{content_types}}) {
  5         6  
139 7         12 my $lc_pattern = lc($pattern);
140 7         14 $lc_pattern =~ s/\s+//g;
141 7 100       18 return 1 if index($content_type, $lc_pattern) == 0;
142             }
143              
144             # Also match any application/XXX+json
145 2 100       9 return 1 if $content_type =~ m{^application/[^;]+\+json};
146              
147 1         2 return 0;
148             }
149              
150             sub _get_header {
151 5     5   11 my ($self, $scope, $name) = @_;
152              
153 5         8 $name = lc($name);
154 5   50     7 for my $h (@{$scope->{headers} // []}) {
  5         12  
155 5 50       22 return $h->[1] if lc($h->[0]) eq $name;
156             }
157 0         0 return;
158             }
159              
160 2     2   3 async sub _send_error {
161 2         5 my ($self, $send, $status, $message) = @_;
162              
163 2         14 my $body = JSON::MaybeXS::encode_json({ error => $message });
164              
165 2         11 await $send->({
166             type => 'http.response.start',
167             status => $status,
168             headers => [
169             ['Content-Type', 'application/json'],
170             ['Content-Length', length($body)],
171             ],
172             });
173 2         63 await $send->({
174             type => 'http.response.body',
175             body => $body,
176             more => 0,
177             });
178             }
179              
180             1;
181              
182             __END__