Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions capabilities/account/egress/egress.ipldsch
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
type GetCaveats struct {
spaces optional [DID]
period optional Period
}

type Period struct {
from ISO8601Date
to ISO8601Date
}

type GetOk struct {
total Int
spaces {DID:SpaceEgress}
}

type SpaceEgress struct {
total Int
dailyStats [DailyStat]
}

type DailyStat struct {
date ISO8601Date
egress Int
}

type GetError struct {
errorName String (rename "name")
message String
}
144 changes: 144 additions & 0 deletions capabilities/account/egress/get.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package egress

import (
"fmt"
"slices"
"time"

"github.com/ipld/go-ipld-prime/datamodel"
"github.com/storacha/go-ucanto/core/ipld"
"github.com/storacha/go-ucanto/core/receipt"
"github.com/storacha/go-ucanto/core/result/failure"
"github.com/storacha/go-ucanto/core/schema"
"github.com/storacha/go-ucanto/did"
"github.com/storacha/go-ucanto/ucan"
"github.com/storacha/go-ucanto/validator"

"github.com/storacha/go-libstoracha/capabilities/types"
)

const GetAbility = "account/egress/get"

type Period struct {
From time.Time
To time.Time

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use a comment. What kind of resolution are we allowed here, if I specify "to" as midday, will I only get egress registered before midday on that day?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From inclusive and to exclusive right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use a comment. What kind of resolution are we allowed here, if I specify "to" as midday, will I only get egress registered before midday on that day?

you'd get egress for the whole day. The focus is on daily stats, so only the date part of the time.Time is used.

From inclusive and to exclusive right?

the etracker will do a BETWEEN query to the dynamoDB table, so yes, both inclusive.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, apparently I cannot read. My initial idea was for both of them to be inclusive, but I see from is inclusive and to is exclusive in account/usage/get, so I guess it's better to keep it consistent here.

}

type GetCaveats struct {
Spaces []did.DID

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume you can specify no spaces here to get egress for them all? Maybe add a comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes yes

Period *Period
}

func (gc GetCaveats) ToIPLD() (datamodel.Node, error) {
return ipld.WrapWithRecovery(&gc, GetCaveatsType(), types.Converters...)
}

var GetCaveatsReader = schema.Struct[GetCaveats](GetCaveatsType(), nil, types.Converters...)

type DailyStats struct {
Date time.Time

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To and From to allow specifying a period that is less than day resolution?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, I'd prefer keeping it as is for now and see if the extra flexibility is actually needed in the future

Egress uint64
}

type SpaceEgress struct {
Total uint64

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use a comment - is this just the sum of all the egress values in DailyStats?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes

DailyStats []DailyStats

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these ordered by date ascending? Would be good to know.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the date is the sort key in the table where the etracker stores these stats, so yes

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might it be worth calling this something more generic to allow breaking down in smaller increments in the future. Like right now you want daily, but it might be desirable to break down into 10 minute intervals or something for a "zoomed in" view.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's an interesting idea. These could be just Stats and we could add an additional caveat to allow asking for different resolutions.

I think it's preferable to keep it simple for now and we can improve it if/when the need arises. I'm not sure right now whether resolutions smaller than a day would be useful for customers.

}

type SpacesModel struct {
Keys []did.DID
Values map[did.DID]SpaceEgress
}

type GetOk struct {
Total uint64

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The total egress across all spaces for the period?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exactly

Spaces SpacesModel
}

func (gok GetOk) ToIPLD() (datamodel.Node, error) {
return ipld.WrapWithRecovery(&gok, GetOkType(), types.Converters...)
}

type GetError struct {
ErrorName string
Message string
}

const AccountNotFoundErrorName = "AccountNotFoundError"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need an error for if you ask for egress for space(s) you do not have access to?

Also a range error for when you ask for a period that is bigger than what we want to allow?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need an error for if you ask for egress for space(s) you do not have access to?

if there are no stats for the space, you'll get the space in the map with 0 egress. If you don't have access to the space, you won't have the space in the map at all. That would be enough for the client to know what happened. But I like being explicit.

What should happen if you request several spaces and have access to some of them but not all? Should we fail the whole request? I think ignoring non-authorized spaces could be a better approach. At the same time, if none of the requested spaces is accessible, returning an ok result with an empty spaces map sounds a bit weird to me.

I think ignoring non-authorized spaces works here because the resource of the capability is the account, so we will expose spaces that were created by that account, and not those the account has access to.

WDYT?

Also a range error for when you ask for a period that is bigger than what we want to allow?

👍🏻

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

about the spaces thing, I'll keep it consistent with the behavior of account/usage/get, which fails the whole request when any of the requested spaces is not authorized.


func NewAccountNotFoundError(msg string) GetError {
return GetError{
ErrorName: AccountNotFoundErrorName,
Message: msg,
}
}

func (ge GetError) Name() string {
return ge.ErrorName
}

func (ge GetError) Error() string {
return ge.Message
}

func (ge GetError) ToIPLD() (datamodel.Node, error) {
return ipld.WrapWithRecovery(&ge, GetErrorType(), types.Converters...)
}

var GetErrorReader = schema.Mapped(
schema.Struct[GetError](GetErrorType(), nil, types.Converters...),
func(ge GetError) (GetError, failure.Failure) {
if ge.Name() != AccountNotFoundErrorName {
return GetError{}, failure.FromError(fmt.Errorf("incorrect name: %s, expected: %s", ge.Name(), AccountNotFoundErrorName))
}
return ge, nil
},
)

type GetReceipt receipt.Receipt[GetOk, GetError]
type GetReceiptReader receipt.ReceiptReader[GetOk, GetError]

