Skip to content

Commit 2912062

Browse files
authored
feat: receipts in the server can be logged (#67)
Ref: storacha/piri#174 When client code registers UCAN handlers in a go-ucanto server, there is currently no way for that code to grasp the receipt the server generates and sends back to the invoker. This PR adds a new `WithRequestLogger` server option to register a callback the server will use to pass the receipt back. Additionally, a new `WithInvocation` method is added to the `Receipt` type that allows producing a receipt with an attached invocation from one that doesn't have it. Receipts issued by the retrieval server don't include invocation blocks to save space in the headers, but it is useful that logged receipts are full receipts in the sense that they also have the invocation blocks in them.
1 parent fa0eefc commit 2912062

6 files changed

Lines changed: 184 additions & 6 deletions

File tree

core/receipt/receipt.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ type Receipt[O, X any] interface {
4444
Signature() signature.SignatureView
4545
Archive() io.Reader
4646
Export() iter.Seq2[block.Block, error]
47+
Clone() (Receipt[O, X], error)
48+
AttachInvocation(invocation invocation.Invocation) error
4749
}
4850

4951
func toResultModel[O, X any](res result.Result[O, X]) rdm.ResultModel[O, X] {
@@ -221,6 +223,54 @@ func (r *receipt[O, X]) Export() iter.Seq2[block.Block, error] {
221223
return iterable.Concat2(iterators...)
222224
}
223225

226+
// Clone returns a new Receipt by copying r's backing blockstore.
227+
func (r *receipt[O, X]) Clone() (Receipt[O, X], error) {
228+
blks, err := blockstore.NewBlockStore(blockstore.WithBlocksIterator(r.blks.Iterator()))
229+
if err != nil {
230+
return nil, fmt.Errorf("creating block reader: %w", err)
231+
}
232+
return &receipt[O, X]{
233+
rt: r.rt,
234+
blks: blks,
235+
data: r.data,
236+
}, nil
237+
}
238+
239+
// AttachInvocation adds the invocation's blocks to the receipt's blockstore.
240+
// If r already has an invocation, it returns r unchanged.
241+
// If the invocation doesn't match r's ran, it returns an error.
242+
func (r *receipt[O, X]) AttachInvocation(invocation invocation.Invocation) error {
243+
// confirm the invocation matches the receipt
244+
ran := r.Ran()
245+
if ran.Link().String() != invocation.Link().String() {
246+
return fmt.Errorf("expected invocation with CID %s, got %s", ran.Link(), invocation.Link())
247+
}
248+
249+
// don't add the invocation if it's already there
250+
if _, ok := ran.Invocation(); ok {
251+
return nil
252+
}
253+
254+
// no need to copy receipt blocks if the backing BlockReader is actually a BlockStore
255+
if bs, ok := r.blks.(blockstore.BlockStore); ok {
256+
for b, err := range invocation.Export() {
257+
if err != nil {
258+
return fmt.Errorf("attaching invocation blocks: %w", err)
259+
}
260+
bs.Put(b)
261+
}
262+
} else {
263+
blks, err := blockstore.NewBlockReader(blockstore.WithBlocksIterator(iterable.Concat2(r.blks.Iterator(), invocation.Export())))
264+
if err != nil {
265+
return fmt.Errorf("creating block reader: %w", err)
266+
}
267+
268+
r.blks = blks
269+
}
270+
271+
return nil
272+
}
273+
224274
type ReceiptReader[O, X any] interface {
225275
Read(rcpt ipld.Link, blks iter.Seq2[block.Block, error]) (Receipt[O, X], error)
226276
}

core/receipt/receipt_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,91 @@ func TestExport(t *testing.T) {
310310
require.Contains(t, blklnks, otherblk.Link().String())
311311
}
312312

313+
func TestAttachInvocation(t *testing.T) {
314+
inv, err := invocation.Invoke(
315+
fixtures.Alice,
316+
fixtures.Bob,
317+
ucan.NewCapability("ran/invoke", fixtures.Alice.DID().String(), ucan.NoCaveats{}),
318+
)
319+
require.NoError(t, err)
320+
321+
out := result.Ok[someOkType, someErrorType](someOkType{SomeOkProperty: "some ok value"})
322+
323+
t.Run("adds invocation to receipt without one", func(t *testing.T) {
324+
issuedRcpt, err := Issue(fixtures.Alice, out, ran.FromLink(inv.Link()))
325+
require.NoError(t, err)
326+
327+
ranInv, ok := issuedRcpt.Ran().Invocation()
328+
require.False(t, ok)
329+
require.Nil(t, ranInv)
330+
331+
err = issuedRcpt.AttachInvocation(inv)
332+
require.NoError(t, err)
333+
334+
ranInv, ok = issuedRcpt.Ran().Invocation()
335+
require.True(t, ok)
336+
require.Equal(t, inv.Link().String(), ranInv.Link().String())
337+
})
338+
339+
t.Run("doesn't fail if receipt already has invocation and invocations match", func(t *testing.T) {
340+
issuedRcpt, err := Issue(fixtures.Alice, out, ran.FromInvocation(inv))
341+
require.NoError(t, err)
342+
343+
ranInv, ok := issuedRcpt.Ran().Invocation()
344+
require.True(t, ok)
345+
require.Equal(t, inv.Link().String(), ranInv.Link().String())
346+
347+
err = issuedRcpt.AttachInvocation(inv)
348+
require.NoError(t, err)
349+
})
350+
351+
t.Run("fails if receipt invocations don't match", func(t *testing.T) {
352+
issuedRcpt, err := Issue(fixtures.Alice, out, ran.FromLink(inv.Link()))
353+
require.NoError(t, err)
354+
355+
inv2, err := invocation.Invoke(
356+
fixtures.Alice,
357+
fixtures.Service, // previous invocation's audience is Bob
358+
ucan.NewCapability("ran/invoke", fixtures.Alice.DID().String(), ucan.NoCaveats{}),
359+
)
360+
require.NoError(t, err)
361+
362+
err = issuedRcpt.AttachInvocation(inv2)
363+
require.Error(t, err)
364+
})
365+
}
366+
367+
func TestClone(t *testing.T) {
368+
inv, err := invocation.Invoke(
369+
fixtures.Alice,
370+
fixtures.Bob,
371+
ucan.NewCapability("ran/invoke", fixtures.Alice.DID().String(), ucan.NoCaveats{}),
372+
)
373+
require.NoError(t, err)
374+
375+
out := result.Ok[someOkType, someErrorType](someOkType{SomeOkProperty: "some ok value"})
376+
377+
rcpt1, err := Issue(fixtures.Alice, out, ran.FromLink(inv.Link()))
378+
require.NoError(t, err)
379+
380+
rcpt2, err := rcpt1.Clone()
381+
require.NoError(t, err)
382+
383+
// attach an invocation to rcpt2 and confirm it doesn't affect rcpt1
384+
err = rcpt2.AttachInvocation(inv)
385+
require.NoError(t, err)
386+
387+
rcpt1NumBlocks := 0
388+
for range rcpt1.Blocks() {
389+
rcpt1NumBlocks++
390+
}
391+
rcpt2NumBlocks := 0
392+
for range rcpt2.Blocks() {
393+
rcpt2NumBlocks++
394+
}
395+
require.True(t, rcpt2NumBlocks > rcpt1NumBlocks)
396+
}
397+
313398
func TestAnyReceiptReader(t *testing.T) {
314399
ranInv, err := invocation.Invoke(
315400
fixtures.Alice,

server/options.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type srvConfig struct {
2828
authorityProofs []delegation.Delegation
2929
altAudiences []ucan.Principal
3030
catch ErrorHandlerFunc
31+
logReceipt ReceiptLoggerFunc
3132
}
3233

3334
func WithServiceMethod[O ipld.Builder, X failure.IPLDBuilderFailure](can string, handleFunc ServiceMethod[O, X]) Option {
@@ -75,6 +76,15 @@ func WithErrorHandler(fn ErrorHandlerFunc) Option {
7576
}
7677
}
7778

79+
// WithReceiptLogger configures a function to be called when a receipt is
80+
// issued.
81+
func WithReceiptLogger(fn ReceiptLoggerFunc) Option {
82+
return func(cfg *srvConfig) error {
83+
cfg.logReceipt = fn
84+
return nil
85+
}
86+
}
87+
7888
// WithCanIssue configures a function that determines whether a given capability
7989
// can be issued by a given DID or whether it needs to be delegated to the
8090
// issuer.

server/retrieval/options.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type srvConfig struct {
2828
authorityProofs []delegation.Delegation
2929
altAudiences []ucan.Principal
3030
catch server.ErrorHandlerFunc
31+
logReceipt server.ReceiptLoggerFunc
3132
delegationCache delegation.Store
3233
}
3334

@@ -67,6 +68,15 @@ func WithErrorHandler(fn server.ErrorHandlerFunc) Option {
6768
}
6869
}
6970

71+
// WithReceiptLogger configures a function to be called when a receipt is generated,
72+
// allowing access to the receipts produced by the server.
73+
func WithReceiptLogger(fn server.ReceiptLoggerFunc) Option {
74+
return func(cfg *srvConfig) error {
75+
cfg.logReceipt = fn
76+
return nil
77+
}
78+
}
79+
7080
// WithCanIssue configures a function that determines whether a given capability
7181
// can be issued by a given DID or whether it needs to be delegated to the
7282
// issuer.

server/retrieval/server.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ func NewServer(id principal.Signer, options ...Option) (*Server, error) {
121121
if cfg.catch != nil {
122122
srvOpts = append(srvOpts, server.WithErrorHandler(cfg.catch))
123123
}
124+
if cfg.logReceipt != nil {
125+
srvOpts = append(srvOpts, server.WithReceiptLogger(cfg.logReceipt))
126+
}
124127
if cfg.validateAuthorization != nil {
125128
srvOpts = append(srvOpts, server.WithRevocationChecker(cfg.validateAuthorization))
126129
}
@@ -188,6 +191,10 @@ func (srv *Server) Catch(err server.HandlerExecutionError[any]) {
188191
srv.server.Catch(err)
189192
}
190193

194+
func (srv *Server) LogReceipt(rcpt receipt.AnyReceipt, inv invocation.Invocation) {
195+
srv.server.LogReceipt(rcpt, inv)
196+
}
197+
191198
var _ CachingServer = (*Server)(nil)
192199

193200
func Handle(ctx context.Context, srv CachingServer, request transport.HTTPRequest) (transport.HTTPResponse, error) {
@@ -462,5 +469,7 @@ func Run(ctx context.Context, srv server.Server[Service], invocation server.Serv
462469
return nil, Response{}, err
463470
}
464471

472+
srv.LogReceipt(rcpt, invocation)
473+
465474
return rcpt, resp, nil
466475
}

server/server.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ type Server[S any] interface {
6767
// Service is the actual service providing capability handlers.
6868
Service() S
6969
Catch(err HandlerExecutionError[any])
70+
LogReceipt(rcpt receipt.AnyReceipt, inv invocation.Invocation)
7071
}
7172

7273
// Server is a materialized service that is configured to use a specific
@@ -83,6 +84,10 @@ type ServerView[S any] interface {
8384
// to be logged.
8485
type ErrorHandlerFunc func(err HandlerExecutionError[any])
8586

87+
// ReceiptLoggerFunc allows receipts generated during handler execution to be logged.
88+
// The original invocation is also provided for reference.
89+
type ReceiptLoggerFunc func(receipt.AnyReceipt, invocation.Invocation)
90+
8691
func NewServer(id principal.Signer, options ...Option) (ServerView[Service], error) {
8792
cfg := srvConfig{service: Service{}}
8893
for _, opt := range options {
@@ -131,7 +136,7 @@ func NewServer(id principal.Signer, options ...Option) (ServerView[Service], err
131136
}
132137

133138
ctx := serverContext{id, canIssue, validateAuthorization, resolveProof, parsePrincipal, resolveDIDKey, cfg.authorityProofs, cfg.altAudiences}
134-
svr := &server{id, cfg.service, ctx, codec, catch}
139+
svr := &server{id, cfg.service, ctx, codec, catch, cfg.logReceipt}
135140
return svr, nil
136141
}
137142

@@ -184,11 +189,12 @@ func (sctx serverContext) AlternativeAudiences() []ucan.Principal {
184189
}
185190

186191
type server struct {
187-
id principal.Signer
188-
service Service
189-
context InvocationContext
190-
codec transport.InboundCodec
191-
catch ErrorHandlerFunc
192+
id principal.Signer
193+
service Service
194+
context InvocationContext
195+
codec transport.InboundCodec
196+
catch ErrorHandlerFunc
197+
logReceipt ReceiptLoggerFunc
192198
}
193199

194200
func (srv *server) ID() principal.Signer {
@@ -219,6 +225,12 @@ func (srv *server) Catch(err HandlerExecutionError[any]) {
219225
srv.catch(err)
220226
}
221227

228+
func (srv *server) LogReceipt(rcpt receipt.AnyReceipt, inv invocation.Invocation) {
229+
if srv.logReceipt != nil {
230+
srv.logReceipt(rcpt, inv)
231+
}
232+
}
233+
222234
var _ transport.Channel = (*server)(nil)
223235
var _ ServerView[Service] = (*server)(nil)
224236

@@ -322,5 +334,7 @@ func Run(ctx context.Context, server Server[Service], invocation ServiceInvocati
322334
return receipt.Issue(server.ID(), result.NewFailure(herr), ran.FromInvocation(invocation))
323335
}
324336

337+
server.LogReceipt(rcpt, invocation)
338+
325339
return rcpt, nil
326340
}

0 commit comments

Comments
 (0)