File Coverage

lib/PAGI/Server/EventValidator.pm
Criterion Covered Total %
statement 65 75 86.6
branch 59 78 75.6
condition 24 45 53.3
subroutine 15 17 88.2
pod 3 3 100.0
total 166 218 76.1


line stmt bran cond sub pod time code
1             package PAGI::Server::EventValidator;
2              
3 1     1   293997 use strict;
  1         3  
  1         39  
4 1     1   9 use warnings;
  1         1  
  1         104  
5 1     1   6 use Carp qw(croak);
  1         2  
  1         1557  
6              
7             # =============================================================================
8             # PAGI::Server::EventValidator - Dev-mode event field validation
9             #
10             # Per main.mkdn: Servers must raise exceptions if events are missing required
11             # fields or event fields are of the wrong type.
12             #
13             # This module provides optional validation for PAGI events. Enable in dev mode
14             # for early bug detection; disable in production for zero overhead.
15             # =============================================================================
16              
17             # =============================================================================
18             # HTTP Event Validation
19             # =============================================================================
20              
21             sub validate_http_send {
22 17     17 1 15774 my ($event) = @_;
23 17   50     45 my $type = $event->{type} // '';
24              
25 17 100       46 if ($type eq 'http.response.start') {
    100          
    50          
26 7         11 _validate_http_response_start($event);
27             }
28             elsif ($type eq 'http.response.body') {
29 7         41 _validate_http_response_body($event);
30             }
31             elsif ($type eq 'http.response.trailers') {
32 3         9 _validate_http_response_trailers($event);
33             }
34             # http.fullflush has no required fields beyond type
35             }
36              
37             sub _validate_http_response_start {
38 7     7   6 my ($event) = @_;
39              
40             # status is required (Int)
41             croak "http.response.start requires 'status' field"
42 7 100       157 unless exists $event->{status};
43             croak "http.response.start 'status' must be an integer"
44 6 100 100     176 unless defined $event->{status} && $event->{status} =~ /^\d+$/;
45              
46             # headers must be ArrayRef if present
47 4 100 66     15 if (exists $event->{headers} && defined $event->{headers}) {
48             croak "http.response.start 'headers' must be an array reference"
49 3 100       141 unless ref $event->{headers} eq 'ARRAY';
50             }
51             }
52              
53             sub _validate_http_response_body {
54 7     7   12 my ($event) = @_;
55              
56             # Exactly one of body, file, or fh must be present
57 7         28 my $has_body = exists $event->{body};
58 7         9 my $has_file = exists $event->{file};
59 7         9 my $has_fh = exists $event->{fh};
60 7         10 my $count = $has_body + $has_file + $has_fh;
61              
62 7 100       247 croak "http.response.body requires exactly one of body/file/fh (got $count)"
63             unless $count <= 1; # 0 is OK - defaults to empty body
64              
65             # offset must be integer if present
66 5 100 66     14 if (exists $event->{offset} && defined $event->{offset}) {
67             croak "http.response.body 'offset' must be an integer"
68 1 50       117 unless $event->{offset} =~ /^\d+$/;
69             }
70              
71             # length must be integer if present
72 4 100 66     13 if (exists $event->{length} && defined $event->{length}) {
73             croak "http.response.body 'length' must be an integer"
74 1 50       108 unless $event->{length} =~ /^\d+$/;
75             }
76             }
77              
78             sub _validate_http_response_trailers {
79 3     3   7 my ($event) = @_;
80              
81             # headers must be ArrayRef if present
82 3 100 66     17 if (exists $event->{headers} && defined $event->{headers}) {
83             croak "http.response.trailers 'headers' must be an array reference"
84 2 100       121 unless ref $event->{headers} eq 'ARRAY';
85             }
86             }
87              
88             # =============================================================================
89             # WebSocket Event Validation
90             # =============================================================================
91              
92             sub validate_websocket_send {
93 11     11 1 7639 my ($event) = @_;
94 11   50     29 my $type = $event->{type} // '';
95              
96 11 50       35 if ($type eq 'websocket.accept') {
    100          
    100          
    50          
97 0         0 _validate_websocket_accept($event);
98             }
99             elsif ($type eq 'websocket.send') {
100 4         8 _validate_websocket_send_event($event);
101             }
102             elsif ($type eq 'websocket.close') {
103 3         13 _validate_websocket_close($event);
104             }
105             elsif ($type eq 'websocket.keepalive') {
106 4         9 _validate_websocket_keepalive($event);
107             }
108             }
109              
110             sub _validate_websocket_accept {
111 0     0   0 my ($event) = @_;
112              
113             # headers must be ArrayRef if present
114 0 0 0     0 if (exists $event->{headers} && defined $event->{headers}) {
115             croak "websocket.accept 'headers' must be an array reference"
116 0 0       0 unless ref $event->{headers} eq 'ARRAY';
117             }
118             }
119              
120             sub _validate_websocket_send_event {
121 4     4   6 my ($event) = @_;
122              
123             # Exactly one of bytes or text must be present
124 4         6 my $has_bytes = exists $event->{bytes};
125 4         6 my $has_text = exists $event->{text};
126 4         5 my $count = $has_bytes + $has_text;
127              
128 4 100       225 croak "websocket.send requires exactly one of bytes/text (got $count)"
129             unless $count == 1;
130             }
131              
132             sub _validate_websocket_close {
133 3     3   4 my ($event) = @_;
134              
135             # code must be integer if present
136 3 100 66     16 if (exists $event->{code} && defined $event->{code}) {
137             croak "websocket.close 'code' must be an integer"
138 2 100       170 unless $event->{code} =~ /^\d+$/;
139             }
140             }
141              
142             sub _validate_websocket_keepalive {
143 4     4   6 my ($event) = @_;
144              
145             # interval is required (Number)
146             croak "websocket.keepalive requires 'interval' field"
147 4 100       122 unless exists $event->{interval};
148             croak "websocket.keepalive 'interval' must be a number"
149 3 100 66     103 unless defined $event->{interval} && $event->{interval} =~ /^[\d.]+$/;
150             }
151              
152             # =============================================================================
153             # SSE Event Validation
154             # =============================================================================
155              
156             sub validate_sse_send {
157 10     10 1 7649 my ($event) = @_;
158 10   50     29 my $type = $event->{type} // '';
159              
160 10 50       38 if ($type eq 'sse.start') {
    100          
    100          
    50          
161 0         0 _validate_sse_start($event);
162             }
163             elsif ($type eq 'sse.send') {
164 4         8 _validate_sse_send_event($event);
165             }
166             elsif ($type eq 'sse.comment') {
167 3         8 _validate_sse_comment($event);
168             }
169             elsif ($type eq 'sse.keepalive') {
170 3         7 _validate_sse_keepalive($event);
171             }
172             # http.fullflush has no required fields beyond type
173             }
174              
175             sub _validate_sse_start {
176 0     0   0 my ($event) = @_;
177              
178             # status must be integer if present
179 0 0 0     0 if (exists $event->{status} && defined $event->{status}) {
180             croak "sse.start 'status' must be an integer"
181 0 0       0 unless $event->{status} =~ /^\d+$/;
182             }
183              
184             # headers must be ArrayRef if present
185 0 0 0     0 if (exists $event->{headers} && defined $event->{headers}) {
186             croak "sse.start 'headers' must be an array reference"
187 0 0       0 unless ref $event->{headers} eq 'ARRAY';
188             }
189             }
190              
191             sub _validate_sse_send_event {
192 4     4   6 my ($event) = @_;
193              
194             # data is required (String)
195             croak "sse.send requires 'data' field"
196 4 100       125 unless exists $event->{data};
197             croak "sse.send 'data' must be a string"
198 3 100 66     100 unless defined $event->{data} && !ref $event->{data};
199             }
200              
201             sub _validate_sse_comment {
202 3     3   6 my ($event) = @_;
203              
204             # comment is required (String)
205             croak "sse.comment requires 'comment' field"
206 3 100       120 unless exists $event->{comment};
207             croak "sse.comment 'comment' must be a string"
208 2 100 66     98 unless defined $event->{comment} && !ref $event->{comment};
209             }
210              
211             sub _validate_sse_keepalive {
212 3     3   5 my ($event) = @_;
213              
214             # interval is required (Number)
215             croak "sse.keepalive requires 'interval' field"
216 3 100       122 unless exists $event->{interval};
217             croak "sse.keepalive 'interval' must be a number"
218 2 100 66     90 unless defined $event->{interval} && $event->{interval} =~ /^[\d.]+$/;
219             }
220              
221             1;
222              
223             __END__