-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsession.go
More file actions
199 lines (162 loc) · 5.64 KB
/
session.go
File metadata and controls
199 lines (162 loc) · 5.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
package jmap
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sync"
"time"
)
// SessionGetter is a function that fetches a fresh Session from the server.
// It is passed to [SessionCache.Get] and called only when the cache is empty
// or expired.
type SessionGetter func(ctx context.Context) (Session, error)
// SessionCache manages caching of the JMAP Session object. Implement this
// interface to provide custom caching strategies (e.g. Redis, per-tenant).
type SessionCache interface {
// Get returns a cached Session, calling fn to fetch a fresh one if needed.
Get(ctx context.Context, fn SessionGetter) (Session, error)
// Invalidate discards the cached Session, forcing the next Get to refresh.
Invalidate(ctx context.Context) error
}
// DefaultSessionCache is an in-memory [SessionCache] that refreshes the
// Session after a configurable TTL. It is safe for concurrent use.
type DefaultSessionCache struct {
mu sync.RWMutex
session Session
setAt time.Time
ttl time.Duration
}
// NewDefaultSessionCache returns a [DefaultSessionCache] that caches the
// Session for the given TTL duration.
func NewDefaultSessionCache(ttl time.Duration) *DefaultSessionCache {
return &DefaultSessionCache{
ttl: ttl,
}
}
// Get returns the cached Session if it is still within the TTL, otherwise
// calls fn to fetch a fresh one and stores it. Uses a double-checked lock to
// avoid redundant fetches under concurrent access.
func (c *DefaultSessionCache) Get(ctx context.Context, fn SessionGetter) (Session, error) {
c.mu.RLock()
if c.session.isSet && time.Since(c.setAt) <= c.ttl {
s := c.session
c.mu.RUnlock()
return s, nil
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
if c.session.isSet && time.Since(c.setAt) <= c.ttl {
return c.session, nil
}
s, err := fn(ctx)
if err != nil {
return s, err
}
c.session = s
c.setAt = time.Now()
return s, nil
}
// Invalidate clears the cached Session so the next call to Get fetches a
// fresh one from the server.
func (c *DefaultSessionCache) Invalidate(_ context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
c.session = Session{}
c.setAt = time.Time{}
return nil
}
// Session represents the JMAP Session object defined in RFC 8620 §2.
type Session struct {
isSet bool
// Capabilities advertises the capabilities the server supports,
// keyed by capability URI (e.g., "urn:ietf:params:jmap:core").
Capabilities map[Capability]json.RawMessage `json:"capabilities"`
// Accounts lists accounts the user has access to, keyed by account ID.
Accounts map[string]Account `json:"accounts"`
// PrimaryAccounts maps each capability to the default account ID
// for that capability.
PrimaryAccounts map[Capability]string `json:"primaryAccounts"`
// Username is the user’s primary identifier for this session.
Username string `json:"username"`
// APIUrl is the endpoint used for all JMAP method calls (POST).
APIURL string `json:"apiUrl"`
// DownloadURL is a template for downloading blobs.
// Replace {accountId} and {blobId}.
DownloadURL string `json:"downloadUrl"`
// UploadURL is a template for uploading blobs.
// Replace {accountId} and {blobId}.
UploadURL string `json:"uploadUrl"`
// EventSourceURL is the long-poll URL for push changes.
EventSourceURL string `json:"eventSourceUrl"`
// State is a string used to detect when the session object changes.
State string `json:"state"`
// Extensions can contain any unrecognized or server-specific fields.
Extensions map[string]any `json:"-"`
}
// Account represents a single JMAP account.
type Account struct {
Name string `json:"name"`
IsPersonal bool `json:"isPersonal"`
IsReadOnly bool `json:"isReadOnly"`
AccountCapabilities map[Capability]json.RawMessage `json:"accountCapabilities"`
}
// GetSession returns the cached JMAP session, fetching it from the server if
// the cache is empty or expired.
func (cl *Client) GetSession(ctx context.Context) (Session, error) {
return cl.session.Get(ctx, func(ctx context.Context) (Session, error) {
sessionURL, err := cl.resolveSessionURL(ctx)
if err != nil {
return Session{}, err
}
return cl.fetchSession(ctx, sessionURL)
})
}
// fetchSession performs an authenticated GET request to the JMAP session URL
// and decodes the response into a Session.
func (cl *Client) fetchSession(ctx context.Context, u *url.URL) (Session, error) {
var sess Session
req, err := cl.newRequest(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return sess, err
}
resp, err := cl.http.Do(req)
if err != nil {
return sess, fmt.Errorf("jmap: fetch session error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return sess, fmt.Errorf("jmap: session request failed: %s", resp.Status)
}
if err := json.NewDecoder(resp.Body).Decode(&sess); err != nil {
return sess, fmt.Errorf("jmap: decode session json: %w", err)
}
sess.isSet = true
return sess, nil
}
// resolveSessionURL resolves and caches the JMAP session URL.
// The resolver is called at most once; subsequent calls return the cached result.
func (cl *Client) resolveSessionURL(ctx context.Context) (*url.URL, error) {
// Fast path: return cached URL under read lock.
cl.mu.RLock()
if cl.sessionURL != nil {
cl.mu.RUnlock()
return cl.sessionURL, nil
}
cl.mu.RUnlock()
// Slow path: acquire write lock and resolve.
cl.mu.Lock()
defer cl.mu.Unlock()
// Re-check after acquiring write lock, another goroutine may have resolved.
if cl.sessionURL != nil {
return cl.sessionURL, nil
}
u, err := cl.resolver.Resolve(ctx)
if err != nil {
return nil, err
}
cl.sessionURL = u
return u, nil
}