Marius Orcsik: 1 Adding Repository MarshalJSON and UnmarshalJSON methods 5 files changed, 576 insertions(+), 26 deletions(-)
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.sr.ht/~mariusor/activitypub-go/patches/33043/mbox | git am -3Learn more about email & git
Adding typer function for the RepositoryType Adding some tests --- models/forgefed/forgefed.go | 26 -- models/forgefed/repository.go | 57 +++++ models/forgefed/repository_json_decode.go | 69 ++++++ models/forgefed/repository_json_encode.go | 283 ++++++++++++++++++++++ models/forgefed/repository_test.go | 167 +++++++++++++ 5 files changed, 576 insertions(+), 26 deletions(-) delete mode 100644 models/forgefed/forgefed.go create mode 100644 models/forgefed/repository.go create mode 100644 models/forgefed/repository_json_decode.go create mode 100644 models/forgefed/repository_json_encode.go create mode 100644 models/forgefed/repository_test.go diff --git a/models/forgefed/forgefed.go b/models/forgefed/forgefed.go deleted file mode 100644 index 53c0db646..000000000 --- a/models/forgefed/forgefed.go @@ -1,26 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. - -package forgefed - -import ( - ap "github.com/go-ap/activitypub" -) - -const ( - RepositoryType ap.ActivityVocabularyType = "Repository" -) - -type Repository struct { - ap.Actor - // Team specifies a Collection of actors who are working on the object - Team ap.Item `jsonld:"team,omitempty"` -} - -// RepositoryNew initializes a Repository type actor -func RepositoryNew(id ap.ID) *Repository { - a := ap.ActorNew(id, RepositoryType) - o := Repository{Actor: *a} - return &o -} \ No newline at end of file diff --git a/models/forgefed/repository.go b/models/forgefed/repository.go new file mode 100644 index 000000000..120fcedc1 --- /dev/null +++ b/models/forgefed/repository.go @@ -0,0 +1,57 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package forgefed + +import ( + ap "github.com/go-ap/activitypub" + "github.com/valyala/fastjson" +) + +const ( + RepositoryType ap.ActivityVocabularyType = "Repository" +) + +type Repository struct { + ap.Actor + // Team specifies a Collection of actors who are working on the object + Team ap.Item `jsonld:"team,omitempty"` +} + +// GetItemByType instantiates a new Repository object if the type matches +// otherwise it defaults to existing activitypub package typer function. +func GetItemByType(typ ap.ActivityVocabularyType) (ap.Item, error) { + if typ == RepositoryType { + return RepositoryNew(""), nil + } + return ap.GetItemByType(typ) +} + +// RepositoryNew initializes a Repository type actor +func RepositoryNew(id ap.ID) *Repository { + a := ap.ActorNew(id, RepositoryType) + o := Repository{Actor: *a} + return &o +} + +func (r Repository) MarshalJSON() ([]byte, error) { + b := make([]byte, 0) + write(&b, '{') + + if notEmpty := writeRepositoryJSONValue(&b, r); notEmpty { + write(&b, '}') + return b, nil + } + + return nil, nil +} + +func (r *Repository) UnmarshalJSON(data []byte) error { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + return loadRepository(val, r) +} diff --git a/models/forgefed/repository_json_decode.go b/models/forgefed/repository_json_decode.go new file mode 100644 index 000000000..1833f5dc6 --- /dev/null +++ b/models/forgefed/repository_json_decode.go @@ -0,0 +1,69 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package forgefed + +import ( + ap "github.com/go-ap/activitypub" + "github.com/valyala/fastjson" +) + +func loadRepository(val *fastjson.Value, r *Repository) error { + r.Team = ap.JSONGetItem(val, "team") + + return ap.OnActor(&r.Actor, func(a *ap.Actor) error { + return loadActor(val, a) + }) +} + +func loadActor(val *fastjson.Value, a *ap.Actor) error { + a.PreferredUsername = ap.JSONGetNaturalLanguageField(val, "preferredUsername") + a.Followers = ap.JSONGetItem(val, "followers") + a.Following = ap.JSONGetItem(val, "following") + a.Inbox = ap.JSONGetItem(val, "inbox") + a.Outbox = ap.JSONGetItem(val, "outbox") + a.Liked = ap.JSONGetItem(val, "liked") + a.Endpoints = ap.JSONGetActorEndpoints(val, "endpoints") + a.Streams = ap.JSONGetItems(val, "streams") + a.PublicKey = ap.JSONGetPublicKey(val, "publicKey") + + return ap.OnObject(a, func(o *ap.Object) error { + return loadObject(val, o) + }) +} + +func loadObject(val *fastjson.Value, o *ap.Object) error { + o.ID = ap.JSONGetID(val) + o.Type = ap.JSONGetType(val) + o.Name = ap.JSONGetNaturalLanguageField(val, "name") + o.Content = ap.JSONGetNaturalLanguageField(val, "content") + o.Summary = ap.JSONGetNaturalLanguageField(val, "summary") + o.Context = ap.JSONGetItem(val, "context") + o.URL = ap.JSONGetURIItem(val, "url") + o.MediaType = ap.JSONGetMimeType(val, "mediaType") + o.Generator = ap.JSONGetItem(val, "generator") + o.AttributedTo = ap.JSONGetItem(val, "attributedTo") + o.Attachment = ap.JSONGetItem(val, "attachment") + o.Location = ap.JSONGetItem(val, "location") + o.Published = ap.JSONGetTime(val, "published") + o.StartTime = ap.JSONGetTime(val, "startTime") + o.EndTime = ap.JSONGetTime(val, "endTime") + o.Duration = ap.JSONGetDuration(val, "duration") + o.Icon = ap.JSONGetItem(val, "icon") + o.Preview = ap.JSONGetItem(val, "preview") + o.Image = ap.JSONGetItem(val, "image") + o.Updated = ap.JSONGetTime(val, "updated") + o.InReplyTo = ap.JSONGetItem(val, "inReplyTo") + o.To = ap.JSONGetItems(val, "to") + o.Audience = ap.JSONGetItems(val, "audience") + o.Bto = ap.JSONGetItems(val, "bto") + o.CC = ap.JSONGetItems(val, "cc") + o.BCC = ap.JSONGetItems(val, "bcc") + o.Replies = ap.JSONGetItem(val, "replies") + o.Tag = ap.JSONGetItems(val, "tag") + o.Likes = ap.JSONGetItem(val, "likes") + o.Shares = ap.JSONGetItem(val, "shares") + o.Source = ap.GetAPSource(val) + return nil +} diff --git a/models/forgefed/repository_json_encode.go b/models/forgefed/repository_json_encode.go new file mode 100644 index 000000000..e5aab61ca --- /dev/null +++ b/models/forgefed/repository_json_encode.go @@ -0,0 +1,283 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package forgefed + +import ( + "encoding/json" + "time" + + "git.sr.ht/~mariusor/go-xsd-duration" + ap "github.com/go-ap/activitypub" +) + +func writeRepositoryJSONValue(b *[]byte, r Repository) (notEmpty bool) { + ap.OnActor(r.Actor, func(a *ap.Actor) error { + if a == nil { + return nil + } + notEmpty = writeActorJSONValue(b, a) || notEmpty + return nil + }) + if r.Team != nil { + notEmpty = writeItemJSONProp(b, "team", r.Team) || notEmpty + } + return notEmpty +} + +func writeItemJSONProp(b *[]byte, n string, i ap.Item) (notEmpty bool) { + if i == nil { + return notEmpty + } + if im, ok := i.(json.Marshaler); ok { + v, err := im.MarshalJSON() + if err != nil { + return false + } + return writeJSONProp(b, n, v) + } + return notEmpty +} + +func writeJSONProp(b *[]byte, name string, val []byte) (notEmpty bool) { + if len(val) == 0 { + return false + } + writeComma(b) + success := writePropJSONName(b, name) && writeJSONValue(b, val) + if !success { + *b = (*b)[:len(*b)-1] + } + return success +} + +func writePropJSONName(b *[]byte, s string) (notEmpty bool) { + if len(s) == 0 { + return false + } + write(b, '"') + writeS(b, s) + write(b, '"', ':') + return true +} + +func writeJSONValue(b *[]byte, s []byte) (notEmpty bool) { + if len(s) == 0 { + return false + } + write(b, s...) + return true +} + +func writeActorJSONValue(b *[]byte, a *ap.Actor) (notEmpty bool) { + ap.OnObject(a, func(o *ap.Object) error { + notEmpty = writeObjectJSONValue(b, *o) + return nil + }) + if a.Inbox != nil { + notEmpty = writeItemJSONProp(b, "inbox", a.Inbox) || notEmpty + } + if a.Outbox != nil { + notEmpty = writeItemJSONProp(b, "outbox", a.Outbox) || notEmpty + } + if a.Following != nil { + notEmpty = writeItemJSONProp(b, "following", a.Following) || notEmpty + } + if a.Followers != nil { + notEmpty = writeItemJSONProp(b, "followers", a.Followers) || notEmpty + } + if a.Liked != nil { + notEmpty = writeItemJSONProp(b, "liked", a.Liked) || notEmpty + } + if a.PreferredUsername != nil { + notEmpty = writeNaturalLanguageJSONProp(b, "preferredUsername", a.PreferredUsername) || notEmpty + } + if a.Endpoints != nil { + if v, err := a.Endpoints.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = writeJSONProp(b, "endpoints", v) || notEmpty + } + } + if len(a.Streams) > 0 { + notEmpty = writeItemCollectionJSONProp(b, "streams", a.Streams) + } + if len(a.PublicKey.PublicKeyPem)+len(a.PublicKey.ID) > 0 { + if v, err := a.PublicKey.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = writeJSONProp(b, "publicKey", v) || notEmpty + } + } + return notEmpty +} + +func writeItemCollectionJSONProp(b *[]byte, n string, col ap.ItemCollection) (notEmpty bool) { + if len(col) == 0 { + return notEmpty + } + writeComma(b) + success := writePropJSONName(b, n) && writeItemCollectionJSONValue(b, col) + if !success { + *b = (*b)[:len(*b)-1] + } + return success +} + +func writeItemCollectionJSONValue(b *[]byte, col ap.ItemCollection) (notEmpty bool) { + if len(col) == 0 { + return notEmpty + } + writeCommaIfNotEmpty := func(notEmpty bool) { + if notEmpty { + write(b, ',') + } + } + write(b, '[') + for i, it := range col { + if im, ok := it.(json.Marshaler); ok { + v, err := im.MarshalJSON() + if err != nil { + return false + } + writeCommaIfNotEmpty(i > 0) + write(b, v...) + } + } + write(b, ']') + return true +} + +func writeObjectJSONValue(b *[]byte, o ap.Object) (notEmpty bool) { + if v, err := o.ID.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = writeJSONProp(b, "id", v) || notEmpty + } + if v, err := o.Type.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = writeJSONProp(b, "type", v) || notEmpty + } + if v, err := o.MediaType.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = writeJSONProp(b, "mediaType", v) || notEmpty + } + if len(o.Name) > 0 { + notEmpty = writeNaturalLanguageJSONProp(b, "name", o.Name) || notEmpty + } + if len(o.Summary) > 0 { + notEmpty = writeNaturalLanguageJSONProp(b, "summary", o.Summary) || notEmpty + } + if len(o.Content) > 0 { + notEmpty = writeNaturalLanguageJSONProp(b, "content", o.Content) || notEmpty + } + if o.Attachment != nil { + notEmpty = writeItemJSONProp(b, "attachment", o.Attachment) || notEmpty + } + if o.AttributedTo != nil { + notEmpty = writeItemJSONProp(b, "attributedTo", o.AttributedTo) || notEmpty + } + if o.Audience != nil { + notEmpty = writeItemJSONProp(b, "audience", o.Audience) || notEmpty + } + if o.Context != nil { + notEmpty = writeItemJSONProp(b, "context", o.Context) || notEmpty + } + if o.Generator != nil { + notEmpty = writeItemJSONProp(b, "generator", o.Generator) || notEmpty + } + if o.Icon != nil { + notEmpty = writeItemJSONProp(b, "icon", o.Icon) || notEmpty + } + if o.Image != nil { + notEmpty = writeItemJSONProp(b, "image", o.Image) || notEmpty + } + if o.InReplyTo != nil { + notEmpty = writeItemJSONProp(b, "inReplyTo", o.InReplyTo) || notEmpty + } + if o.Location != nil { + notEmpty = writeItemJSONProp(b, "location", o.Location) || notEmpty + } + if o.Preview != nil { + notEmpty = writeItemJSONProp(b, "preview", o.Preview) || notEmpty + } + if o.Replies != nil { + notEmpty = writeItemJSONProp(b, "replies", o.Replies) || notEmpty + } + if o.Tag != nil { + notEmpty = writeItemJSONProp(b, "tag", o.Tag) || notEmpty + } + if o.URL != nil { + notEmpty = writeItemJSONProp(b, "url", o.URL) || notEmpty + } + if o.To != nil { + notEmpty = writeItemJSONProp(b, "to", o.To) || notEmpty + } + if o.Bto != nil { + notEmpty = writeItemJSONProp(b, "bto", o.Bto) || notEmpty + } + if o.CC != nil { + notEmpty = writeItemJSONProp(b, "cc", o.CC) || notEmpty + } + if o.BCC != nil { + notEmpty = writeItemJSONProp(b, "bcc", o.BCC) || notEmpty + } + if !o.Published.IsZero() { + notEmpty = writeTimeJSONProp(b, "published", o.Published) || notEmpty + } + if !o.Updated.IsZero() { + notEmpty = writeTimeJSONProp(b, "updated", o.Updated) || notEmpty + } + if !o.StartTime.IsZero() { + notEmpty = writeTimeJSONProp(b, "startTime", o.StartTime) || notEmpty + } + if !o.EndTime.IsZero() { + notEmpty = writeTimeJSONProp(b, "endTime", o.EndTime) || notEmpty + } + if o.Duration != 0 { + notEmpty = writeDurationJSONProp(b, "duration", o.Duration) || notEmpty + } + if o.Likes != nil { + notEmpty = writeItemJSONProp(b, "likes", o.Likes) || notEmpty + } + if o.Shares != nil { + notEmpty = writeItemJSONProp(b, "shares", o.Shares) || notEmpty + } + if v, err := o.Source.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = writeJSONProp(b, "source", v) || notEmpty + } + return notEmpty +} + +func writeNaturalLanguageJSONProp(b *[]byte, n string, nl ap.NaturalLanguageValues) (notEmpty bool) { + l := nl.Count() + if l > 1 { + n += "Map" + } + if v, err := nl.MarshalJSON(); err == nil && len(v) > 0 { + return writeJSONProp(b, n, v) + } + return false +} + +func writeTimeJSONProp(b *[]byte, n string, t time.Time) (notEmpty bool) { + var tb []byte + write(&tb, '"') + writeS(&tb, t.UTC().Format(time.RFC3339)) + write(&tb, '"') + return writeJSONProp(b, n, tb) +} + +func writeDurationJSONProp(b *[]byte, n string, d time.Duration) (notEmpty bool) { + if v, err := xsd.Marshal(d); err == nil { + return writeJSONProp(b, n, v) + } + return false +} + +func writeComma(b *[]byte) { + if len(*b) > 1 && (*b)[len(*b)-1] != ',' { + *b = append(*b, ',') + } +} + +func write(b *[]byte, c ...byte) { + *b = append(*b, c...) +} + +func writeS(b *[]byte, s string) { + *b = append(*b, s...) +} diff --git a/models/forgefed/repository_test.go b/models/forgefed/repository_test.go new file mode 100644 index 000000000..4bf36a66c --- /dev/null +++ b/models/forgefed/repository_test.go @@ -0,0 +1,167 @@ +package forgefed + +import ( + "encoding/json" + "fmt" + "reflect" + "testing" + + ap "github.com/go-ap/activitypub" +) + +func Test_GetItemByType(t *testing.T) { + type testtt struct { + typ ap.ActivityVocabularyType + want ap.Item + wantErr error + } + tests := map[string]testtt{ + "invalid type": { + typ: ap.ActivityVocabularyType("invalidtype"), + wantErr: fmt.Errorf("empty ActivityStreams type"), // TODO(marius): this error message needs to be improved in go-ap/activitypub + }, + "Repository": { + typ: RepositoryType, + want: new(Repository), + }, + "Person - fall back": { + typ: ap.PersonType, + want: new(ap.Person), + }, + "Question - fall back": { + typ: ap.QuestionType, + want: new(ap.Question), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + maybeRepository, err := GetItemByType(tt.typ) + if !reflect.DeepEqual(tt.wantErr, err) { + t.Errorf("GetItemByType() error = \"%+v\", wantErr = \"%+v\" when getting Item for type %q", tt.wantErr, err, tt.typ) + } + if reflect.TypeOf(tt.want) != reflect.TypeOf(maybeRepository) { + t.Errorf("Invalid type received %T, expected %T", maybeRepository, tt.want) + } + }) + } +} + +func Test_RepositoryMarshalJSON(t *testing.T) { + type testPair struct { + item Repository + want []byte + wantErr error + } + + tests := map[string]testPair{ + "empty": { + item: Repository{}, + want: nil, + }, + "with ID": { + item: Repository{ + Actor: ap.Actor{ + ID: "https://example.com/1", + }, + Team: nil, + }, + want: []byte(`{"id":"https://example.com/1"}`), + }, + "with Team as IRI": { + item: Repository{ + Team: ap.IRI("https://example.com/1"), + }, + want: []byte(`{"team":"https://example.com/1"}`), + }, + "with Team as IRIs": { + item: Repository{ + Team: ap.ItemCollection{ + ap.IRI("https://example.com/1"), + ap.IRI("https://example.com/2"), + }, + }, + want: []byte(`{"team":["https://example.com/1","https://example.com/2"]}`), + }, + "with Team as Object": { + item: Repository{ + Team: ap.Object{ID: "https://example.com/1"}, + }, + want: []byte(`{"team":{"id":"https://example.com/1"}}`), + }, + "with Team as slice of Objects": { + item: Repository{ + Team: ap.ItemCollection{ + ap.Object{ID: "https://example.com/1"}, + ap.Object{ID: "https://example.com/2"}, + }, + }, + want: []byte(`{"team":[{"id":"https://example.com/1"},{"id":"https://example.com/2"}]}`), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := tt.item.MarshalJSON() + if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() { + t.Errorf("MarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("MarshalJSON() got = %q, want %q", got, tt.want) + } + }) + } +} + +func Test_RepositoryUnmarshalJSON(t *testing.T) { + type testPair struct { + data []byte + want *Repository + wantErr error + } + + tests := map[string]testPair{ + "nil": { + data: nil, + wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")), + }, + "empty": { + data: []byte{}, + wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")), + }, + "with Type": { + data: []byte(`{"type":"Repository"}`), + want: &Repository{ + Actor: ap.Actor{ + Type: RepositoryType, + }, + }, + }, + "with Type and ID": { + data: []byte(`{"id":"https://example.com/1","type":"Repository"}`), + want: &Repository{ + Actor: ap.Actor{ + ID: "https://example.com/1", + Type: RepositoryType, + }, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := new(Repository) + err := got.UnmarshalJSON(tt.data) + if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() { + t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr) + return + } + if tt.want != nil && !reflect.DeepEqual(got, tt.want) { + jGot, _ := json.Marshal(got) + jWant, _ := json.Marshal(tt.want) + t.Errorf("UnmarshalJSON() got = %s, want %s", jGot, jWant) + } + }) + } +} -- 2.36.1