File Coverage

blib/lib/PAGI/Middleware/Lint.pm
Criterion Covered Total %
statement 80 105 76.1
branch 29 64 45.3
condition 7 24 29.1
subroutine 12 12 100.0
pod 1 1 100.0
total 129 206 62.6


line stmt bran cond sub pod time code
1             package PAGI::Middleware::Lint;
2              
3 1     1   558 use strict;
  1         1  
  1         33  
4 1     1   2 use warnings;
  1         2  
  1         45  
5 1     1   3 use parent 'PAGI::Middleware';
  1         1  
  1         12  
6 1     1   56 use Future::AsyncAwait;
  1         1  
  1         11  
7              
8             =head1 NAME
9              
10             PAGI::Middleware::Lint - Validate PAGI application compliance
11              
12             =head1 SYNOPSIS
13              
14             use PAGI::Middleware::Builder;
15              
16             my $app = builder {
17             enable 'Lint',
18             strict => 1,
19             on_warning => sub {
20             my ($msg) = @_; warn "PAGI Lint: $msg\n" };
21             $my_app;
22             };
23              
24             =head1 DESCRIPTION
25              
26             PAGI::Middleware::Lint validates that wrapped applications follow the
27             PAGI specification. It checks for common mistakes and spec violations,
28             helping developers catch issues early.
29              
30             =head1 CONFIGURATION
31              
32             =over 4
33              
34             =item * strict (default: 0)
35              
36             In strict mode, violations throw exceptions instead of warnings.
37              
38             =item * on_warning (optional)
39              
40             Callback for lint warnings. Receives warning message.
41              
42             =item * enabled (default: 1)
43              
44             Set to false to completely disable lint checks.
45              
46             =back
47              
48             =cut
49              
50             sub _init {
51 5     5   7 my ($self, $config) = @_;
52              
53 5   100     24 $self->{strict} = $config->{strict} // 0;
54 5         11 $self->{on_warning} = $config->{on_warning};
55 5   50     16 $self->{enabled} = $config->{enabled} // 1;
56             }
57              
58             sub wrap {
59 5     5 1 46 my ($self, $app) = @_;
60              
61 5     5   90 return async sub {
62 5         9 my ($scope, $receive, $send) = @_;
63 5 50       12 if (!$self->{enabled}) {
64 0         0 await $app->($scope, $receive, $send);
65 0         0 return;
66             }
67              
68             # Validate scope
69 5         10 $self->_lint_scope($scope);
70              
71 5         6 my $response_started = 0;
72 5         5 my $response_finished = 0;
73 5         8 my $body_started = 0;
74 5         5 my $event_count = 0;
75              
76             # Wrap send to validate outgoing events
77 4         106 my $wrapped_send = async sub {
78 4         7 my ($event) = @_;
79 4         4 $event_count++;
80              
81 4         9 $self->_lint_event($event, $scope->{type});
82              
83 4 100       10 if ($event->{type} eq 'http.response.start') {
    50          
    0          
    0          
84 1 50       3 if ($response_started) {
85 0         0 $self->_warn("http.response.start sent multiple times");
86             }
87 1         1 $response_started = 1;
88 1         4 $self->_lint_response_start($event);
89             } elsif ($event->{type} eq 'http.response.body') {
90 3 100       7 if (!$response_started) {
91 2         3 $self->_warn("http.response.body sent before http.response.start");
92             }
93 2 50       5 if ($response_finished) {
94 0         0 $self->_warn("http.response.body sent after response finished (more=0)");
95             }
96 2         2 $body_started = 1;
97 2 50       5 if (!$event->{more}) {
98 2         2 $response_finished = 1;
99             }
100 2         4 $self->_lint_response_body($event);
101             } elsif ($event->{type} eq 'websocket.accept') {
102 0 0       0 if ($scope->{type} ne 'websocket') {
103 0         0 $self->_warn("websocket.accept sent for non-websocket scope");
104             }
105             } elsif ($event->{type} eq 'sse.start') {
106 0 0       0 if ($scope->{type} ne 'sse') {
107 0         0 $self->_warn("sse.start sent for non-sse scope");
108             }
109             }
110              
111 3         10 await $send->($event);
112 5         24 };
113              
114 5         7 eval {
115 5         9 await $app->($scope, $receive, $wrapped_send);
116             };
117 5         330 my $err = $@;
118              
119             # If app threw an error, prioritize it but add lint context
120 5 100       9 if ($err) {
121 2         4 my $lint_context = "";
122 2 50 33     10 if ($scope->{type} eq 'http' && !$response_started) {
    0 0        
      0        
123 2         3 $lint_context = "\n(Lint note: app exited without sending http.response.start)";
124             } elsif ($scope->{type} eq 'http' && $response_started && !$response_finished) {
125 0         0 $lint_context = "\n(Lint note: app exited without sending final http.response.body)";
126             }
127 2         29 die "$err$lint_context";
128             }
129              
130             # Post-completion checks (only if app completed without throwing)
131 5 50       9 if ($scope->{type} eq 'http') {
132 3 100       5 if (!$response_started) {
133 2         5 $self->_warn(
134             "HTTP app completed without sending http.response.start. "
135             . "This usually means you forgot to 'await' your \$send calls, "
136             . "or used ->retain for response-affecting work. "
137             . "See PAGI::Tutorial for correct async patterns."
138             );
139             }
140 3 50 66     42 if ($response_started && !$response_finished) {
141 0         0 $self->_warn(
142             "HTTP app completed without sending final http.response.body (more=0). "
143             . "Did you forget to 'await' the final \$send call?"
144             );
145             }
146             }
147 5         22 };
148             }
149              
150             sub _lint_scope {
151 5     5   7 my ($self, $scope) = @_;
152              
153             # Check required scope keys
154 5 50       10 unless (defined $scope->{type}) {
155 0         0 $self->_warn("scope missing required 'type' key");
156             }
157              
158 5 50       11 if ($scope->{type} eq 'http') {
159 5         9 my @required = qw(method path scheme);
160 5         7 for my $key (@required) {
161 15 50       44 unless (defined $scope->{$key}) {
162 0         0 $self->_warn("HTTP scope missing required '$key' key");
163             }
164             }
165              
166             # Check headers format
167 5 50       11 if (exists $scope->{headers}) {
168 5 50       13 unless (ref $scope->{headers} eq 'ARRAY') {
169 0         0 $self->_warn("scope headers must be arrayref, got " . ref($scope->{headers}));
170             } else {
171 5         31 for my $h (@{$scope->{headers}}) {
  5         14  
172 0 0 0     0 unless (ref $h eq 'ARRAY' && @$h == 2) {
173 0         0 $self->_warn("scope header must be [name, value] pair");
174             }
175             # Check lowercase header names
176 0 0       0 if ($h->[0] =~ /[A-Z]/) {
177 0         0 $self->_warn("header name should be lowercase: '$h->[0]'");
178             }
179             }
180             }
181             }
182             }
183             }
184              
185             sub _lint_event {
186 4     4   7 my ($self, $event, $scope_type) = @_;
187              
188 4 50       8 unless (ref $event eq 'HASH') {
189 0         0 $self->_warn("event must be hashref, got " . ref($event));
190 0         0 return;
191             }
192              
193 4 50       9 unless (defined $event->{type}) {
194 0         0 $self->_warn("event missing required 'type' key");
195             }
196             }
197              
198             sub _lint_response_start {
199 1     1   2 my ($self, $event) = @_;
200              
201 1 50 50     7 unless (defined $event->{status}) {
202 0         0 $self->_warn("http.response.start missing 'status' key");
203             } elsif ($event->{status} !~ /^\d{3}$/) {
204             $self->_warn("http.response.start status must be 3-digit code, got '$event->{status}'");
205             }
206              
207 1 50       3 if (exists $event->{headers}) {
208 1 50       3 unless (ref $event->{headers} eq 'ARRAY') {
209 0         0 $self->_warn("response headers must be arrayref");
210             } else {
211 1         2 for my $h (@{$event->{headers}}) {
  1         2  
212 0 0 0     0 unless (ref $h eq 'ARRAY' && @$h == 2) {
213 0         0 $self->_warn("response header must be [name, value] pair");
214             }
215             }
216             }
217             }
218             }
219              
220             sub _lint_response_body {
221 2     2   4 my ($self, $event) = @_;
222              
223             # 'more' key is optional - defaults to 0 (false) per PAGI spec
224             # No validation needed here
225             }
226              
227             sub _warn {
228 4     4   7 my ($self, $msg) = @_;
229              
230 4 100       6 if ($self->{strict}) {
231 1         15 die "PAGI Lint Error: $msg\n";
232             }
233              
234 3 50       6 if ($self->{on_warning}) {
235 3         5 $self->{on_warning}->($msg);
236             } else {
237 0           warn "PAGI Lint Warning: $msg\n";
238             }
239             }
240              
241             1;
242              
243             __END__