| line |
stmt |
bran |
cond |
sub |
pod |
time |
code |
|
1
|
|
|
|
|
|
|
package PAGI::Response; |
|
2
|
|
|
|
|
|
|
$PAGI::Response::VERSION = '0.002000'; |
|
3
|
19
|
|
|
19
|
|
835815
|
use strict; |
|
|
19
|
|
|
|
|
26
|
|
|
|
19
|
|
|
|
|
690
|
|
|
4
|
19
|
|
|
19
|
|
87
|
use warnings; |
|
|
19
|
|
|
|
|
64
|
|
|
|
19
|
|
|
|
|
787
|
|
|
5
|
|
|
|
|
|
|
|
|
6
|
19
|
|
|
19
|
|
92
|
use Future::AsyncAwait; |
|
|
19
|
|
|
|
|
23
|
|
|
|
19
|
|
|
|
|
160
|
|
|
7
|
19
|
|
|
19
|
|
948
|
use Carp qw(croak); |
|
|
19
|
|
|
|
|
46
|
|
|
|
19
|
|
|
|
|
1071
|
|
|
8
|
19
|
|
|
19
|
|
3026
|
use Encode qw(encode FB_CROAK); |
|
|
19
|
|
|
|
|
99893
|
|
|
|
19
|
|
|
|
|
1418
|
|
|
9
|
19
|
|
|
19
|
|
5902
|
use JSON::MaybeXS (); |
|
|
19
|
|
|
|
|
140746
|
|
|
|
19
|
|
|
|
|
681
|
|
|
10
|
19
|
|
|
19
|
|
6585
|
use PAGI::Headers (); |
|
|
19
|
|
|
|
|
34
|
|
|
|
19
|
|
|
|
|
84971
|
|
|
11
|
|
|
|
|
|
|
|
|
12
|
|
|
|
|
|
|
|
|
13
|
|
|
|
|
|
|
=encoding UTF-8 |
|
14
|
|
|
|
|
|
|
|
|
15
|
|
|
|
|
|
|
=head1 NAME |
|
16
|
|
|
|
|
|
|
|
|
17
|
|
|
|
|
|
|
PAGI::Response - Fluent response builder for PAGI applications |
|
18
|
|
|
|
|
|
|
|
|
19
|
|
|
|
|
|
|
=head1 SYNOPSIS |
|
20
|
|
|
|
|
|
|
|
|
21
|
|
|
|
|
|
|
use PAGI::Response; |
|
22
|
|
|
|
|
|
|
use Future::AsyncAwait; |
|
23
|
|
|
|
|
|
|
|
|
24
|
|
|
|
|
|
|
# A response is a VALUE: build it, then send it (or return it, or mount it). |
|
25
|
|
|
|
|
|
|
|
|
26
|
|
|
|
|
|
|
# Raw PAGI app: build the value, send it with respond($send) |
|
27
|
|
|
|
|
|
|
async sub app ($scope, $receive, $send) { |
|
28
|
|
|
|
|
|
|
my $res = PAGI::Response->new($scope); # detached -- no connection |
|
29
|
|
|
|
|
|
|
await $res->status(200) |
|
30
|
|
|
|
|
|
|
->header('X-Custom' => 'value') |
|
31
|
|
|
|
|
|
|
->json({ message => 'Hello' }) # sets the body, returns $self |
|
32
|
|
|
|
|
|
|
->respond($send); # the single send step |
|
33
|
|
|
|
|
|
|
} |
|
34
|
|
|
|
|
|
|
|
|
35
|
|
|
|
|
|
|
# In an endpoint you just RETURN it; dispatch sends it for you: |
|
36
|
|
|
|
|
|
|
async sub get ($self, $ctx) { |
|
37
|
|
|
|
|
|
|
return $ctx->json({ message => 'Hello' }, status => 200); |
|
38
|
|
|
|
|
|
|
} |
|
39
|
|
|
|
|
|
|
|
|
40
|
|
|
|
|
|
|
# Class-method factories build a detached response in one call; |
|
41
|
|
|
|
|
|
|
# status/content_type/headers go as trailing options: |
|
42
|
|
|
|
|
|
|
my $res = PAGI::Response->text("Hello World"); |
|
43
|
|
|
|
|
|
|
my $res = PAGI::Response->html("Hello"); |
|
44
|
|
|
|
|
|
|
my $res = PAGI::Response->json({ data => 'value' }); |
|
45
|
|
|
|
|
|
|
my $res = PAGI::Response->json({ error => 'not found' }, status => 404); |
|
46
|
|
|
|
|
|
|
my $res = PAGI::Response->redirect('/login'); |
|
47
|
|
|
|
|
|
|
|
|
48
|
|
|
|
|
|
|
# Because it's a value, it works anywhere an app does: |
|
49
|
|
|
|
|
|
|
$router->mount('/health' => PAGI::Response->json({ ok => \1 })); |
|
50
|
|
|
|
|
|
|
|
|
51
|
|
|
|
|
|
|
# Streaming: the callback runs at send time (auto-closes when done) |
|
52
|
|
|
|
|
|
|
await PAGI::Response->new($scope) |
|
53
|
|
|
|
|
|
|
->content_type('text/csv') |
|
54
|
|
|
|
|
|
|
->stream(async sub ($writer) { |
|
55
|
|
|
|
|
|
|
await $writer->write("id,name\n"); |
|
56
|
|
|
|
|
|
|
await $writer->write("1,Alice\n"); |
|
57
|
|
|
|
|
|
|
}) |
|
58
|
|
|
|
|
|
|
->respond($send); |
|
59
|
|
|
|
|
|
|
|
|
60
|
|
|
|
|
|
|
# File downloads: |
|
61
|
|
|
|
|
|
|
await PAGI::Response->new($scope) |
|
62
|
|
|
|
|
|
|
->send_file('/path/to/file.pdf', filename => 'doc.pdf') |
|
63
|
|
|
|
|
|
|
->respond($send); |
|
64
|
|
|
|
|
|
|
|
|
65
|
|
|
|
|
|
|
=head1 DESCRIPTION |
|
66
|
|
|
|
|
|
|
|
|
67
|
|
|
|
|
|
|
PAGI::Response provides a fluent interface for building HTTP responses in |
|
68
|
|
|
|
|
|
|
PAGI applications. It is a detached value object: it holds status, headers, |
|
69
|
|
|
|
|
|
|
and body but has no connection. Sending is done via L or L. |
|
70
|
|
|
|
|
|
|
|
|
71
|
|
|
|
|
|
|
B (C, C |
|
72
|
|
|
|
|
|
|
return C<$self> for fluent chaining. |
|
73
|
|
|
|
|
|
|
|
|
74
|
|
|
|
|
|
|
B (C, C, C, C, etc.) set the |
|
75
|
|
|
|
|
|
|
response body and also return C<$self>. They can be called as class-method |
|
76
|
|
|
|
|
|
|
factories (C<< PAGI::Response->json($data) >>) or as instance methods |
|
77
|
|
|
|
|
|
|
(C<< $res->json($data) >>). |
|
78
|
|
|
|
|
|
|
|
|
79
|
|
|
|
|
|
|
=head1 CONSTRUCTOR |
|
80
|
|
|
|
|
|
|
|
|
81
|
|
|
|
|
|
|
=head2 new |
|
82
|
|
|
|
|
|
|
|
|
83
|
|
|
|
|
|
|
my $res = PAGI::Response->new; |
|
84
|
|
|
|
|
|
|
my $res = PAGI::Response->new($scope); |
|
85
|
|
|
|
|
|
|
|
|
86
|
|
|
|
|
|
|
Creates a detached response value. The response holds no connection and no |
|
87
|
|
|
|
|
|
|
C<$send> callback — it is a pure value object that accumulates status, |
|
88
|
|
|
|
|
|
|
headers, and body via the chainer methods. |
|
89
|
|
|
|
|
|
|
|
|
90
|
|
|
|
|
|
|
=over 4 |
|
91
|
|
|
|
|
|
|
|
|
92
|
|
|
|
|
|
|
=item C<$scope> - Optional. A PAGI scope hashref. When provided it is stored |
|
93
|
|
|
|
|
|
|
inert (for accessors like C and helpers like L). |
|
94
|
|
|
|
|
|
|
It is B used as a connection — no C<$send> is stored here. |
|
95
|
|
|
|
|
|
|
|
|
96
|
|
|
|
|
|
|
=back |
|
97
|
|
|
|
|
|
|
|
|
98
|
|
|
|
|
|
|
To actually send the response, call L with the C<$send> callback, |
|
99
|
|
|
|
|
|
|
or mount it as a PAGI app via L. |
|
100
|
|
|
|
|
|
|
|
|
101
|
|
|
|
|
|
|
Because the constructor stores no connection, the same response value can be |
|
102
|
|
|
|
|
|
|
served to multiple connections (re-entrantly) by calling C more than |
|
103
|
|
|
|
|
|
|
once. |
|
104
|
|
|
|
|
|
|
|
|
105
|
|
|
|
|
|
|
=head1 CHAINABLE METHODS |
|
106
|
|
|
|
|
|
|
|
|
107
|
|
|
|
|
|
|
These methods return C<$self> for fluent chaining. |
|
108
|
|
|
|
|
|
|
|
|
109
|
|
|
|
|
|
|
=head2 status |
|
110
|
|
|
|
|
|
|
|
|
111
|
|
|
|
|
|
|
$res->status(404); |
|
112
|
|
|
|
|
|
|
my $code = $res->status; |
|
113
|
|
|
|
|
|
|
|
|
114
|
|
|
|
|
|
|
Set or get the HTTP status code (100-599). Returns C<$self> when setting |
|
115
|
|
|
|
|
|
|
for fluent chaining. When getting, returns 200 if no status has been set. |
|
116
|
|
|
|
|
|
|
|
|
117
|
|
|
|
|
|
|
my $res = PAGI::Response->new($scope); |
|
118
|
|
|
|
|
|
|
$res->status; # 200 (default, nothing set yet) |
|
119
|
|
|
|
|
|
|
$res->has_status; # false |
|
120
|
|
|
|
|
|
|
$res->status(201); # set explicitly |
|
121
|
|
|
|
|
|
|
$res->has_status; # true |
|
122
|
|
|
|
|
|
|
|
|
123
|
|
|
|
|
|
|
=head2 status_try |
|
124
|
|
|
|
|
|
|
|
|
125
|
|
|
|
|
|
|
$res->status_try(404); |
|
126
|
|
|
|
|
|
|
|
|
127
|
|
|
|
|
|
|
Set the HTTP status code only if one hasn't been set yet. Useful in |
|
128
|
|
|
|
|
|
|
middleware or error handlers to provide fallback status codes without |
|
129
|
|
|
|
|
|
|
overriding choices made by the application: |
|
130
|
|
|
|
|
|
|
|
|
131
|
|
|
|
|
|
|
$res->status_try(202); # sets to 202 (nothing was set) |
|
132
|
|
|
|
|
|
|
$res->status_try(500); # no-op, 202 already set |
|
133
|
|
|
|
|
|
|
|
|
134
|
|
|
|
|
|
|
=head2 header |
|
135
|
|
|
|
|
|
|
|
|
136
|
|
|
|
|
|
|
$res->header('X-Custom' => 'value'); |
|
137
|
|
|
|
|
|
|
my $value = $res->header('X-Custom'); |
|
138
|
|
|
|
|
|
|
|
|
139
|
|
|
|
|
|
|
Add a response header. Can be called multiple times to add multiple headers. |
|
140
|
|
|
|
|
|
|
If called with only a name, returns the last value for that header or C. |
|
141
|
|
|
|
|
|
|
|
|
142
|
|
|
|
|
|
|
=head2 headers |
|
143
|
|
|
|
|
|
|
|
|
144
|
|
|
|
|
|
|
my $headers = $res->headers; |
|
145
|
|
|
|
|
|
|
|
|
146
|
|
|
|
|
|
|
Returns the response headers as a L object. The object's C<@{}> |
|
147
|
|
|
|
|
|
|
overload yields a copy of the C<[name, value]> pairs in insertion order, so |
|
148
|
|
|
|
|
|
|
existing code that iterates C<@{$res->headers}> continues to work. |
|
149
|
|
|
|
|
|
|
|
|
150
|
|
|
|
|
|
|
=head2 header_all |
|
151
|
|
|
|
|
|
|
|
|
152
|
|
|
|
|
|
|
my @values = $res->header_all('Set-Cookie'); |
|
153
|
|
|
|
|
|
|
|
|
154
|
|
|
|
|
|
|
Returns all values for the given header name (case-insensitive). |
|
155
|
|
|
|
|
|
|
|
|
156
|
|
|
|
|
|
|
=head2 header_try |
|
157
|
|
|
|
|
|
|
|
|
158
|
|
|
|
|
|
|
$res->header_try('X-Custom' => 'value'); |
|
159
|
|
|
|
|
|
|
|
|
160
|
|
|
|
|
|
|
Add a response header only if that header name has not already been set. |
|
161
|
|
|
|
|
|
|
|
|
162
|
|
|
|
|
|
|
=head2 remove_header |
|
163
|
|
|
|
|
|
|
|
|
164
|
|
|
|
|
|
|
$res->remove_header('X-Custom'); |
|
165
|
|
|
|
|
|
|
|
|
166
|
|
|
|
|
|
|
Remove all instances of the named header (case-insensitive). Returns C<$self> |
|
167
|
|
|
|
|
|
|
for fluent chaining. No-op if the header was not set. |
|
168
|
|
|
|
|
|
|
|
|
169
|
|
|
|
|
|
|
=head2 content_type |
|
170
|
|
|
|
|
|
|
|
|
171
|
|
|
|
|
|
|
$res->content_type('text/html; charset=utf-8'); |
|
172
|
|
|
|
|
|
|
my $type = $res->content_type; |
|
173
|
|
|
|
|
|
|
$res->content_type(undef); # clears Content-Type so a body method can re-default it |
|
174
|
|
|
|
|
|
|
|
|
175
|
|
|
|
|
|
|
Set the Content-Type header, replacing any existing one. Passing C |
|
176
|
|
|
|
|
|
|
removes Content-Type entirely, which lets a subsequent body method (C, |
|
177
|
|
|
|
|
|
|
C, C) re-apply its default. |
|
178
|
|
|
|
|
|
|
|
|
179
|
|
|
|
|
|
|
=head2 content_type_try |
|
180
|
|
|
|
|
|
|
|
|
181
|
|
|
|
|
|
|
$res->content_type_try('text/html; charset=utf-8'); |
|
182
|
|
|
|
|
|
|
|
|
183
|
|
|
|
|
|
|
Set the Content-Type header only if it has not already been set. |
|
184
|
|
|
|
|
|
|
|
|
185
|
|
|
|
|
|
|
=head2 cookie |
|
186
|
|
|
|
|
|
|
|
|
187
|
|
|
|
|
|
|
$res->cookie('session' => 'abc123', |
|
188
|
|
|
|
|
|
|
max_age => 3600, |
|
189
|
|
|
|
|
|
|
path => '/', |
|
190
|
|
|
|
|
|
|
domain => 'example.com', |
|
191
|
|
|
|
|
|
|
secure => 1, |
|
192
|
|
|
|
|
|
|
httponly => 1, |
|
193
|
|
|
|
|
|
|
samesite => 'Strict', |
|
194
|
|
|
|
|
|
|
); |
|
195
|
|
|
|
|
|
|
|
|
196
|
|
|
|
|
|
|
Set a response cookie. Options: max_age, expires, path, domain, secure, |
|
197
|
|
|
|
|
|
|
httponly, samesite. |
|
198
|
|
|
|
|
|
|
|
|
199
|
|
|
|
|
|
|
=head2 delete_cookie |
|
200
|
|
|
|
|
|
|
|
|
201
|
|
|
|
|
|
|
$res->delete_cookie('session'); |
|
202
|
|
|
|
|
|
|
|
|
203
|
|
|
|
|
|
|
Delete a cookie by setting it with Max-Age=0. |
|
204
|
|
|
|
|
|
|
|
|
205
|
|
|
|
|
|
|
=head2 scope |
|
206
|
|
|
|
|
|
|
|
|
207
|
|
|
|
|
|
|
my $scope = $res->scope; |
|
208
|
|
|
|
|
|
|
|
|
209
|
|
|
|
|
|
|
Returns the raw PAGI scope hashref. Useful for constructing helper |
|
210
|
|
|
|
|
|
|
objects like L and L: |
|
211
|
|
|
|
|
|
|
|
|
212
|
|
|
|
|
|
|
my $stash = PAGI::Stash->new($res); |
|
213
|
|
|
|
|
|
|
|
|
214
|
|
|
|
|
|
|
=head2 Per-Request Shared State |
|
215
|
|
|
|
|
|
|
|
|
216
|
|
|
|
|
|
|
See L for per-request shared state. Construct from a |
|
217
|
|
|
|
|
|
|
Response object or from the shared scope: |
|
218
|
|
|
|
|
|
|
|
|
219
|
|
|
|
|
|
|
use PAGI::Stash; |
|
220
|
|
|
|
|
|
|
my $stash = PAGI::Stash->new($res); |
|
221
|
|
|
|
|
|
|
|
|
222
|
|
|
|
|
|
|
=head2 is_sent |
|
223
|
|
|
|
|
|
|
|
|
224
|
|
|
|
|
|
|
if ($res->is_sent) { |
|
225
|
|
|
|
|
|
|
warn "Response already sent, cannot send error"; |
|
226
|
|
|
|
|
|
|
return; |
|
227
|
|
|
|
|
|
|
} |
|
228
|
|
|
|
|
|
|
|
|
229
|
|
|
|
|
|
|
Returns true if the server-owned C object for this request |
|
230
|
|
|
|
|
|
|
reports C — meaning the response has started on this |
|
231
|
|
|
|
|
|
|
connection (headers have been emitted). Reflects a server-owned fact, not a |
|
232
|
|
|
|
|
|
|
flag on this Response value. |
|
233
|
|
|
|
|
|
|
|
|
234
|
|
|
|
|
|
|
Returns 0 if there is no C in scope (server-less / not |
|
235
|
|
|
|
|
|
|
started). Dies (C) if a C is present but lacks the |
|
236
|
|
|
|
|
|
|
C method, which indicates a non-conforming server. |
|
237
|
|
|
|
|
|
|
|
|
238
|
|
|
|
|
|
|
=head2 has_status |
|
239
|
|
|
|
|
|
|
|
|
240
|
|
|
|
|
|
|
if ($res->has_status) { ... } |
|
241
|
|
|
|
|
|
|
|
|
242
|
|
|
|
|
|
|
Returns true if a status code has been explicitly set via C or |
|
243
|
|
|
|
|
|
|
C. |
|
244
|
|
|
|
|
|
|
|
|
245
|
|
|
|
|
|
|
=head2 has_header |
|
246
|
|
|
|
|
|
|
|
|
247
|
|
|
|
|
|
|
if ($res->has_header('content-type')) { ... } |
|
248
|
|
|
|
|
|
|
|
|
249
|
|
|
|
|
|
|
Returns true if the given header name has been set via C |
|
250
|
|
|
|
|
|
|
C. Header names are case-insensitive. |
|
251
|
|
|
|
|
|
|
|
|
252
|
|
|
|
|
|
|
=head2 has_content_type |
|
253
|
|
|
|
|
|
|
|
|
254
|
|
|
|
|
|
|
if ($res->has_content_type) { ... } |
|
255
|
|
|
|
|
|
|
|
|
256
|
|
|
|
|
|
|
Returns true if Content-Type has been explicitly set via C, |
|
257
|
|
|
|
|
|
|
C, or C/C with a Content-Type name. |
|
258
|
|
|
|
|
|
|
|
|
259
|
|
|
|
|
|
|
=head2 has_body_source |
|
260
|
|
|
|
|
|
|
|
|
261
|
|
|
|
|
|
|
if ($res->has_body_source) { ... } |
|
262
|
|
|
|
|
|
|
|
|
263
|
|
|
|
|
|
|
Returns true if a B has been registered on the response — a |
|
264
|
|
|
|
|
|
|
buffered body (via C/C/C/C/C/C/ |
|
265
|
|
|
|
|
|
|
C), a file (via C), or a stream callback (via C). |
|
266
|
|
|
|
|
|
|
|
|
267
|
|
|
|
|
|
|
This is a B signal. It answers "did the handler |
|
268
|
|
|
|
|
|
|
register something to send?", B "have any bytes been produced or sent": |
|
269
|
|
|
|
|
|
|
|
|
270
|
|
|
|
|
|
|
=over 4 |
|
271
|
|
|
|
|
|
|
|
|
272
|
|
|
|
|
|
|
=item * For a C, it is true the instant the callback is registered, |
|
273
|
|
|
|
|
|
|
B C runs it and before a single byte is written. A registered |
|
274
|
|
|
|
|
|
|
stream that has produced zero bytes still reports C true — that |
|
275
|
|
|
|
|
|
|
is the only coherent meaning, since C is what drives the stream. |
|
276
|
|
|
|
|
|
|
|
|
277
|
|
|
|
|
|
|
=item * An B counts: C, C, and |
|
278
|
|
|
|
|
|
|
C all register a body (the empty string), so they report true. |
|
279
|
|
|
|
|
|
|
A response that has had no body method called reports false. |
|
280
|
|
|
|
|
|
|
|
|
281
|
|
|
|
|
|
|
=item * It is independent of L. C describes the |
|
282
|
|
|
|
|
|
|
value; C describes whether the value has gone out on a connection. For |
|
283
|
|
|
|
|
|
|
"has the response been emitted", use C; for "has the stream finished", |
|
284
|
|
|
|
|
|
|
C the Future returned by C. |
|
285
|
|
|
|
|
|
|
|
|
286
|
|
|
|
|
|
|
=back |
|
287
|
|
|
|
|
|
|
|
|
288
|
|
|
|
|
|
|
A response whose handler set only a status or a header (no body method) reports |
|
289
|
|
|
|
|
|
|
C false even though it is a legitimate response (e.g. a bare |
|
290
|
|
|
|
|
|
|
C<204> or a redirect built only via C + C |
|
291
|
|
|
|
|
|
|
whether to auto-send should therefore test C<< $res->has_body_source || |
|
292
|
|
|
|
|
|
|
$res->has_status >>. See the L |
|
293
|
|
|
|
|
|
|
section for the full state model. |
|
294
|
|
|
|
|
|
|
|
|
295
|
|
|
|
|
|
|
=head2 cors |
|
296
|
|
|
|
|
|
|
|
|
297
|
|
|
|
|
|
|
# Allow all origins (simplest case) |
|
298
|
|
|
|
|
|
|
$res->cors->json({ data => 'value' }); |
|
299
|
|
|
|
|
|
|
|
|
300
|
|
|
|
|
|
|
# Allow specific origin |
|
301
|
|
|
|
|
|
|
$res->cors(origin => 'https://example.com')->json($data); |
|
302
|
|
|
|
|
|
|
|
|
303
|
|
|
|
|
|
|
# Full configuration |
|
304
|
|
|
|
|
|
|
$res->cors( |
|
305
|
|
|
|
|
|
|
origin => 'https://example.com', |
|
306
|
|
|
|
|
|
|
methods => [qw(GET POST PUT DELETE)], |
|
307
|
|
|
|
|
|
|
headers => [qw(Content-Type Authorization)], |
|
308
|
|
|
|
|
|
|
expose => [qw(X-Request-Id X-RateLimit-Remaining)], |
|
309
|
|
|
|
|
|
|
credentials => 1, |
|
310
|
|
|
|
|
|
|
max_age => 86400, |
|
311
|
|
|
|
|
|
|
preflight => 0, |
|
312
|
|
|
|
|
|
|
)->json($data); |
|
313
|
|
|
|
|
|
|
|
|
314
|
|
|
|
|
|
|
Add CORS (Cross-Origin Resource Sharing) headers to the response. |
|
315
|
|
|
|
|
|
|
Returns C<$self> for chaining. |
|
316
|
|
|
|
|
|
|
|
|
317
|
|
|
|
|
|
|
B |
|
318
|
|
|
|
|
|
|
|
|
319
|
|
|
|
|
|
|
=over 4 |
|
320
|
|
|
|
|
|
|
|
|
321
|
|
|
|
|
|
|
=item * C - Allowed origin. Default: C<'*'> (all origins). |
|
322
|
|
|
|
|
|
|
Can be a specific origin like C<'https://example.com'> or C<'*'> for any. |
|
323
|
|
|
|
|
|
|
|
|
324
|
|
|
|
|
|
|
=item * C - Arrayref of allowed HTTP methods for preflight. |
|
325
|
|
|
|
|
|
|
Default: C<[qw(GET POST PUT DELETE PATCH OPTIONS)]>. |
|
326
|
|
|
|
|
|
|
|
|
327
|
|
|
|
|
|
|
=item * C - Arrayref of allowed request headers for preflight. |
|
328
|
|
|
|
|
|
|
Default: C<[qw(Content-Type Authorization X-Requested-With)]>. |
|
329
|
|
|
|
|
|
|
|
|
330
|
|
|
|
|
|
|
=item * C - Arrayref of response headers to expose to the client. |
|
331
|
|
|
|
|
|
|
By default, only simple headers (Cache-Control, Content-Language, etc.) |
|
332
|
|
|
|
|
|
|
are accessible. Use this to expose custom headers. |
|
333
|
|
|
|
|
|
|
|
|
334
|
|
|
|
|
|
|
=item * C - Boolean. If true, sets |
|
335
|
|
|
|
|
|
|
C, allowing cookies and |
|
336
|
|
|
|
|
|
|
Authorization headers. Default: C<0>. |
|
337
|
|
|
|
|
|
|
|
|
338
|
|
|
|
|
|
|
=item * C - How long (in seconds) browsers should cache preflight |
|
339
|
|
|
|
|
|
|
results. Default: C<86400> (24 hours). |
|
340
|
|
|
|
|
|
|
|
|
341
|
|
|
|
|
|
|
=item * C - Boolean. If true, includes preflight response headers |
|
342
|
|
|
|
|
|
|
(Allow-Methods, Allow-Headers, Max-Age). Set this when handling OPTIONS |
|
343
|
|
|
|
|
|
|
requests. Default: C<0>. |
|
344
|
|
|
|
|
|
|
|
|
345
|
|
|
|
|
|
|
=item * C - The Origin header value from the request. |
|
346
|
|
|
|
|
|
|
Required when C is true and C is C<'*'>, because |
|
347
|
|
|
|
|
|
|
the CORS spec forbids using C<'*'> with credentials. Pass the actual |
|
348
|
|
|
|
|
|
|
request origin to echo it back. |
|
349
|
|
|
|
|
|
|
|
|
350
|
|
|
|
|
|
|
=back |
|
351
|
|
|
|
|
|
|
|
|
352
|
|
|
|
|
|
|
B |
|
353
|
|
|
|
|
|
|
|
|
354
|
|
|
|
|
|
|
=over 4 |
|
355
|
|
|
|
|
|
|
|
|
356
|
|
|
|
|
|
|
=item * When C is true, you cannot use C<< origin => '*' >>. |
|
357
|
|
|
|
|
|
|
Either specify an exact origin, or pass C with the |
|
358
|
|
|
|
|
|
|
client's actual Origin header. |
|
359
|
|
|
|
|
|
|
|
|
360
|
|
|
|
|
|
|
=item * The C header is always set to ensure proper caching |
|
361
|
|
|
|
|
|
|
when origin-specific responses are used. |
|
362
|
|
|
|
|
|
|
|
|
363
|
|
|
|
|
|
|
=item * For preflight (OPTIONS) requests, set C<< preflight => 1 >> and |
|
364
|
|
|
|
|
|
|
typically respond with C<< $res->status(204)->empty() >>. |
|
365
|
|
|
|
|
|
|
|
|
366
|
|
|
|
|
|
|
=back |
|
367
|
|
|
|
|
|
|
|
|
368
|
|
|
|
|
|
|
=head1 SEND PRIMITIVE AND APP MOUNTING |
|
369
|
|
|
|
|
|
|
|
|
370
|
|
|
|
|
|
|
=head2 respond |
|
371
|
|
|
|
|
|
|
|
|
372
|
|
|
|
|
|
|
await $res->respond($send); |
|
373
|
|
|
|
|
|
|
|
|
374
|
|
|
|
|
|
|
The single send primitive for a detached response value. Reads the |
|
375
|
|
|
|
|
|
|
accumulated status, headers, and body from C<$self> and emits the |
|
376
|
|
|
|
|
|
|
appropriate PAGI protocol events via C<$send>. |
|
377
|
|
|
|
|
|
|
|
|
378
|
|
|
|
|
|
|
C<$send> must be a coderef (the PAGI send callback). C does |
|
379
|
|
|
|
|
|
|
B mutate the response object, so the same response value can be |
|
380
|
|
|
|
|
|
|
passed to C multiple times for different connections. |
|
381
|
|
|
|
|
|
|
|
|
382
|
|
|
|
|
|
|
For streaming responses (set up via the C<_stream> slot), C |
|
383
|
|
|
|
|
|
|
sends the start event, runs the stream callback with a |
|
384
|
|
|
|
|
|
|
L, and ensures the writer is closed. |
|
385
|
|
|
|
|
|
|
|
|
386
|
|
|
|
|
|
|
Returns a L. |
|
387
|
|
|
|
|
|
|
|
|
388
|
|
|
|
|
|
|
=head2 to_app |
|
389
|
|
|
|
|
|
|
|
|
390
|
|
|
|
|
|
|
my $app = $res->to_app; |
|
391
|
|
|
|
|
|
|
|
|
392
|
|
|
|
|
|
|
Returns a PAGI application coderef C that |
|
393
|
|
|
|
|
|
|
calls L with the given C<$send> when invoked. Use this to mount |
|
394
|
|
|
|
|
|
|
a response value directly as a PAGI app: |
|
395
|
|
|
|
|
|
|
|
|
396
|
|
|
|
|
|
|
my $not_found = PAGI::Response->new |
|
397
|
|
|
|
|
|
|
->status(404) |
|
398
|
|
|
|
|
|
|
->_set_body('Not Found', 'text/plain'); |
|
399
|
|
|
|
|
|
|
|
|
400
|
|
|
|
|
|
|
# Mount as a fallback app |
|
401
|
|
|
|
|
|
|
my $app = $not_found->to_app; |
|
402
|
|
|
|
|
|
|
|
|
403
|
|
|
|
|
|
|
=head1 BODY METHODS |
|
404
|
|
|
|
|
|
|
|
|
405
|
|
|
|
|
|
|
These methods set the response body and return C<$self>. Sending happens via |
|
406
|
|
|
|
|
|
|
L / L or the endpoint return contract. |
|
407
|
|
|
|
|
|
|
|
|
408
|
|
|
|
|
|
|
Each method works as both a B and an B: |
|
409
|
|
|
|
|
|
|
|
|
410
|
|
|
|
|
|
|
# Class-method factory — creates a new detached response and returns it |
|
411
|
|
|
|
|
|
|
return $ctx->json($data); # instance method on existing $res |
|
412
|
|
|
|
|
|
|
return PAGI::Response->json($data); # factory shorthand |
|
413
|
|
|
|
|
|
|
|
|
414
|
|
|
|
|
|
|
# Chain body with other setters before sending |
|
415
|
|
|
|
|
|
|
PAGI::Response->json($data)->status(201)->respond($send)->get; |
|
416
|
|
|
|
|
|
|
|
|
417
|
|
|
|
|
|
|
The Content-Type these methods set is a B: an explicit C |
|
418
|
|
|
|
|
|
|
set beforehand is preserved, not overridden. |
|
419
|
|
|
|
|
|
|
|
|
420
|
|
|
|
|
|
|
These helpers UTF-8-encode the body, so they make the Content-Type advertise |
|
421
|
|
|
|
|
|
|
that encoding. When you preset a charset-less type they append C<; charset=utf-8> |
|
422
|
|
|
|
|
|
|
to it — C<< content_type('application/xml')->html($xml) >> sends |
|
423
|
|
|
|
|
|
|
C (charset is meaningful for XML and C, |
|
424
|
|
|
|
|
|
|
RFC 7303). The exceptions are C and the C<+json> structured-suffix |
|
425
|
|
|
|
|
|
|
types, which are left bare: JSON is always UTF-8 and defines no charset parameter |
|
426
|
|
|
|
|
|
|
(RFC 8259). An explicit charset you set yourself is never overridden. If you need |
|
427
|
|
|
|
|
|
|
a body in some other encoding, encode it yourself and use L. |
|
428
|
|
|
|
|
|
|
|
|
429
|
|
|
|
|
|
|
=head2 Trailing options (status, content_type, headers) |
|
430
|
|
|
|
|
|
|
|
|
431
|
|
|
|
|
|
|
The body methods C, C, C, C, and C accept |
|
432
|
|
|
|
|
|
|
trailing named options as a convenience so you can set status, content-type, |
|
433
|
|
|
|
|
|
|
and extra headers in a single call without chaining: |
|
434
|
|
|
|
|
|
|
|
|
435
|
|
|
|
|
|
|
PAGI::Response->json($data, status => 404); |
|
436
|
|
|
|
|
|
|
PAGI::Response->text('Hi', status => 201, headers => ['X-Foo' => 'bar']); |
|
437
|
|
|
|
|
|
|
PAGI::Response->send_raw($bytes, content_type => 'application/octet-stream'); |
|
438
|
|
|
|
|
|
|
PAGI::Response->empty(status => 304); |
|
439
|
|
|
|
|
|
|
|
|
440
|
|
|
|
|
|
|
Recognised options: |
|
441
|
|
|
|
|
|
|
|
|
442
|
|
|
|
|
|
|
=over 4 |
|
443
|
|
|
|
|
|
|
|
|
444
|
|
|
|
|
|
|
=item B — HTTP status code (integer). |
|
445
|
|
|
|
|
|
|
|
|
446
|
|
|
|
|
|
|
=item B — sets the Content-Type header, overriding any default. |
|
447
|
|
|
|
|
|
|
|
|
448
|
|
|
|
|
|
|
=item B — a flat arrayref of C<< name => value >> pairs to append. |
|
449
|
|
|
|
|
|
|
Example: C<< headers => ['X-Foo' => 'bar', 'X-Baz' => 'qux'] >>. |
|
450
|
|
|
|
|
|
|
|
|
451
|
|
|
|
|
|
|
=back |
|
452
|
|
|
|
|
|
|
|
|
453
|
|
|
|
|
|
|
An unrecognised option name causes an immediate C, catching typos such |
|
454
|
|
|
|
|
|
|
as C 404> before they silently send 200. |
|
455
|
|
|
|
|
|
|
|
|
456
|
|
|
|
|
|
|
The existing chaining form C<< ->json($data)->status(404) >> keeps working. |
|
457
|
|
|
|
|
|
|
|
|
458
|
|
|
|
|
|
|
=head2 text |
|
459
|
|
|
|
|
|
|
|
|
460
|
|
|
|
|
|
|
$res->text("Hello World"); |
|
461
|
|
|
|
|
|
|
PAGI::Response->text("Hello World"); |
|
462
|
|
|
|
|
|
|
PAGI::Response->text("Not found", status => 404); |
|
463
|
|
|
|
|
|
|
|
|
464
|
|
|
|
|
|
|
Set body to the UTF-8–encoded string with Content-Type: text/plain; charset=utf-8. |
|
465
|
|
|
|
|
|
|
Accepts trailing options (C, C, C). Returns C<$self>. |
|
466
|
|
|
|
|
|
|
|
|
467
|
|
|
|
|
|
|
=head2 html |
|
468
|
|
|
|
|
|
|
|
|
469
|
|
|
|
|
|
|
$res->html("Hello"); |
|
470
|
|
|
|
|
|
|
PAGI::Response->html("Hello"); |
|
471
|
|
|
|
|
|
|
PAGI::Response->html(" Error ", status => 500); |
|
472
|
|
|
|
|
|
|
|
|
473
|
|
|
|
|
|
|
Set body to the UTF-8–encoded string with Content-Type: text/html; charset=utf-8. |
|
474
|
|
|
|
|
|
|
Accepts trailing options (C, C, C). Returns C<$self>. |
|
475
|
|
|
|
|
|
|
|
|
476
|
|
|
|
|
|
|
=head2 json |
|
477
|
|
|
|
|
|
|
|
|
478
|
|
|
|
|
|
|
$res->json({ message => 'Hello' }); |
|
479
|
|
|
|
|
|
|
PAGI::Response->json({ message => 'Hello' }); |
|
480
|
|
|
|
|
|
|
PAGI::Response->json({ error => 'nope' }, status => 404); |
|
481
|
|
|
|
|
|
|
|
|
482
|
|
|
|
|
|
|
Set body to the JSON-encoded data with Content-Type: application/json. No charset |
|
483
|
|
|
|
|
|
|
parameter is added — JSON is always UTF-8 and C defines none |
|
484
|
|
|
|
|
|
|
(RFC 8259). Accepts trailing options (C, C, C). |
|
485
|
|
|
|
|
|
|
Returns C<$self>. |
|
486
|
|
|
|
|
|
|
|
|
487
|
|
|
|
|
|
|
=head2 redirect |
|
488
|
|
|
|
|
|
|
|
|
489
|
|
|
|
|
|
|
$res->redirect('/login'); |
|
490
|
|
|
|
|
|
|
$res->redirect('/new-url', 301); |
|
491
|
|
|
|
|
|
|
PAGI::Response->redirect('/login'); |
|
492
|
|
|
|
|
|
|
|
|
493
|
|
|
|
|
|
|
Set an empty body and a Location header. Default status is 302. Returns C<$self>. |
|
494
|
|
|
|
|
|
|
|
|
495
|
|
|
|
|
|
|
B While RFC 7231 suggests including a short HTML body with a |
|
496
|
|
|
|
|
|
|
hyperlink for clients that don't auto-follow redirects, all modern browsers |
|
497
|
|
|
|
|
|
|
and HTTP clients ignore redirect bodies. If you need a body for legacy |
|
498
|
|
|
|
|
|
|
compatibility, set it explicitly after calling C. |
|
499
|
|
|
|
|
|
|
|
|
500
|
|
|
|
|
|
|
=head2 empty |
|
501
|
|
|
|
|
|
|
|
|
502
|
|
|
|
|
|
|
$res->empty; |
|
503
|
|
|
|
|
|
|
PAGI::Response->new->empty; |
|
504
|
|
|
|
|
|
|
PAGI::Response->empty(status => 304); |
|
505
|
|
|
|
|
|
|
|
|
506
|
|
|
|
|
|
|
Set an empty body with status 204 No Content (or keep a previously set status). |
|
507
|
|
|
|
|
|
|
Accepts trailing options (C, C, C); an explicit |
|
508
|
|
|
|
|
|
|
C option overrides the 204 default. Returns C<$self>. |
|
509
|
|
|
|
|
|
|
|
|
510
|
|
|
|
|
|
|
=head2 send |
|
511
|
|
|
|
|
|
|
|
|
512
|
|
|
|
|
|
|
$res->send($text); |
|
513
|
|
|
|
|
|
|
$res->send($text, charset => 'iso-8859-1'); |
|
514
|
|
|
|
|
|
|
|
|
515
|
|
|
|
|
|
|
Set body to the encoded text (UTF-8 by default, or the specified charset). |
|
516
|
|
|
|
|
|
|
Defaults the Content-Type to C and appends the charset to a |
|
517
|
|
|
|
|
|
|
charset-less type, on the same rules as L (C and |
|
518
|
|
|
|
|
|
|
C<+json> types stay bare). Returns C<$self>. |
|
519
|
|
|
|
|
|
|
|
|
520
|
|
|
|
|
|
|
=head2 send_raw |
|
521
|
|
|
|
|
|
|
|
|
522
|
|
|
|
|
|
|
$res->send_raw($bytes); |
|
523
|
|
|
|
|
|
|
PAGI::Response->send_raw($bytes, content_type => 'application/octet-stream'); |
|
524
|
|
|
|
|
|
|
|
|
525
|
|
|
|
|
|
|
Set body to raw bytes without any encoding. Use for binary data or pre-encoded |
|
526
|
|
|
|
|
|
|
content. Accepts trailing options (C, C, C). |
|
527
|
|
|
|
|
|
|
Returns C<$self>. |
|
528
|
|
|
|
|
|
|
|
|
529
|
|
|
|
|
|
|
=head2 stream |
|
530
|
|
|
|
|
|
|
|
|
531
|
|
|
|
|
|
|
$res->stream(async sub { |
|
532
|
|
|
|
|
|
|
my ($writer) = @_; |
|
533
|
|
|
|
|
|
|
await $writer->write("chunk1"); |
|
534
|
|
|
|
|
|
|
await $writer->write("chunk2"); |
|
535
|
|
|
|
|
|
|
await $writer->close(); |
|
536
|
|
|
|
|
|
|
}); |
|
537
|
|
|
|
|
|
|
PAGI::Response->stream($callback); |
|
538
|
|
|
|
|
|
|
|
|
539
|
|
|
|
|
|
|
Store a streaming callback. When the response is sent via L, the callback |
|
540
|
|
|
|
|
|
|
receives a L and streams chunks. Returns C<$self>. |
|
541
|
|
|
|
|
|
|
|
|
542
|
|
|
|
|
|
|
=head2 writer |
|
543
|
|
|
|
|
|
|
|
|
544
|
|
|
|
|
|
|
my $writer = await $res->writer($send); |
|
545
|
|
|
|
|
|
|
my $writer = await $res->writer($send, on_close => sub { cleanup() }); |
|
546
|
|
|
|
|
|
|
my $writer = await $res->writer($send, on_close => async sub { await cleanup() }); |
|
547
|
|
|
|
|
|
|
|
|
548
|
|
|
|
|
|
|
Returns a L directly, sending headers immediately. |
|
549
|
|
|
|
|
|
|
Unlike C, the writer is not scoped to a callback — you own it |
|
550
|
|
|
|
|
|
|
and must call C when done. |
|
551
|
|
|
|
|
|
|
|
|
552
|
|
|
|
|
|
|
C<$send> must be a coderef (the PAGI send callback). This is the same |
|
553
|
|
|
|
|
|
|
C<$send> you would pass to L. |
|
554
|
|
|
|
|
|
|
|
|
555
|
|
|
|
|
|
|
This is useful when the writer needs to be passed to event handlers, |
|
556
|
|
|
|
|
|
|
pub/sub callbacks, timers, or other contexts outside a single function: |
|
557
|
|
|
|
|
|
|
|
|
558
|
|
|
|
|
|
|
async sub live_feed { |
|
559
|
|
|
|
|
|
|
my ($self, $ctx) = @_; |
|
560
|
|
|
|
|
|
|
my $writer = await $ctx->response |
|
561
|
|
|
|
|
|
|
->content_type('text/plain') |
|
562
|
|
|
|
|
|
|
->writer($ctx->send, on_close => sub { $bus->unsubscribe($id) }); |
|
563
|
|
|
|
|
|
|
|
|
564
|
|
|
|
|
|
|
my $id = $bus->subscribe(async sub ($line) { |
|
565
|
|
|
|
|
|
|
await $writer->write("$line\n"); |
|
566
|
|
|
|
|
|
|
}); |
|
567
|
|
|
|
|
|
|
|
|
568
|
|
|
|
|
|
|
await $ctx->receive; # wait for disconnect |
|
569
|
|
|
|
|
|
|
await $writer->close; |
|
570
|
|
|
|
|
|
|
} |
|
571
|
|
|
|
|
|
|
|
|
572
|
|
|
|
|
|
|
The optional C callback is registered before headers are sent, |
|
573
|
|
|
|
|
|
|
eliminating any race window with fast client disconnects. Sync and async |
|
574
|
|
|
|
|
|
|
callbacks are both supported — see L under L. |
|
575
|
|
|
|
|
|
|
|
|
576
|
|
|
|
|
|
|
=head2 send_file |
|
577
|
|
|
|
|
|
|
|
|
578
|
|
|
|
|
|
|
$res->send_file('/path/to/file.pdf'); |
|
579
|
|
|
|
|
|
|
$res->send_file('/path/to/file.pdf', |
|
580
|
|
|
|
|
|
|
filename => 'download.pdf', |
|
581
|
|
|
|
|
|
|
inline => 1, |
|
582
|
|
|
|
|
|
|
); |
|
583
|
|
|
|
|
|
|
PAGI::Response->send_file('/path/to/file.pdf'); |
|
584
|
|
|
|
|
|
|
|
|
585
|
|
|
|
|
|
|
# Partial file (for range requests) |
|
586
|
|
|
|
|
|
|
$res->send_file('/path/to/video.mp4', |
|
587
|
|
|
|
|
|
|
offset => 1024, # Start from byte 1024 |
|
588
|
|
|
|
|
|
|
length => 65536, # Send 64KB |
|
589
|
|
|
|
|
|
|
); |
|
590
|
|
|
|
|
|
|
|
|
591
|
|
|
|
|
|
|
Set the response to serve a file. Stats the file and sets Content-Type, |
|
592
|
|
|
|
|
|
|
Content-Length, and Content-Disposition at call time. The PAGI protocol's |
|
593
|
|
|
|
|
|
|
C key is used for efficient server-side streaming (file not read into |
|
594
|
|
|
|
|
|
|
memory) when L is called. Returns C<$self>. |
|
595
|
|
|
|
|
|
|
|
|
596
|
|
|
|
|
|
|
For production, use L to delegate file serving |
|
597
|
|
|
|
|
|
|
to your reverse proxy. |
|
598
|
|
|
|
|
|
|
|
|
599
|
|
|
|
|
|
|
B |
|
600
|
|
|
|
|
|
|
|
|
601
|
|
|
|
|
|
|
=over 4 |
|
602
|
|
|
|
|
|
|
|
|
603
|
|
|
|
|
|
|
=item * C - Set Content-Disposition attachment filename |
|
604
|
|
|
|
|
|
|
|
|
605
|
|
|
|
|
|
|
=item * C - Use Content-Disposition: inline instead of attachment |
|
606
|
|
|
|
|
|
|
|
|
607
|
|
|
|
|
|
|
=item * C - Start position in bytes (default: 0). For range requests. |
|
608
|
|
|
|
|
|
|
|
|
609
|
|
|
|
|
|
|
=item * C - Number of bytes to send. Defaults to file size minus offset. |
|
610
|
|
|
|
|
|
|
|
|
611
|
|
|
|
|
|
|
=back |
|
612
|
|
|
|
|
|
|
|
|
613
|
|
|
|
|
|
|
B |
|
614
|
|
|
|
|
|
|
|
|
615
|
|
|
|
|
|
|
# Manual range request handling |
|
616
|
|
|
|
|
|
|
async sub handle_video { |
|
617
|
|
|
|
|
|
|
my ($req, $send) = @_; |
|
618
|
|
|
|
|
|
|
my $path = '/videos/movie.mp4'; |
|
619
|
|
|
|
|
|
|
my $size = -s $path; |
|
620
|
|
|
|
|
|
|
|
|
621
|
|
|
|
|
|
|
my $range = $req->header('Range'); |
|
622
|
|
|
|
|
|
|
if ($range && $range =~ /bytes=(\d+)-(\d*)/) { |
|
623
|
|
|
|
|
|
|
my $start = $1; |
|
624
|
|
|
|
|
|
|
my $end = $2 || ($size - 1); |
|
625
|
|
|
|
|
|
|
my $length = $end - $start + 1; |
|
626
|
|
|
|
|
|
|
|
|
627
|
|
|
|
|
|
|
return await PAGI::Response->new |
|
628
|
|
|
|
|
|
|
->status(206) |
|
629
|
|
|
|
|
|
|
->header('Content-Range' => "bytes $start-$end/$size") |
|
630
|
|
|
|
|
|
|
->header('Accept-Ranges' => 'bytes') |
|
631
|
|
|
|
|
|
|
->send_file($path, offset => $start, length => $length) |
|
632
|
|
|
|
|
|
|
->respond($send); |
|
633
|
|
|
|
|
|
|
} |
|
634
|
|
|
|
|
|
|
|
|
635
|
|
|
|
|
|
|
return await PAGI::Response->new |
|
636
|
|
|
|
|
|
|
->header('Accept-Ranges' => 'bytes') |
|
637
|
|
|
|
|
|
|
->send_file($path) |
|
638
|
|
|
|
|
|
|
->respond($send); |
|
639
|
|
|
|
|
|
|
} |
|
640
|
|
|
|
|
|
|
|
|
641
|
|
|
|
|
|
|
B For production file serving with full features (ETag caching, |
|
642
|
|
|
|
|
|
|
automatic range request handling, conditional GETs, directory indexes), |
|
643
|
|
|
|
|
|
|
use L instead: |
|
644
|
|
|
|
|
|
|
|
|
645
|
|
|
|
|
|
|
use PAGI::App::File; |
|
646
|
|
|
|
|
|
|
my $files = PAGI::App::File->new(root => '/var/www/static'); |
|
647
|
|
|
|
|
|
|
my $app = $files->to_app; |
|
648
|
|
|
|
|
|
|
|
|
649
|
|
|
|
|
|
|
=head1 EXAMPLES |
|
650
|
|
|
|
|
|
|
|
|
651
|
|
|
|
|
|
|
=head2 Complete Raw PAGI Application |
|
652
|
|
|
|
|
|
|
|
|
653
|
|
|
|
|
|
|
use Future::AsyncAwait; |
|
654
|
|
|
|
|
|
|
use PAGI::Request; |
|
655
|
|
|
|
|
|
|
use PAGI::Response; |
|
656
|
|
|
|
|
|
|
|
|
657
|
|
|
|
|
|
|
my $app = async sub ($scope, $receive, $send) { |
|
658
|
|
|
|
|
|
|
return await handle_lifespan($scope, $receive, $send) |
|
659
|
|
|
|
|
|
|
if $scope->{type} eq 'lifespan'; |
|
660
|
|
|
|
|
|
|
|
|
661
|
|
|
|
|
|
|
my $req = PAGI::Request->new($scope, $receive); |
|
662
|
|
|
|
|
|
|
my $res = $req->response; |
|
663
|
|
|
|
|
|
|
|
|
664
|
|
|
|
|
|
|
if ($req->method eq 'GET' && $req->path eq '/') { |
|
665
|
|
|
|
|
|
|
return await $res->html('Welcome')->respond($send); |
|
666
|
|
|
|
|
|
|
} |
|
667
|
|
|
|
|
|
|
|
|
668
|
|
|
|
|
|
|
if ($req->method eq 'POST' && $req->path eq '/api/users') { |
|
669
|
|
|
|
|
|
|
my $data = await $req->json; |
|
670
|
|
|
|
|
|
|
# ... create user ... |
|
671
|
|
|
|
|
|
|
return await $res->status(201) |
|
672
|
|
|
|
|
|
|
->header('Location' => '/api/users/123') |
|
673
|
|
|
|
|
|
|
->json({ id => 123, name => $data->{name} }) |
|
674
|
|
|
|
|
|
|
->respond($send); |
|
675
|
|
|
|
|
|
|
} |
|
676
|
|
|
|
|
|
|
|
|
677
|
|
|
|
|
|
|
return await $res->status(404)->json({ error => 'Not Found' })->respond($send); |
|
678
|
|
|
|
|
|
|
}; |
|
679
|
|
|
|
|
|
|
|
|
680
|
|
|
|
|
|
|
=head2 Form Validation with Error Response |
|
681
|
|
|
|
|
|
|
|
|
682
|
|
|
|
|
|
|
async sub handle_contact ($req, $send) { |
|
683
|
|
|
|
|
|
|
my $res = $req->response; |
|
684
|
|
|
|
|
|
|
my $form = await $req->form_params; |
|
685
|
|
|
|
|
|
|
|
|
686
|
|
|
|
|
|
|
my @errors; |
|
687
|
|
|
|
|
|
|
my $email = $form->get('email') // ''; |
|
688
|
|
|
|
|
|
|
my $message = $form->get('message') // ''; |
|
689
|
|
|
|
|
|
|
|
|
690
|
|
|
|
|
|
|
push @errors, 'Email required' unless $email; |
|
691
|
|
|
|
|
|
|
push @errors, 'Invalid email' unless $email =~ /@/; |
|
692
|
|
|
|
|
|
|
push @errors, 'Message required' unless $message; |
|
693
|
|
|
|
|
|
|
|
|
694
|
|
|
|
|
|
|
if (@errors) { |
|
695
|
|
|
|
|
|
|
return await $res->status(422) |
|
696
|
|
|
|
|
|
|
->json({ error => 'Validation failed', errors => \@errors }) |
|
697
|
|
|
|
|
|
|
->respond($send); |
|
698
|
|
|
|
|
|
|
} |
|
699
|
|
|
|
|
|
|
|
|
700
|
|
|
|
|
|
|
# Process valid form... |
|
701
|
|
|
|
|
|
|
return await $res->json({ success => 1 })->respond($send); |
|
702
|
|
|
|
|
|
|
} |
|
703
|
|
|
|
|
|
|
|
|
704
|
|
|
|
|
|
|
=head2 Authentication with Cookies |
|
705
|
|
|
|
|
|
|
|
|
706
|
|
|
|
|
|
|
async sub handle_login ($req, $send) { |
|
707
|
|
|
|
|
|
|
my $res = $req->response; |
|
708
|
|
|
|
|
|
|
my $data = await $req->json; |
|
709
|
|
|
|
|
|
|
|
|
710
|
|
|
|
|
|
|
my $user = authenticate($data->{email}, $data->{password}); |
|
711
|
|
|
|
|
|
|
|
|
712
|
|
|
|
|
|
|
unless ($user) { |
|
713
|
|
|
|
|
|
|
return await $res->status(401)->json({ error => 'Invalid credentials' })->respond($send); |
|
714
|
|
|
|
|
|
|
} |
|
715
|
|
|
|
|
|
|
|
|
716
|
|
|
|
|
|
|
my $session_id = create_session($user); |
|
717
|
|
|
|
|
|
|
|
|
718
|
|
|
|
|
|
|
return await $res->cookie('session' => $session_id, |
|
719
|
|
|
|
|
|
|
path => '/', |
|
720
|
|
|
|
|
|
|
httponly => 1, |
|
721
|
|
|
|
|
|
|
secure => 1, |
|
722
|
|
|
|
|
|
|
samesite => 'Strict', |
|
723
|
|
|
|
|
|
|
max_age => 86400, # 24 hours |
|
724
|
|
|
|
|
|
|
) |
|
725
|
|
|
|
|
|
|
->json({ user => { id => $user->{id}, name => $user->{name} } }) |
|
726
|
|
|
|
|
|
|
->respond($send); |
|
727
|
|
|
|
|
|
|
} |
|
728
|
|
|
|
|
|
|
|
|
729
|
|
|
|
|
|
|
async sub handle_logout ($req, $send) { |
|
730
|
|
|
|
|
|
|
my $res = $req->response; |
|
731
|
|
|
|
|
|
|
|
|
732
|
|
|
|
|
|
|
return await $res->delete_cookie('session', path => '/') |
|
733
|
|
|
|
|
|
|
->json({ logged_out => 1 }) |
|
734
|
|
|
|
|
|
|
->respond($send); |
|
735
|
|
|
|
|
|
|
} |
|
736
|
|
|
|
|
|
|
|
|
737
|
|
|
|
|
|
|
=head2 File Download |
|
738
|
|
|
|
|
|
|
|
|
739
|
|
|
|
|
|
|
async sub handle_download ($req, $send) { |
|
740
|
|
|
|
|
|
|
my $res = $req->response; |
|
741
|
|
|
|
|
|
|
my $file_id = $req->path_param('id'); |
|
742
|
|
|
|
|
|
|
|
|
743
|
|
|
|
|
|
|
my $file = get_file($file_id); # Be sure to clean $file |
|
744
|
|
|
|
|
|
|
unless ($file && -f $file->{path}) { |
|
745
|
|
|
|
|
|
|
return await $res->status(404)->json({ error => 'File not found' })->respond($send); |
|
746
|
|
|
|
|
|
|
} |
|
747
|
|
|
|
|
|
|
|
|
748
|
|
|
|
|
|
|
return await $res->send_file($file->{path}, |
|
749
|
|
|
|
|
|
|
filename => $file->{original_name}, |
|
750
|
|
|
|
|
|
|
)->respond($send); |
|
751
|
|
|
|
|
|
|
} |
|
752
|
|
|
|
|
|
|
|
|
753
|
|
|
|
|
|
|
=head2 Streaming Large Data |
|
754
|
|
|
|
|
|
|
|
|
755
|
|
|
|
|
|
|
async sub handle_export ($req, $send) { |
|
756
|
|
|
|
|
|
|
my $res = $req->response; |
|
757
|
|
|
|
|
|
|
|
|
758
|
|
|
|
|
|
|
await $res->content_type('text/csv') |
|
759
|
|
|
|
|
|
|
->header('Content-Disposition' => 'attachment; filename="export.csv"') |
|
760
|
|
|
|
|
|
|
->stream(async sub ($writer) { |
|
761
|
|
|
|
|
|
|
# Write CSV header |
|
762
|
|
|
|
|
|
|
await $writer->write("id,name,email\n"); |
|
763
|
|
|
|
|
|
|
|
|
764
|
|
|
|
|
|
|
# Stream rows from database |
|
765
|
|
|
|
|
|
|
my $cursor = get_all_users_cursor(); |
|
766
|
|
|
|
|
|
|
while (my $user = $cursor->next) { |
|
767
|
|
|
|
|
|
|
await $writer->write("$user->{id},$user->{name},$user->{email}\n"); |
|
768
|
|
|
|
|
|
|
} |
|
769
|
|
|
|
|
|
|
}) |
|
770
|
|
|
|
|
|
|
->respond($send); |
|
771
|
|
|
|
|
|
|
} |
|
772
|
|
|
|
|
|
|
|
|
773
|
|
|
|
|
|
|
=head2 Server-Sent Events Style Streaming |
|
774
|
|
|
|
|
|
|
|
|
775
|
|
|
|
|
|
|
async sub handle_events ($req, $send) { |
|
776
|
|
|
|
|
|
|
my $res = $req->response; |
|
777
|
|
|
|
|
|
|
|
|
778
|
|
|
|
|
|
|
await $res->content_type('text/event-stream') |
|
779
|
|
|
|
|
|
|
->header('Cache-Control' => 'no-cache') |
|
780
|
|
|
|
|
|
|
->stream(async sub ($writer) { |
|
781
|
|
|
|
|
|
|
for my $i (1..10) { |
|
782
|
|
|
|
|
|
|
await $writer->write("data: Event $i\n\n"); |
|
783
|
|
|
|
|
|
|
await some_delay(1); # Wait 1 second |
|
784
|
|
|
|
|
|
|
} |
|
785
|
|
|
|
|
|
|
}) |
|
786
|
|
|
|
|
|
|
->respond($send); |
|
787
|
|
|
|
|
|
|
} |
|
788
|
|
|
|
|
|
|
|
|
789
|
|
|
|
|
|
|
=head2 Conditional Responses |
|
790
|
|
|
|
|
|
|
|
|
791
|
|
|
|
|
|
|
async sub handle_resource ($req, $send) { |
|
792
|
|
|
|
|
|
|
my $res = $req->response; |
|
793
|
|
|
|
|
|
|
my $etag = '"abc123"'; |
|
794
|
|
|
|
|
|
|
|
|
795
|
|
|
|
|
|
|
# Check If-None-Match for caching |
|
796
|
|
|
|
|
|
|
my $if_none_match = $req->header('If-None-Match') // ''; |
|
797
|
|
|
|
|
|
|
if ($if_none_match eq $etag) { |
|
798
|
|
|
|
|
|
|
return await $res->status(304)->empty()->respond($send); |
|
799
|
|
|
|
|
|
|
} |
|
800
|
|
|
|
|
|
|
|
|
801
|
|
|
|
|
|
|
return await $res->header('ETag' => $etag) |
|
802
|
|
|
|
|
|
|
->header('Cache-Control' => 'max-age=3600') |
|
803
|
|
|
|
|
|
|
->json({ data => 'expensive computation result' }) |
|
804
|
|
|
|
|
|
|
->respond($send); |
|
805
|
|
|
|
|
|
|
} |
|
806
|
|
|
|
|
|
|
|
|
807
|
|
|
|
|
|
|
=head2 CORS API Endpoint |
|
808
|
|
|
|
|
|
|
|
|
809
|
|
|
|
|
|
|
# Simple CORS - allow all origins |
|
810
|
|
|
|
|
|
|
async sub handle_api ($scope, $receive, $send) { |
|
811
|
|
|
|
|
|
|
my $res = PAGI::Response->new($scope); |
|
812
|
|
|
|
|
|
|
|
|
813
|
|
|
|
|
|
|
return await $res->cors->json({ status => 'ok' })->respond($send); |
|
814
|
|
|
|
|
|
|
} |
|
815
|
|
|
|
|
|
|
|
|
816
|
|
|
|
|
|
|
# CORS with credentials (e.g., cookies, auth headers) |
|
817
|
|
|
|
|
|
|
async sub handle_api_with_auth ($scope, $receive, $send) { |
|
818
|
|
|
|
|
|
|
my $req = PAGI::Request->new($scope, $receive); |
|
819
|
|
|
|
|
|
|
my $res = $req->response; |
|
820
|
|
|
|
|
|
|
|
|
821
|
|
|
|
|
|
|
# Get the Origin header from request |
|
822
|
|
|
|
|
|
|
my $origin = $req->header('Origin'); |
|
823
|
|
|
|
|
|
|
|
|
824
|
|
|
|
|
|
|
return await $res->cors( |
|
825
|
|
|
|
|
|
|
origin => 'https://myapp.com', # Or use request_origin |
|
826
|
|
|
|
|
|
|
credentials => 1, |
|
827
|
|
|
|
|
|
|
expose => [qw(X-Request-Id)], |
|
828
|
|
|
|
|
|
|
)->json({ user => 'authenticated' })->respond($send); |
|
829
|
|
|
|
|
|
|
} |
|
830
|
|
|
|
|
|
|
|
|
831
|
|
|
|
|
|
|
=head2 CORS Preflight Handler |
|
832
|
|
|
|
|
|
|
|
|
833
|
|
|
|
|
|
|
# Handle OPTIONS preflight requests |
|
834
|
|
|
|
|
|
|
async sub app ($scope, $receive, $send) { |
|
835
|
|
|
|
|
|
|
my $req = PAGI::Request->new($scope, $receive); |
|
836
|
|
|
|
|
|
|
my $res = $req->response; |
|
837
|
|
|
|
|
|
|
|
|
838
|
|
|
|
|
|
|
# Handle preflight |
|
839
|
|
|
|
|
|
|
if ($req->method eq 'OPTIONS') { |
|
840
|
|
|
|
|
|
|
return await $res->cors( |
|
841
|
|
|
|
|
|
|
origin => 'https://myapp.com', |
|
842
|
|
|
|
|
|
|
methods => [qw(GET POST PUT DELETE)], |
|
843
|
|
|
|
|
|
|
headers => [qw(Content-Type Authorization X-Custom-Header)], |
|
844
|
|
|
|
|
|
|
credentials => 1, |
|
845
|
|
|
|
|
|
|
max_age => 86400, |
|
846
|
|
|
|
|
|
|
preflight => 1, # Include preflight headers |
|
847
|
|
|
|
|
|
|
)->status(204)->empty()->respond($send); |
|
848
|
|
|
|
|
|
|
} |
|
849
|
|
|
|
|
|
|
|
|
850
|
|
|
|
|
|
|
# Handle actual request |
|
851
|
|
|
|
|
|
|
return await $res->cors( |
|
852
|
|
|
|
|
|
|
origin => 'https://myapp.com', |
|
853
|
|
|
|
|
|
|
credentials => 1, |
|
854
|
|
|
|
|
|
|
)->json({ data => 'response' })->respond($send); |
|
855
|
|
|
|
|
|
|
} |
|
856
|
|
|
|
|
|
|
|
|
857
|
|
|
|
|
|
|
=head2 Dynamic CORS Origin |
|
858
|
|
|
|
|
|
|
|
|
859
|
|
|
|
|
|
|
# Allow multiple origins dynamically |
|
860
|
|
|
|
|
|
|
my %ALLOWED_ORIGINS = map { $_ => 1 } qw( |
|
861
|
|
|
|
|
|
|
https://app1.example.com |
|
862
|
|
|
|
|
|
|
https://app2.example.com |
|
863
|
|
|
|
|
|
|
https://localhost:3000 |
|
864
|
|
|
|
|
|
|
); |
|
865
|
|
|
|
|
|
|
|
|
866
|
|
|
|
|
|
|
async sub handle_api ($scope, $receive, $send) { |
|
867
|
|
|
|
|
|
|
my $req = PAGI::Request->new($scope, $receive); |
|
868
|
|
|
|
|
|
|
my $res = $req->response; |
|
869
|
|
|
|
|
|
|
|
|
870
|
|
|
|
|
|
|
my $request_origin = $req->header('Origin') // ''; |
|
871
|
|
|
|
|
|
|
|
|
872
|
|
|
|
|
|
|
# Check if origin is allowed |
|
873
|
|
|
|
|
|
|
if ($ALLOWED_ORIGINS{$request_origin}) { |
|
874
|
|
|
|
|
|
|
return await $res->cors( |
|
875
|
|
|
|
|
|
|
origin => $request_origin, # Echo back the allowed origin |
|
876
|
|
|
|
|
|
|
credentials => 1, |
|
877
|
|
|
|
|
|
|
)->json({ data => 'allowed' })->respond($send); |
|
878
|
|
|
|
|
|
|
} |
|
879
|
|
|
|
|
|
|
|
|
880
|
|
|
|
|
|
|
# Origin not allowed - respond without CORS headers |
|
881
|
|
|
|
|
|
|
return await $res->status(403)->json({ error => 'Origin not allowed' })->respond($send); |
|
882
|
|
|
|
|
|
|
} |
|
883
|
|
|
|
|
|
|
|
|
884
|
|
|
|
|
|
|
=head1 WRITER OBJECT |
|
885
|
|
|
|
|
|
|
|
|
886
|
|
|
|
|
|
|
The C method passes a writer object to its callback, and |
|
887
|
|
|
|
|
|
|
C returns one directly. The writer has the following methods: |
|
888
|
|
|
|
|
|
|
|
|
889
|
|
|
|
|
|
|
=head3 write |
|
890
|
|
|
|
|
|
|
|
|
891
|
|
|
|
|
|
|
await $writer->write($chunk); |
|
892
|
|
|
|
|
|
|
|
|
893
|
|
|
|
|
|
|
Write a chunk of data to the response stream. Returns a L. |
|
894
|
|
|
|
|
|
|
|
|
895
|
|
|
|
|
|
|
Writing after close returns a failed L rather than throwing. |
|
896
|
|
|
|
|
|
|
This allows cleanup code that races with close to handle the error |
|
897
|
|
|
|
|
|
|
gracefully via C. |
|
898
|
|
|
|
|
|
|
|
|
899
|
|
|
|
|
|
|
=head3 close |
|
900
|
|
|
|
|
|
|
|
|
901
|
|
|
|
|
|
|
await $writer->close; |
|
902
|
|
|
|
|
|
|
|
|
903
|
|
|
|
|
|
|
Close the stream. Returns a L. Calling close multiple times is |
|
904
|
|
|
|
|
|
|
safe — subsequent calls are no-ops. |
|
905
|
|
|
|
|
|
|
|
|
906
|
|
|
|
|
|
|
=head3 bytes_written |
|
907
|
|
|
|
|
|
|
|
|
908
|
|
|
|
|
|
|
my $n = $writer->bytes_written; |
|
909
|
|
|
|
|
|
|
|
|
910
|
|
|
|
|
|
|
Returns the total number of bytes written so far. |
|
911
|
|
|
|
|
|
|
|
|
912
|
|
|
|
|
|
|
=head3 on_close |
|
913
|
|
|
|
|
|
|
|
|
914
|
|
|
|
|
|
|
# Sync callback |
|
915
|
|
|
|
|
|
|
$writer->on_close(sub { cleanup() }); |
|
916
|
|
|
|
|
|
|
|
|
917
|
|
|
|
|
|
|
# Async callback — return value is awaited automatically |
|
918
|
|
|
|
|
|
|
$writer->on_close(async sub { |
|
919
|
|
|
|
|
|
|
await notify_stream_ended(); |
|
920
|
|
|
|
|
|
|
}); |
|
921
|
|
|
|
|
|
|
|
|
922
|
|
|
|
|
|
|
# Chaining |
|
923
|
|
|
|
|
|
|
$writer->on_close(sub { ... }) |
|
924
|
|
|
|
|
|
|
->on_close(sub { ... }); |
|
925
|
|
|
|
|
|
|
|
|
926
|
|
|
|
|
|
|
Registers a callback to fire when the writer closes (either explicitly |
|
927
|
|
|
|
|
|
|
or via C auto-close). Callbacks can be regular subs or async |
|
928
|
|
|
|
|
|
|
subs — async results are automatically awaited. Multiple callbacks run |
|
929
|
|
|
|
|
|
|
in registration order. Exceptions are caught and warned but do not |
|
930
|
|
|
|
|
|
|
prevent other callbacks from running. Returns C<$self> for chaining. |
|
931
|
|
|
|
|
|
|
|
|
932
|
|
|
|
|
|
|
B If your callback captures the writer |
|
933
|
|
|
|
|
|
|
object in a closure, use C to avoid a memory leak: |
|
934
|
|
|
|
|
|
|
|
|
935
|
|
|
|
|
|
|
use Scalar::Util qw(weaken); |
|
936
|
|
|
|
|
|
|
my $weak_writer = $writer; |
|
937
|
|
|
|
|
|
|
weaken($weak_writer); |
|
938
|
|
|
|
|
|
|
$writer->on_close(sub { $weak_writer->... if $weak_writer }); |
|
939
|
|
|
|
|
|
|
|
|
940
|
|
|
|
|
|
|
The callback array is cleared after firing, so any cycle via a closure |
|
941
|
|
|
|
|
|
|
is broken when the writer closes, but C prevents the object |
|
942
|
|
|
|
|
|
|
from being kept alive until that point. |
|
943
|
|
|
|
|
|
|
|
|
944
|
|
|
|
|
|
|
=head3 is_closed |
|
945
|
|
|
|
|
|
|
|
|
946
|
|
|
|
|
|
|
if ($writer->is_closed) { ... } |
|
947
|
|
|
|
|
|
|
|
|
948
|
|
|
|
|
|
|
Returns true if the writer has been closed. |
|
949
|
|
|
|
|
|
|
|
|
950
|
|
|
|
|
|
|
The writer automatically closes when the C callback completes, |
|
951
|
|
|
|
|
|
|
but calling C explicitly is recommended for clarity. |
|
952
|
|
|
|
|
|
|
|
|
953
|
|
|
|
|
|
|
=head1 ERROR AND ALTERNATE RESPONSES |
|
954
|
|
|
|
|
|
|
|
|
955
|
|
|
|
|
|
|
A response is a value, so "produce a 404 instead" is just returning a different |
|
956
|
|
|
|
|
|
|
value -- no exceptions needed: |
|
957
|
|
|
|
|
|
|
|
|
958
|
|
|
|
|
|
|
async sub show ($self, $ctx) { |
|
959
|
|
|
|
|
|
|
my $user = await find_user($ctx->req->path_param('id')); |
|
960
|
|
|
|
|
|
|
return PAGI::Response->json({ error => 'not found' }, status => 404) |
|
961
|
|
|
|
|
|
|
unless $user; |
|
962
|
|
|
|
|
|
|
return $ctx->json($user); |
|
963
|
|
|
|
|
|
|
} |
|
964
|
|
|
|
|
|
|
|
|
965
|
|
|
|
|
|
|
For cases that recur across handlers, prefer modeling the absence as a value |
|
966
|
|
|
|
|
|
|
(a "null object") whose own method returns the right response, instead of |
|
967
|
|
|
|
|
|
|
throwing from deep in the stack: |
|
968
|
|
|
|
|
|
|
|
|
969
|
|
|
|
|
|
|
my $user = await find_user($ctx) // UnauthenticatedUser->new($ctx); |
|
970
|
|
|
|
|
|
|
return $user->dashboard; # a real user renders; an UnauthenticatedUser |
|
971
|
|
|
|
|
|
|
# returns a 401 / login response |
|
972
|
|
|
|
|
|
|
|
|
973
|
|
|
|
|
|
|
Here C is a class you define; its C method |
|
974
|
|
|
|
|
|
|
returns a C just as a real user's would. |
|
975
|
|
|
|
|
|
|
|
|
976
|
|
|
|
|
|
|
=head1 SUBCLASSING (FRAMEWORK INTEGRATION) |
|
977
|
|
|
|
|
|
|
|
|
978
|
|
|
|
|
|
|
Framework authors can subclass C to add their own response |
|
979
|
|
|
|
|
|
|
sugar while reusing the value machinery. The contract is small and stable: |
|
980
|
|
|
|
|
|
|
|
|
981
|
|
|
|
|
|
|
=over 4 |
|
982
|
|
|
|
|
|
|
|
|
983
|
|
|
|
|
|
|
=item * B C<< $class->new($scope) >>. The scope is optional and |
|
984
|
|
|
|
|
|
|
inert (used only for C and helpers like L); a response |
|
985
|
|
|
|
|
|
|
never holds a connection. A Moose subclass can C and |
|
986
|
|
|
|
|
|
|
provide C returning C<($scope)>. |
|
987
|
|
|
|
|
|
|
|
|
988
|
|
|
|
|
|
|
=item * B C<< respond($send) >> to customize how the response is sent. |
|
989
|
|
|
|
|
|
|
Call C<< $self->SUPER::respond($send) >> to do the actual emission. The |
|
990
|
|
|
|
|
|
|
connection (C<$send>) arrives as the argument; do not store or re-bind it -- a |
|
991
|
|
|
|
|
|
|
response value is connection-free until the moment it is sent. |
|
992
|
|
|
|
|
|
|
|
|
993
|
|
|
|
|
|
|
=item * B -- C, C |
|
994
|
|
|
|
|
|
|
C, C, C, C, the C predicates |
|
995
|
|
|
|
|
|
|
(C, C, C, C), and |
|
996
|
|
|
|
|
|
|
the body methods (C/C/C/C/C/C/ |
|
997
|
|
|
|
|
|
|
C/C, with trailing options). Do B reach into the |
|
998
|
|
|
|
|
|
|
C<_>-prefixed internals (C<_headers>, C<_body>, C<_status>, C<_stream>, ...); |
|
999
|
|
|
|
|
|
|
they are private and may change. |
|
1000
|
|
|
|
|
|
|
|
|
1001
|
|
|
|
|
|
|
=item * Adding response sugar via a role/mixin works unchanged -- a role that |
|
1002
|
|
|
|
|
|
|
calls the public chainers and body methods needs no special support. |
|
1003
|
|
|
|
|
|
|
|
|
1004
|
|
|
|
|
|
|
=back |
|
1005
|
|
|
|
|
|
|
|
|
1006
|
|
|
|
|
|
|
A response value never needs C<$send> until it is sent, so "I don't have a |
|
1007
|
|
|
|
|
|
|
connection here" just means "I am not sending yet": hold the value and call |
|
1008
|
|
|
|
|
|
|
C (or return it from an endpoint, where dispatch sends it) when a |
|
1009
|
|
|
|
|
|
|
connection is available. |
|
1010
|
|
|
|
|
|
|
|
|
1011
|
|
|
|
|
|
|
=head1 SEE ALSO |
|
1012
|
|
|
|
|
|
|
|
|
1013
|
|
|
|
|
|
|
L, L, L |
|
1014
|
|
|
|
|
|
|
|
|
1015
|
|
|
|
|
|
|
=head1 AUTHOR |
|
1016
|
|
|
|
|
|
|
|
|
1017
|
|
|
|
|
|
|
PAGI Contributors |
|
1018
|
|
|
|
|
|
|
|
|
1019
|
|
|
|
|
|
|
=cut |
|
1020
|
|
|
|
|
|
|
|
|
1021
|
|
|
|
|
|
|
sub new { |
|
1022
|
181
|
|
|
181
|
1
|
982832
|
my ($class, $scope) = @_; |
|
1023
|
181
|
100
|
100
|
|
|
868
|
croak("scope must be a hashref") if defined $scope && ref($scope) ne 'HASH'; |
|
1024
|
180
|
|
|
|
|
691
|
return bless { |
|
1025
|
|
|
|
|
|
|
scope => $scope, # optional, inert (accessors / Stash); NOT a connection |
|
1026
|
|
|
|
|
|
|
_headers => PAGI::Headers->new, |
|
1027
|
|
|
|
|
|
|
}, $class; |
|
1028
|
|
|
|
|
|
|
} |
|
1029
|
|
|
|
|
|
|
|
|
1030
|
|
|
|
|
|
|
sub status { |
|
1031
|
197
|
|
|
197
|
1
|
1994
|
my ($self, $code) = @_; |
|
1032
|
197
|
100
|
100
|
|
|
741
|
return $self->{_status} // 200 if @_ == 1; # lazy default |
|
1033
|
60
|
100
|
100
|
|
|
871
|
croak("Status must be a number between 100-599") |
|
|
|
|
100
|
|
|
|
|
|
1034
|
|
|
|
|
|
|
unless $code =~ /^\d+$/ && $code >= 100 && $code <= 599; |
|
1035
|
57
|
|
|
|
|
128
|
$self->{_status} = $code; |
|
1036
|
57
|
|
|
|
|
158
|
return $self; |
|
1037
|
|
|
|
|
|
|
} |
|
1038
|
|
|
|
|
|
|
|
|
1039
|
|
|
|
|
|
|
sub status_try { |
|
1040
|
10
|
|
|
10
|
1
|
18
|
my ($self, $code) = @_; |
|
1041
|
10
|
100
|
|
|
|
63
|
return $self if exists $self->{_status}; |
|
1042
|
7
|
|
|
|
|
22
|
return $self->status($code); |
|
1043
|
|
|
|
|
|
|
} |
|
1044
|
|
|
|
|
|
|
|
|
1045
|
|
|
|
|
|
|
sub header { |
|
1046
|
66
|
|
|
66
|
1
|
1740
|
my ($self, $name, $value) = @_; |
|
1047
|
66
|
50
|
|
|
|
118
|
croak("Header name is required") unless defined $name; |
|
1048
|
66
|
100
|
|
|
|
130
|
return $self->{_headers}->get($name) if @_ == 2; # getter: last value |
|
1049
|
64
|
|
|
|
|
163
|
$self->{_headers}->add($name, $value); # setter: append |
|
1050
|
64
|
|
|
|
|
176
|
return $self; |
|
1051
|
|
|
|
|
|
|
} |
|
1052
|
|
|
|
|
|
|
|
|
1053
|
4
|
|
|
4
|
1
|
34
|
sub headers { return $_[0]->{_headers} } |
|
1054
|
|
|
|
|
|
|
|
|
1055
|
|
|
|
|
|
|
sub header_all { |
|
1056
|
1
|
|
|
1
|
1
|
3
|
my ($self, $name) = @_; |
|
1057
|
1
|
50
|
|
|
|
5
|
croak("Header name is required") unless defined $name; |
|
1058
|
1
|
|
|
|
|
5
|
return $self->{_headers}->get_all($name); |
|
1059
|
|
|
|
|
|
|
} |
|
1060
|
|
|
|
|
|
|
|
|
1061
|
|
|
|
|
|
|
sub header_try { |
|
1062
|
2
|
|
|
2
|
1
|
6
|
my ($self, $name, $value) = @_; |
|
1063
|
2
|
|
|
|
|
6
|
$self->{_headers}->set_default($name, $value); |
|
1064
|
2
|
|
|
|
|
2
|
return $self; |
|
1065
|
|
|
|
|
|
|
} |
|
1066
|
|
|
|
|
|
|
|
|
1067
|
|
|
|
|
|
|
sub remove_header { |
|
1068
|
1
|
|
|
1
|
1
|
2
|
my ($self, $name) = @_; |
|
1069
|
1
|
50
|
|
|
|
3
|
croak("Header name is required") unless defined $name; |
|
1070
|
1
|
|
|
|
|
4
|
$self->{_headers}->remove($name); |
|
1071
|
1
|
|
|
|
|
5
|
return $self; |
|
1072
|
|
|
|
|
|
|
} |
|
1073
|
|
|
|
|
|
|
|
|
1074
|
|
|
|
|
|
|
sub content_type { |
|
1075
|
121
|
|
|
121
|
1
|
1736
|
my ($self, $type) = @_; |
|
1076
|
121
|
100
|
|
|
|
354
|
return $self->{_headers}->get('content-type') if @_ == 1; # getter |
|
1077
|
39
|
100
|
|
|
|
66
|
if (defined $type) { $self->{_headers}->set('content-type', $type) } |
|
|
38
|
|
|
|
|
90
|
|
|
1078
|
1
|
|
|
|
|
4
|
else { $self->{_headers}->remove('content-type') } # content_type(undef) clears |
|
1079
|
39
|
|
|
|
|
88
|
return $self; |
|
1080
|
|
|
|
|
|
|
} |
|
1081
|
|
|
|
|
|
|
|
|
1082
|
|
|
|
|
|
|
sub content_type_try { |
|
1083
|
90
|
|
|
90
|
1
|
965
|
my ($self, $type) = @_; |
|
1084
|
90
|
|
|
|
|
247
|
$self->{_headers}->set_default('content-type', $type); |
|
1085
|
90
|
|
|
|
|
126
|
return $self; |
|
1086
|
|
|
|
|
|
|
} |
|
1087
|
|
|
|
|
|
|
|
|
1088
|
|
|
|
|
|
|
sub has_status { |
|
1089
|
3
|
|
|
3
|
1
|
53
|
my ($self) = @_; |
|
1090
|
3
|
100
|
|
|
|
23
|
return exists $self->{_status} ? 1 : 0; |
|
1091
|
|
|
|
|
|
|
} |
|
1092
|
|
|
|
|
|
|
|
|
1093
|
4
|
|
|
4
|
1
|
13
|
sub has_header { return $_[0]->{_headers}->has($_[1]) } |
|
1094
|
85
|
|
|
85
|
1
|
184
|
sub has_content_type { return $_[0]->{_headers}->has('content-type') } |
|
1095
|
|
|
|
|
|
|
|
|
1096
|
|
|
|
|
|
|
sub has_body_source { |
|
1097
|
12
|
|
|
12
|
1
|
35
|
my ($self) = @_; |
|
1098
|
12
|
100
|
100
|
|
|
62
|
return (exists $self->{_body} || exists $self->{_stream} || exists $self->{_file}) ? 1 : 0; |
|
1099
|
|
|
|
|
|
|
} |
|
1100
|
|
|
|
|
|
|
|
|
1101
|
8
|
|
|
8
|
1
|
1162
|
sub scope { shift->{scope} } |
|
1102
|
|
|
|
|
|
|
|
|
1103
|
|
|
|
|
|
|
sub _set_body { |
|
1104
|
119
|
|
|
119
|
|
3572
|
my ($self, $bytes, $default_type) = @_; |
|
1105
|
119
|
|
|
|
|
203
|
$self->{_body} = $bytes; |
|
1106
|
119
|
100
|
|
|
|
402
|
$self->content_type_try($default_type) if defined $default_type; |
|
1107
|
119
|
|
|
|
|
163
|
return $self; |
|
1108
|
|
|
|
|
|
|
} |
|
1109
|
|
|
|
|
|
|
|
|
1110
|
|
|
|
|
|
|
# The UTF-8 text body helpers call this so the Content-Type advertises the |
|
1111
|
|
|
|
|
|
|
# encoding they just applied. A charset is appended only when the type both |
|
1112
|
|
|
|
|
|
|
# lacks one and actually defines a charset parameter. application/json and the |
|
1113
|
|
|
|
|
|
|
# structured-suffix +json types define none — JSON is always UTF-8 per RFC 8259 |
|
1114
|
|
|
|
|
|
|
# — so they are left bare; application/xml, text/*, and the +xml types do carry |
|
1115
|
|
|
|
|
|
|
# charset (RFC 7303), so they get it. |
|
1116
|
|
|
|
|
|
|
sub _ensure_charset { |
|
1117
|
79
|
|
|
79
|
|
106
|
my ($self, $charset) = @_; |
|
1118
|
79
|
|
100
|
|
|
275
|
$charset //= 'utf-8'; |
|
1119
|
79
|
50
|
|
|
|
175
|
return $self unless $self->has_content_type; |
|
1120
|
79
|
|
|
|
|
166
|
my $ct = $self->content_type; |
|
1121
|
79
|
100
|
|
|
|
343
|
return $self if $ct =~ /charset=/i; |
|
1122
|
35
|
|
|
|
|
154
|
my ($type) = $ct =~ m{^\s*([^;]+)}; |
|
1123
|
35
|
|
50
|
|
|
73
|
$type //= ''; |
|
1124
|
35
|
|
|
|
|
76
|
$type =~ s/\s+\z//; |
|
1125
|
35
|
100
|
100
|
|
|
134
|
return $self if lc($type) eq 'application/json' || $type =~ /\+json\z/i; |
|
1126
|
6
|
|
|
|
|
20
|
$self->content_type("$ct; charset=$charset"); |
|
1127
|
6
|
|
|
|
|
7
|
return $self; |
|
1128
|
|
|
|
|
|
|
} |
|
1129
|
|
|
|
|
|
|
|
|
1130
|
|
|
|
|
|
|
sub _render_headers { |
|
1131
|
134
|
|
|
134
|
|
179
|
my ($self, $extra_len) = @_; |
|
1132
|
134
|
|
|
|
|
293
|
my $pairs = $self->{_headers}->to_pairs; |
|
1133
|
134
|
100
|
|
|
|
237
|
if (defined $extra_len) { |
|
1134
|
|
|
|
|
|
|
# Buffered response: Content-Length is authoritative. Drop any user-set |
|
1135
|
|
|
|
|
|
|
# Content-Length (no duplicates) and any Transfer-Encoding (CL+TE is a |
|
1136
|
|
|
|
|
|
|
# request-smuggling vector), then append the one true length. |
|
1137
|
|
|
|
|
|
|
@$pairs = grep { |
|
1138
|
108
|
|
|
|
|
144
|
(my $k = $_->[0]) =~ tr/A-Z/a-z/; # ASCII fold (field names are ASCII tokens) |
|
|
135
|
|
|
|
|
264
|
|
|
1139
|
135
|
100
|
|
|
|
444
|
$k ne 'content-length' && $k ne 'transfer-encoding' |
|
1140
|
|
|
|
|
|
|
} @$pairs; |
|
1141
|
108
|
|
|
|
|
231
|
push @$pairs, ['content-length', $extra_len]; |
|
1142
|
|
|
|
|
|
|
} |
|
1143
|
134
|
|
|
|
|
524
|
return $pairs; |
|
1144
|
|
|
|
|
|
|
} |
|
1145
|
|
|
|
|
|
|
|
|
1146
|
129
|
|
|
129
|
1
|
263
|
async sub respond { |
|
1147
|
129
|
|
|
|
|
170
|
my ($self, $send) = @_; |
|
1148
|
129
|
50
|
|
|
|
266
|
croak("send must be a coderef") unless ref($send) eq 'CODE'; |
|
1149
|
|
|
|
|
|
|
|
|
1150
|
129
|
100
|
|
|
|
244
|
if ($self->{_stream}) { |
|
1151
|
15
|
|
|
|
|
36
|
await $send->({ |
|
1152
|
|
|
|
|
|
|
type => 'http.response.start', |
|
1153
|
|
|
|
|
|
|
status => $self->status, |
|
1154
|
|
|
|
|
|
|
headers => $self->_render_headers(undef), |
|
1155
|
|
|
|
|
|
|
}); |
|
1156
|
15
|
|
|
|
|
608
|
my $writer = PAGI::Response::Writer->new($send); |
|
1157
|
15
|
|
|
|
|
33
|
await $self->{_stream}->($writer); |
|
1158
|
15
|
100
|
|
|
|
1974
|
await $writer->close() unless $writer->is_closed; |
|
1159
|
15
|
|
|
|
|
121
|
return; |
|
1160
|
|
|
|
|
|
|
} |
|
1161
|
|
|
|
|
|
|
|
|
1162
|
114
|
100
|
|
|
|
199
|
if ($self->{_file}) { |
|
1163
|
6
|
|
|
|
|
8
|
my $fd = $self->{_file}; |
|
1164
|
|
|
|
|
|
|
# Headers (incl. content-length) were set at send_file() build time. |
|
1165
|
6
|
|
|
|
|
12
|
await $send->({ |
|
1166
|
|
|
|
|
|
|
type => 'http.response.start', |
|
1167
|
|
|
|
|
|
|
status => $self->status, |
|
1168
|
|
|
|
|
|
|
headers => $self->_render_headers(undef), |
|
1169
|
|
|
|
|
|
|
}); |
|
1170
|
|
|
|
|
|
|
my $body_event = { |
|
1171
|
|
|
|
|
|
|
type => 'http.response.body', |
|
1172
|
|
|
|
|
|
|
file => $fd->{path}, |
|
1173
|
6
|
|
|
|
|
286
|
}; |
|
1174
|
6
|
100
|
|
|
|
13
|
$body_event->{offset} = $fd->{offset} if exists $fd->{offset}; |
|
1175
|
6
|
100
|
|
|
|
12
|
$body_event->{length} = $fd->{length} if exists $fd->{length}; |
|
1176
|
6
|
|
|
|
|
10
|
await $send->($body_event); |
|
1177
|
6
|
|
|
|
|
148
|
return; |
|
1178
|
|
|
|
|
|
|
} |
|
1179
|
|
|
|
|
|
|
|
|
1180
|
108
|
|
50
|
|
|
199
|
my $body = $self->{_body} // ''; |
|
1181
|
108
|
|
|
|
|
218
|
await $send->({ |
|
1182
|
|
|
|
|
|
|
type => 'http.response.start', |
|
1183
|
|
|
|
|
|
|
status => $self->status, |
|
1184
|
|
|
|
|
|
|
headers => $self->_render_headers(length $body), |
|
1185
|
|
|
|
|
|
|
}); |
|
1186
|
108
|
|
|
|
|
4222
|
await $send->({ type => 'http.response.body', body => $body, more => 0 }); |
|
1187
|
108
|
|
|
|
|
2556
|
return; |
|
1188
|
|
|
|
|
|
|
} |
|
1189
|
|
|
|
|
|
|
|
|
1190
|
|
|
|
|
|
|
sub to_app { |
|
1191
|
1
|
|
|
1
|
1
|
36
|
my ($self) = @_; |
|
1192
|
1
|
|
|
1
|
|
336
|
return async sub { |
|
1193
|
1
|
|
|
|
|
3
|
my ($scope, $receive, $send) = @_; |
|
1194
|
1
|
|
|
|
|
3
|
await $self->respond($send); |
|
1195
|
1
|
|
|
|
|
6
|
}; |
|
1196
|
|
|
|
|
|
|
} |
|
1197
|
|
|
|
|
|
|
|
|
1198
|
|
|
|
|
|
|
|
|
1199
|
|
|
|
|
|
|
sub is_sent { |
|
1200
|
7
|
|
|
7
|
1
|
60
|
my ($self) = @_; |
|
1201
|
7
|
50
|
|
|
|
14
|
my $conn = $self->{scope} ? $self->{scope}{'pagi.connection'} : undef; |
|
1202
|
7
|
50
|
|
|
|
13
|
return 0 unless $conn; # no connection object: server-less / not started |
|
1203
|
7
|
100
|
|
|
|
106
|
Carp::croak("pagi.connection lacks response_started (non-conforming server)") |
|
1204
|
|
|
|
|
|
|
unless $conn->can('response_started'); |
|
1205
|
6
|
100
|
|
|
|
10
|
return $conn->response_started ? 1 : 0; |
|
1206
|
|
|
|
|
|
|
} |
|
1207
|
|
|
|
|
|
|
|
|
1208
|
|
|
|
|
|
|
# Returns the invocant if it is already an instance; otherwise creates a new |
|
1209
|
|
|
|
|
|
|
# detached instance from the class name. Allows finisher methods to be called |
|
1210
|
|
|
|
|
|
|
# as either class-method factories or instance methods. |
|
1211
|
|
|
|
|
|
|
sub _self_or_new { |
|
1212
|
145
|
|
|
145
|
|
199
|
my ($proto) = @_; |
|
1213
|
145
|
100
|
|
|
|
315
|
return ref($proto) ? $proto : $proto->new; |
|
1214
|
|
|
|
|
|
|
} |
|
1215
|
|
|
|
|
|
|
|
|
1216
|
|
|
|
|
|
|
# Encode a text string to UTF-8 bytes, croaking on invalid characters. |
|
1217
|
|
|
|
|
|
|
# Replicates the encoding used by the old send() method. |
|
1218
|
|
|
|
|
|
|
sub _enc { |
|
1219
|
52
|
|
|
52
|
|
96
|
my ($str, $charset) = @_; |
|
1220
|
52
|
|
100
|
|
|
207
|
$charset //= 'utf-8'; |
|
1221
|
52
|
|
50
|
|
|
526
|
return encode($charset, $str // '', FB_CROAK); |
|
1222
|
|
|
|
|
|
|
} |
|
1223
|
|
|
|
|
|
|
|
|
1224
|
|
|
|
|
|
|
my %_RESPONSE_OPTS = map { $_ => 1 } qw(status content_type headers); |
|
1225
|
|
|
|
|
|
|
|
|
1226
|
|
|
|
|
|
|
sub _apply_opts { |
|
1227
|
103
|
|
|
103
|
|
154
|
my ($self, %opts) = @_; |
|
1228
|
103
|
|
|
|
|
175
|
for my $k (keys %opts) { |
|
1229
|
|
|
|
|
|
|
croak "Unknown response option '$k' (known: status, content_type, headers)" |
|
1230
|
9
|
100
|
|
|
|
196
|
unless $_RESPONSE_OPTS{$k}; |
|
1231
|
|
|
|
|
|
|
} |
|
1232
|
102
|
100
|
|
|
|
212
|
$self->status($opts{status}) if defined $opts{status}; |
|
1233
|
102
|
100
|
|
|
|
180
|
$self->content_type($opts{content_type}) if defined $opts{content_type}; |
|
1234
|
102
|
100
|
|
|
|
191
|
if (my $h = $opts{headers}) { |
|
1235
|
2
|
100
|
|
|
|
118
|
croak "headers must be an even-length arrayref [ name => value, ... ]" |
|
1236
|
|
|
|
|
|
|
if @$h % 2; |
|
1237
|
1
|
|
|
|
|
3
|
my @pairs = @$h; |
|
1238
|
1
|
|
|
|
|
2
|
while (@pairs) { |
|
1239
|
1
|
|
|
|
|
3
|
my ($name, $value) = splice(@pairs, 0, 2); |
|
1240
|
1
|
|
|
|
|
3
|
$self->header($name, $value); |
|
1241
|
|
|
|
|
|
|
} |
|
1242
|
|
|
|
|
|
|
} |
|
1243
|
101
|
|
|
|
|
140
|
return $self; |
|
1244
|
|
|
|
|
|
|
} |
|
1245
|
|
|
|
|
|
|
|
|
1246
|
|
|
|
|
|
|
sub send_raw { |
|
1247
|
17
|
|
|
17
|
1
|
774
|
my ($proto, $body, %opts) = @_; |
|
1248
|
17
|
|
|
|
|
38
|
my $self = $proto->_self_or_new; |
|
1249
|
17
|
|
50
|
|
|
61
|
$self->_set_body($body // '', undef); |
|
1250
|
17
|
|
|
|
|
56
|
$self->_apply_opts(%opts); |
|
1251
|
17
|
|
|
|
|
48
|
return $self; |
|
1252
|
|
|
|
|
|
|
} |
|
1253
|
|
|
|
|
|
|
|
|
1254
|
|
|
|
|
|
|
sub send { |
|
1255
|
3
|
|
|
3
|
1
|
12
|
my ($proto, $body, %opts) = @_; |
|
1256
|
3
|
|
|
|
|
9
|
my $self = $proto->_self_or_new; |
|
1257
|
3
|
|
50
|
|
|
14
|
my $charset = $opts{charset} // 'utf-8'; |
|
1258
|
3
|
|
|
|
|
9
|
my $encoded = _enc($body, $charset); |
|
1259
|
3
|
100
|
|
|
|
292
|
$self->content_type("text/plain; charset=$charset") unless $self->has_content_type; |
|
1260
|
3
|
|
|
|
|
13
|
$self->_ensure_charset($charset); |
|
1261
|
3
|
|
|
|
|
6
|
$self->{_body} = $encoded; |
|
1262
|
3
|
|
|
|
|
9
|
return $self; |
|
1263
|
|
|
|
|
|
|
} |
|
1264
|
|
|
|
|
|
|
|
|
1265
|
|
|
|
|
|
|
sub text { |
|
1266
|
42
|
|
|
42
|
1
|
3790
|
my ($proto, $body, %opts) = @_; |
|
1267
|
42
|
|
|
|
|
98
|
my $self = $proto->_self_or_new; |
|
1268
|
42
|
|
|
|
|
83
|
$self->_set_body(_enc($body), 'text/plain; charset=utf-8'); |
|
1269
|
42
|
|
|
|
|
108
|
$self->_apply_opts(%opts); |
|
1270
|
41
|
|
|
|
|
87
|
$self->_ensure_charset; |
|
1271
|
41
|
|
|
|
|
279
|
return $self; |
|
1272
|
|
|
|
|
|
|
} |
|
1273
|
|
|
|
|
|
|
|
|
1274
|
|
|
|
|
|
|
sub html { |
|
1275
|
7
|
|
|
7
|
1
|
354
|
my ($proto, $body, %opts) = @_; |
|
1276
|
7
|
|
|
|
|
16
|
my $self = $proto->_self_or_new; |
|
1277
|
7
|
|
|
|
|
16
|
$self->_set_body(_enc($body), 'text/html; charset=utf-8'); |
|
1278
|
7
|
|
|
|
|
20
|
$self->_apply_opts(%opts); |
|
1279
|
7
|
|
|
|
|
15
|
$self->_ensure_charset; |
|
1280
|
7
|
|
|
|
|
34
|
return $self; |
|
1281
|
|
|
|
|
|
|
} |
|
1282
|
|
|
|
|
|
|
|
|
1283
|
|
|
|
|
|
|
sub json { |
|
1284
|
29
|
|
|
29
|
1
|
3820
|
my ($proto, $data, %opts) = @_; |
|
1285
|
29
|
|
|
|
|
79
|
my $self = $proto->_self_or_new; |
|
1286
|
29
|
|
|
|
|
164
|
my $body = JSON::MaybeXS->new(utf8 => 1, canonical => 1)->encode($data); |
|
1287
|
29
|
|
|
|
|
769
|
$self->_set_body($body, 'application/json'); |
|
1288
|
29
|
|
|
|
|
74
|
$self->_apply_opts(%opts); |
|
1289
|
28
|
|
|
|
|
66
|
$self->_ensure_charset; |
|
1290
|
28
|
|
|
|
|
166
|
return $self; |
|
1291
|
|
|
|
|
|
|
} |
|
1292
|
|
|
|
|
|
|
|
|
1293
|
|
|
|
|
|
|
sub redirect { |
|
1294
|
13
|
|
|
13
|
1
|
418
|
my ($proto, $url, $status) = @_; |
|
1295
|
13
|
|
|
|
|
24
|
my $self = $proto->_self_or_new; |
|
1296
|
13
|
|
100
|
|
|
50
|
$self->status($status // 302)->header('location', $url); |
|
1297
|
13
|
|
|
|
|
29
|
$self->_set_body('', undef); |
|
1298
|
13
|
|
|
|
|
85
|
return $self; |
|
1299
|
|
|
|
|
|
|
} |
|
1300
|
|
|
|
|
|
|
|
|
1301
|
|
|
|
|
|
|
sub empty { |
|
1302
|
8
|
|
|
8
|
1
|
652
|
my ($proto, %opts) = @_; |
|
1303
|
8
|
|
|
|
|
19
|
my $self = $proto->_self_or_new; |
|
1304
|
8
|
|
|
|
|
23
|
$self->status_try(204); |
|
1305
|
8
|
|
|
|
|
20
|
$self->_set_body('', undef); |
|
1306
|
8
|
|
|
|
|
20
|
$self->_apply_opts(%opts); |
|
1307
|
8
|
|
|
|
|
28
|
return $self; |
|
1308
|
|
|
|
|
|
|
} |
|
1309
|
|
|
|
|
|
|
|
|
1310
|
|
|
|
|
|
|
sub cookie { |
|
1311
|
5
|
|
|
5
|
1
|
25
|
my ($self, $name, $value, %opts) = @_; |
|
1312
|
5
|
|
|
|
|
10
|
my @parts = ("$name=$value"); |
|
1313
|
|
|
|
|
|
|
|
|
1314
|
5
|
100
|
|
|
|
16
|
push @parts, "Max-Age=$opts{max_age}" if defined $opts{max_age}; |
|
1315
|
5
|
50
|
|
|
|
8
|
push @parts, "Expires=$opts{expires}" if defined $opts{expires}; |
|
1316
|
5
|
100
|
|
|
|
10
|
push @parts, "Path=$opts{path}" if defined $opts{path}; |
|
1317
|
5
|
100
|
|
|
|
23
|
push @parts, "Domain=$opts{domain}" if defined $opts{domain}; |
|
1318
|
5
|
100
|
|
|
|
8
|
push @parts, "Secure" if $opts{secure}; |
|
1319
|
5
|
100
|
|
|
|
9
|
push @parts, "HttpOnly" if $opts{httponly}; |
|
1320
|
5
|
100
|
|
|
|
10
|
push @parts, "SameSite=$opts{samesite}" if defined $opts{samesite}; |
|
1321
|
|
|
|
|
|
|
|
|
1322
|
5
|
|
|
|
|
11
|
my $cookie_str = join('; ', @parts); |
|
1323
|
5
|
|
|
|
|
15
|
$self->{_headers}->add('set-cookie', $cookie_str); |
|
1324
|
|
|
|
|
|
|
|
|
1325
|
5
|
|
|
|
|
15
|
return $self; |
|
1326
|
|
|
|
|
|
|
} |
|
1327
|
|
|
|
|
|
|
|
|
1328
|
|
|
|
|
|
|
sub delete_cookie { |
|
1329
|
1
|
|
|
1
|
1
|
6
|
my ($self, $name, %opts) = @_; |
|
1330
|
|
|
|
|
|
|
return $self->cookie($name, '', |
|
1331
|
|
|
|
|
|
|
max_age => 0, |
|
1332
|
|
|
|
|
|
|
path => $opts{path}, |
|
1333
|
|
|
|
|
|
|
domain => $opts{domain}, |
|
1334
|
1
|
|
|
|
|
7
|
); |
|
1335
|
|
|
|
|
|
|
} |
|
1336
|
|
|
|
|
|
|
|
|
1337
|
|
|
|
|
|
|
sub cors { |
|
1338
|
6
|
|
|
6
|
1
|
39
|
my ($self, %opts) = @_; |
|
1339
|
6
|
|
100
|
|
|
16
|
my $origin = $opts{origin} // '*'; |
|
1340
|
6
|
|
100
|
|
|
15
|
my $credentials = $opts{credentials} // 0; |
|
1341
|
6
|
|
100
|
|
|
18
|
my $methods = $opts{methods} // [qw(GET POST PUT DELETE PATCH OPTIONS)]; |
|
1342
|
6
|
|
100
|
|
|
43
|
my $headers = $opts{headers} // [qw(Content-Type Authorization X-Requested-With)]; |
|
1343
|
6
|
|
100
|
|
|
16
|
my $expose = $opts{expose} // []; |
|
1344
|
6
|
|
100
|
|
|
13
|
my $max_age = $opts{max_age} // 86400; |
|
1345
|
6
|
|
100
|
|
|
11
|
my $preflight = $opts{preflight} // 0; |
|
1346
|
|
|
|
|
|
|
|
|
1347
|
|
|
|
|
|
|
# Determine the origin to send back |
|
1348
|
6
|
|
|
|
|
7
|
my $allow_origin; |
|
1349
|
6
|
100
|
100
|
|
|
18
|
if ($origin eq '*' && $credentials) { |
|
1350
|
|
|
|
|
|
|
# With credentials, can't use wildcard - use request_origin if provided |
|
1351
|
1
|
|
50
|
|
|
3
|
$allow_origin = $opts{request_origin} // '*'; |
|
1352
|
|
|
|
|
|
|
} else { |
|
1353
|
5
|
|
|
|
|
7
|
$allow_origin = $origin; |
|
1354
|
|
|
|
|
|
|
} |
|
1355
|
|
|
|
|
|
|
|
|
1356
|
|
|
|
|
|
|
# Core CORS headers (always set) |
|
1357
|
6
|
|
|
|
|
18
|
$self->header('Access-Control-Allow-Origin', $allow_origin); |
|
1358
|
6
|
|
|
|
|
12
|
$self->header('Vary', 'Origin'); |
|
1359
|
|
|
|
|
|
|
|
|
1360
|
6
|
100
|
|
|
|
10
|
if ($credentials) { |
|
1361
|
2
|
|
|
|
|
5
|
$self->header('Access-Control-Allow-Credentials', 'true'); |
|
1362
|
|
|
|
|
|
|
} |
|
1363
|
|
|
|
|
|
|
|
|
1364
|
6
|
100
|
|
|
|
8
|
if (@$expose) { |
|
1365
|
1
|
|
|
|
|
5
|
$self->header('Access-Control-Expose-Headers', join(', ', @$expose)); |
|
1366
|
|
|
|
|
|
|
} |
|
1367
|
|
|
|
|
|
|
|
|
1368
|
|
|
|
|
|
|
# Preflight headers (for OPTIONS responses or when explicitly requested) |
|
1369
|
6
|
100
|
|
|
|
11
|
if ($preflight) { |
|
1370
|
1
|
|
|
|
|
4
|
$self->header('Access-Control-Allow-Methods', join(', ', @$methods)); |
|
1371
|
1
|
|
|
|
|
4
|
$self->header('Access-Control-Allow-Headers', join(', ', @$headers)); |
|
1372
|
1
|
|
|
|
|
2
|
$self->header('Access-Control-Max-Age', $max_age); |
|
1373
|
|
|
|
|
|
|
} |
|
1374
|
|
|
|
|
|
|
|
|
1375
|
6
|
|
|
|
|
24
|
return $self; |
|
1376
|
|
|
|
|
|
|
} |
|
1377
|
|
|
|
|
|
|
|
|
1378
|
|
|
|
|
|
|
sub stream { |
|
1379
|
15
|
|
|
15
|
1
|
146
|
my ($proto, $callback) = @_; |
|
1380
|
15
|
|
|
|
|
32
|
my $self = $proto->_self_or_new; |
|
1381
|
15
|
|
|
|
|
29
|
$self->{_stream} = $callback; |
|
1382
|
15
|
|
|
|
|
50
|
return $self; |
|
1383
|
|
|
|
|
|
|
} |
|
1384
|
|
|
|
|
|
|
|
|
1385
|
6
|
|
|
6
|
1
|
77
|
async sub writer { |
|
1386
|
6
|
|
|
|
|
11
|
my ($self, $send, %opts) = @_; |
|
1387
|
6
|
50
|
|
|
|
14
|
croak("send must be a coderef") unless ref($send) eq 'CODE'; |
|
1388
|
|
|
|
|
|
|
# A writer takes over the connection for live streaming; it can only be |
|
1389
|
|
|
|
|
|
|
# taken once on a given response value. (The cross-stack "did a response |
|
1390
|
|
|
|
|
|
|
# start" fact lives on pagi.connection; this is a local single-takeover guard.) |
|
1391
|
6
|
100
|
|
|
|
216
|
croak("Response already sent") if $self->{_writer_started}; |
|
1392
|
5
|
|
|
|
|
10
|
$self->{_writer_started} = 1; |
|
1393
|
|
|
|
|
|
|
|
|
1394
|
|
|
|
|
|
|
# Send headers |
|
1395
|
5
|
|
|
|
|
12
|
await $send->({ |
|
1396
|
|
|
|
|
|
|
type => 'http.response.start', |
|
1397
|
|
|
|
|
|
|
status => $self->status, |
|
1398
|
|
|
|
|
|
|
headers => $self->_render_headers(undef), |
|
1399
|
|
|
|
|
|
|
}); |
|
1400
|
|
|
|
|
|
|
|
|
1401
|
5
|
|
|
|
|
204
|
return PAGI::Response::Writer->new($send, %opts); |
|
1402
|
|
|
|
|
|
|
} |
|
1403
|
|
|
|
|
|
|
|
|
1404
|
|
|
|
|
|
|
# Simple MIME type mapping |
|
1405
|
|
|
|
|
|
|
my %MIME_TYPES = ( |
|
1406
|
|
|
|
|
|
|
'.html' => 'text/html', |
|
1407
|
|
|
|
|
|
|
'.htm' => 'text/html', |
|
1408
|
|
|
|
|
|
|
'.txt' => 'text/plain', |
|
1409
|
|
|
|
|
|
|
'.css' => 'text/css', |
|
1410
|
|
|
|
|
|
|
'.js' => 'application/javascript', |
|
1411
|
|
|
|
|
|
|
'.json' => 'application/json', |
|
1412
|
|
|
|
|
|
|
'.xml' => 'application/xml', |
|
1413
|
|
|
|
|
|
|
'.pdf' => 'application/pdf', |
|
1414
|
|
|
|
|
|
|
'.zip' => 'application/zip', |
|
1415
|
|
|
|
|
|
|
'.png' => 'image/png', |
|
1416
|
|
|
|
|
|
|
'.jpg' => 'image/jpeg', |
|
1417
|
|
|
|
|
|
|
'.jpeg' => 'image/jpeg', |
|
1418
|
|
|
|
|
|
|
'.gif' => 'image/gif', |
|
1419
|
|
|
|
|
|
|
'.svg' => 'image/svg+xml', |
|
1420
|
|
|
|
|
|
|
'.ico' => 'image/x-icon', |
|
1421
|
|
|
|
|
|
|
'.woff' => 'font/woff', |
|
1422
|
|
|
|
|
|
|
'.woff2'=> 'font/woff2', |
|
1423
|
|
|
|
|
|
|
); |
|
1424
|
|
|
|
|
|
|
|
|
1425
|
|
|
|
|
|
|
sub _mime_type { |
|
1426
|
7
|
|
|
7
|
|
11
|
my ($path) = @_; |
|
1427
|
7
|
|
|
|
|
22
|
my ($ext) = $path =~ /(\.[^.]+)$/; |
|
1428
|
7
|
|
100
|
|
|
51
|
return $MIME_TYPES{lc($ext // '')} // 'application/octet-stream'; |
|
|
|
|
100
|
|
|
|
|
|
1429
|
|
|
|
|
|
|
} |
|
1430
|
|
|
|
|
|
|
|
|
1431
|
|
|
|
|
|
|
sub send_file { |
|
1432
|
11
|
|
|
11
|
1
|
140
|
my ($proto, $path, %opts) = @_; |
|
1433
|
11
|
|
|
|
|
25
|
my $self = $proto->_self_or_new; |
|
1434
|
|
|
|
|
|
|
|
|
1435
|
11
|
100
|
|
|
|
356
|
croak("File not found: $path") unless -f $path; |
|
1436
|
10
|
50
|
|
|
|
74
|
croak("Cannot read file: $path") unless -r $path; |
|
1437
|
|
|
|
|
|
|
|
|
1438
|
|
|
|
|
|
|
# Get file size |
|
1439
|
10
|
|
|
|
|
51
|
my $file_size = -s $path; |
|
1440
|
|
|
|
|
|
|
|
|
1441
|
|
|
|
|
|
|
# Handle offset and length for range requests |
|
1442
|
10
|
|
100
|
|
|
37
|
my $offset = $opts{offset} // 0; |
|
1443
|
10
|
|
|
|
|
14
|
my $length = $opts{length}; |
|
1444
|
|
|
|
|
|
|
|
|
1445
|
|
|
|
|
|
|
# Validate offset |
|
1446
|
10
|
100
|
|
|
|
167
|
croak("offset must be non-negative") if $offset < 0; |
|
1447
|
9
|
100
|
|
|
|
116
|
croak("offset exceeds file size") if $offset > $file_size; |
|
1448
|
|
|
|
|
|
|
|
|
1449
|
|
|
|
|
|
|
# Calculate actual length to send |
|
1450
|
8
|
|
|
|
|
12
|
my $max_length = $file_size - $offset; |
|
1451
|
8
|
100
|
|
|
|
14
|
if (defined $length) { |
|
1452
|
3
|
100
|
|
|
|
107
|
croak("length must be non-negative") if $length < 0; |
|
1453
|
2
|
100
|
|
|
|
5
|
$length = $max_length if $length > $max_length; |
|
1454
|
|
|
|
|
|
|
} else { |
|
1455
|
5
|
|
|
|
|
7
|
$length = $max_length; |
|
1456
|
|
|
|
|
|
|
} |
|
1457
|
|
|
|
|
|
|
|
|
1458
|
|
|
|
|
|
|
# Set content-type if not already set |
|
1459
|
7
|
|
|
|
|
14
|
$self->content_type_try(_mime_type($path)); |
|
1460
|
|
|
|
|
|
|
|
|
1461
|
|
|
|
|
|
|
# Set content-length based on actual bytes to send |
|
1462
|
7
|
|
|
|
|
23
|
$self->header('content-length', $length); |
|
1463
|
|
|
|
|
|
|
|
|
1464
|
|
|
|
|
|
|
# Set content-disposition |
|
1465
|
7
|
|
|
|
|
8
|
my $disposition; |
|
1466
|
7
|
100
|
|
|
|
23
|
if ($opts{inline}) { |
|
|
|
100
|
|
|
|
|
|
|
1467
|
1
|
|
|
|
|
1
|
$disposition = 'inline'; |
|
1468
|
|
|
|
|
|
|
} elsif ($opts{filename}) { |
|
1469
|
|
|
|
|
|
|
# Sanitize filename for header |
|
1470
|
1
|
|
|
|
|
2
|
my $safe_filename = $opts{filename}; |
|
1471
|
1
|
|
|
|
|
4
|
$safe_filename =~ s/["\r\n]//g; |
|
1472
|
1
|
|
|
|
|
3
|
$disposition = "attachment; filename=\"$safe_filename\""; |
|
1473
|
|
|
|
|
|
|
} |
|
1474
|
7
|
100
|
|
|
|
16
|
$self->header('content-disposition', $disposition) if $disposition; |
|
1475
|
|
|
|
|
|
|
|
|
1476
|
|
|
|
|
|
|
# Store the file send descriptor; respond() handles the actual emission. |
|
1477
|
|
|
|
|
|
|
# offset/length are stored only when they narrow the full-file default. |
|
1478
|
7
|
|
|
|
|
25
|
my $file_desc = { path => $path }; |
|
1479
|
7
|
100
|
|
|
|
17
|
$file_desc->{offset} = $offset if $offset > 0; |
|
1480
|
7
|
100
|
|
|
|
12
|
$file_desc->{length} = $length if $length < $max_length; |
|
1481
|
7
|
|
|
|
|
12
|
$self->{_file} = $file_desc; |
|
1482
|
|
|
|
|
|
|
|
|
1483
|
7
|
|
|
|
|
27
|
return $self; |
|
1484
|
|
|
|
|
|
|
} |
|
1485
|
|
|
|
|
|
|
|
|
1486
|
|
|
|
|
|
|
# Writer class for streaming responses |
|
1487
|
|
|
|
|
|
|
package PAGI::Response::Writer { |
|
1488
|
19
|
|
|
19
|
|
182
|
use strict; |
|
|
19
|
|
|
|
|
31
|
|
|
|
19
|
|
|
|
|
520
|
|
|
1489
|
19
|
|
|
19
|
|
92
|
use warnings; |
|
|
19
|
|
|
|
|
45
|
|
|
|
19
|
|
|
|
|
967
|
|
|
1490
|
19
|
|
|
19
|
|
87
|
use Future::AsyncAwait; |
|
|
19
|
|
|
|
|
31
|
|
|
|
19
|
|
|
|
|
180
|
|
|
1491
|
19
|
|
|
19
|
|
1044
|
use Carp qw(croak); |
|
|
19
|
|
|
|
|
49
|
|
|
|
19
|
|
|
|
|
1244
|
|
|
1492
|
19
|
|
|
19
|
|
150
|
use Scalar::Util qw(blessed); |
|
|
19
|
|
|
|
|
25
|
|
|
|
19
|
|
|
|
|
12854
|
|
|
1493
|
|
|
|
|
|
|
|
|
1494
|
|
|
|
|
|
|
sub new { |
|
1495
|
20
|
|
|
20
|
|
34
|
my ($class, $send, %opts) = @_; |
|
1496
|
20
|
|
|
|
|
66
|
my $self = bless { |
|
1497
|
|
|
|
|
|
|
send => $send, |
|
1498
|
|
|
|
|
|
|
bytes_written => 0, |
|
1499
|
|
|
|
|
|
|
closed => 0, |
|
1500
|
|
|
|
|
|
|
_on_close => [], |
|
1501
|
|
|
|
|
|
|
}, $class; |
|
1502
|
20
|
100
|
|
|
|
45
|
push @{$self->{_on_close}}, $opts{on_close} if $opts{on_close}; |
|
|
1
|
|
|
|
|
2
|
|
|
1503
|
20
|
|
|
|
|
42
|
return $self; |
|
1504
|
|
|
|
|
|
|
} |
|
1505
|
|
|
|
|
|
|
|
|
1506
|
17
|
|
|
17
|
|
1632
|
async sub write { |
|
1507
|
17
|
|
|
|
|
27
|
my ($self, $chunk) = @_; |
|
1508
|
17
|
100
|
|
|
|
64
|
die 'Writer already closed' if $self->{closed}; |
|
1509
|
15
|
|
50
|
|
|
32
|
$self->{bytes_written} += length($chunk // ''); |
|
1510
|
15
|
|
|
|
|
55
|
await $self->{send}->({ |
|
1511
|
|
|
|
|
|
|
type => 'http.response.body', |
|
1512
|
|
|
|
|
|
|
body => $chunk, |
|
1513
|
|
|
|
|
|
|
more => 1, |
|
1514
|
|
|
|
|
|
|
}); |
|
1515
|
|
|
|
|
|
|
} |
|
1516
|
|
|
|
|
|
|
|
|
1517
|
17
|
|
|
17
|
|
448
|
async sub close { |
|
1518
|
17
|
|
|
|
|
22
|
my ($self) = @_; |
|
1519
|
17
|
50
|
|
|
|
29
|
return if $self->{closed}; |
|
1520
|
17
|
|
|
|
|
45
|
$self->{closed} = 1; |
|
1521
|
17
|
|
|
|
|
71
|
await $self->{send}->({ |
|
1522
|
|
|
|
|
|
|
type => 'http.response.body', |
|
1523
|
|
|
|
|
|
|
body => '', |
|
1524
|
|
|
|
|
|
|
more => 0, |
|
1525
|
|
|
|
|
|
|
}); |
|
1526
|
17
|
|
|
|
|
373
|
for my $cb (@{$self->{_on_close}}) { |
|
|
17
|
|
|
|
|
35
|
|
|
1527
|
15
|
|
|
|
|
23
|
eval { |
|
1528
|
15
|
|
|
|
|
22
|
my $r = $cb->(); |
|
1529
|
14
|
100
|
100
|
|
|
128
|
if (blessed($r) && $r->isa('Future')) { |
|
1530
|
2
|
|
|
|
|
6
|
await $r; |
|
1531
|
|
|
|
|
|
|
} |
|
1532
|
|
|
|
|
|
|
}; |
|
1533
|
15
|
100
|
|
|
|
57
|
if ($@) { |
|
1534
|
2
|
|
|
|
|
21
|
warn "PAGI::Response::Writer on_close callback error: $@"; |
|
1535
|
|
|
|
|
|
|
} |
|
1536
|
|
|
|
|
|
|
} |
|
1537
|
|
|
|
|
|
|
|
|
1538
|
|
|
|
|
|
|
# Clear callback array to break any closure-based cycles |
|
1539
|
17
|
|
|
|
|
74
|
$self->{_on_close} = []; |
|
1540
|
|
|
|
|
|
|
} |
|
1541
|
|
|
|
|
|
|
|
|
1542
|
|
|
|
|
|
|
sub on_close { |
|
1543
|
14
|
|
|
14
|
|
123
|
my ($self, $cb) = @_; |
|
1544
|
14
|
|
|
|
|
14
|
push @{$self->{_on_close}}, $cb; |
|
|
14
|
|
|
|
|
22
|
|
|
1545
|
14
|
|
|
|
|
21
|
return $self; |
|
1546
|
|
|
|
|
|
|
} |
|
1547
|
|
|
|
|
|
|
|
|
1548
|
18
|
|
|
18
|
|
128
|
sub is_closed { $_[0]->{closed} } |
|
1549
|
|
|
|
|
|
|
|
|
1550
|
1
|
|
|
1
|
|
42
|
sub bytes_written { $_[0]->{bytes_written} } |
|
1551
|
|
|
|
|
|
|
} |
|
1552
|
|
|
|
|
|
|
$PAGI::Response::Writer::VERSION = '0.002000'; |
|
1553
|
|
|
|
|
|
|
|
|
1554
|
|
|
|
|
|
|
1; |