~mariusor/activitypub-go

Adding Repository MarshalJSON and UnmarshalJSON methods v1 PROPOSED

Marius Orcsik: 1
 Adding Repository MarshalJSON and UnmarshalJSON methods

 5 files changed, 576 insertions(+), 26 deletions(-)
Export patchset (mbox)
How do I use this?

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 -3
Learn more about email & git

[PATCH] Adding Repository MarshalJSON and UnmarshalJSON methods Export this patch

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