func NewGetReceiptReader() (GetReceiptReader, error) {
return receipt.NewReceiptReaderFromTypes[GetOk, GetError](GetOkType(), GetErrorType(), types.Converters...)
}

var GetOkReader = schema.Struct[GetOk](GetOkType(), nil, types.Converters...)

var Get = validator.NewCapability(
GetAbility,
schema.DIDString(),
GetCaveatsReader,
getDerives,
)

func getDerives(claimed, delegated ucan.Capability[GetCaveats]) failure.Failure {
if claimed.With() != delegated.With() {
return failure.FromError(fmt.Errorf("can not derive %s with %s from %s", claimed.Can(), claimed.With(), delegated.With()))
}

if delegated.Nb().Spaces != nil {
if claimed.Nb().Spaces == nil {
return failure.FromError(fmt.Errorf("constraint violation: violates imposed spaces constraint %v because it asks for all spaces", delegated.Nb().Spaces))
}

for _, s := range claimed.Nb().Spaces {
if !slices.Contains(delegated.Nb().Spaces, s) {
return failure.FromError(fmt.Errorf("constraint violation: violates imposed spaces constraint %v because it asks for space %s", delegated.Nb().Spaces, s))
}
}
}

if delegated.Nb().Period != nil {
if claimed.Nb().Period == nil {
return failure.FromError(fmt.Errorf("constraint violation: violates imposed period constraint %v because it doesn't have a period constraint", delegated.Nb().Period))
}
if claimed.Nb().Period.From.Before(delegated.Nb().Period.From) {
return failure.FromError(fmt.Errorf("constraint violation: violates imposed period constraint because it requests dates before %s", delegated.Nb().Period.From))
}
if claimed.Nb().Period.To.After(delegated.Nb().Period.To) {
return failure.FromError(fmt.Errorf("constraint violation: violates imposed period constraint because it requests dates after %s", delegated.Nb().Period.To))
}
}

return nil
}
91 changes: 91 additions & 0 deletions capabilities/account/egress/get_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package egress_test

import (
"testing"
"time"

"github.com/storacha/go-libstoracha/capabilities/account/egress"
"github.com/storacha/go-libstoracha/testutil"
"github.com/storacha/go-ucanto/did"

"github.com/stretchr/testify/require"
)

func TestRoundTripGetCaveats(t *testing.T) {
t.Run("marshals and unmarshals correctly", func(t *testing.T) {
space1 := testutil.RandomDID(t)
space2 := testutil.RandomDID(t)

nb := egress.GetCaveats{
Spaces: []did.DID{space1, space2},
Period: &egress.Period{
From: time.UnixMilli(123000).UTC(),
To: time.UnixMilli(456000).UTC(),
},
}

node, err := nb.ToIPLD()
require.NoError(t, err)

rnb, err := egress.GetCaveatsReader.Read(node)
require.NoError(t, err)
require.Equal(t, nb.Spaces[0].String(), rnb.Spaces[0].String())
require.Equal(t, nb.Spaces[1].String(), rnb.Spaces[1].String())
require.True(t, nb.Period.From.Equal(rnb.Period.From))
require.True(t, nb.Period.To.Equal(rnb.Period.To))
})

t.Run("properties are optional", func(t *testing.T) {
nb := egress.GetCaveats{}

node, err := nb.ToIPLD()
require.NoError(t, err)

rnb, err := egress.GetCaveatsReader.Read(node)
require.NoError(t, err)
require.Nil(t, rnb.Spaces)
require.Nil(t, rnb.Period)
})
}

func TestNewGetReceiptReader(t *testing.T) {
_, err := egress.NewGetReceiptReader()
require.NoError(t, err)
}

func TestRoundTripGetOk(t *testing.T) {
space1 := testutil.RandomDID(t)

ok := egress.GetOk{
Total: 1000,
Spaces: egress.SpacesModel{
Keys: []did.DID{space1},
Values: map[did.DID]egress.SpaceEgress{
space1: {
Total: 500,
DailyStats: []egress.DailyStats{
{
Date: time.Now().Truncate(time.Second).UTC(),
Egress: 250,
},
{
Date: time.Now().Add(-24 * time.Hour).Truncate(time.Second).UTC(),
Egress: 250,
},
},
},
},
},
}

node, err := ok.ToIPLD()
require.NoError(t, err)

rok, err := egress.GetOkReader.Read(node)
require.NoError(t, err)
require.Equal(t, ok.Total, rok.Total)
require.Equal(t, len(ok.Spaces.Keys), len(rok.Spaces.Keys))
require.Equal(t, ok.Spaces.Values[space1].Total, rok.Spaces.Values[space1].Total)
require.Equal(t, len(ok.Spaces.Values[space1].DailyStats), len(rok.Spaces.Values[space1].DailyStats))
require.True(t, ok.Spaces.Values[space1].DailyStats[0].Date.Equal(rok.Spaces.Values[space1].DailyStats[0].Date))
}
34 changes: 34 additions & 0 deletions capabilities/account/egress/schema.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package egress

import (
_ "embed"
"fmt"

"github.com/ipld/go-ipld-prime/schema"
captypes "github.com/storacha/go-libstoracha/capabilities/types"
)

//go:embed egress.ipldsch
var egressSchema []byte

var egressTS = mustLoadTS()

func mustLoadTS() *schema.TypeSystem {
ts, err := captypes.LoadSchemaBytes(egressSchema)
if err != nil {
panic(fmt.Errorf("loading egress schema: %w", err))
}
return ts
}

func GetCaveatsType() schema.Type {
return egressTS.TypeByName("GetCaveats")
}

func GetOkType() schema.Type {
return egressTS.TypeByName("GetOk")
}

func GetErrorType() schema.Type {
return egressTS.TypeByName("GetError")
}