File Coverage

blib/lib/PAGI/Session.pm
Criterion Covered Total %
statement 62 62 100.0
branch 21 24 87.5
condition 7 12 58.3
subroutine 16 16 100.0
pod 13 13 100.0
total 119 127 93.7


line stmt bran cond sub pod time code
1             package PAGI::Session;
2              
3 2     2   151522 use strict;
  2         4  
  2         58  
4 2     2   7 use warnings;
  2         3  
  2         70  
5 2     2   6 use Scalar::Util 'blessed';
  2         4  
  2         1795  
6              
7             =head1 NAME
8              
9             PAGI::Session - Standalone helper object for session data access
10              
11             =head1 SYNOPSIS
12              
13             use PAGI::Session;
14              
15             # Construct from scope or request object
16             my $session = PAGI::Session->new($scope);
17             my $session = PAGI::Session->new($req); # any object with ->scope
18             my $session = PAGI::Session->new(@_); # extra args ignored
19              
20             # Test convenience - wrap raw data directly
21             my $session = PAGI::Session->from_data({ _id => 'test', user_id => 42 });
22              
23             # Strict get - dies if key doesn't exist (catches typos)
24             my $user_id = $session->get('user_id');
25              
26             # Safe get with default for optional keys
27             my $theme = $session->get('theme', 'light');
28              
29             # Set, delete, check existence
30             $session->set('cart_count', 3);
31             $session->delete('cart_count');
32             if ($session->exists('user_id')) { ... }
33              
34             # List user keys (excludes internal _prefixed keys)
35             my @keys = $session->keys;
36              
37             # Session lifecycle
38             $session->regenerate; # Request new session ID
39             $session->destroy; # Mark session for deletion
40              
41             =head1 DESCRIPTION
42              
43             PAGI::Session wraps the raw session data hashref and provides a clean
44             accessor interface with strict key checking. It is a standalone helper
45             that is not attached to any request or protocol object.
46              
47             The strict C method dies when a key does not exist, catching
48             typos at runtime. Use the two-argument form C
49             for keys that may or may not be present.
50              
51             =head1 CONSTRUCTOR
52              
53             =head2 new
54              
55             my $session = PAGI::Session->new($scope);
56             my $session = PAGI::Session->new($request); # any object with ->scope
57             my $session = PAGI::Session->new(@_); # extra args ignored
58              
59             Scope-based constructor. Resolves to the C<< $scope->{'pagi.session'} >>
60             hashref. Accepts a scope hashref directly, or any blessed object with a
61             C method. Extra positional arguments (C<$receive>, C<$send>) are
62             silently ignored, so you can write C<< PAGI::Session->new(@_) >> in a
63             handler.
64              
65             Dies if the scope does not contain a C key (session
66             middleware must run first).
67              
68             =head2 from_data
69              
70             my $session = PAGI::Session->from_data({ _id => 'test', user_id => 42 });
71              
72             Test convenience constructor. Wraps a raw hashref directly as the backing
73             data, bypassing scope resolution. The returned object behaves identically
74             to one created via C.
75              
76             =cut
77              
78             sub new {
79 15     15 1 11914 my ($class, @args) = @_;
80              
81 15         22 my $arg = $args[0];
82              
83             # Object with ->scope method (e.g., PAGI::Request, PAGI::SSE)
84 15 100 66     49 if (blessed($arg) && $arg->can('scope')) {
85 2         5 my $scope = $arg->scope;
86             die "PAGI::Session requires scope hashref with 'pagi.session' key\n"
87 2 50 33     16 unless ref $scope eq 'HASH' && exists $scope->{'pagi.session'};
88 2         8 return bless { _data => $scope->{'pagi.session'} }, $class;
89             }
90              
91             # Scope hashref - must have pagi.session key
92 13 100 100     44 if (ref $arg eq 'HASH' && exists $arg->{'pagi.session'}) {
93 4         17 return bless { _data => $arg->{'pagi.session'} }, $class;
94             }
95              
96 9         46 die "PAGI::Session requires a scope hashref (with 'pagi.session' key) or object with ->scope method\n";
97             }
98              
99             sub from_data {
100 33     33 1 198097 my ($class, $data) = @_;
101 33 50       83 die "from_data() requires a hashref\n" unless ref $data eq 'HASH';
102 33         88 return bless { _data => $data }, $class;
103             }
104              
105             =head1 METHODS
106              
107             =head2 id
108              
109             my $id = $session->id;
110              
111             Returns the session ID from C<< $data->{_id} >>.
112              
113             =cut
114              
115             sub id {
116 9     9 1 361 my ($self) = @_;
117 9         46 return $self->{_data}{_id};
118             }
119              
120             =head2 data
121              
122             my $href = $session->data;
123             $href->{key} = $value; # direct mutation
124              
125             Returns the raw backing hashref. Mutations are visible through
126             C/C since they operate on the same reference.
127              
128             =cut
129              
130             sub data {
131 3     3 1 9 my ($self) = @_;
132 3         8 return $self->{_data};
133             }
134              
135             =head2 get
136              
137             my $value = $session->get('key'); # dies if missing
138             my $value = $session->get('key', $default); # returns $default if missing
139              
140             Retrieves a value from the session. With one argument, dies with an
141             error including the key name if the key does not exist. With a default
142             argument, returns the default when the key is missing (even if the
143             default is C).
144              
145             =cut
146              
147             sub get {
148 23     23 1 107 my ($self, @args) = @_;
149 23 50 33     81 die "get() requires 1 or 2 arguments\n" if @args == 0 || @args > 2;
150 23         34 my ($key, @rest) = @args;
151 23 100       42 if (!exists $self->{_data}{$key}) {
152 7 100       14 return $rest[0] if @rest;
153 4         5 my @user_keys = sort grep { !/^_/ } keys %{$self->{_data}};
  19         63  
  4         11  
154 4 100       8 if (@user_keys <= 10) {
155 3         20 die "Session key '$key' does not exist. Available keys: "
156             . join(', ', @user_keys) . "\n";
157             }
158             else {
159 1         7 die "Session key '$key' does not exist (session has "
160             . scalar(@user_keys) . " user keys)\n";
161             }
162             }
163 16         58 return $self->{_data}{$key};
164             }
165              
166             =head2 set
167              
168             $session->set('key', $value);
169             $session->set(user_id => 42, role => 'admin', email => 'john@example.com');
170             $session->set('a', 1)->set('b', 2); # chaining
171              
172             Sets one or more keys in the session data. Accepts key-value pairs.
173             Returns C<$self> for method chaining. With zero arguments, acts as a
174             no-op returning C<$self>. Dies if given an odd number of arguments.
175              
176             =cut
177              
178             sub set {
179 17     17 1 261 my ($self, @args) = @_;
180 17 100       29 return $self unless @args;
181 16 100       37 die "set() requires key => value pairs\n" if @args % 2;
182 13         25 my %pairs = @args;
183 13         36 $self->{_data}{$_} = $pairs{$_} for CORE::keys %pairs;
184 13         28 return $self;
185             }
186              
187             =head2 exists
188              
189             if ($session->exists('key')) { ... }
190              
191             Returns true if the key exists in the session data.
192              
193             =cut
194              
195             sub exists {
196 7     7 1 16 my ($self, $key) = @_;
197 7 100       29 return exists $self->{_data}{$key} ? 1 : 0;
198             }
199              
200             =head2 delete
201              
202             $session->delete('key');
203             $session->delete('k1', 'k2', 'k3');
204             $session->delete('a')->delete('b'); # chaining
205              
206             Removes one or more keys from the session data. Returns C<$self> for
207             method chaining.
208              
209             =cut
210              
211             sub delete {
212 5     5 1 234 my ($self, @keys) = @_;
213 5         16 delete $self->{_data}{$_} for @keys;
214 5         8 return $self;
215             }
216              
217             =head2 keys
218              
219             my @keys = $session->keys;
220              
221             Returns a list of user keys, filtering out internal keys that start
222             with an underscore (e.g. C<_id>, C<_created>, C<_last_access>).
223              
224             =cut
225              
226             sub keys {
227 4     4 1 8 my ($self) = @_;
228 4         4 return grep { !/^_/ } keys %{$self->{_data}};
  18         45  
  4         9  
229             }
230              
231             =head2 slice
232              
233             my %data = $session->slice('user_id', 'role', 'email');
234              
235             Returns a hash of key-value pairs for the requested keys. Keys that
236             do not exist in the session are silently skipped (unlike C,
237             which dies on missing keys).
238              
239             =cut
240              
241             sub slice {
242 3     3 1 13 my ($self, @keys) = @_;
243 3 100       6 return map { CORE::exists($self->{_data}{$_}) ? ($_ => $self->{_data}{$_}) : () } @keys;
  7         20  
244             }
245              
246             =head2 clear
247              
248             $session->clear;
249              
250             Removes all user keys from the session, preserving internal
251             C<_>-prefixed keys (C<_id>, C<_created>, C<_last_access>, etc.).
252             Use this for a "soft logout" that keeps the session ID but wipes
253             application data.
254              
255             =cut
256              
257             sub clear {
258 2     2 1 8 my ($self) = @_;
259 2         5 for my $key ($self->keys) {
260 3         5 delete $self->{_data}{$key};
261             }
262             }
263              
264             =head2 regenerate
265              
266             $session->regenerate;
267              
268             Requests session ID regeneration. The middleware will generate a new
269             session ID, delete the old session from the store, save session data
270             under the new ID, and update the client cookie/header.
271              
272             B Always call this after authentication (login) to prevent
273             session fixation attacks.
274              
275             =cut
276              
277             sub regenerate {
278 1     1 1 5 my ($self) = @_;
279 1         4 $self->{_data}{_regenerated} = 1;
280             }
281              
282             =head2 destroy
283              
284             $session->destroy;
285              
286             Marks the session for destruction. The middleware will delete the
287             session data from the store and clear the client-side state (e.g.,
288             expire the cookie). Use this for logout.
289              
290             =cut
291              
292             sub destroy {
293 1     1 1 6 my ($self) = @_;
294 1         3 $self->{_data}{_destroyed} = 1;
295             }
296              
297             1;
298              
299             __END__