diff --git a/.build.yml b/.build.yml new file mode 100644 index 0000000..c5e57f1 --- /dev/null +++ b/.build.yml @@ -0,0 +1,15 @@ +image: archlinux +packages: + - go +sources: + - https://github.com/go-ap/activitypub +environment: + GO111MODULE: 'on' +tasks: + - tests: | + cd activitypub + make test + make TEST_TARGET=./tests test + - coverage: | + set -a +x + cd activitypub && make coverage diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42d9ac4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Gogland +.idea/ + +# Binaries for programs and plugins +*.so + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tools +*.out +*.coverprofile + +*pkg diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be7ff3e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Golang ActitvityPub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bacea87 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +SHELL := bash +.ONESHELL: +.SHELLFLAGS := -eu -o pipefail -c +.DELETE_ON_ERROR: +MAKEFLAGS += --warn-undefined-variables +MAKEFLAGS += --no-builtin-rules + +GO ?= go +TEST := $(GO) test +TEST_FLAGS ?= -v +TEST_TARGET ?= . +GO111MODULE = on +PROJECT_NAME := $(shell basename $(PWD)) + +.PHONY: test coverage clean download + +download: + $(GO) mod download all + $(GO) mod tidy + +test: download + $(TEST) $(TEST_FLAGS) $(TEST_TARGET) + +coverage: TEST_TARGET := . +coverage: TEST_FLAGS += -covermode=count -coverprofile $(PROJECT_NAME).coverprofile +coverage: test + +clean: + $(RM) -v *.coverprofile diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b611ef --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# About GoActivityPub: Vocabulary + +[![MIT Licensed](https://img.shields.io/github/license/go-ap/activitypub.svg)](https://raw.githubusercontent.com/go-ap/activitypub/master/LICENSE) +[![Build Status](https://builds.sr.ht/~mariusor/activitypub.svg)](https://builds.sr.ht/~mariusor/activitypub) +[![Test Coverage](https://img.shields.io/codecov/c/github/go-ap/activitypub.svg)](https://codecov.io/gh/go-ap/activitypub) +[![Go Report Card](https://goreportcard.com/badge/github.com/go-ap/activitypub)](https://goreportcard.com/report/github.com/go-ap/activitypub) + +This project is part of the [GoActivityPub](https://github.com/go-ap) library which helps with creating ActivityPub applications using the Go programming language. + +It contains data types for most of the [Activity Vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/) and the [ActivityPub](https://www.w3.org/TR/activitypub/) extension. +They are documented accordingly with annotations from these specifications. + +You can find an expanded documentation about the whole library [on SourceHut](https://man.sr.ht/~mariusor/go-activitypub/go-ap/index.md). + +For discussions about the projects you can write to the discussions mailing list: [~mariusor/go-activitypub-discuss@lists.sr.ht](mailto:~mariusor/go-activitypub-discuss@lists.sr.ht) + +For patches and bug reports please use the dev mailing list: [~mariusor/go-activitypub-dev@lists.sr.ht](mailto:~mariusor/go-activitypub-dev@lists.sr.ht) + +## Usage + +```go +import vocab "github.com/go-ap/activitypub" + +follow := vocab.Activity{ + Type: vocab.FollowType, + Actor: vocab.IRI("https://example.com/alice"), + Object: vocab.IRI("https://example.com/janedoe"), +} + +``` + +## Note about generics + +The module contains helper functions which make it simpler to deal with the `vocab.Item` +interfaces and they come in two flavours: explicit `OnXXX` and `ToXXX` functions corresponding +to each type and, a generic pair of functions `On[T]` and `To[T]`. + +```go +import ( + "fmt" + + vocab "github.com/go-ap/activitypub" +) + +var it vocab.Item = ... // an ActivityPub object unmarshaled from a request + +err := vocab.OnActivity(it, func(act *vocab.Activity) error { + if vocab.ContentManagementActivityTypes.Contains(act.Type) { + fmt.Printf("This is a Content Management type activity: %q", act.Type) + } + return nil +}) + +err := vocab.On[vocab.Activity](it, func(act *vocab.Activity) error { + if vocab.ReactionsActivityTypes.Contains(act.Type) { + fmt.Printf("This is a Reaction type activity: %q", act.Type) + } + return nil +}) + +``` + +Before using the generic versions you should consider that they come with a pretty heavy performance penalty: + +``` +goos: linux +goarch: amd64 +pkg: github.com/go-ap/activitypub +cpu: Intel(R) Core(TM) i7-6700K CPU @ 4.00GHz +Benchmark_OnT_vs_On_T/OnObject-8 752387791 1.633 ns/op +Benchmark_OnT_vs_On_T/On_T_Object-8 4656264 261.8 ns/op +Benchmark_OnT_vs_On_T/OnActor-8 739833261 1.596 ns/op +Benchmark_OnT_vs_On_T/On_T_Actor-8 4035148 301.9 ns/op +Benchmark_OnT_vs_On_T/OnActivity-8 751173854 1.604 ns/op +Benchmark_OnT_vs_On_T/On_T_Activity-8 4062598 285.9 ns/op +Benchmark_OnT_vs_On_T/OnIntransitiveActivity-8 675824500 1.640 ns/op +Benchmark_OnT_vs_On_T/On_T_IntransitiveActivity-8 4372798 274.1 ns/op +PASS +ok github.com/go-ap/activitypub 11.350s +``` diff --git a/activity.go b/activity.go new file mode 100644 index 0000000..126f000 --- /dev/null +++ b/activity.go @@ -0,0 +1,948 @@ +package activitypub + +import ( + "bytes" + "encoding/gob" + "fmt" + "io" + "reflect" + "strings" + "time" + + "github.com/valyala/fastjson" +) + +// Activity Types +const ( + AcceptType ActivityVocabularyType = "Accept" + AddType ActivityVocabularyType = "Add" + AnnounceType ActivityVocabularyType = "Announce" + ArriveType ActivityVocabularyType = "Arrive" + BlockType ActivityVocabularyType = "Block" + CreateType ActivityVocabularyType = "Create" + DeleteType ActivityVocabularyType = "Delete" + DislikeType ActivityVocabularyType = "Dislike" + FlagType ActivityVocabularyType = "Flag" + FollowType ActivityVocabularyType = "Follow" + IgnoreType ActivityVocabularyType = "Ignore" + InviteType ActivityVocabularyType = "Invite" + JoinType ActivityVocabularyType = "Join" + LeaveType ActivityVocabularyType = "Leave" + LikeType ActivityVocabularyType = "Like" + ListenType ActivityVocabularyType = "Listen" + MoveType ActivityVocabularyType = "Move" + OfferType ActivityVocabularyType = "Offer" + QuestionType ActivityVocabularyType = "Question" + RejectType ActivityVocabularyType = "Reject" + ReadType ActivityVocabularyType = "Read" + RemoveType ActivityVocabularyType = "Remove" + TentativeRejectType ActivityVocabularyType = "TentativeReject" + TentativeAcceptType ActivityVocabularyType = "TentativeAccept" + TravelType ActivityVocabularyType = "Travel" + UndoType ActivityVocabularyType = "Undo" + UpdateType ActivityVocabularyType = "Update" + ViewType ActivityVocabularyType = "View" +) + +func (a ActivityVocabularyTypes) Contains(typ ActivityVocabularyType) bool { + for _, v := range a { + if strings.EqualFold(string(v), string(typ)) { + return true + } + } + return false +} + +// ContentManagementActivityTypes use case primarily deals with activities that involve the creation, modification or +// deletion of content. +// +// https://www.w3.org/TR/activitystreams-vocabulary/#motivations-crud +// +// This includes, for instance, activities such as "John created a new note", "Sally updated an article", and +// "Joe deleted the photo". +var ContentManagementActivityTypes = ActivityVocabularyTypes{ + CreateType, + DeleteType, + UpdateType, +} + +// CollectionManagementActivityTypes use case primarily deals with activities involving the management of content within +// collections. +// +// https://www.w3.org/TR/activitystreams-vocabulary/#motivations-collection +// +// Examples of collections include things like folders, albums, friend lists, etc. +// This includes, for instance, activities such as "Sally added a file to Folder A", "John moved the file from Folder A +// to Folder B", etc. +var CollectionManagementActivityTypes = ActivityVocabularyTypes{ + AddType, + MoveType, + RemoveType, +} + +// ReactionsActivityTypes use case primarily deals with reactions to content. +// +// https://www.w3.org/TR/activitystreams-vocabulary/#motivations-reactions +// +// This can include activities such as liking or disliking content, ignoring updates, flagging content as being +// inappropriate, accepting or rejecting objects, etc. +var ReactionsActivityTypes = ActivityVocabularyTypes{ + AcceptType, + BlockType, + DislikeType, + FlagType, + IgnoreType, + LikeType, + RejectType, + TentativeAcceptType, + TentativeRejectType, +} + +// EventRSVPActivityTypes use case primarily deals with invitations to events and RSVP type responses. +// +// https://www.w3.org/TR/activitystreams-vocabulary/#motivations-rsvp +var EventRSVPActivityTypes = ActivityVocabularyTypes{ + AcceptType, + IgnoreType, + InviteType, + RejectType, + TentativeAcceptType, + TentativeRejectType, +} + +// GroupManagementActivityTypes use case primarily deals with management of groups. +// +// https://www.w3.org/TR/activitystreams-vocabulary/#motivations-group +// +// It can include, for instance, activities such as "John added Sally to Group A", "Sally joined Group A", +// "Joe left Group A", etc. +var GroupManagementActivityTypes = ActivityVocabularyTypes{ + AddType, + JoinType, + LeaveType, + RemoveType, +} + +// ContentExperienceActivityTypes use case primarily deals with describing activities involving listening to, reading, +// or viewing content. +// +// https://www.w3.org/TR/activitystreams-vocabulary/#motivations-experience +// +// For instance, "Sally read the article", "Joe listened to the song". +var ContentExperienceActivityTypes = ActivityVocabularyTypes{ + ListenType, + ReadType, + ViewType, +} + +// GeoSocialEventsActivityTypes use case primarily deals with activities involving geo-tagging type activities. +// +// https://www.w3.org/TR/activitystreams-vocabulary/#motivations-geo +// +// For instance, it can include activities such as "Joe arrived at work", "Sally left work", and +// "John is travel from home to work". +var GeoSocialEventsActivityTypes = ActivityVocabularyTypes{ + ArriveType, + LeaveType, + TravelType, +} + +// NotificationActivityTypes use case primarily deals with calling attention to particular objects or notifications. +// +// https://www.w3.org/TR/activitystreams-vocabulary/#motivations-notification +var NotificationActivityTypes = ActivityVocabularyTypes{ + AnnounceType, +} + +// QuestionActivityTypes use case primarily deals with representing inquiries of any type. +// +// https://www.w3.org/TR/activitystreams-vocabulary/#motivations-questions +// +// See 5.4 Representing Questions for more information. +// https://www.w3.org/TR/activitystreams-vocabulary/#questions +var QuestionActivityTypes = ActivityVocabularyTypes{ + QuestionType, +} + +// RelationshipManagementActivityTypes use case primarily deals with representing activities involving the management of +// interpersonal and social relationships +// +// https://www.w3.org/TR/activitystreams-vocabulary/#motivations-relationships +// +// (e.g. friend requests, management of social network, etc). See 5.2 Representing Relationships Between Entities +// for more information. +// https://www.w3.org/TR/activitystreams-vocabulary/#connections +var RelationshipManagementActivityTypes = ActivityVocabularyTypes{ + AcceptType, + AddType, + BlockType, + CreateType, + DeleteType, + FollowType, + IgnoreType, + InviteType, + RejectType, +} + +// NegatingActivityTypes use case primarily deals with the ability to redact previously completed activities. +// +// https://www.w3.org/TR/activitystreams-vocabulary/#motivations-undo +// +// See 5.5 Inverse Activities and "Undo" for more information. +// https://www.w3.org/TR/activitystreams-vocabulary/#inverse +var NegatingActivityTypes = ActivityVocabularyTypes{ + UndoType, +} + +// OffersActivityTypes use case deals with activities involving offering one object to another. +// +// https://www.w3.org/TR/activitystreams-vocabulary/#motivations-offers +// +// It can include, for instance, activities such as "Company A is offering a discount on purchase of Product Z to Sally", +// "Sally is offering to add a File to Folder A", etc. +var OffersActivityTypes = ActivityVocabularyTypes{ + OfferType, +} + +var IntransitiveActivityTypes = ActivityVocabularyTypes{ + ArriveType, + TravelType, + QuestionType, +} + +var ActivityTypes = ActivityVocabularyTypes{ + AcceptType, + AddType, + AnnounceType, + BlockType, + CreateType, + DeleteType, + DislikeType, + FlagType, + FollowType, + IgnoreType, + InviteType, + JoinType, + LeaveType, + LikeType, + ListenType, + MoveType, + OfferType, + RejectType, + ReadType, + RemoveType, + TentativeRejectType, + TentativeAcceptType, + UndoType, + UpdateType, + ViewType, +} + +// HasRecipients is an interface implemented by activities to return their audience +// for further propagation +// +// Please take care to the fact that the de-duplication functionality requires a pointer receiver +// therefore a valid Item interface that wraps around an Object struct, can not be type asserted +// to HasRecipients. +type HasRecipients interface { + // Recipients is a method that should do a recipients de-duplication step and then return + // the remaining recipients. + Recipients() ItemCollection + // Clean is a method that removes BCC/Bto recipients in preparation for public consumption of + // the Object. + Clean() +} + +type Activities interface { + Activity +} + +// Activity is a subtype of Object that describes some form of action that may happen, +// is currently happening, or has already happened. +// The Activity type itself serves as an abstract base type for all types of activities. +// It is important to note that the Activity type itself does not carry any specific semantics +// about the kind of action being taken. +type Activity struct { + // ID provides the globally unique identifier for anActivity Pub Object or Link. + ID ID `jsonld:"id,omitempty"` + // Type identifies the Activity Pub Object or Link type. Multiple values may be specified. + Type ActivityVocabularyType `jsonld:"type,omitempty"` + // Name a simple, human-readable, plain-text name for the object. + // HTML markup MUST NOT be included. The name MAY be expressed using multiple language-tagged values. + Name NaturalLanguageValues `jsonld:"name,omitempty,collapsible"` + // Attachment identifies a resource attached or related to an object that potentially requires special handling. + // The intent is to provide a model that is at least semantically similar to attachments in email. + Attachment Item `jsonld:"attachment,omitempty"` + // AttributedTo identifies one or more entities to which this object is attributed. The attributed entities might not be Actors. + // For instance, an object might be attributed to the completion of another activity. + AttributedTo Item `jsonld:"attributedTo,omitempty"` + // Audience identifies one or more entities that represent the total population of entities + // for which the object can considered to be relevant. + Audience ItemCollection `jsonld:"audience,omitempty"` + // Content or textual representation of the Activity Pub Object encoded as a JSON string. + // By default, the value of content is HTML. + // The mediaType property can be used in the object to indicate a different content type. + // (The content MAY be expressed using multiple language-tagged values.) + Content NaturalLanguageValues `jsonld:"content,omitempty,collapsible"` + // Context identifies the context within which the object exists or an activity was performed. + // The notion of "context" used is intentionally vague. + // The intended function is to serve as a means of grouping objects and activities that share a + // common originating context or purpose. An example could be all activities relating to a common project or event. + Context Item `jsonld:"context,omitempty"` + // MediaType when used on an Object, identifies the MIME media type of the value of the content property. + // If not specified, the content property is assumed to contain text/html content. + MediaType MimeType `jsonld:"mediaType,omitempty"` + // EndTime the date and time describing the actual or expected ending time of the object. + // When used with an Activity object, for instance, the endTime property specifies the moment + // the activity concluded or is expected to conclude. + EndTime time.Time `jsonld:"endTime,omitempty"` + // Generator identifies the entity (e.g. an application) that generated the object. + Generator Item `jsonld:"generator,omitempty"` + // Icon indicates an entity that describes an icon for this object. + // The image should have an aspect ratio of one (horizontal) to one (vertical) + // and should be suitable for presentation at a small size. + Icon Item `jsonld:"icon,omitempty"` + // Image indicates an entity that describes an image for this object. + // Unlike the icon property, there are no aspect ratio or display size limitations assumed. + Image Item `jsonld:"image,omitempty"` + // InReplyTo indicates one or more entities for which this object is considered a response. + InReplyTo Item `jsonld:"inReplyTo,omitempty"` + // Location indicates one or more physical or logical locations associated with the object. + Location Item `jsonld:"location,omitempty"` + // Preview identifies an entity that provides a preview of this object. + Preview Item `jsonld:"preview,omitempty"` + // Published the date and time at which the object was published + Published time.Time `jsonld:"published,omitempty"` + // Replies identifies a Collection containing objects considered to be responses to this object. + Replies Item `jsonld:"replies,omitempty"` + // StartTime the date and time describing the actual or expected starting time of the object. + // When used with an Activity object, for instance, the startTime property specifies + // the moment the activity began or is scheduled to begin. + StartTime time.Time `jsonld:"startTime,omitempty"` + // Summary a natural language summarization of the object encoded as HTML. + // *Multiple language tagged summaries may be provided.) + Summary NaturalLanguageValues `jsonld:"summary,omitempty,collapsible"` + // Tag one or more "tags" that have been associated with an objects. A tag can be any kind of Activity Pub Object. + // The key difference between attachment and tag is that the former implies association by inclusion, + // while the latter implies associated by reference. + Tag ItemCollection `jsonld:"tag,omitempty"` + // Updated the date and time at which the object was updated + Updated time.Time `jsonld:"updated,omitempty"` + // URL identifies one or more links to representations of the object + URL Item `jsonld:"url,omitempty"` + // To identifies an entity considered to be part of the public primary audience of an Activity Pub Object + To ItemCollection `jsonld:"to,omitempty"` + // Bto identifies anActivity Pub Object that is part of the private primary audience of this Activity Pub Object. + Bto ItemCollection `jsonld:"bto,omitempty"` + // CC identifies anActivity Pub Object that is part of the public secondary audience of this Activity Pub Object. + CC ItemCollection `jsonld:"cc,omitempty"` + // BCC identifies one or more Objects that are part of the private secondary audience of this Activity Pub Object. + BCC ItemCollection `jsonld:"bcc,omitempty"` + // Duration when the object describes a time-bound resource, such as an audio or video, a meeting, etc, + // the duration property indicates the object's approximate duration. + // The value must be expressed as an xsd:duration as defined by [ xmlschema11-2], + // section 3.3.6 (e.g. a period of 5 seconds is represented as "PT5S"). + Duration time.Duration `jsonld:"duration,omitempty"` + // This is a list of all Like activities with this object as the object property, added as a side effect. + // The likes collection MUST be either an OrderedCollection or a Collection and MAY be filtered on privileges + // of an authenticated user or as appropriate when no authentication is given. + Likes Item `jsonld:"likes,omitempty"` + // This is a list of all Announce activities with this object as the object property, added as a side effect. + // The shares collection MUST be either an OrderedCollection or a Collection and MAY be filtered on privileges + // of an authenticated user or as appropriate when no authentication is given. + Shares Item `jsonld:"shares,omitempty"` + // Source property is intended to convey some sort of source from which the content markup was derived, + // as a form of provenance, or to support future editing by clients. + // In general, clients do the conversion from source to content, not the other way around. + Source Source `jsonld:"source,omitempty"` + // CanReceiveActivities describes one or more entities that either performed or are expected to perform the activity. + // Any single activity can have multiple actors. The actor may be specified using an indirect Link. + Actor Item `jsonld:"actor,omitempty"` + // Target describes the indirect object, or target, of the activity. + // The precise meaning of the target is largely dependent on the type of action being described + // but will often be the object of the English preposition "to". + // For instance, in the activity "John added a movie to his wishlist", + // the target of the activity is John's wishlist. An activity can have more than one target. + Target Item `jsonld:"target,omitempty"` + // Result describes the result of the activity. For instance, if a particular action results in the creation + // of a new resource, the result property can be used to describe that new resource. + Result Item `jsonld:"result,omitempty"` + // Origin describes an indirect object of the activity from which the activity is directed. + // The precise meaning of the origin is the object of the English preposition "from". + // For instance, in the activity "John moved an item to List B from List A", the origin of the activity is "List A". + Origin Item `jsonld:"origin,omitempty"` + // Instrument identifies one or more objects used (or to be used) in the completion of an Activity. + Instrument Item `jsonld:"instrument,omitempty"` + // Object When used within an Activity, describes the direct object of the activity. + // For instance, in the activity "John added a movie to his wishlist", + // the object of the activity is the movie added. + // When used within a Relationship describes the entity to which the subject is related. + Object Item `jsonld:"object,omitempty"` +} + +// GetType returns the ActivityVocabulary type of the current Activity +func (a Activity) GetType() ActivityVocabularyType { + return a.Type +} + +// IsLink returns false for Activity objects +func (a Activity) IsLink() bool { + return false +} + +// GetID returns the ID corresponding to the Activity object +func (a Activity) GetID() ID { + return a.ID +} + +// GetLink returns the IRI corresponding to the Activity object +func (a Activity) GetLink() IRI { + return IRI(a.ID) +} + +// IsObject returns true for Activity objects +func (a Activity) IsObject() bool { + return true +} + +// IsCollection returns false for Activity objects +func (a Activity) IsCollection() bool { + return false +} + +func removeFromCollection(col ItemCollection, items ...Item) ItemCollection { + result := make(ItemCollection, 0) + if len(items) == 0 { + return col + } + for _, ob := range col { + found := false + for _, it := range items { + if ob.GetID().Equals(it.GetID(), false) { + found = true + break + } + } + if !found { + result = append(result, ob) + } + } + return result +} + +func removeFromAudience(a *Activity, items ...Item) error { + if a.To != nil { + a.To = removeFromCollection(a.To, items...) + } + if a.Bto != nil { + a.Bto = removeFromCollection(a.Bto, items...) + } + if a.CC != nil { + a.CC = removeFromCollection(a.CC, items...) + } + if a.BCC != nil { + a.BCC = removeFromCollection(a.BCC, items...) + } + if a.Audience != nil { + a.Audience = removeFromCollection(a.Audience, items...) + } + return nil +} + +// Recipients performs recipient de-duplication on the Activity's To, Bto, CC and BCC properties +func (a *Activity) Recipients() ItemCollection { + var alwaysRemove ItemCollection + if a.GetType() == BlockType && a.Object != nil { + alwaysRemove = append(alwaysRemove, a.Object) + } + if len(alwaysRemove) > 0 { + _ = removeFromAudience(a, alwaysRemove...) + } + aud := a.Audience + return ItemCollectionDeduplication(&a.To, &a.CC, &a.Bto, &a.BCC, &aud) +} + +// CleanRecipients checks if the "it" Item has recipients and cleans them if it does +func CleanRecipients(it Item) Item { + if IsNil(it) { + return nil + } + if s, ok := it.(HasRecipients); ok { + s.Clean() + } + return it +} + +// Clean removes Bto and BCC properties +func (a *Activity) Clean() { + _ = OnObject(a, func(o *Object) error { + o.Clean() + return nil + }) + CleanRecipients(a.Object) + CleanRecipients(a.Actor) + CleanRecipients(a.Target) +} + +type ( + // Accept indicates that the actor accepts the object. The target property can be used in certain circumstances to indicate + // the context into which the object has been accepted. + Accept = Activity + + // Add indicates that the actor has added the object to the target. If the target property is not explicitly specified, + // the target would need to be determined implicitly by context. + // The origin can be used to identify the context from which the object originated. + Add = Activity + + // Announce indicates that the actor is calling the target's attention the object. + // The origin typically has no defined meaning. + Announce = Activity + + // Block indicates that the actor is blocking the object. Blocking is a stronger form of Ignore. + // The typical use is to support social systems that allow one user to block activities or content of other users. + // The target and origin typically have no defined meaning. + Block = Ignore + + // Create indicates that the actor has created the object. + Create = Activity + + // Delete indicates that the actor has deleted the object. + // If specified, the origin indicates the context from which the object was deleted. + Delete = Activity + + // Dislike indicates that the actor dislikes the object. + Dislike = Activity + + // Flag indicates that the actor is "flagging" the object. + // Flagging is defined in the sense common to many social platforms as reporting content as being + // inappropriate for any number of reasons. + Flag = Activity + + // Follow indicates that the actor is "following" the object. Following is defined in the sense typically used within + // Social systems in which the actor is interested in any activity performed by or on the object. + // The target and origin typically have no defined meaning. + Follow = Activity + + // Ignore indicates that the actor is ignoring the object. The target and origin typically have no defined meaning. + Ignore = Activity + + // Invite is a specialization of Offer in which the actor is extending an invitation for the object to the target. + Invite = Offer + + // Join indicates that the actor has joined the object. The target and origin typically have no defined meaning. + Join = Activity + + // Leave indicates that the actor has left the object. The target and origin typically have no meaning. + Leave = Activity + + // Like indicates that the actor likes, recommends or endorses the object. + // The target and origin typically have no defined meaning. + Like = Activity + + // Listen inherits all properties from Activity. + Listen = Activity + + // Move indicates that the actor has moved object from origin to target. + // If the origin or target are not specified, either can be determined by context. + Move = Activity + + // Offer indicates that the actor is offering the object. + // If specified, the target indicates the entity to which the object is being offered. + Offer = Activity + + // Reject indicates that the actor is rejecting the object. The target and origin typically have no defined meaning. + Reject = Activity + + // Read indicates that the actor has read the object. + Read = Activity + + // Remove indicates that the actor is removing the object. If specified, + // the origin indicates the context from which the object is being removed. + Remove = Activity + + // TentativeReject is a specialization of Reject in which the rejection is considered tentative. + TentativeReject = Reject + + // TentativeAccept is a specialization of Accept indicating that the acceptance is tentative. + TentativeAccept = Accept + + // Undo indicates that the actor is undoing the object. In most cases, the object will be an Activity describing + // some previously performed action (for instance, a person may have previously "liked" an article but, + // for whatever reason, might choose to undo that like at some later point in time). + // The target and origin typically have no defined meaning. + Undo = Activity + + // Update indicates that the actor has updated the object. Note, however, that this vocabulary does not define a mechanism + // for describing the actual set of modifications made to object. + // The target and origin typically have no defined meaning. + Update = Activity + + // View indicates that the actor has viewed the object. + View = Activity +) + +// AcceptNew initializes an Accept activity +func AcceptNew(id ID, ob Item) *Accept { + a := ActivityNew(id, AcceptType, ob) + o := Accept(*a) + return &o +} + +// AddNew initializes an Add activity +func AddNew(id ID, ob, trgt Item) *Add { + a := ActivityNew(id, AddType, ob) + o := Add(*a) + o.Target = trgt + return &o +} + +// AnnounceNew initializes an Announce activity +func AnnounceNew(id ID, ob Item) *Announce { + a := ActivityNew(id, AnnounceType, ob) + o := Announce(*a) + return &o +} + +// BlockNew initializes a Block activity +func BlockNew(id ID, ob Item) *Block { + a := ActivityNew(id, BlockType, ob) + o := Block(*a) + return &o +} + +// CreateNew initializes a Create activity +func CreateNew(id ID, ob Item) *Create { + a := ActivityNew(id, CreateType, ob) + o := Create(*a) + return &o +} + +// DeleteNew initializes a Delete activity +func DeleteNew(id ID, ob Item) *Delete { + a := ActivityNew(id, DeleteType, ob) + o := Delete(*a) + return &o +} + +// DislikeNew initializes a Dislike activity +func DislikeNew(id ID, ob Item) *Dislike { + a := ActivityNew(id, DislikeType, ob) + o := Dislike(*a) + return &o +} + +// FlagNew initializes a Flag activity +func FlagNew(id ID, ob Item) *Flag { + a := ActivityNew(id, FlagType, ob) + o := Flag(*a) + return &o +} + +// FollowNew initializes a Follow activity +func FollowNew(id ID, ob Item) *Follow { + a := ActivityNew(id, FollowType, ob) + o := Follow(*a) + return &o +} + +// IgnoreNew initializes an Ignore activity +func IgnoreNew(id ID, ob Item) *Ignore { + a := ActivityNew(id, IgnoreType, ob) + o := Ignore(*a) + return &o +} + +// InviteNew initializes an Invite activity +func InviteNew(id ID, ob Item) *Invite { + a := ActivityNew(id, InviteType, ob) + o := Invite(*a) + return &o +} + +// JoinNew initializes a Join activity +func JoinNew(id ID, ob Item) *Join { + a := ActivityNew(id, JoinType, ob) + o := Join(*a) + return &o +} + +// LeaveNew initializes a Leave activity +func LeaveNew(id ID, ob Item) *Leave { + a := ActivityNew(id, LeaveType, ob) + o := Leave(*a) + return &o +} + +// LikeNew initializes a Like activity +func LikeNew(id ID, ob Item) *Like { + a := ActivityNew(id, LikeType, ob) + o := Like(*a) + return &o +} + +// ListenNew initializes a Listen activity +func ListenNew(id ID, ob Item) *Listen { + a := ActivityNew(id, ListenType, ob) + o := Listen(*a) + return &o +} + +// MoveNew initializes a Move activity +func MoveNew(id ID, ob Item) *Move { + a := ActivityNew(id, MoveType, ob) + o := Move(*a) + return &o +} + +// OfferNew initializes an Offer activity +func OfferNew(id ID, ob Item) *Offer { + a := ActivityNew(id, OfferType, ob) + o := Offer(*a) + return &o +} + +// RejectNew initializes a Reject activity +func RejectNew(id ID, ob Item) *Reject { + a := ActivityNew(id, RejectType, ob) + o := Reject(*a) + return &o +} + +// ReadNew initializes a Read activity +func ReadNew(id ID, ob Item) *Read { + a := ActivityNew(id, ReadType, ob) + o := Read(*a) + return &o +} + +// RemoveNew initializes a Remove activity +func RemoveNew(id ID, ob, trgt Item) *Remove { + a := ActivityNew(id, RemoveType, ob) + o := Remove(*a) + o.Target = trgt + return &o +} + +// TentativeRejectNew initializes a TentativeReject activity +func TentativeRejectNew(id ID, ob Item) *TentativeReject { + a := ActivityNew(id, TentativeRejectType, ob) + o := TentativeReject(*a) + return &o +} + +// TentativeAcceptNew initializes a TentativeAccept activity +func TentativeAcceptNew(id ID, ob Item) *TentativeAccept { + a := ActivityNew(id, TentativeAcceptType, ob) + o := TentativeAccept(*a) + return &o +} + +// UndoNew initializes an Undo activity +func UndoNew(id ID, ob Item) *Undo { + a := ActivityNew(id, UndoType, ob) + o := Undo(*a) + return &o +} + +// UpdateNew initializes an Update activity +func UpdateNew(id ID, ob Item) *Update { + a := ActivityNew(id, UpdateType, ob) + u := Update(*a) + return &u +} + +// ViewNew initializes a View activity +func ViewNew(id ID, ob Item) *View { + a := ActivityNew(id, ViewType, ob) + o := View(*a) + return &o +} + +// ActivityNew initializes a basic activity +func ActivityNew(id ID, typ ActivityVocabularyType, ob Item) *Activity { + if !ActivityTypes.Contains(typ) { + typ = ActivityType + } + a := Activity{ID: id, Type: typ} + a.Name = NaturalLanguageValuesNew() + a.Content = NaturalLanguageValuesNew() + + a.Object = ob + + return &a +} + +// UnmarshalJSON decodes an incoming JSON document into the receiver object. +func (a *Activity) UnmarshalJSON(data []byte) error { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + return JSONLoadActivity(val, a) +} + +func fmtActivityProps(w io.Writer) func(*Activity) error { + return func(a *Activity) error { + if !IsNil(a.Object) { + _, _ = fmt.Fprintf(w, " object: %s", a.Object) + } + return OnIntransitiveActivity(a, fmtIntransitiveActivityProps(w)) + } +} + +func (a Activity) Format(s fmt.State, verb rune) { + switch verb { + case 's': + if a.Type != "" && a.ID != "" { + _, _ = fmt.Fprintf(s, "%T[%s]( %s )", a, a.Type, a.ID) + } else if a.ID != "" { + _, _ = fmt.Fprintf(s, "%T( %s )", a, a.ID) + } else { + _, _ = fmt.Fprintf(s, "%T[%p]", a, &a) + } + case 'v': + _, _ = fmt.Fprintf(s, "%T[%s] {", a, a.Type) + fmtActivityProps(s)(&a) + _, _ = io.WriteString(s, " }") + } +} + +// ToActivity +func ToActivity(it Item) (*Activity, error) { + switch i := it.(type) { + case *Activity: + return i, nil + case Activity: + return &i, nil + default: + // NOTE(marius): this is an ugly way of dealing with the interface conversion error: types from different scopes + typ := reflect.TypeOf(new(Activity)) + if reflect.TypeOf(it).ConvertibleTo(typ) { + if i, ok := reflect.ValueOf(it).Convert(typ).Interface().(*Activity); ok { + return i, nil + } + } + } + return nil, ErrorInvalidType[Activity](it) +} + +// MarshalJSON encodes the receiver object to a JSON document. +func (a Activity) MarshalJSON() ([]byte, error) { + b := make([]byte, 0) + JSONWrite(&b, '{') + + if !JSONWriteActivityValue(&b, a) { + return nil, nil + } + JSONWrite(&b, '}') + return b, nil +} + +// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. +func (a *Activity) UnmarshalBinary(data []byte) error { + return a.GobDecode(data) +} + +// MarshalBinary implements the encoding.BinaryMarshaler interface. +func (a Activity) MarshalBinary() ([]byte, error) { + return a.GobEncode() +} + +func mapIntransitiveActivityProperties(mm map[string][]byte, a *IntransitiveActivity) (hasData bool, err error) { + err = OnObject(a, func(o *Object) error { + hasData, err = mapObjectProperties(mm, o) + return err + }) + if a.Actor != nil { + if mm["actor"], err = gobEncodeItem(a.Actor); err != nil { + return hasData, err + } + hasData = true + } + if a.Target != nil { + if mm["target"], err = gobEncodeItem(a.Target); err != nil { + return hasData, err + } + hasData = true + } + if a.Result != nil { + if mm["result"], err = gobEncodeItem(a.Result); err != nil { + return hasData, err + } + hasData = true + } + if a.Instrument != nil { + if mm["instrument"], err = gobEncodeItem(a.Instrument); err != nil { + return hasData, err + } + hasData = true + } + return hasData, err +} + +func mapActivityProperties(mm map[string][]byte, a *Activity) (hasData bool, err error) { + err = OnIntransitiveActivity(a, func(a *IntransitiveActivity) error { + hasData, err = mapIntransitiveActivityProperties(mm, a) + return err + }) + if a.Object != nil { + if mm["object"], err = gobEncodeItem(a.Object); err != nil { + return hasData, err + } + hasData = true + } + return hasData, err +} + +// GobEncode +func (a Activity) GobEncode() ([]byte, error) { + mm := make(map[string][]byte) + hasData, err := mapActivityProperties(mm, &a) + if err != nil { + return nil, err + } + if !hasData { + return []byte{}, nil + } + bb := bytes.Buffer{} + g := gob.NewEncoder(&bb) + if err := g.Encode(mm); err != nil { + return nil, err + } + return bb.Bytes(), nil +} + +// GobDecode +func (a *Activity) GobDecode(data []byte) error { + if len(data) == 0 { + return nil + } + mm, err := gobDecodeObjectAsMap(data) + if err != nil { + return err + } + return unmapActivityProperties(mm, a) +} + +// Equals verifies if our receiver Object is equals with the "with" Object +func (a Activity) Equals(with Item) bool { + result := true + err := OnActivity(with, func(w *Activity) error { + _ = OnIntransitiveActivity(a, func(oi *IntransitiveActivity) error { + result = oi.Equals(w) + return nil + }) + if w.Object != nil { + if !ItemsEqual(a.Object, w.Object) { + result = false + return nil + } + } + return nil + }) + if err != nil { + result = false + } + return result +} diff --git a/activity_test.go b/activity_test.go new file mode 100644 index 0000000..6af3d25 --- /dev/null +++ b/activity_test.go @@ -0,0 +1,1711 @@ +package activitypub + +import ( + "bytes" + "fmt" + "testing" + "time" +) + +func TestActivityNew(t *testing.T) { + testValue := ID("test") + var testType ActivityVocabularyType = "Accept" + + a := ActivityNew(testValue, testType, nil) + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != testType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, testType) + } + + g := ActivityNew(testValue, "", nil) + + if g.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", g.ID, testValue) + } + if g.Type != ActivityType { + t.Errorf("Activity Type '%v' different than expected '%v'", g.Type, ActivityType) + } +} + +func TestAcceptNew(t *testing.T) { + testValue := ID("test") + + a := AcceptNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != AcceptType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, AcceptType) + } +} + +func TestAddNew(t *testing.T) { + testValue := ID("test") + + a := AddNew(testValue, nil, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != AddType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, AddType) + } +} + +func TestAnnounceNew(t *testing.T) { + testValue := ID("test") + + a := AnnounceNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != AnnounceType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, AnnounceType) + } +} + +func TestBlockNew(t *testing.T) { + testValue := ID("test") + + a := BlockNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != BlockType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, BlockType) + } +} + +func TestCreateNew(t *testing.T) { + testValue := ID("test") + + a := CreateNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != CreateType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, CreateType) + } +} + +func TestDeleteNew(t *testing.T) { + testValue := ID("test") + + a := DeleteNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != DeleteType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, DeleteType) + } +} + +func TestDislikeNew(t *testing.T) { + testValue := ID("test") + + a := DislikeNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != DislikeType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, DislikeType) + } +} + +func TestFlagNew(t *testing.T) { + testValue := ID("test") + + a := FlagNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != FlagType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, FlagType) + } +} + +func TestFollowNew(t *testing.T) { + testValue := ID("test") + + a := FollowNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != FollowType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, FollowType) + } +} + +func TestIgnoreNew(t *testing.T) { + testValue := ID("test") + + a := IgnoreNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != IgnoreType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, IgnoreType) + } +} + +func TestInviteNew(t *testing.T) { + testValue := ID("test") + + a := InviteNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != InviteType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, InviteType) + } +} + +func TestJoinNew(t *testing.T) { + testValue := ID("test") + + a := JoinNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != JoinType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, JoinType) + } +} + +func TestLeaveNew(t *testing.T) { + testValue := ID("test") + + a := LeaveNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != LeaveType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, LeaveType) + } +} + +func TestLikeNew(t *testing.T) { + testValue := ID("test") + + a := LikeNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != LikeType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, LikeType) + } +} + +func TestListenNew(t *testing.T) { + testValue := ID("test") + + a := ListenNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != ListenType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, ListenType) + } +} + +func TestMoveNew(t *testing.T) { + testValue := ID("test") + + a := MoveNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != MoveType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, MoveType) + } +} + +func TestOfferNew(t *testing.T) { + testValue := ID("test") + + a := OfferNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != OfferType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, OfferType) + } +} + +func TestRejectNew(t *testing.T) { + testValue := ID("test") + + a := RejectNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != RejectType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, RejectType) + } +} + +func TestReadNew(t *testing.T) { + testValue := ID("test") + + a := ReadNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != ReadType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, ReadType) + } +} + +func TestRemoveNew(t *testing.T) { + testValue := ID("test") + + a := RemoveNew(testValue, nil, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != RemoveType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, RemoveType) + } +} + +func TestTentativeRejectNew(t *testing.T) { + testValue := ID("test") + + a := TentativeRejectNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != TentativeRejectType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, TentativeRejectType) + } +} + +func TestTentativeAcceptNew(t *testing.T) { + testValue := ID("test") + + a := TentativeAcceptNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != TentativeAcceptType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, TentativeAcceptType) + } +} + +func TestUndoNew(t *testing.T) { + testValue := ID("test") + + a := UndoNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != UndoType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, UndoType) + } +} + +func TestUpdateNew(t *testing.T) { + testValue := ID("test") + + a := UpdateNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != UpdateType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, UpdateType) + } +} + +func TestViewNew(t *testing.T) { + testValue := ID("test") + + a := ViewNew(testValue, nil) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != ViewType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, ViewType) + } +} + +func TestActivityRecipients(t *testing.T) { + bob := PersonNew("bob") + alice := PersonNew("alice") + foo := OrganizationNew("foo") + bar := GroupNew("bar") + + a := ActivityNew("t", "test", nil) + + a.To.Append(bob) + a.To.Append(alice) + a.To.Append(foo) + a.To.Append(bar) + if len(a.To) != 4 { + t.Errorf("%T.To should have exactly 4(four) elements, not %d", a, len(a.To)) + } + + a.To.Append(bar) + a.To.Append(alice) + a.To.Append(foo) + a.To.Append(bob) + if len(a.To) != 4 { + t.Errorf("%T.To should still have exactly 4(eight) elements, not %d", a, len(a.To)) + } + + a.Recipients() + if len(a.To) != 4 { + t.Errorf("%T.To should have exactly 4(four) elements, not %d", a, len(a.To)) + } + + b := ActivityNew("t", "test", nil) + + b.To.Append(bar) + b.To.Append(alice) + b.To.Append(foo) + b.To.Append(bob) + b.Bto.Append(bar) + b.Bto.Append(alice) + b.Bto.Append(foo) + b.Bto.Append(bob) + b.CC.Append(bar) + b.CC.Append(alice) + b.CC.Append(foo) + b.CC.Append(bob) + b.BCC.Append(bar) + b.BCC.Append(alice) + b.BCC.Append(foo) + b.BCC.Append(bob) + + b.Recipients() + if len(b.To) != 4 { + t.Errorf("%T.To should have exactly 4(four) elements, not %d", b, len(b.To)) + } + if len(b.Bto) != 0 { + t.Errorf("%T.Bto should have exactly 0(zero) elements, not %d", b, len(b.Bto)) + } + if len(b.CC) != 0 { + t.Errorf("%T.CC should have exactly 0(zero) elements, not %d", b, len(b.CC)) + } + if len(b.BCC) != 0 { + t.Errorf("%T.BCC should have exactly 0(zero) elements, not %d", b, len(b.BCC)) + } +} + +func TestBlockRecipients(t *testing.T) { + bob := PersonNew("bob") + alice := PersonNew("alice") + foo := OrganizationNew("foo") + bar := GroupNew("bar") + + a := BlockNew("bbb", bob) + + a.To.Append(bob) + a.To.Append(alice) + a.To.Append(foo) + a.To.Append(bar) + if len(a.To) != 4 { + t.Errorf("%T.To should have exactly 4(four) elements, not %d", a, len(a.To)) + } + + a.To.Append(bar) + a.To.Append(alice) + a.To.Append(foo) + a.To.Append(bob) + if len(a.To) != 4 { + t.Errorf("%T.To should still have exactly 4(eight) elements, not %d", a, len(a.To)) + } + + a.Recipients() + if len(a.To) != 3 { + t.Errorf("%T.To should have exactly 3(three) elements, not %d", a, len(a.To)) + } + + b := BlockNew("t", bob) + + b.To.Append(bar) + b.To.Append(alice) + b.To.Append(foo) + b.To.Append(bob) + b.Bto.Append(bar) + b.Bto.Append(alice) + b.Bto.Append(foo) + b.Bto.Append(bob) + b.CC.Append(bar) + b.CC.Append(alice) + b.CC.Append(foo) + b.CC.Append(bob) + b.BCC.Append(bar) + b.BCC.Append(alice) + b.BCC.Append(foo) + b.BCC.Append(bob) + + b.Recipients() + if len(b.To) != 3 { + t.Errorf("%T.To should have exactly 3(three) elements, not %d", b, len(b.To)) + } + if len(b.Bto) != 0 { + t.Errorf("%T.Bto should have exactly 0(zero) elements, not %d", b, len(b.Bto)) + } + if len(b.CC) != 0 { + t.Errorf("%T.CC should have exactly 0(zero) elements, not %d", b, len(b.CC)) + } + if len(b.BCC) != 0 { + t.Errorf("%T.BCC should have exactly 0(zero) elements, not %d", b, len(b.BCC)) + } + var err error + recIds := make([]ID, 0) + err = checkDedup(b.To, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(b.Bto, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(b.CC, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(b.BCC, &recIds) + if err != nil { + t.Error(err) + } +} + +func TestCreate_Recipients(t *testing.T) { + to := PersonNew("bob") + o := ObjectNew(ArticleType) + cc := PersonNew("alice") + + o.ID = "something" + + c := CreateNew("act", o) + c.To.Append(to) + c.CC.Append(cc) + c.BCC.Append(cc) + + c.Recipients() + + var err error + recIds := make([]ID, 0) + err = checkDedup(c.To, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(c.Bto, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(c.CC, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(c.BCC, &recIds) + if err != nil { + t.Error(err) + } +} + +func TestDislike_Recipients(t *testing.T) { + to := PersonNew("bob") + o := ObjectNew(ArticleType) + cc := PersonNew("alice") + + o.ID = "something" + + d := DislikeNew("act", o) + d.To.Append(to) + d.CC.Append(cc) + d.BCC.Append(cc) + + d.Recipients() + + var err error + recIds := make([]ID, 0) + err = checkDedup(d.To, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(d.Bto, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(d.CC, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(d.BCC, &recIds) + if err != nil { + t.Error(err) + } +} + +func TestLike_Recipients(t *testing.T) { + to := PersonNew("bob") + o := ObjectNew(ArticleType) + cc := PersonNew("alice") + + o.ID = "something" + + l := LikeNew("act", o) + l.To.Append(to) + l.CC.Append(cc) + l.BCC.Append(cc) + + l.Recipients() + + var err error + recIds := make([]ID, 0) + err = checkDedup(l.To, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(l.Bto, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(l.CC, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(l.BCC, &recIds) + if err != nil { + t.Error(err) + } +} + +func TestUpdate_Recipients(t *testing.T) { + to := PersonNew("bob") + o := ObjectNew(ArticleType) + cc := PersonNew("alice") + + o.ID = "something" + + u := UpdateNew("act", o) + u.To.Append(to) + u.CC.Append(cc) + u.BCC.Append(cc) + + u.Recipients() + + var err error + recIds := make([]ID, 0) + err = checkDedup(u.To, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(u.Bto, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(u.CC, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(u.BCC, &recIds) + if err != nil { + t.Error(err) + } +} + +func TestActivity_GetID(t *testing.T) { + a := ActivityNew("test", ActivityType, Person{}) + + if a.GetID() != "test" { + t.Errorf("%T should return an empty %T object. Received %#v", a, a.GetID(), a.GetID()) + } +} + +func TestActivity_GetIDGetType(t *testing.T) { + a := ActivityNew("test", ActivityType, Person{}) + + if a.GetID() != "test" || a.GetType() != ActivityType { + t.Errorf("%T should not return an empty %T object. Received %#v", a, a.GetID(), a.GetID()) + } +} + +func TestActivity_IsLink(t *testing.T) { + a := ActivityNew("test", ActivityType, Person{}) + + if a.IsLink() { + t.Errorf("%T should not respond true to IsLink", a) + } +} + +func TestActivity_IsObject(t *testing.T) { + a := ActivityNew("test", ActivityType, Person{}) + + if !a.IsObject() { + t.Errorf("%T should respond true to IsObject", a) + } +} + +func checkDedup(list ItemCollection, recIds *[]ID) error { + for _, rec := range list { + for _, id := range *recIds { + if rec.GetID() == id { + return fmt.Errorf("%T[%s] already stored in recipients list, Deduplication faild", rec, id) + } + } + *recIds = append(*recIds, rec.GetID()) + } + return nil +} + +func TestActivity_Recipients(t *testing.T) { + to := PersonNew("bob") + o := ObjectNew(ArticleType) + cc := PersonNew("alice") + + o.ID = "something" + + c := ActivityNew("act", ActivityType, o) + c.To.Append(to) + c.CC.Append(cc) + c.BCC.Append(cc) + + c.Recipients() + + var err error + recIds := make([]ID, 0) + err = checkDedup(c.To, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(c.Bto, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(c.CC, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(c.BCC, &recIds) + if err != nil { + t.Error(err) + } +} + +func TestBlock_Recipients(t *testing.T) { + to := PersonNew("bob") + o := ObjectNew(ArticleType) + cc := PersonNew("alice") + + o.ID = "something" + + b := BlockNew("act", o) + b.To.Append(to) + b.CC.Append(cc) + b.BCC.Append(cc) + + b.Recipients() + + var err error + recIds := make([]ID, 0) + err = checkDedup(b.To, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(b.Bto, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(b.CC, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(b.BCC, &recIds) + if err != nil { + t.Error(err) + } +} + +func TestActivity_UnmarshalJSON(t *testing.T) { + a := Activity{} + + dataEmpty := []byte("{}") + a.UnmarshalJSON(dataEmpty) + if a.ID != "" { + t.Errorf("Unmarshaled object %T should have empty ID, received %q", a, a.ID) + } + if a.Type != "" { + t.Errorf("Unmarshaled object %T should have empty Type, received %q", a, a.Type) + } + if a.AttributedTo != nil { + t.Errorf("Unmarshaled object %T should have empty AttributedTo, received %q", a, a.AttributedTo) + } + if len(a.Name) != 0 { + t.Errorf("Unmarshaled object %T should have empty Name, received %q", a, a.Name) + } + if len(a.Summary) != 0 { + t.Errorf("Unmarshaled object %T should have empty Summary, received %q", a, a.Summary) + } + if len(a.Content) != 0 { + t.Errorf("Unmarshaled object %T should have empty Content, received %q", a, a.Content) + } + if a.URL != nil { + t.Errorf("Unmarshaled object %T should have empty URL, received %v", a, a.URL) + } + if !a.Published.IsZero() { + t.Errorf("Unmarshaled object %T should have empty Published, received %q", a, a.Published) + } + if !a.StartTime.IsZero() { + t.Errorf("Unmarshaled object %T should have empty StartTime, received %q", a, a.StartTime) + } + if !a.Updated.IsZero() { + t.Errorf("Unmarshaled object %T should have empty Updated, received %q", a, a.Updated) + } +} + +func TestCreate_UnmarshalJSON(t *testing.T) { + c := Create{} + + dataEmpty := []byte("{}") + c.UnmarshalJSON(dataEmpty) + if c.ID != "" { + t.Errorf("Unmarshaled object %T should have empty ID, received %q", c, c.ID) + } + if c.Type != "" { + t.Errorf("Unmarshaled object %T should have empty Type, received %q", c, c.Type) + } + if c.AttributedTo != nil { + t.Errorf("Unmarshaled object %T should have empty AttributedTo, received %q", c, c.AttributedTo) + } + if len(c.Name) != 0 { + t.Errorf("Unmarshaled object %T should have empty Name, received %q", c, c.Name) + } + if len(c.Summary) != 0 { + t.Errorf("Unmarshaled object %T should have empty Summary, received %q", c, c.Summary) + } + if len(c.Content) != 0 { + t.Errorf("Unmarshaled object %T should have empty Content, received %q", c, c.Content) + } + if c.URL != nil { + t.Errorf("Unmarshaled object %T should have empty URL, received %v", c, c.URL) + } + if !c.Published.IsZero() { + t.Errorf("Unmarshaled object %T should have empty Published, received %q", c, c.Published) + } + if !c.StartTime.IsZero() { + t.Errorf("Unmarshaled object %T should have empty StartTime, received %q", c, c.StartTime) + } + if !c.Updated.IsZero() { + t.Errorf("Unmarshaled object %T should have empty Updated, received %q", c, c.Updated) + } +} + +func TestDislike_UnmarshalJSON(t *testing.T) { + d := Dislike{} + + dataEmpty := []byte("{}") + d.UnmarshalJSON(dataEmpty) + if d.ID != "" { + t.Errorf("Unmarshaled object %T should have empty ID, received %q", d, d.ID) + } + if d.Type != "" { + t.Errorf("Unmarshaled object %T should have empty Type, received %q", d, d.Type) + } + if d.AttributedTo != nil { + t.Errorf("Unmarshaled object %T should have empty AttributedTo, received %q", d, d.AttributedTo) + } + if len(d.Name) != 0 { + t.Errorf("Unmarshaled object %T should have empty Name, received %q", d, d.Name) + } + if len(d.Summary) != 0 { + t.Errorf("Unmarshaled object %T should have empty Summary, received %q", d, d.Summary) + } + if len(d.Content) != 0 { + t.Errorf("Unmarshaled object %T should have empty Content, received %q", d, d.Content) + } + if d.URL != nil { + t.Errorf("Unmarshaled object %T should have empty URL, received %v", d, d.URL) + } + if !d.Published.IsZero() { + t.Errorf("Unmarshaled object %T should have empty Published, received %q", d, d.Published) + } + if !d.StartTime.IsZero() { + t.Errorf("Unmarshaled object %T should have empty StartTime, received %q", d, d.StartTime) + } + if !d.Updated.IsZero() { + t.Errorf("Unmarshaled object %T should have empty Updated, received %q", d, d.Updated) + } +} + +func TestLike_UnmarshalJSON(t *testing.T) { + l := Like{} + + dataEmpty := []byte("{}") + l.UnmarshalJSON(dataEmpty) + if l.ID != "" { + t.Errorf("Unmarshaled object %T should have empty ID, received %q", l, l.ID) + } + if l.Type != "" { + t.Errorf("Unmarshaled object %T should have empty Type, received %q", l, l.Type) + } + if l.AttributedTo != nil { + t.Errorf("Unmarshaled object %T should have empty AttributedTo, received %q", l, l.AttributedTo) + } + if len(l.Name) != 0 { + t.Errorf("Unmarshaled object %T should have empty Name, received %q", l, l.Name) + } + if len(l.Summary) != 0 { + t.Errorf("Unmarshaled object %T should have empty Summary, received %q", l, l.Summary) + } + if len(l.Content) != 0 { + t.Errorf("Unmarshaled object %T should have empty Content, received %q", l, l.Content) + } + if l.URL != nil { + t.Errorf("Unmarshaled object %T should have empty URL, received %v", l, l.URL) + } + if !l.Published.IsZero() { + t.Errorf("Unmarshaled object %T should have empty Published, received %q", l, l.Published) + } + if !l.StartTime.IsZero() { + t.Errorf("Unmarshaled object %T should have empty StartTime, received %q", l, l.StartTime) + } + if !l.Updated.IsZero() { + t.Errorf("Unmarshaled object %T should have empty Updated, received %q", l, l.Updated) + } +} + +func TestUpdate_UnmarshalJSON(t *testing.T) { + u := Update{} + + dataEmpty := []byte("{}") + u.UnmarshalJSON(dataEmpty) + if u.ID != "" { + t.Errorf("Unmarshaled object %T should have empty ID, received %q", u, u.ID) + } + if u.Type != "" { + t.Errorf("Unmarshaled object %T should have empty Type, received %q", u, u.Type) + } + if u.AttributedTo != nil { + t.Errorf("Unmarshaled object %T should have empty AttributedTo, received %q", u, u.AttributedTo) + } + if len(u.Name) != 0 { + t.Errorf("Unmarshaled object %T should have empty Name, received %q", u, u.Name) + } + if len(u.Summary) != 0 { + t.Errorf("Unmarshaled object %T should have empty Summary, received %q", u, u.Summary) + } + if len(u.Content) != 0 { + t.Errorf("Unmarshaled object %T should have empty Content, received %q", u, u.Content) + } + if u.URL != nil { + t.Errorf("Unmarshaled object %T should have empty URL, received %v", u, u.URL) + } + if !u.Published.IsZero() { + t.Errorf("Unmarshaled object %T should have empty Published, received %q", u, u.Published) + } + if !u.StartTime.IsZero() { + t.Errorf("Unmarshaled object %T should have empty StartTime, received %q", u, u.StartTime) + } + if !u.Updated.IsZero() { + t.Errorf("Unmarshaled object %T should have empty Updated, received %q", u, u.Updated) + } +} + +func TestToActivity(t *testing.T) { + var it Item + act := ActivityNew(ID("test"), CreateType, nil) + it = act + + a, err := ToActivity(it) + if err != nil { + t.Error(err) + } + if a != act { + t.Errorf("Invalid activity returned by ToActivity #%v", a) + } + + ob := ObjectNew(ArticleType) + it = ob + + o, err := ToActivity(it) + if err == nil { + t.Errorf("Error returned when calling ToActivity with object should not be nil") + } + if o != nil { + t.Errorf("Invalid return by ToActivity #%v, should have been nil", o) + } +} + +func TestValidEventRSVPActivityType(t *testing.T) { + t.Skipf("TODO") +} + +func TestValidGroupManagementActivityType(t *testing.T) { + t.Skipf("TODO") +} + +func TestActivity_Clean(t *testing.T) { + t.Skipf("TODO") +} + +func TestActivity_IsCollection(t *testing.T) { + t.Skipf("TODO") +} + +func TestActivity_GetLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestActivity_GetType(t *testing.T) { + t.Skipf("TODO") +} + +func TestActivity_MarshalJSON(t *testing.T) { + type fields struct { + ID ID + Type ActivityVocabularyType + Name NaturalLanguageValues + Attachment Item + AttributedTo Item + Audience ItemCollection + Content NaturalLanguageValues + Context Item + MediaType MimeType + EndTime time.Time + Generator Item + Icon Item + Image Item + InReplyTo Item + Location Item + Preview Item + Published time.Time + Replies Item + StartTime time.Time + Summary NaturalLanguageValues + Tag ItemCollection + Updated time.Time + URL Item + To ItemCollection + Bto ItemCollection + CC ItemCollection + BCC ItemCollection + Duration time.Duration + Likes Item + Shares Item + Source Source + Actor Item + Target Item + Result Item + Origin Item + Instrument Item + Object Item + } + tests := []struct { + name string + fields fields + want []byte + wantErr bool + }{ + { + name: "Empty", + fields: fields{}, + want: nil, + wantErr: false, + }, + { + name: "JustID", + fields: fields{ + ID: ID("example.com"), + }, + want: []byte(`{"id":"example.com"}`), + wantErr: false, + }, + { + name: "JustType", + fields: fields{ + Type: ActivityVocabularyType("myType"), + }, + want: []byte(`{"type":"myType"}`), + wantErr: false, + }, + { + name: "JustOneName", + fields: fields{ + Name: NaturalLanguageValues{ + {Ref: NilLangRef, Value: Content("ana")}, + }, + }, + want: []byte(`{"name":"ana"}`), + wantErr: false, + }, + { + name: "MoreNames", + fields: fields{ + Name: NaturalLanguageValues{ + {Ref: "en", Value: Content("anna")}, + {Ref: "fr", Value: Content("anne")}, + }, + }, + want: []byte(`{"nameMap":{"en":"anna","fr":"anne"}}`), + wantErr: false, + }, + { + name: "JustOneSummary", + fields: fields{ + Summary: NaturalLanguageValues{ + {Ref: NilLangRef, Value: Content("test summary")}, + }, + }, + want: []byte(`{"summary":"test summary"}`), + wantErr: false, + }, + { + name: "MoreSummaryEntries", + fields: fields{ + Summary: NaturalLanguageValues{ + {Ref: "en", Value: Content("test summary")}, + {Ref: "fr", Value: Content("teste summary")}, + }, + }, + want: []byte(`{"summaryMap":{"en":"test summary","fr":"teste summary"}}`), + wantErr: false, + }, + { + name: "JustOneContent", + fields: fields{ + Content: NaturalLanguageValues{ + {Ref: NilLangRef, Value: Content("test content")}, + }, + }, + want: []byte(`{"content":"test content"}`), + wantErr: false, + }, + { + name: "MoreContentEntries", + fields: fields{ + Content: NaturalLanguageValues{ + {Ref: "en", Value: Content("test content")}, + {Ref: "fr", Value: Content("teste content")}, + }, + }, + want: []byte(`{"contentMap":{"en":"test content","fr":"teste content"}}`), + wantErr: false, + }, + { + name: "MediaType", + fields: fields{ + MediaType: MimeType("text/stupid"), + }, + want: []byte(`{"mediaType":"text/stupid"}`), + wantErr: false, + }, + { + name: "Attachment", + fields: fields{ + Attachment: &Object{ + ID: "some example", + Type: VideoType, + }, + }, + want: []byte(`{"attachment":{"id":"some example","type":"Video"}}`), + wantErr: false, + }, + { + name: "AttributedTo", + fields: fields{ + AttributedTo: &Actor{ + ID: "http://example.com/ana", + Type: PersonType, + }, + }, + want: []byte(`{"attributedTo":{"id":"http://example.com/ana","type":"Person"}}`), + wantErr: false, + }, + { + name: "AttributedToDouble", + fields: fields{ + AttributedTo: ItemCollection{ + &Actor{ + ID: "http://example.com/ana", + Type: PersonType, + }, + &Actor{ + ID: "http://example.com/GGG", + Type: GroupType, + }, + }, + }, + want: []byte(`{"attributedTo":[{"id":"http://example.com/ana","type":"Person"},{"id":"http://example.com/GGG","type":"Group"}]}`), + wantErr: false, + }, + { + name: "Source", + fields: fields{ + Source: Source{ + MediaType: MimeType("text/plain"), + Content: NaturalLanguageValues{}, + }, + }, + want: []byte(`{"source":{"mediaType":"text/plain"}}`), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := Activity{ + ID: tt.fields.ID, + Type: tt.fields.Type, + Name: tt.fields.Name, + Attachment: tt.fields.Attachment, + AttributedTo: tt.fields.AttributedTo, + Audience: tt.fields.Audience, + Content: tt.fields.Content, + Context: tt.fields.Context, + MediaType: tt.fields.MediaType, + EndTime: tt.fields.EndTime, + Generator: tt.fields.Generator, + Icon: tt.fields.Icon, + Image: tt.fields.Image, + InReplyTo: tt.fields.InReplyTo, + Location: tt.fields.Location, + Preview: tt.fields.Preview, + Published: tt.fields.Published, + Replies: tt.fields.Replies, + StartTime: tt.fields.StartTime, + Summary: tt.fields.Summary, + Tag: tt.fields.Tag, + Updated: tt.fields.Updated, + URL: tt.fields.URL, + To: tt.fields.To, + Bto: tt.fields.Bto, + CC: tt.fields.CC, + BCC: tt.fields.BCC, + Duration: tt.fields.Duration, + Likes: tt.fields.Likes, + Shares: tt.fields.Shares, + Source: tt.fields.Source, + Actor: tt.fields.Actor, + Target: tt.fields.Target, + Result: tt.fields.Result, + Origin: tt.fields.Origin, + Instrument: tt.fields.Instrument, + Object: tt.fields.Object, + } + got, err := a.MarshalJSON() + if (err != nil) != tt.wantErr { + t.Errorf("MarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !bytes.Equal(got, tt.want) { + t.Errorf("MarshalJSON() got = %s, want %s", got, tt.want) + } + }) + } +} + +func TestIntransitiveActivity_MarshalJSON(t *testing.T) { + type fields struct { + ID ID + Type ActivityVocabularyType + Name NaturalLanguageValues + Attachment Item + AttributedTo Item + Audience ItemCollection + Content NaturalLanguageValues + Context Item + MediaType MimeType + EndTime time.Time + Generator Item + Icon Item + Image Item + InReplyTo Item + Location Item + Preview Item + Published time.Time + Replies Item + StartTime time.Time + Summary NaturalLanguageValues + Tag ItemCollection + Updated time.Time + URL Item + To ItemCollection + Bto ItemCollection + CC ItemCollection + BCC ItemCollection + Duration time.Duration + Likes Item + Shares Item + Source Source + Actor CanReceiveActivities + Target Item + Result Item + Origin Item + Instrument Item + } + tests := []struct { + name string + fields fields + want []byte + wantErr bool + }{ + { + name: "Empty", + fields: fields{}, + want: nil, + wantErr: false, + }, + { + name: "JustID", + fields: fields{ + ID: ID("example.com"), + }, + want: []byte(`{"id":"example.com"}`), + wantErr: false, + }, + { + name: "JustType", + fields: fields{ + Type: ActivityVocabularyType("myType"), + }, + want: []byte(`{"type":"myType"}`), + wantErr: false, + }, + { + name: "JustOneName", + fields: fields{ + Name: NaturalLanguageValues{ + {Ref: NilLangRef, Value: Content("ana")}, + }, + }, + want: []byte(`{"name":"ana"}`), + wantErr: false, + }, + { + name: "MoreNames", + fields: fields{ + Name: NaturalLanguageValues{ + {Ref: "en", Value: Content("anna")}, + {Ref: "fr", Value: Content("anne")}, + }, + }, + want: []byte(`{"nameMap":{"en":"anna","fr":"anne"}}`), + wantErr: false, + }, + { + name: "JustOneSummary", + fields: fields{ + Summary: NaturalLanguageValues{ + {Ref: NilLangRef, Value: Content("test summary")}, + }, + }, + want: []byte(`{"summary":"test summary"}`), + wantErr: false, + }, + { + name: "MoreSummaryEntries", + fields: fields{ + Summary: NaturalLanguageValues{ + {Ref: "en", Value: Content("test summary")}, + {Ref: "fr", Value: Content("teste summary")}, + }, + }, + want: []byte(`{"summaryMap":{"en":"test summary","fr":"teste summary"}}`), + wantErr: false, + }, + { + name: "JustOneContent", + fields: fields{ + Content: NaturalLanguageValues{ + {Ref: NilLangRef, Value: Content("test content")}, + }, + }, + want: []byte(`{"content":"test content"}`), + wantErr: false, + }, + { + name: "MoreContentEntries", + fields: fields{ + Content: NaturalLanguageValues{ + {Ref: "en", Value: Content("test content")}, + {Ref: "fr", Value: Content("teste content")}, + }, + }, + want: []byte(`{"contentMap":{"en":"test content","fr":"teste content"}}`), + wantErr: false, + }, + { + name: "MediaType", + fields: fields{ + MediaType: MimeType("text/stupid"), + }, + want: []byte(`{"mediaType":"text/stupid"}`), + wantErr: false, + }, + { + name: "Attachment", + fields: fields{ + Attachment: &Object{ + ID: "some example", + Type: VideoType, + }, + }, + want: []byte(`{"attachment":{"id":"some example","type":"Video"}}`), + wantErr: false, + }, + { + name: "AttributedTo", + fields: fields{ + AttributedTo: &Actor{ + ID: "http://example.com/ana", + Type: PersonType, + }, + }, + want: []byte(`{"attributedTo":{"id":"http://example.com/ana","type":"Person"}}`), + wantErr: false, + }, + { + name: "AttributedToDouble", + fields: fields{ + AttributedTo: ItemCollection{ + &Actor{ + ID: "http://example.com/ana", + Type: PersonType, + }, + &Actor{ + ID: "http://example.com/GGG", + Type: GroupType, + }, + }, + }, + want: []byte(`{"attributedTo":[{"id":"http://example.com/ana","type":"Person"},{"id":"http://example.com/GGG","type":"Group"}]}`), + wantErr: false, + }, + { + name: "Source", + fields: fields{ + Source: Source{ + MediaType: MimeType("text/plain"), + Content: NaturalLanguageValues{}, + }, + }, + want: []byte(`{"source":{"mediaType":"text/plain"}}`), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + i := IntransitiveActivity{ + ID: tt.fields.ID, + Type: tt.fields.Type, + Name: tt.fields.Name, + Attachment: tt.fields.Attachment, + AttributedTo: tt.fields.AttributedTo, + Audience: tt.fields.Audience, + Content: tt.fields.Content, + Context: tt.fields.Context, + MediaType: tt.fields.MediaType, + EndTime: tt.fields.EndTime, + Generator: tt.fields.Generator, + Icon: tt.fields.Icon, + Image: tt.fields.Image, + InReplyTo: tt.fields.InReplyTo, + Location: tt.fields.Location, + Preview: tt.fields.Preview, + Published: tt.fields.Published, + Replies: tt.fields.Replies, + StartTime: tt.fields.StartTime, + Summary: tt.fields.Summary, + Tag: tt.fields.Tag, + Updated: tt.fields.Updated, + URL: tt.fields.URL, + To: tt.fields.To, + Bto: tt.fields.Bto, + CC: tt.fields.CC, + BCC: tt.fields.BCC, + Duration: tt.fields.Duration, + Likes: tt.fields.Likes, + Shares: tt.fields.Shares, + Source: tt.fields.Source, + Actor: tt.fields.Actor, + Target: tt.fields.Target, + Result: tt.fields.Result, + Origin: tt.fields.Origin, + Instrument: tt.fields.Instrument, + } + got, err := i.MarshalJSON() + if (err != nil) != tt.wantErr { + t.Errorf("MarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !bytes.Equal(got, tt.want) { + t.Errorf("MarshalJSON() got = %s, want %s", got, tt.want) + } + }) + } +} + +func TestActivity_Equals(t *testing.T) { + type fields struct { + ID ID + Type ActivityVocabularyType + Name NaturalLanguageValues + Attachment Item + AttributedTo Item + Audience ItemCollection + Content NaturalLanguageValues + Context Item + MediaType MimeType + EndTime time.Time + Generator Item + Icon Item + Image Item + InReplyTo Item + Location Item + Preview Item + Published time.Time + Replies Item + StartTime time.Time + Summary NaturalLanguageValues + Tag ItemCollection + Updated time.Time + URL Item + To ItemCollection + Bto ItemCollection + CC ItemCollection + BCC ItemCollection + Duration time.Duration + Likes Item + Shares Item + Source Source + Actor Item + Target Item + Result Item + Origin Item + Instrument Item + Object Item + } + tests := []struct { + name string + fields fields + arg Item + want bool + }{ + { + name: "equal-empty-activity", + fields: fields{}, + arg: Activity{}, + want: true, + }, + { + name: "equal-activity-just-id", + fields: fields{ID: "test"}, + arg: Activity{ID: "test"}, + want: true, + }, + { + name: "equal-activity-id", + fields: fields{ID: "test", URL: IRI("example.com")}, + arg: Activity{ID: "test"}, + want: true, + }, + { + name: "equal-false-with-id-and-url", + fields: fields{ID: "test"}, + arg: Activity{ID: "test", URL: IRI("example.com")}, + want: false, + }, + { + name: "not a valid activity", + fields: fields{ID: "http://example.com"}, + arg: Link{ID: "http://example.com"}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := Activity{ + ID: tt.fields.ID, + Type: tt.fields.Type, + Name: tt.fields.Name, + Attachment: tt.fields.Attachment, + AttributedTo: tt.fields.AttributedTo, + Audience: tt.fields.Audience, + Content: tt.fields.Content, + Context: tt.fields.Context, + MediaType: tt.fields.MediaType, + EndTime: tt.fields.EndTime, + Generator: tt.fields.Generator, + Icon: tt.fields.Icon, + Image: tt.fields.Image, + InReplyTo: tt.fields.InReplyTo, + Location: tt.fields.Location, + Preview: tt.fields.Preview, + Published: tt.fields.Published, + Replies: tt.fields.Replies, + StartTime: tt.fields.StartTime, + Summary: tt.fields.Summary, + Tag: tt.fields.Tag, + Updated: tt.fields.Updated, + URL: tt.fields.URL, + To: tt.fields.To, + Bto: tt.fields.Bto, + CC: tt.fields.CC, + BCC: tt.fields.BCC, + Duration: tt.fields.Duration, + Likes: tt.fields.Likes, + Shares: tt.fields.Shares, + Source: tt.fields.Source, + Actor: tt.fields.Actor, + Target: tt.fields.Target, + Result: tt.fields.Result, + Origin: tt.fields.Origin, + Instrument: tt.fields.Instrument, + Object: tt.fields.Object, + } + if got := a.Equals(tt.arg); got != tt.want { + t.Errorf("Equals() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCleanRecipients(t *testing.T) { + tests := []struct { + name string + it Item + }{ + { + name: "nil", + it: nil, + }, + { + name: "empty Object", + it: &Object{}, + }, + { + name: "Object with Bto", + it: &Object{Bto: ItemCollection{IRI("https://example.com")}}, + }, + { + name: "Object with BCC", + it: &Object{BCC: ItemCollection{IRI("https://example.com")}}, + }, + { + name: "Object with Bto/BCC", + it: &Object{ + Bto: ItemCollection{IRI("https://example.com/1")}, + BCC: ItemCollection{IRI("https://example.com/2")}, + }, + }, + { + name: "empty Actor", + it: &Actor{}, + }, + { + name: "Actor with Bto", + it: &Actor{Bto: ItemCollection{IRI("https://example.com")}}, + }, + { + name: "Actor with BCC", + it: &Actor{BCC: ItemCollection{IRI("https://example.com")}}, + }, + { + name: "Actor with Bto/BCC", + it: &Actor{ + Bto: ItemCollection{IRI("https://example.com/1")}, + BCC: ItemCollection{IRI("https://example.com/2")}, + }, + }, + { + name: "empty Activity", + it: &Activity{}, + }, + { + name: "Activity with Bto", + it: &Activity{Bto: ItemCollection{IRI("https://example.com")}}, + }, + { + name: "Activity with BCC", + it: &Activity{BCC: ItemCollection{IRI("https://example.com")}}, + }, + { + name: "Activity with Bto/BCC", + it: &Activity{ + Bto: ItemCollection{IRI("https://example.com/1")}, + BCC: ItemCollection{IRI("https://example.com/2")}, + }, + }, + { + name: "empty IntransitiveActivity", + it: &IntransitiveActivity{}, + }, + { + name: "IntransitiveActivity with Bto", + it: &IntransitiveActivity{Bto: ItemCollection{IRI("https://example.com")}}, + }, + { + name: "IntransitiveActivity with BCC", + it: &IntransitiveActivity{BCC: ItemCollection{IRI("https://example.com")}}, + }, + { + name: "IntransitiveActivity with Bto/BCC", + it: &IntransitiveActivity{ + Bto: ItemCollection{IRI("https://example.com/1")}, + BCC: ItemCollection{IRI("https://example.com/2")}, + }, + }, + { + name: "empty Collection", + it: &Collection{}, + }, + { + name: "Collection with Bto", + it: &Collection{Bto: ItemCollection{IRI("https://example.com")}}, + }, + { + name: "Collection with BCC", + it: &Collection{BCC: ItemCollection{IRI("https://example.com")}}, + }, + { + name: "Collection with Bto/BCC", + it: &Collection{ + Bto: ItemCollection{IRI("https://example.com/1")}, + BCC: ItemCollection{IRI("https://example.com/2")}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + it := CleanRecipients(tt.it) + _ = OnObject(it, func(o *Object) error { + if len(o.Bto) > 0 { + t.Errorf("Bto failed to be cleaned: %v", o.Bto) + } + if len(o.BCC) > 0 { + t.Errorf("BCC failed to be cleaned: %v", o.BCC) + } + return nil + }) + }) + } +} diff --git a/actor.go b/actor.go new file mode 100644 index 0000000..fe97be9 --- /dev/null +++ b/actor.go @@ -0,0 +1,615 @@ +package activitypub + +import ( + "bytes" + "encoding/gob" + "encoding/json" + "fmt" + "time" + + "github.com/valyala/fastjson" +) + +// CanReceiveActivities Types +const ( + ApplicationType ActivityVocabularyType = "Application" + GroupType ActivityVocabularyType = "Group" + OrganizationType ActivityVocabularyType = "Organization" + PersonType ActivityVocabularyType = "Person" + ServiceType ActivityVocabularyType = "Service" +) + +// ActorTypes represent the valid Actor types. +var ActorTypes = ActivityVocabularyTypes{ + ApplicationType, + GroupType, + OrganizationType, + PersonType, + ServiceType, +} + +// CanReceiveActivities is generally one of the ActivityStreams Actor Types, but they don't have to be. +// For example, a Profile object might be used as an actor, or a type from an ActivityStreams extension. +// Actors are retrieved like any other Object in ActivityPub. +// Like other ActivityStreams objects, actors have an id, which is a URI. +type CanReceiveActivities Item + +type Actors interface { + Actor +} + +// Actor is generally one of the ActivityStreams actor Types, but they don't have to be. +// For example, a Profile object might be used as an actor, or a type from an ActivityStreams extension. +// Actors are retrieved like any other Object in ActivityPub. +// Like other ActivityStreams objects, actors have an id, which is a URI. +type Actor struct { + // ID provides the globally unique identifier for anActivity Pub Object or Link. + ID ID `jsonld:"id,omitempty"` + // Type identifies the Activity Pub Object or Link type. Multiple values may be specified. + Type ActivityVocabularyType `jsonld:"type,omitempty"` + // Name a simple, human-readable, plain-text name for the object. + // HTML markup MUST NOT be included. The name MAY be expressed using multiple language-tagged values. + Name NaturalLanguageValues `jsonld:"name,omitempty,collapsible"` + // Attachment identifies a resource attached or related to an object that potentially requires special handling. + // The intent is to provide a model that is at least semantically similar to attachments in email. + Attachment Item `jsonld:"attachment,omitempty"` + // AttributedTo identifies one or more entities to which this object is attributed. The attributed entities might not be Actors. + // For instance, an object might be attributed to the completion of another activity. + AttributedTo Item `jsonld:"attributedTo,omitempty"` + // Audience identifies one or more entities that represent the total population of entities + // for which the object can considered to be relevant. + Audience ItemCollection `jsonld:"audience,omitempty"` + // Content or textual representation of the Activity Pub Object encoded as a JSON string. + // By default, the value of content is HTML. + // The mediaType property can be used in the object to indicate a different content type. + // (The content MAY be expressed using multiple language-tagged values.) + Content NaturalLanguageValues `jsonld:"content,omitempty,collapsible"` + // Context identifies the context within which the object exists or an activity was performed. + // The notion of "context" used is intentionally vague. + // The intended function is to serve as a means of grouping objects and activities that share a + // common originating context or purpose. An example could be all activities relating to a common project or event. + Context Item `jsonld:"context,omitempty"` + // MediaType when used on an Object, identifies the MIME media type of the value of the content property. + // If not specified, the content property is assumed to contain text/html content. + MediaType MimeType `jsonld:"mediaType,omitempty"` + // EndTime the date and time describing the actual or expected ending time of the object. + // When used with an Activity object, for instance, the endTime property specifies the moment + // the activity concluded or is expected to conclude. + EndTime time.Time `jsonld:"endTime,omitempty"` + // Generator identifies the entity (e.g. an application) that generated the object. + Generator Item `jsonld:"generator,omitempty"` + // Icon indicates an entity that describes an icon for this object. + // The image should have an aspect ratio of one (horizontal) to one (vertical) + // and should be suitable for presentation at a small size. + Icon Item `jsonld:"icon,omitempty"` + // Image indicates an entity that describes an image for this object. + // Unlike the icon property, there are no aspect ratio or display size limitations assumed. + Image Item `jsonld:"image,omitempty"` + // InReplyTo indicates one or more entities for which this object is considered a response. + InReplyTo Item `jsonld:"inReplyTo,omitempty"` + // Location indicates one or more physical or logical locations associated with the object. + Location Item `jsonld:"location,omitempty"` + // Preview identifies an entity that provides a preview of this object. + Preview Item `jsonld:"preview,omitempty"` + // Published the date and time at which the object was published + Published time.Time `jsonld:"published,omitempty"` + // Replies identifies a Collection containing objects considered to be responses to this object. + Replies Item `jsonld:"replies,omitempty"` + // StartTime the date and time describing the actual or expected starting time of the object. + // When used with an Activity object, for instance, the startTime property specifies + // the moment the activity began or is scheduled to begin. + StartTime time.Time `jsonld:"startTime,omitempty"` + // Summary a natural language summarization of the object encoded as HTML. + // *Multiple language tagged summaries may be provided.) + Summary NaturalLanguageValues `jsonld:"summary,omitempty,collapsible"` + // Tag one or more "tags" that have been associated with an objects. A tag can be any kind of Activity Pub Object. + // The key difference between attachment and tag is that the former implies association by inclusion, + // while the latter implies associated by reference. + Tag ItemCollection `jsonld:"tag,omitempty"` + // Updated the date and time at which the object was updated + Updated time.Time `jsonld:"updated,omitempty"` + // URL identifies one or more links to representations of the object + URL Item `jsonld:"url,omitempty"` + // To identifies an entity considered to be part of the public primary audience of an Activity Pub Object + To ItemCollection `jsonld:"to,omitempty"` + // Bto identifies anActivity Pub Object that is part of the private primary audience of this Activity Pub Object. + Bto ItemCollection `jsonld:"bto,omitempty"` + // CC identifies anActivity Pub Object that is part of the public secondary audience of this Activity Pub Object. + CC ItemCollection `jsonld:"cc,omitempty"` + // BCC identifies one or more Objects that are part of the private secondary audience of this Activity Pub Object. + BCC ItemCollection `jsonld:"bcc,omitempty"` + // Duration when the object describes a time-bound resource, such as an audio or video, a meeting, etc, + // the duration property indicates the object's approximate duration. + // The value must be expressed as an xsd:duration as defined by [ xmlschema11-2], + // section 3.3.6 (e.g. a period of 5 seconds is represented as "PT5S"). + Duration time.Duration `jsonld:"duration,omitempty"` + // This is a list of all Like activities with this object as the object property, added as a side effect. + // The likes collection MUST be either an OrderedCollection or a Collection and MAY be filtered on privileges + // of an authenticated user or as appropriate when no authentication is given. + Likes Item `jsonld:"likes,omitempty"` + // This is a list of all Announce activities with this object as the object property, added as a side effect. + // The shares collection MUST be either an OrderedCollection or a Collection and MAY be filtered on privileges + // of an authenticated user or as appropriate when no authentication is given. + Shares Item `jsonld:"shares,omitempty"` + // Source property is intended to convey some sort of source from which the content markup was derived, + // as a form of provenance, or to support future editing by clients. + // In general, clients do the conversion from source to content, not the other way around. + Source Source `jsonld:"source,omitempty"` + // A reference to an [ActivityStreams] OrderedCollection comprised of all the messages received by the actor; + // see 5.2 Inbox. + Inbox Item `jsonld:"inbox,omitempty"` + // An [ActivityStreams] OrderedCollection comprised of all the messages produced by the actor; + // see 5.1 outbox. + Outbox Item `jsonld:"outbox,omitempty"` + // A link to an [ActivityStreams] collection of the actors that this actor is following; + // see 5.4 Following Collection + Following Item `jsonld:"following,omitempty"` + // A link to an [ActivityStreams] collection of the actors that follow this actor; + // see 5.3 Followers Collection. + Followers Item `jsonld:"followers,omitempty"` + // A link to an [ActivityStreams] collection of objects this actor has liked; + // see 5.5 Liked Collection. + Liked Item `jsonld:"liked,omitempty"` + // A short username which may be used to refer to the actor, with no uniqueness guarantees. + PreferredUsername NaturalLanguageValues `jsonld:"preferredUsername,omitempty,collapsible"` + // A json object which maps additional (typically server/domain-wide) endpoints which may be useful either + // for this actor or someone referencing this actor. + // This mapping may be nested inside the actor document as the value or may be a link + // to a JSON-LD document with these properties. + Endpoints *Endpoints `jsonld:"endpoints,omitempty"` + // A list of supplementary Collections which may be of interest. + Streams ItemCollection `jsonld:"streams,omitempty"` + PublicKey PublicKey `jsonld:"publicKey,omitempty"` +} + +// GetID returns the ID corresponding to the current Actor +func (a Actor) GetID() ID { + return a.ID +} + +// GetLink returns the IRI corresponding to the current Actor +func (a Actor) GetLink() IRI { + return IRI(a.ID) +} + +// GetType returns the type of the current Actor +func (a Actor) GetType() ActivityVocabularyType { + return a.Type +} + +// IsLink validates if currentActivity Pub Actor is a Link +func (a Actor) IsLink() bool { + return false +} + +// IsObject validates if currentActivity Pub Actor is an Object +func (a Actor) IsObject() bool { + return true +} + +// IsCollection returns false for Actor Objects +func (a Actor) IsCollection() bool { + return false +} + +// PublicKey holds the ActivityPub compatible public key data +// The document reference can be found at: +// https://w3c-ccg.github.io/security-vocab/#publicKey +type PublicKey struct { + ID ID `jsonld:"id,omitempty"` + Owner IRI `jsonld:"owner,omitempty"` + PublicKeyPem string `jsonld:"publicKeyPem,omitempty"` +} + +func (p *PublicKey) UnmarshalJSON(data []byte) error { + par := fastjson.Parser{} + val, err := par.ParseBytes(data) + if err != nil { + return err + } + + return JSONLoadPublicKey(val, p) +} + +func (p PublicKey) MarshalJSON() ([]byte, error) { + b := make([]byte, 0) + notEmpty := true + JSONWrite(&b, '{') + if v, err := p.ID.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = !JSONWriteProp(&b, "id", v) + } + if len(p.Owner) > 0 { + notEmpty = JSONWriteIRIProp(&b, "owner", p.Owner) || notEmpty + } + if len(p.PublicKeyPem) > 0 { + if pem, err := json.Marshal(p.PublicKeyPem); err == nil { + notEmpty = JSONWriteProp(&b, "publicKeyPem", pem) || notEmpty + } + } + + if notEmpty { + JSONWrite(&b, '}') + return b, nil + } + return nil, nil +} + +// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. +func (a *Actor) UnmarshalBinary(data []byte) error { + return a.GobDecode(data) +} + +// MarshalBinary implements the encoding.BinaryMarshaler interface. +func (a Actor) MarshalBinary() ([]byte, error) { + return a.GobEncode() +} + +func (a Actor) GobEncode() ([]byte, error) { + mm := make(map[string][]byte) + hasData, err := mapActorProperties(mm, &a) + if err != nil { + return nil, err + } + if !hasData { + return []byte{}, nil + } + bb := bytes.Buffer{} + g := gob.NewEncoder(&bb) + if err := g.Encode(mm); err != nil { + return nil, err + } + return bb.Bytes(), nil +} + +func (a *Actor) GobDecode(data []byte) error { + if len(data) == 0 { + return nil + } + mm, err := gobDecodeObjectAsMap(data) + if err != nil { + return err + } + return unmapActorProperties(mm, a) +} + +type ( + // Application describes a software application. + Application = Actor + + // Group represents a formal or informal collective of Actors. + Group = Actor + + // Organization represents an organization. + Organization = Actor + + // Person represents an individual person. + Person = Actor + + // Service represents a service of any kind. + Service = Actor +) + +// ActorNew initializes an CanReceiveActivities type actor +func ActorNew(id ID, typ ActivityVocabularyType) *Actor { + if !ActorTypes.Contains(typ) { + typ = ActorType + } + + a := Actor{ID: id, Type: typ} + a.Name = NaturalLanguageValuesNew() + a.Content = NaturalLanguageValuesNew() + a.Summary = NaturalLanguageValuesNew() + a.PreferredUsername = NaturalLanguageValuesNew() + + return &a +} + +// ApplicationNew initializes an Application type actor +func ApplicationNew(id ID) *Application { + a := ActorNew(id, ApplicationType) + o := Application(*a) + return &o +} + +// GroupNew initializes a Group type actor +func GroupNew(id ID) *Group { + a := ActorNew(id, GroupType) + o := Group(*a) + return &o +} + +// OrganizationNew initializes an Organization type actor +func OrganizationNew(id ID) *Organization { + a := ActorNew(id, OrganizationType) + o := Organization(*a) + return &o +} + +// PersonNew initializes a Person type actor +func PersonNew(id ID) *Person { + a := ActorNew(id, PersonType) + o := Person(*a) + return &o +} + +// ServiceNew initializes a Service type actor +func ServiceNew(id ID) *Service { + a := ActorNew(id, ServiceType) + o := Service(*a) + return &o +} + +func (a *Actor) Recipients() ItemCollection { + aud := a.Audience + return ItemCollectionDeduplication(&a.To, &a.CC, &a.Bto, &a.BCC, &aud) +} + +func (a *Actor) Clean() { + _ = OnObject(a, func(o *Object) error { + o.Clean() + return nil + }) +} + +func (a *Actor) UnmarshalJSON(data []byte) error { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + return JSONLoadActor(val, a) +} + +func (a Actor) MarshalJSON() ([]byte, error) { + b := make([]byte, 0) + notEmpty := false + JSONWrite(&b, '{') + + OnObject(a, func(o *Object) error { + notEmpty = JSONWriteObjectValue(&b, *o) + return nil + }) + if a.Inbox != nil { + notEmpty = JSONWriteItemProp(&b, "inbox", a.Inbox) || notEmpty + } + if a.Outbox != nil { + notEmpty = JSONWriteItemProp(&b, "outbox", a.Outbox) || notEmpty + } + if a.Following != nil { + notEmpty = JSONWriteItemProp(&b, "following", a.Following) || notEmpty + } + if a.Followers != nil { + notEmpty = JSONWriteItemProp(&b, "followers", a.Followers) || notEmpty + } + if a.Liked != nil { + notEmpty = JSONWriteItemProp(&b, "liked", a.Liked) || notEmpty + } + if a.PreferredUsername != nil { + notEmpty = JSONWriteNaturalLanguageProp(&b, "preferredUsername", a.PreferredUsername) || notEmpty + } + if a.Endpoints != nil { + if v, err := a.Endpoints.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = JSONWriteProp(&b, "endpoints", v) || notEmpty + } + } + if len(a.Streams) > 0 { + notEmpty = JSONWriteItemCollectionProp(&b, "streams", a.Streams, false) + } + if len(a.PublicKey.PublicKeyPem)+len(a.PublicKey.ID) > 0 { + if v, err := a.PublicKey.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = JSONWriteProp(&b, "publicKey", v) || notEmpty + } + } + + if notEmpty { + JSONWrite(&b, '}') + return b, nil + } + return nil, nil +} + +func (a Actor) Format(s fmt.State, verb rune) { + switch verb { + case 's': + if a.Type != "" && a.ID != "" { + _, _ = fmt.Fprintf(s, "%T[%s]( %s )", a, a.Type, a.ID) + } else if a.ID != "" { + _, _ = fmt.Fprintf(s, "%T( %s )", a, a.ID) + } else { + _, _ = fmt.Fprintf(s, "%T[%p]", a, &a) + } + case 'v': + _, _ = fmt.Fprintf(s, "%T[%s] { }", a, a.Type) + } +} + +// Endpoints a json object which maps additional (typically server/domain-wide) +// endpoints which may be useful either for this actor or someone referencing this actor. +// This mapping may be nested inside the actor document as the value or may be a link to +// a JSON-LD document with these properties. +type Endpoints struct { + // UploadMedia Upload endpoint URI for this user for binary data. + UploadMedia Item `jsonld:"uploadMedia,omitempty"` + // OauthAuthorizationEndpoint Endpoint URI so this actor's clients may access remote ActivityStreams objects which require authentication + // to access. To use this endpoint, the client posts an x-www-form-urlencoded id parameter with the value being + // the id of the requested ActivityStreams object. + OauthAuthorizationEndpoint Item `jsonld:"oauthAuthorizationEndpoint,omitempty"` + // OauthTokenEndpoint If OAuth 2.0 bearer tokens [RFC6749] [RFC6750] are being used for authenticating client to server interactions, + // this endpoint specifies a URI at which a browser-authenticated user may obtain a new authorization grant. + OauthTokenEndpoint Item `jsonld:"oauthTokenEndpoint,omitempty"` + // ProvideClientKey If OAuth 2.0 bearer tokens [RFC6749] [RFC6750] are being used for authenticating client to server interactions, + // this endpoint specifies a URI at which a client may acquire an access token. + ProvideClientKey Item `jsonld:"provideClientKey,omitempty"` + // SignClientKey If Linked Data Signatures and HTTP Signatures are being used for authentication and authorization, + // this endpoint specifies a URI at which browser-authenticated users may authorize a client's public + // key for client to server interactions. + SignClientKey Item `jsonld:"signClientKey,omitempty"` + // SharedInbox An optional endpoint used for wide delivery of publicly addressed activities and activities sent to followers. + // SharedInbox endpoints SHOULD also be publicly readable OrderedCollection objects containing objects addressed to the + // Public special collection. Reading from the sharedInbox endpoint MUST NOT present objects which are not addressed to the Public endpoint. + SharedInbox Item `jsonld:"sharedInbox,omitempty"` +} + +// UnmarshalJSON decodes an incoming JSON document into the receiver object. +func (e *Endpoints) UnmarshalJSON(data []byte) error { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + e.OauthAuthorizationEndpoint = JSONGetItem(val, "oauthAuthorizationEndpoint") + e.OauthTokenEndpoint = JSONGetItem(val, "oauthTokenEndpoint") + e.UploadMedia = JSONGetItem(val, "uploadMedia") + e.ProvideClientKey = JSONGetItem(val, "provideClientKey") + e.SignClientKey = JSONGetItem(val, "signClientKey") + e.SharedInbox = JSONGetItem(val, "sharedInbox") + return nil +} + +// MarshalJSON encodes the receiver object to a JSON document. +func (e Endpoints) MarshalJSON() ([]byte, error) { + b := make([]byte, 0) + notEmpty := false + + JSONWrite(&b, '{') + if e.OauthAuthorizationEndpoint != nil { + notEmpty = JSONWriteItemProp(&b, "oauthAuthorizationEndpoint", e.OauthAuthorizationEndpoint) || notEmpty + } + if e.OauthTokenEndpoint != nil { + notEmpty = JSONWriteItemProp(&b, "oauthTokenEndpoint", e.OauthTokenEndpoint) || notEmpty + } + if e.ProvideClientKey != nil { + notEmpty = JSONWriteItemProp(&b, "provideClientKey", e.ProvideClientKey) || notEmpty + } + if e.SignClientKey != nil { + notEmpty = JSONWriteItemProp(&b, "signClientKey", e.SignClientKey) || notEmpty + } + if e.SharedInbox != nil { + notEmpty = JSONWriteItemProp(&b, "sharedInbox", e.SharedInbox) || notEmpty + } + if e.UploadMedia != nil { + notEmpty = JSONWriteItemProp(&b, "uploadMedia", e.UploadMedia) || notEmpty + } + if notEmpty { + JSONWrite(&b, '}') + return b, nil + } + return nil, nil +} + +// ToActor +func ToActor(it Item) (*Actor, error) { + switch i := it.(type) { + case *Actor: + return i, nil + case Actor: + return &i, nil + default: + return reflectItemToType[Actor](it) + } +} + +// Equals verifies if our receiver Object is equals with the "with" Object +func (a Actor) Equals(with Item) bool { + result := true + err := OnActor(with, func(w *Actor) error { + _ = OnObject(a, func(oa *Object) error { + result = oa.Equals(w) + return nil + }) + if w.Inbox != nil { + if !ItemsEqual(a.Inbox, w.Inbox) { + result = false + return nil + } + } + if w.Outbox != nil { + if !ItemsEqual(a.Outbox, w.Outbox) { + result = false + return nil + } + } + if w.Liked != nil { + if !ItemsEqual(a.Liked, w.Liked) { + result = false + return nil + } + } + if w.PreferredUsername != nil { + if !a.PreferredUsername.Equals(w.PreferredUsername) { + result = false + return nil + } + } + return nil + }) + if err != nil { + result = false + } + return result +} + +func (e Endpoints) GobEncode() ([]byte, error) { + return nil, nil +} + +func (e *Endpoints) GobDecode(data []byte) error { + return nil +} + +func (p PublicKey) GobEncode() ([]byte, error) { + var ( + mm = make(map[string][]byte) + err error + hasData bool + ) + if len(p.ID) > 0 { + if mm["id"], err = p.ID.GobEncode(); err != nil { + return nil, err + } + hasData = true + } + if len(p.PublicKeyPem) > 0 { + mm["publicKeyPem"] = []byte(p.PublicKeyPem) + hasData = true + } + if len(p.Owner) > 0 { + if mm["owner"], err = gobEncodeItem(p.Owner); err != nil { + return nil, err + } + hasData = true + } + if !hasData { + return []byte{}, nil + } + bb := bytes.Buffer{} + g := gob.NewEncoder(&bb) + if err := g.Encode(mm); err != nil { + return nil, err + } + return bb.Bytes(), nil +} + +func (p *PublicKey) GobDecode(data []byte) error { + if len(data) == 0 { + return nil + } + mm, err := gobDecodeObjectAsMap(data) + if err != nil { + return err + } + if raw, ok := mm["id"]; ok { + if err = p.ID.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["owner"]; ok { + if err = p.Owner.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["publicKeyPem"]; ok { + p.PublicKeyPem = string(raw) + } + return nil +} diff --git a/actor_test.go b/actor_test.go new file mode 100644 index 0000000..200d4a4 --- /dev/null +++ b/actor_test.go @@ -0,0 +1,571 @@ +package activitypub + +import ( + "fmt" + "reflect" + "testing" + "time" +) + +func TestActorNew(t *testing.T) { + testValue := ID("test") + testType := ApplicationType + + o := ActorNew(testValue, testType) + + if o.ID != testValue { + t.Errorf("APObject Id '%v' different than expected '%v'", o.ID, testValue) + } + if o.Type != testType { + t.Errorf("APObject Type '%v' different than expected '%v'", o.Type, testType) + } + + n := ActorNew(testValue, "") + if n.ID != testValue { + t.Errorf("APObject Id '%v' different than expected '%v'", n.ID, testValue) + } + if n.Type != ActorType { + t.Errorf("APObject Type '%v' different than expected '%v'", n.Type, ActorType) + } +} + +func TestPersonNew(t *testing.T) { + testValue := ID("test") + + o := PersonNew(testValue) + if o.ID != testValue { + t.Errorf("APObject Id '%v' different than expected '%v'", o.ID, testValue) + } + if o.Type != PersonType { + t.Errorf("APObject Type '%v' different than expected '%v'", o.Type, PersonType) + } +} + +func TestApplicationNew(t *testing.T) { + testValue := ID("test") + + o := ApplicationNew(testValue) + if o.ID != testValue { + t.Errorf("APObject Id '%v' different than expected '%v'", o.ID, testValue) + } + if o.Type != ApplicationType { + t.Errorf("APObject Type '%v' different than expected '%v'", o.Type, ApplicationType) + } +} + +func TestGroupNew(t *testing.T) { + testValue := ID("test") + + o := GroupNew(testValue) + if o.ID != testValue { + t.Errorf("APObject Id '%v' different than expected '%v'", o.ID, testValue) + } + if o.Type != GroupType { + t.Errorf("APObject Type '%v' different than expected '%v'", o.Type, GroupType) + } +} + +func TestOrganizationNew(t *testing.T) { + testValue := ID("test") + + o := OrganizationNew(testValue) + if o.ID != testValue { + t.Errorf("APObject Id '%v' different than expected '%v'", o.ID, testValue) + } + if o.Type != OrganizationType { + t.Errorf("APObject Type '%v' different than expected '%v'", o.Type, OrganizationType) + } +} + +func TestServiceNew(t *testing.T) { + testValue := ID("test") + + o := ServiceNew(testValue) + if o.ID != testValue { + t.Errorf("APObject Id '%v' different than expected '%v'", o.ID, testValue) + } + if o.Type != ServiceType { + t.Errorf("APObject Type '%v' different than expected '%v'", o.Type, ServiceType) + } +} + +func TestActor_IsLink(t *testing.T) { + m := ActorNew("test", ActorType) + if m.IsLink() { + t.Errorf("%#v should not be a valid Link", m.Type) + } +} + +func TestActor_IsObject(t *testing.T) { + m := ActorNew("test", ActorType) + if !m.IsObject() { + t.Errorf("%#v should be a valid object", m.Type) + } +} + +func TestActor_Object(t *testing.T) { + m := ActorNew("test", ActorType) + if reflect.DeepEqual(ID(""), m.GetID()) { + t.Errorf("%#v should not be an empty activity pub object", m.GetID()) + } +} + +func TestActor_Type(t *testing.T) { + m := ActorNew("test", ActorType) + if m.GetType() != ActorType { + t.Errorf("%#v should be an empty Link object", m.GetType()) + } +} + +func TestPerson_IsLink(t *testing.T) { + m := PersonNew("test") + if m.IsLink() { + t.Errorf("%T should not be a valid Link", m) + } +} + +func TestPerson_IsObject(t *testing.T) { + m := PersonNew("test") + if !m.IsObject() { + t.Errorf("%T should be a valid object", m) + } +} + +func TestActor_UnmarshalJSON(t *testing.T) { + t.Skipf("TODO") +} + +func TestActor_GetActor(t *testing.T) { + t.Skipf("TODO") +} + +func TestActor_GetID(t *testing.T) { + t.Skipf("TODO") +} + +func TestActor_GetLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestActor_GetType(t *testing.T) { + t.Skipf("TODO") +} + +func TestApplication_GetActor(t *testing.T) { + t.Skipf("TODO") +} + +func TestApplication_GetID(t *testing.T) { + t.Skipf("TODO") +} + +func TestApplication_GetLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestApplication_GetType(t *testing.T) { + t.Skipf("TODO") +} + +func TestApplication_IsLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestApplication_IsObject(t *testing.T) { + t.Skipf("TODO") +} + +func TestGroup_GetActor(t *testing.T) { + t.Skipf("TODO") +} + +func TestGroup_GetID(t *testing.T) { + t.Skipf("TODO") +} + +func TestGroup_GetLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestGroup_GetType(t *testing.T) { + t.Skipf("TODO") +} + +func TestGroup_IsLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestGroup_IsObject(t *testing.T) { + t.Skipf("TODO") +} + +func TestOrganization_GetActor(t *testing.T) { + t.Skipf("TODO") +} + +func TestOrganization_GetID(t *testing.T) { + t.Skipf("TODO") +} + +func TestOrganization_GetLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestOrganization_GetType(t *testing.T) { + t.Skipf("TODO") +} + +func TestOrganization_IsLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestOrganization_IsObject(t *testing.T) { + t.Skipf("TODO") +} + +func TestPerson_GetActor(t *testing.T) { + t.Skipf("TODO") +} + +func TestPerson_GetID(t *testing.T) { + t.Skipf("TODO") +} + +func TestPerson_GetLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestPerson_GetType(t *testing.T) { + t.Skipf("TODO") +} + +func validateEmptyPerson(p Person, t *testing.T) { + if p.ID != "" { + t.Errorf("Unmarshaled object %T should have empty ID, received %q", p, p.ID) + } + if p.Type != "" { + t.Errorf("Unmarshaled object %T should have empty Type, received %q", p, p.Type) + } + if p.AttributedTo != nil { + t.Errorf("Unmarshaled object %T should have empty AttributedTo, received %q", p, p.AttributedTo) + } + if len(p.Name) != 0 { + t.Errorf("Unmarshaled object %T should have empty Name, received %q", p, p.Name) + } + if len(p.Summary) != 0 { + t.Errorf("Unmarshaled object %T should have empty Summary, received %q", p, p.Summary) + } + if len(p.Content) != 0 { + t.Errorf("Unmarshaled object %T should have empty Content, received %q", p, p.Content) + } + if p.URL != nil { + t.Errorf("Unmarshaled object %T should have empty URL, received %v", p, p.URL) + } + if !p.Published.IsZero() { + t.Errorf("Unmarshaled object %T should have empty Published, received %q", p, p.Published) + } + if !p.StartTime.IsZero() { + t.Errorf("Unmarshaled object %T should have empty StartTime, received %q", p, p.StartTime) + } + if !p.Updated.IsZero() { + t.Errorf("Unmarshaled object %T should have empty Updated, received %q", p, p.Updated) + } +} + +func TestPerson_UnmarshalJSON(t *testing.T) { + p := Person{} + + dataEmpty := []byte("{}") + p.UnmarshalJSON(dataEmpty) + validateEmptyPerson(p, t) +} + +func TestApplication_UnmarshalJSON(t *testing.T) { + a := Application{} + + dataEmpty := []byte("{}") + a.UnmarshalJSON(dataEmpty) + validateEmptyPerson(Person(a), t) +} + +func TestGroup_UnmarshalJSON(t *testing.T) { + g := Group{} + + dataEmpty := []byte("{}") + g.UnmarshalJSON(dataEmpty) + validateEmptyPerson(Person(g), t) +} + +func TestOrganization_UnmarshalJSON(t *testing.T) { + o := Organization{} + + dataEmpty := []byte("{}") + o.UnmarshalJSON(dataEmpty) + validateEmptyPerson(Person(o), t) +} + +func TestService_UnmarshalJSON(t *testing.T) { + s := Service{} + + dataEmpty := []byte("{}") + s.UnmarshalJSON(dataEmpty) + validateEmptyPerson(Person(s), t) +} + +func TestService_GetActor(t *testing.T) { + t.Skipf("TODO") +} + +func TestService_GetID(t *testing.T) { + t.Skipf("TODO") +} + +func TestService_GetLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestService_GetType(t *testing.T) { + t.Skipf("TODO") +} + +func TestService_IsLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestService_IsObject(t *testing.T) { + t.Skipf("TODO") +} + +func TestToPerson(t *testing.T) { + t.Skipf("TODO") +} + +func TestEndpoints_UnmarshalJSON(t *testing.T) { + t.Skipf("TODO") +} + +func TestActor_Clean(t *testing.T) { + t.Skipf("TODO") +} + +func TestToActor(t *testing.T) { + t.Skipf("TODO") +} + +func TestActor_IsCollection(t *testing.T) { + t.Skipf("TODO") +} + +func TestActor_Recipients(t *testing.T) { + t.Skipf("TODO") +} + +func TestPublicKey_UnmarshalJSON(t *testing.T) { + t.Skipf("TODO") +} + +func TestActor_MarshalJSON(t *testing.T) { + t.Skipf("TODO") +} + +func TestEndpoints_MarshalJSON(t *testing.T) { + t.Skipf("TODO") +} + +func TestPublicKey_MarshalJSON(t *testing.T) { + t.Skipf("TODO") +} + +func assertPersonWithTesting(fn canErrorFunc, expected Item) WithActorFn { + return func(p *Person) error { + if !assertDeepEquals(fn, p, expected) { + return fmt.Errorf("not equal") + } + return nil + } +} + +func TestOnActor(t *testing.T) { + testPerson := Actor{ + ID: "https://example.com", + } + type args struct { + it Item + fn func(canErrorFunc, Item) WithActorFn + } + tests := []struct { + name string + args args + expected Item + wantErr bool + }{ + { + name: "single", + args: args{testPerson, assertPersonWithTesting}, + expected: &testPerson, + wantErr: false, + }, + { + name: "single fails", + args: args{Person{ID: "https://not-equals"}, assertPersonWithTesting}, + expected: &testPerson, + wantErr: true, + }, + { + name: "collectionOfPersons", + args: args{ItemCollection{testPerson, testPerson}, assertPersonWithTesting}, + expected: &testPerson, + wantErr: false, + }, + { + name: "collectionOfPersons fails", + args: args{ItemCollection{testPerson, Person{ID: "https://not-equals"}}, assertPersonWithTesting}, + expected: &testPerson, + wantErr: true, + }, + } + for _, tt := range tests { + var logFn canErrorFunc + if tt.wantErr { + logFn = t.Logf + } else { + logFn = t.Errorf + } + t.Run(tt.name, func(t *testing.T) { + if err := OnActor(tt.args.it, tt.args.fn(logFn, tt.expected)); (err != nil) != tt.wantErr { + t.Errorf("OnPerson() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestActor_Equals(t *testing.T) { + type fields struct { + ID ID + Type ActivityVocabularyType + Name NaturalLanguageValues + Attachment Item + AttributedTo Item + Audience ItemCollection + Content NaturalLanguageValues + Context Item + MediaType MimeType + EndTime time.Time + Generator Item + Icon Item + Image Item + InReplyTo Item + Location Item + Preview Item + Published time.Time + Replies Item + StartTime time.Time + Summary NaturalLanguageValues + Tag ItemCollection + Updated time.Time + URL Item + To ItemCollection + Bto ItemCollection + CC ItemCollection + BCC ItemCollection + Duration time.Duration + Likes Item + Shares Item + Source Source + Inbox Item + Outbox Item + Following Item + Followers Item + Liked Item + PreferredUsername NaturalLanguageValues + Endpoints *Endpoints + Streams ItemCollection + PublicKey PublicKey + } + tests := []struct { + name string + fields fields + arg Item + want bool + }{ + { + name: "equal-empty-actor", + fields: fields{}, + arg: Actor{}, + want: true, + }, + { + name: "equal-actor-just-id", + fields: fields{ID: "test"}, + arg: Actor{ID: "test"}, + want: true, + }, + { + name: "equal-actor-id", + fields: fields{ID: "test", URL: IRI("example.com")}, + arg: Actor{ID: "test"}, + want: true, + }, + { + name: "equal-false-with-id-and-url", + fields: fields{ID: "test"}, + arg: Actor{ID: "test", URL: IRI("example.com")}, + want: false, + }, + { + name: "not a valid actor", + fields: fields{ID: "http://example.com"}, + arg: Activity{ID: "http://example.com"}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := Actor{ + ID: tt.fields.ID, + Type: tt.fields.Type, + Name: tt.fields.Name, + Attachment: tt.fields.Attachment, + AttributedTo: tt.fields.AttributedTo, + Audience: tt.fields.Audience, + Content: tt.fields.Content, + Context: tt.fields.Context, + MediaType: tt.fields.MediaType, + EndTime: tt.fields.EndTime, + Generator: tt.fields.Generator, + Icon: tt.fields.Icon, + Image: tt.fields.Image, + InReplyTo: tt.fields.InReplyTo, + Location: tt.fields.Location, + Preview: tt.fields.Preview, + Published: tt.fields.Published, + Replies: tt.fields.Replies, + StartTime: tt.fields.StartTime, + Summary: tt.fields.Summary, + Tag: tt.fields.Tag, + Updated: tt.fields.Updated, + URL: tt.fields.URL, + To: tt.fields.To, + Bto: tt.fields.Bto, + CC: tt.fields.CC, + BCC: tt.fields.BCC, + Duration: tt.fields.Duration, + Likes: tt.fields.Likes, + Shares: tt.fields.Shares, + Source: tt.fields.Source, + Inbox: tt.fields.Inbox, + Outbox: tt.fields.Outbox, + Following: tt.fields.Following, + Followers: tt.fields.Followers, + Liked: tt.fields.Liked, + PreferredUsername: tt.fields.PreferredUsername, + Endpoints: tt.fields.Endpoints, + Streams: tt.fields.Streams, + PublicKey: tt.fields.PublicKey, + } + if got := a.Equals(tt.arg); got != tt.want { + t.Errorf("Equals() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/collection.go b/collection.go new file mode 100644 index 0000000..8d7f254 --- /dev/null +++ b/collection.go @@ -0,0 +1,439 @@ +package activitypub + +import ( + "bytes" + "encoding/gob" + "fmt" + "time" + "unsafe" + + "github.com/valyala/fastjson" +) + +const CollectionOfIRIs ActivityVocabularyType = "IRICollection" +const CollectionOfItems ActivityVocabularyType = "ItemCollection" + +var CollectionTypes = ActivityVocabularyTypes{ + CollectionOfItems, + CollectionType, + OrderedCollectionType, + CollectionPageType, + OrderedCollectionPageType, +} + +// Collections +// +// https://www.w3.org/TR/activitypub/#collections +// +// [ActivityStreams] defines the collection concept; ActivityPub defines several collections with special behavior. +// +// Note that ActivityPub makes use of ActivityStreams paging to traverse large sets of objects. +// +// Note that some of these collections are specified to be of type OrderedCollection specifically, +// while others are permitted to be either a Collection or an OrderedCollection. +// An OrderedCollection MUST be presented consistently in reverse chronological order. +// +// NOTE +// What property is used to determine the reverse chronological order is intentionally left as an implementation detail. +// For example, many SQL-style databases use an incrementing integer as an identifier, which can be reasonably used for +// handling insertion order in most cases. In other databases, an insertion time timestamp may be preferred. +// What is used isn't important, but the ordering of elements must remain intact, with newer items first. +// A property which changes regularly, such a "last updated" timestamp, should not be used. +type Collections interface { + Collection | CollectionPage | OrderedCollection | OrderedCollectionPage | ItemCollection | IRIs +} + +type CollectionInterface interface { + ObjectOrLink + Collection() ItemCollection + Append(ob ...Item) error + Count() uint + Contains(Item) bool +} + +// Collection is a subtype of Activity Pub Object that represents ordered or unordered sets of Activity Pub Object or Link instances. +type Collection struct { + // ID provides the globally unique identifier for anActivity Pub Object or Link. + ID ID `jsonld:"id,omitempty"` + // Type identifies the Activity Pub Object or Link type. Multiple values may be specified. + Type ActivityVocabularyType `jsonld:"type,omitempty"` + // Name a simple, human-readable, plain-text name for the object. + // HTML markup MUST NOT be included. The name MAY be expressed using multiple language-tagged values. + Name NaturalLanguageValues `jsonld:"name,omitempty,collapsible"` + // Attachment identifies a resource attached or related to an object that potentially requires special handling. + // The intent is to provide a model that is at least semantically similar to attachments in email. + Attachment Item `jsonld:"attachment,omitempty"` + // AttributedTo identifies one or more entities to which this object is attributed. The attributed entities might not be Actors. + // For instance, an object might be attributed to the completion of another activity. + AttributedTo Item `jsonld:"attributedTo,omitempty"` + // Audience identifies one or more entities that represent the total population of entities + // for which the object can considered to be relevant. + Audience ItemCollection `jsonld:"audience,omitempty"` + // Content or textual representation of the Activity Pub Object encoded as a JSON string. + // By default, the value of content is HTML. + // The mediaType property can be used in the object to indicate a different content type. + // (The content MAY be expressed using multiple language-tagged values.) + Content NaturalLanguageValues `jsonld:"content,omitempty,collapsible"` + // Context identifies the context within which the object exists or an activity was performed. + // The notion of "context" used is intentionally vague. + // The intended function is to serve as a means of grouping objects and activities that share a + // common originating context or purpose. An example could be all activities relating to a common project or event. + Context Item `jsonld:"context,omitempty"` + // MediaType when used on an Object, identifies the MIME media type of the value of the content property. + // If not specified, the content property is assumed to contain text/html content. + MediaType MimeType `jsonld:"mediaType,omitempty"` + // EndTime the date and time describing the actual or expected ending time of the object. + // When used with an Activity object, for instance, the endTime property specifies the moment + // the activity concluded or is expected to conclude. + EndTime time.Time `jsonld:"endTime,omitempty"` + // Generator identifies the entity (e.g. an application) that generated the object. + Generator Item `jsonld:"generator,omitempty"` + // Icon indicates an entity that describes an icon for this object. + // The image should have an aspect ratio of one (horizontal) to one (vertical) + // and should be suitable for presentation at a small size. + Icon Item `jsonld:"icon,omitempty"` + // Image indicates an entity that describes an image for this object. + // Unlike the icon property, there are no aspect ratio or display size limitations assumed. + Image Item `jsonld:"image,omitempty"` + // InReplyTo indicates one or more entities for which this object is considered a response. + InReplyTo Item `jsonld:"inReplyTo,omitempty"` + // Location indicates one or more physical or logical locations associated with the object. + Location Item `jsonld:"location,omitempty"` + // Preview identifies an entity that provides a preview of this object. + Preview Item `jsonld:"preview,omitempty"` + // Published the date and time at which the object was published + Published time.Time `jsonld:"published,omitempty"` + // Replies identifies a Collection containing objects considered to be responses to this object. + Replies Item `jsonld:"replies,omitempty"` + // StartTime the date and time describing the actual or expected starting time of the object. + // When used with an Activity object, for instance, the startTime property specifies + // the moment the activity began or is scheduled to begin. + StartTime time.Time `jsonld:"startTime,omitempty"` + // Summary a natural language summarization of the object encoded as HTML. + // *Multiple language tagged summaries may be provided.) + Summary NaturalLanguageValues `jsonld:"summary,omitempty,collapsible"` + // Tag one or more "tags" that have been associated with an objects. A tag can be any kind of Activity Pub Object. + // The key difference between attachment and tag is that the former implies association by inclusion, + // while the latter implies associated by reference. + Tag ItemCollection `jsonld:"tag,omitempty"` + // Updated the date and time at which the object was updated + Updated time.Time `jsonld:"updated,omitempty"` + // URL identifies one or more links to representations of the object + URL Item `jsonld:"url,omitempty"` + // To identifies an entity considered to be part of the public primary audience of an Activity Pub Object + To ItemCollection `jsonld:"to,omitempty"` + // Bto identifies anActivity Pub Object that is part of the private primary audience of this Activity Pub Object. + Bto ItemCollection `jsonld:"bto,omitempty"` + // CC identifies anActivity Pub Object that is part of the public secondary audience of this Activity Pub Object. + CC ItemCollection `jsonld:"cc,omitempty"` + // BCC identifies one or more Objects that are part of the private secondary audience of this Activity Pub Object. + BCC ItemCollection `jsonld:"bcc,omitempty"` + // Duration when the object describes a time-bound resource, such as an audio or video, a meeting, etc, + // the duration property indicates the object's approximate duration. + // The value must be expressed as an xsd:duration as defined by [ xmlschema11-2], + // section 3.3.6 (e.g. a period of 5 seconds is represented as "PT5S"). + Duration time.Duration `jsonld:"duration,omitempty"` + // This is a list of all Like activities with this object as the object property, added as a side effect. + // The likes collection MUST be either an OrderedCollection or a Collection and MAY be filtered on privileges + // of an authenticated user or as appropriate when no authentication is given. + Likes Item `jsonld:"likes,omitempty"` + // This is a list of all Announce activities with this object as the object property, added as a side effect. + // The shares collection MUST be either an OrderedCollection or a Collection and MAY be filtered on privileges + // of an authenticated user or as appropriate when no authentication is given. + Shares Item `jsonld:"shares,omitempty"` + // Source property is intended to convey some sort of source from which the content markup was derived, + // as a form of provenance, or to support future editing by clients. + // In general, clients do the conversion from source to content, not the other way around. + Source Source `jsonld:"source,omitempty"` + // In a paged Collection, indicates the page that contains the most recently updated member items. + Current ObjectOrLink `jsonld:"current,omitempty"` + // In a paged Collection, indicates the furthest preceding page of items in the collection. + First ObjectOrLink `jsonld:"first,omitempty"` + // In a paged Collection, indicates the furthest proceeding page of the collection. + Last ObjectOrLink `jsonld:"last,omitempty"` + // A non-negative integer specifying the total number of objects contained by the logical view of the collection. + // This number might not reflect the actual number of items serialized within the Collection object instance. + TotalItems uint `jsonld:"totalItems"` + // Identifies the items contained in a collection. The items might be ordered or unordered. + Items ItemCollection `jsonld:"items,omitempty"` +} + +type ( + // FollowersCollection is a collection of followers + FollowersCollection = Collection + + // FollowingCollection is a list of everybody that the actor has followed, added as a side effect. + // The following collection MUST be either an OrderedCollection or a Collection and MAY + // be filtered on privileges of an authenticated user or as appropriate when no authentication is given. + FollowingCollection = Collection +) + +// CollectionNew initializes a new Collection +func CollectionNew(id ID) *Collection { + c := Collection{ID: id, Type: CollectionType} + c.Name = NaturalLanguageValuesNew() + c.Content = NaturalLanguageValuesNew() + c.Summary = NaturalLanguageValuesNew() + return &c +} + +// OrderedCollectionNew initializes a new OrderedCollection +func OrderedCollectionNew(id ID) *OrderedCollection { + o := OrderedCollection{ID: id, Type: OrderedCollectionType} + o.Name = NaturalLanguageValuesNew() + o.Content = NaturalLanguageValuesNew() + + return &o +} + +// GetID returns the ID corresponding to the Collection object +func (c Collection) GetID() ID { + return c.ID +} + +// GetType returns the Collection's type +func (c Collection) GetType() ActivityVocabularyType { + return c.Type +} + +// IsLink returns false for a Collection object +func (c Collection) IsLink() bool { + return false +} + +// IsObject returns true for a Collection object +func (c Collection) IsObject() bool { + return true +} + +// IsCollection returns true for Collection objects +func (c Collection) IsCollection() bool { + return true +} + +// GetLink returns the IRI corresponding to the Collection object +func (c Collection) GetLink() IRI { + return IRI(c.ID) +} + +// Collection returns the Collection's items +func (c Collection) Collection() ItemCollection { + return c.Items +} + +// Append adds an element to a Collection +func (c *Collection) Append(it ...Item) error { + for _, ob := range it { + if c.Items.Contains(ob) { + continue + } + c.Items = append(c.Items, ob) + } + return nil +} + +// Count returns the maximum between the length of Items in collection and its TotalItems property +func (c *Collection) Count() uint { + if c == nil { + return 0 + } + return uint(len(c.Items)) +} + +// Contains verifies if Collection array contains the received one +func (c Collection) Contains(r Item) bool { + if len(c.Items) == 0 { + return false + } + for _, it := range c.Items { + if ItemsEqual(it, r) { + return true + } + } + return false +} + +// UnmarshalJSON decodes an incoming JSON document into the receiver object. +func (c *Collection) UnmarshalJSON(data []byte) error { + par := fastjson.Parser{} + val, err := par.ParseBytes(data) + if err != nil { + return err + } + return JSONLoadCollection(val, c) +} + +// MarshalJSON encodes the receiver object to a JSON document. +func (c Collection) MarshalJSON() ([]byte, error) { + b := make([]byte, 0) + notEmpty := false + JSONWrite(&b, '{') + + OnObject(c, func(o *Object) error { + notEmpty = JSONWriteObjectValue(&b, *o) + return nil + }) + if c.Current != nil { + notEmpty = JSONWriteItemProp(&b, "current", c.Current) || notEmpty + } + if c.First != nil { + notEmpty = JSONWriteItemProp(&b, "first", c.First) || notEmpty + } + if c.Last != nil { + notEmpty = JSONWriteItemProp(&b, "last", c.Last) || notEmpty + } + notEmpty = JSONWriteIntProp(&b, "totalItems", int64(c.TotalItems)) || notEmpty + if c.Items != nil { + notEmpty = JSONWriteItemCollectionProp(&b, "items", c.Items, false) || notEmpty + } + if notEmpty { + JSONWrite(&b, '}') + return b, nil + } + return nil, nil +} + +// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. +func (c *Collection) UnmarshalBinary(data []byte) error { + return c.GobDecode(data) +} + +// MarshalBinary implements the encoding.BinaryMarshaler interface. +func (c Collection) MarshalBinary() ([]byte, error) { + return c.GobEncode() +} + +func (c Collection) GobEncode() ([]byte, error) { + mm := make(map[string][]byte) + hasData, err := mapCollectionProperties(mm, c) + if err != nil { + return nil, err + } + if !hasData { + return []byte{}, nil + } + bb := bytes.Buffer{} + g := gob.NewEncoder(&bb) + if err := g.Encode(mm); err != nil { + return nil, err + } + return bb.Bytes(), nil +} + +func (c *Collection) GobDecode(data []byte) error { + if len(data) == 0 { + return nil + } + mm, err := gobDecodeObjectAsMap(data) + if err != nil { + return err + } + return unmapCollectionProperties(mm, c) +} + +func (c Collection) Format(s fmt.State, verb rune) { + switch verb { + case 's', 'v': + _, _ = fmt.Fprintf(s, "%T[%s] { totalItems: %d }", c, c.Type, c.TotalItems) + } +} + +// ToCollection +func ToCollection(it Item) (*Collection, error) { + switch i := it.(type) { + case *Collection: + return i, nil + case Collection: + return &i, nil + case *CollectionPage: + return (*Collection)(unsafe.Pointer(i)), nil + case CollectionPage: + return (*Collection)(unsafe.Pointer(&i)), nil + // NOTE(marius): let's try again to convert OrderedCollection -> Collection, as they have the same + // shape in memory. + case *OrderedCollection: + return (*Collection)(unsafe.Pointer(i)), nil + case OrderedCollection: + return (*Collection)(unsafe.Pointer(&i)), nil + case *OrderedCollectionPage: + return (*Collection)(unsafe.Pointer(i)), nil + case OrderedCollectionPage: + return (*Collection)(unsafe.Pointer(&i)), nil + default: + return reflectItemToType[Collection](it) + } +} + +// ItemsMatch +func (c Collection) ItemsMatch(col ...Item) bool { + for _, it := range col { + if match := c.Items.Contains(it); !match { + return false + } + } + return true +} + +// Equals +func (c Collection) Equals(with Item) bool { + if IsNil(with) { + return false + } + if !with.IsCollection() { + return false + } + result := true + _ = OnCollection(with, func(w *Collection) error { + _ = OnObject(w, func(wo *Object) error { + if !wo.Equals(c) { + result = false + return nil + } + return nil + }) + if w.TotalItems > 0 { + if w.TotalItems != c.TotalItems { + result = false + return nil + } + } + if w.Current != nil { + if !ItemsEqual(c.Current, w.Current) { + result = false + return nil + } + } + if w.First != nil { + if !ItemsEqual(c.First, w.First) { + result = false + return nil + } + } + if w.Last != nil { + if !ItemsEqual(c.Last, w.Last) { + result = false + return nil + } + } + if w.Items != nil { + if !ItemsEqual(c.Items, w.Items) { + result = false + return nil + } + } + return nil + }) + return result +} + +func (c *Collection) Recipients() ItemCollection { + aud := c.Audience + return ItemCollectionDeduplication(&c.To, &c.CC, &c.Bto, &c.BCC, &aud) +} + +func (c *Collection) Clean() { + _ = OnObject(c, func(o *Object) error { + o.Clean() + return nil + }) +} diff --git a/collection_page.go b/collection_page.go new file mode 100644 index 0000000..3ef7300 --- /dev/null +++ b/collection_page.go @@ -0,0 +1,433 @@ +package activitypub + +import ( + "bytes" + "encoding/gob" + "fmt" + "time" + "unsafe" + + "github.com/valyala/fastjson" +) + +// CollectionPage is a Collection that contains a large number of items and when it becomes impractical +// for an implementation to serialize every item contained by a Collection using the items +// property alone. In such cases, the items within a Collection can be divided into distinct subsets or "pages". +type CollectionPage struct { + // ID provides the globally unique identifier for anActivity Pub Object or Link. + ID ID `jsonld:"id,omitempty"` + // Type identifies the Activity Pub Object or Link type. Multiple values may be specified. + Type ActivityVocabularyType `jsonld:"type,omitempty"` + // Name a simple, human-readable, plain-text name for the object. + // HTML markup MUST NOT be included. The name MAY be expressed using multiple language-tagged values. + Name NaturalLanguageValues `jsonld:"name,omitempty,collapsible"` + // Attachment identifies a resource attached or related to an object that potentially requires special handling. + // The intent is to provide a model that is at least semantically similar to attachments in email. + Attachment Item `jsonld:"attachment,omitempty"` + // AttributedTo identifies one or more entities to which this object is attributed. The attributed entities might not be Actors. + // For instance, an object might be attributed to the completion of another activity. + AttributedTo Item `jsonld:"attributedTo,omitempty"` + // Audience identifies one or more entities that represent the total population of entities + // for which the object can considered to be relevant. + Audience ItemCollection `jsonld:"audience,omitempty"` + // Content or textual representation of the Activity Pub Object encoded as a JSON string. + // By default, the value of content is HTML. + // The mediaType property can be used in the object to indicate a different content type. + // (The content MAY be expressed using multiple language-tagged values.) + Content NaturalLanguageValues `jsonld:"content,omitempty,collapsible"` + // Context identifies the context within which the object exists or an activity was performed. + // The notion of "context" used is intentionally vague. + // The intended function is to serve as a means of grouping objects and activities that share a + // common originating context or purpose. An example could be all activities relating to a common project or event. + Context Item `jsonld:"context,omitempty"` + // MediaType when used on an Object, identifies the MIME media type of the value of the content property. + // If not specified, the content property is assumed to contain text/html content. + MediaType MimeType `jsonld:"mediaType,omitempty"` + // EndTime the date and time describing the actual or expected ending time of the object. + // When used with an Activity object, for instance, the endTime property specifies the moment + // the activity concluded or is expected to conclude. + EndTime time.Time `jsonld:"endTime,omitempty"` + // Generator identifies the entity (e.g. an application) that generated the object. + Generator Item `jsonld:"generator,omitempty"` + // Icon indicates an entity that describes an icon for this object. + // The image should have an aspect ratio of one (horizontal) to one (vertical) + // and should be suitable for presentation at a small size. + Icon Item `jsonld:"icon,omitempty"` + // Image indicates an entity that describes an image for this object. + // Unlike the icon property, there are no aspect ratio or display size limitations assumed. + Image Item `jsonld:"image,omitempty"` + // InReplyTo indicates one or more entities for which this object is considered a response. + InReplyTo Item `jsonld:"inReplyTo,omitempty"` + // Location indicates one or more physical or logical locations associated with the object. + Location Item `jsonld:"location,omitempty"` + // Preview identifies an entity that provides a preview of this object. + Preview Item `jsonld:"preview,omitempty"` + // Published the date and time at which the object was published + Published time.Time `jsonld:"published,omitempty"` + // Replies identifies a Collection containing objects considered to be responses to this object. + Replies Item `jsonld:"replies,omitempty"` + // StartTime the date and time describing the actual or expected starting time of the object. + // When used with an Activity object, for instance, the startTime property specifies + // the moment the activity began or is scheduled to begin. + StartTime time.Time `jsonld:"startTime,omitempty"` + // Summary a natural language summarization of the object encoded as HTML. + // *Multiple language tagged summaries may be provided.) + Summary NaturalLanguageValues `jsonld:"summary,omitempty,collapsible"` + // Tag one or more "tags" that have been associated with an objects. A tag can be any kind of Activity Pub Object. + // The key difference between attachment and tag is that the former implies association by inclusion, + // while the latter implies associated by reference. + Tag ItemCollection `jsonld:"tag,omitempty"` + // Updated the date and time at which the object was updated + Updated time.Time `jsonld:"updated,omitempty"` + // URL identifies one or more links to representations of the object + URL Item `jsonld:"url,omitempty"` + // To identifies an entity considered to be part of the public primary audience of an Activity Pub Object + To ItemCollection `jsonld:"to,omitempty"` + // Bto identifies anActivity Pub Object that is part of the private primary audience of this Activity Pub Object. + Bto ItemCollection `jsonld:"bto,omitempty"` + // CC identifies anActivity Pub Object that is part of the public secondary audience of this Activity Pub Object. + CC ItemCollection `jsonld:"cc,omitempty"` + // BCC identifies one or more Objects that are part of the private secondary audience of this Activity Pub Object. + BCC ItemCollection `jsonld:"bcc,omitempty"` + // Duration when the object describes a time-bound resource, such as an audio or video, a meeting, etc, + // the duration property indicates the object's approximate duration. + // The value must be expressed as an xsd:duration as defined by [ xmlschema11-2], + // section 3.3.6 (e.g. a period of 5 seconds is represented as "PT5S"). + Duration time.Duration `jsonld:"duration,omitempty"` + // This is a list of all Like activities with this object as the object property, added as a side effect. + // The likes collection MUST be either an OrderedCollection or a Collection and MAY be filtered on privileges + // of an authenticated user or as appropriate when no authentication is given. + Likes Item `jsonld:"likes,omitempty"` + // This is a list of all Announce activities with this object as the object property, added as a side effect. + // The shares collection MUST be either an OrderedCollection or a Collection and MAY be filtered on privileges + // of an authenticated user or as appropriate when no authentication is given. + Shares Item `jsonld:"shares,omitempty"` + // Source property is intended to convey some sort of source from which the content markup was derived, + // as a form of provenance, or to support future editing by clients. + // In general, clients do the conversion from source to content, not the other way around. + Source Source `jsonld:"source,omitempty"` + // In a paged Collection, indicates the page that contains the most recently updated member items. + Current ObjectOrLink `jsonld:"current,omitempty"` + // In a paged Collection, indicates the furthest preceding page of items in the collection. + First ObjectOrLink `jsonld:"first,omitempty"` + // In a paged Collection, indicates the furthest proceeding page of the collection. + Last ObjectOrLink `jsonld:"last,omitempty"` + // A non-negative integer specifying the total number of objects contained by the logical view of the collection. + // This number might not reflect the actual number of items serialized within the Collection object instance. + TotalItems uint `jsonld:"totalItems"` + // Identifies the items contained in a collection. The items might be unordered. + Items ItemCollection `jsonld:"items,omitempty"` + // Identifies the Collection to which a CollectionPage objects items belong. + PartOf Item `jsonld:"partOf,omitempty"` + // In a paged Collection, indicates the next page of items. + Next Item `jsonld:"next,omitempty"` + // In a paged Collection, identifies the previous page of items. + Prev Item `jsonld:"prev,omitempty"` +} + +// GetID returns the ID corresponding to the CollectionPage object +func (c CollectionPage) GetID() ID { + return c.ID +} + +// GetType returns the CollectionPage's type +func (c CollectionPage) GetType() ActivityVocabularyType { + return c.Type +} + +// IsLink returns false for a CollectionPage object +func (c CollectionPage) IsLink() bool { + return false +} + +// IsObject returns true for a CollectionPage object +func (c CollectionPage) IsObject() bool { + return true +} + +// IsCollection returns true for CollectionPage objects +func (c CollectionPage) IsCollection() bool { + return true +} + +// GetLink returns the IRI corresponding to the CollectionPage object +func (c CollectionPage) GetLink() IRI { + return IRI(c.ID) +} + +// Collection returns the ColleCollectionPagection items +func (c CollectionPage) Collection() ItemCollection { + return c.Items +} + +// Count returns the maximum between the length of Items in the collection page and its TotalItems property +func (c *CollectionPage) Count() uint { + if c == nil { + return 0 + } + return uint(len(c.Items)) +} + +// Append adds an element to a CollectionPage +func (c *CollectionPage) Append(it ...Item) error { + for _, ob := range it { + if c.Items.Contains(ob) { + continue + } + c.Items = append(c.Items, ob) + } + return nil +} + +// Contains verifies if CollectionPage array contains the received one +func (c CollectionPage) Contains(r Item) bool { + if len(c.Items) == 0 { + return false + } + for _, it := range c.Items { + if ItemsEqual(it, r) { + return true + } + } + return false +} + +// UnmarshalJSON decodes an incoming JSON document into the receiver object. +func (c *CollectionPage) UnmarshalJSON(data []byte) error { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + return JSONLoadCollectionPage(val, c) +} + +// MarshalJSON encodes the receiver object to a JSON document. +func (c CollectionPage) MarshalJSON() ([]byte, error) { + b := make([]byte, 0) + notEmpty := false + JSONWrite(&b, '{') + + OnObject(c, func(o *Object) error { + notEmpty = JSONWriteObjectValue(&b, *o) + return nil + }) + if c.PartOf != nil { + notEmpty = JSONWriteItemProp(&b, "partOf", c.PartOf) || notEmpty + } + if c.Current != nil { + notEmpty = JSONWriteItemProp(&b, "current", c.Current) || notEmpty + } + if c.First != nil { + notEmpty = JSONWriteItemProp(&b, "first", c.First) || notEmpty + } + if c.Last != nil { + notEmpty = JSONWriteItemProp(&b, "last", c.Last) || notEmpty + } + if c.Next != nil { + notEmpty = JSONWriteItemProp(&b, "next", c.Next) || notEmpty + } + if c.Prev != nil { + notEmpty = JSONWriteItemProp(&b, "prev", c.Prev) || notEmpty + } + notEmpty = JSONWriteIntProp(&b, "totalItems", int64(c.TotalItems)) || notEmpty + if c.Items != nil { + notEmpty = JSONWriteItemCollectionProp(&b, "items", c.Items, false) || notEmpty + } + if notEmpty { + JSONWrite(&b, '}') + return b, nil + } + return nil, nil +} + +// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. +func (c *CollectionPage) UnmarshalBinary(data []byte) error { + return c.GobDecode(data) +} + +// MarshalBinary implements the encoding.BinaryMarshaler interface. +func (c CollectionPage) MarshalBinary() ([]byte, error) { + return c.GobEncode() +} + +func (c CollectionPage) GobEncode() ([]byte, error) { + mm := make(map[string][]byte) + hasData, err := mapCollectionPageProperties(mm, c) + if err != nil { + return nil, err + } + if !hasData { + return []byte{}, nil + } + bb := bytes.Buffer{} + g := gob.NewEncoder(&bb) + if err := g.Encode(mm); err != nil { + return nil, err + } + return bb.Bytes(), nil +} + +func (c *CollectionPage) GobDecode(data []byte) error { + if len(data) == 0 { + return nil + } + mm, err := gobDecodeObjectAsMap(data) + if err != nil { + return err + } + return unmapCollectionPageProperties(mm, c) +} + +// CollectionNew initializes a new CollectionPage +func CollectionPageNew(parent CollectionInterface) *CollectionPage { + p := CollectionPage{ + PartOf: parent.GetLink(), + } + if pc, ok := parent.(*Collection); ok { + copyCollectionToPage(pc, &p) + } + p.Type = CollectionPageType + return &p +} + +func copyCollectionToPage(c *Collection, p *CollectionPage) error { + p.Type = CollectionPageType + p.Name = c.Name + p.Content = c.Content + p.Summary = c.Summary + p.Context = c.Context + p.URL = c.URL + p.MediaType = c.MediaType + p.Generator = c.Generator + p.AttributedTo = c.AttributedTo + p.Attachment = c.Attachment + p.Location = c.Location + p.Published = c.Published + p.StartTime = c.StartTime + p.EndTime = c.EndTime + p.Duration = c.Duration + p.Icon = c.Icon + p.Preview = c.Preview + p.Image = c.Image + p.Updated = c.Updated + p.InReplyTo = c.InReplyTo + p.To = c.To + p.Audience = c.Audience + p.Bto = c.Bto + p.CC = c.CC + p.BCC = c.BCC + p.Replies = c.Replies + p.Tag = c.Tag + p.TotalItems = c.TotalItems + p.Items = c.Items + p.Current = c.Current + p.First = c.First + p.PartOf = c.GetLink() + return nil +} + +// ToCollectionPage +func ToCollectionPage(it Item) (*CollectionPage, error) { + switch i := it.(type) { + case *CollectionPage: + return i, nil + case CollectionPage: + return &i, nil + // NOTE(marius): let's try again to convert OrderedCollectionPage -> CollectionPage, as they have the same + // shape in memory. + case *OrderedCollectionPage: + return (*CollectionPage)(unsafe.Pointer(i)), nil + case OrderedCollectionPage: + return (*CollectionPage)(unsafe.Pointer(&i)), nil + default: + return reflectItemToType[CollectionPage](it) + } +} + +// ItemsMatch +func (c CollectionPage) ItemsMatch(col ...Item) bool { + for _, it := range col { + if match := c.Items.Contains(it); !match { + return false + } + } + return true +} + +// Equals +func (c CollectionPage) Equals(with Item) bool { + if IsNil(with) { + return false + } + if !with.IsCollection() { + return false + } + result := true + OnCollectionPage(with, func(w *CollectionPage) error { + OnCollection(w, func(wo *Collection) error { + if !wo.Equals(c) { + result = false + return nil + } + return nil + }) + if w.PartOf != nil { + if !ItemsEqual(c.PartOf, w.PartOf) { + result = false + return nil + } + } + if w.Current != nil { + if !ItemsEqual(c.Current, w.Current) { + result = false + return nil + } + } + if w.First != nil { + if !ItemsEqual(c.First, w.First) { + result = false + return nil + } + } + if w.Last != nil { + if !ItemsEqual(c.Last, w.Last) { + result = false + return nil + } + } + if w.Next != nil { + if !ItemsEqual(c.Next, w.Next) { + result = false + return nil + } + } + if w.Prev != nil { + if !ItemsEqual(c.Prev, w.Prev) { + result = false + return nil + } + } + return nil + }) + return result +} + +func (c CollectionPage) Format(s fmt.State, verb rune) { + switch verb { + case 's', 'v': + _, _ = fmt.Fprintf(s, "%T[%s] { totalItems: %d }", c, c.Type, c.TotalItems) + } +} + +func (c *CollectionPage) Recipients() ItemCollection { + aud := c.Audience + return ItemCollectionDeduplication(&c.To, &c.CC, &c.Bto, &c.BCC, &aud) +} + +func (c *CollectionPage) Clean() { + _ = OnObject(c, func(o *Object) error { + o.Clean() + return nil + }) +} diff --git a/collection_page_test.go b/collection_page_test.go new file mode 100644 index 0000000..62bf7b5 --- /dev/null +++ b/collection_page_test.go @@ -0,0 +1,226 @@ +package activitypub + +import ( + "reflect" + "testing" +) + +func TestCollectionPageNew(t *testing.T) { + testValue := ID("test") + + c := CollectionNew(testValue) + p := CollectionPageNew(c) + if reflect.DeepEqual(p.Collection, c) { + t.Errorf("Invalid collection parent '%v'", p.PartOf) + } + if p.PartOf != c.GetLink() { + t.Errorf("Invalid collection '%v'", p.PartOf) + } +} + +func TestCollectionPage_Append(t *testing.T) { + id := ID("test") + + val := Object{ID: ID("grrr")} + + c := CollectionNew(id) + + p := CollectionPageNew(c) + p.Append(val) + + if p.PartOf != c.GetLink() { + t.Errorf("Collection page should point to collection %q", c.GetLink()) + } + if p.Count() != 1 { + t.Errorf("Collection page of %q should have exactly one element", p.GetID()) + } + if !reflect.DeepEqual(p.Items[0], val) { + t.Errorf("First item in Inbox is does not match %q", val.ID) + } +} + +func TestCollectionPage_UnmarshalJSON(t *testing.T) { + p := CollectionPage{} + + dataEmpty := []byte("{}") + p.UnmarshalJSON(dataEmpty) + if p.ID != "" { + t.Errorf("Unmarshaled object should have empty ID, received %q", p.ID) + } + if p.Type != "" { + t.Errorf("Unmarshaled object should have empty Type, received %q", p.Type) + } + if p.AttributedTo != nil { + t.Errorf("Unmarshaled object should have empty AttributedTo, received %q", p.AttributedTo) + } + if len(p.Name) != 0 { + t.Errorf("Unmarshaled object should have empty Name, received %q", p.Name) + } + if len(p.Summary) != 0 { + t.Errorf("Unmarshaled object should have empty Summary, received %q", p.Summary) + } + if len(p.Content) != 0 { + t.Errorf("Unmarshaled object should have empty Content, received %q", p.Content) + } + if p.TotalItems != 0 { + t.Errorf("Unmarshaled object should have empty TotalItems, received %d", p.TotalItems) + } + if len(p.Items) > 0 { + t.Errorf("Unmarshaled object should have empty Items, received %v", p.Items) + } + if p.URL != nil { + t.Errorf("Unmarshaled object should have empty URL, received %v", p.URL) + } + if !p.Published.IsZero() { + t.Errorf("Unmarshaled object should have empty Published, received %q", p.Published) + } + if !p.StartTime.IsZero() { + t.Errorf("Unmarshaled object should have empty StartTime, received %q", p.StartTime) + } + if !p.Updated.IsZero() { + t.Errorf("Unmarshaled object should have empty Updated, received %q", p.Updated) + } + if p.PartOf != nil { + t.Errorf("Unmarshaled object should have empty PartOf, received %q", p.PartOf) + } + if p.Current != nil { + t.Errorf("Unmarshaled object should have empty Current, received %q", p.Current) + } + if p.First != nil { + t.Errorf("Unmarshaled object should have empty First, received %q", p.First) + } + if p.Last != nil { + t.Errorf("Unmarshaled object should have empty Last, received %q", p.Last) + } + if p.Next != nil { + t.Errorf("Unmarshaled object should have empty Next, received %q", p.Next) + } + if p.Prev != nil { + t.Errorf("Unmarshaled object should have empty Prev, received %q", p.Prev) + } +} + +func TestCollectionPage_Collection(t *testing.T) { + id := ID("test") + + c := CollectionNew(id) + p := CollectionPageNew(c) + + if !reflect.DeepEqual(p.Collection(), p.Items) { + t.Errorf("Collection items should be equal %v %v", p.Collection(), p.Items) + } +} + +func TestCollectionPage_Count(t *testing.T) { + id := ID("test") + + c := CollectionNew(id) + p := CollectionPageNew(c) + + if p.TotalItems != 0 { + t.Errorf("Empty object should have empty TotalItems, received %d", p.TotalItems) + } + if len(p.Items) > 0 { + t.Errorf("Empty object should have empty Items, received %v", p.Items) + } + if p.Count() != uint(len(p.Items)) { + t.Errorf("%T.Count() returned %d, expected %d", c, p.Count(), len(p.Items)) + } + + p.Append(IRI("test")) + if p.TotalItems != 0 { + t.Errorf("Empty object should have empty TotalItems, received %d", p.TotalItems) + } + if p.Count() != uint(len(p.Items)) { + t.Errorf("%T.Count() returned %d, expected %d", c, p.Count(), len(p.Items)) + } +} + +func TestToCollectionPage(t *testing.T) { + err := func(it Item) error { return ErrorInvalidType[CollectionPage](it) } + tests := map[string]struct { + it Item + want *CollectionPage + wantErr error + }{ + "CollectionPage": { + it: new(CollectionPage), + want: new(CollectionPage), + wantErr: nil, + }, + "OrderedCollectionPage": { + it: new(OrderedCollectionPage), + want: new(CollectionPage), + wantErr: nil, + }, + "OrderedCollection": { + it: new(OrderedCollection), + want: new(CollectionPage), + wantErr: err(new(OrderedCollection)), + }, + "Collection": { + it: new(Collection), + want: new(CollectionPage), + wantErr: err(new(Collection)), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := ToCollectionPage(tt.it) + if tt.wantErr != nil && err == nil { + t.Errorf("ToCollectionPage() no error returned, wanted error = [%T]%s", tt.wantErr, tt.wantErr) + return + } + if err != nil { + if tt.wantErr == nil { + t.Errorf("ToCollectionPage() returned unexpected error[%T]%s", err, err) + return + } + if !reflect.DeepEqual(err, tt.wantErr) { + t.Errorf("ToCollectionPage() received error %v, wanted error %v", err, tt.wantErr) + return + } + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ToCollectionPage() got %v, want %v", got, tt.want) + } + }) + } +} + +func TestCollectionPage_Contains(t *testing.T) { + t.Skipf("TODO") +} + +func TestCollectionPage_GetID(t *testing.T) { + t.Skipf("TODO") +} + +func TestCollectionPage_GetLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestCollectionPage_GetType(t *testing.T) { + t.Skipf("TODO") +} + +func TestCollectionPage_IsCollection(t *testing.T) { + t.Skipf("TODO") +} + +func TestCollectionPage_IsLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestCollectionPage_IsObject(t *testing.T) { + t.Skipf("TODO") +} + +func TestCollectionPage_MarshalJSON(t *testing.T) { + t.Skipf("TODO") +} + +func TestCollectionPage_ItemMatches(t *testing.T) { + t.Skipf("TODO") +} diff --git a/collection_test.go b/collection_test.go new file mode 100644 index 0000000..99c4d7a --- /dev/null +++ b/collection_test.go @@ -0,0 +1,279 @@ +package activitypub + +import ( + "reflect" + "testing" +) + +func TestCollectionNew(t *testing.T) { + testValue := ID("test") + + c := CollectionNew(testValue) + + if c.ID != testValue { + t.Errorf("APObject Id '%v' different than expected '%v'", c.ID, testValue) + } + if c.Type != CollectionType { + t.Errorf("APObject Type '%v' different than expected '%v'", c.Type, CollectionType) + } +} + +func TestCollection_Append(t *testing.T) { + id := ID("test") + + val := Object{ID: ID("grrr")} + + c := CollectionNew(id) + c.Append(val) + + if c.Count() != 1 { + t.Errorf("Inbox collectionPath of %q should have one element", c.GetID()) + } + if !reflect.DeepEqual(c.Items[0], val) { + t.Errorf("First item in Inbox is does not match %q", val.ID) + } +} + +func TestCollection_Collection(t *testing.T) { + id := ID("test") + + c := CollectionNew(id) + + if !reflect.DeepEqual(c.Collection(), c.Items) { + t.Errorf("Collection items should be equal %v %v", c.Collection(), c.Items) + } +} + +func TestCollection_GetID(t *testing.T) { + id := ID("test") + + c := CollectionNew(id) + + if c.GetID() != id { + t.Errorf("GetID should return %s, received %s", id, c.GetID()) + } +} + +func TestCollection_GetLink(t *testing.T) { + id := ID("test") + link := IRI(id) + + c := CollectionNew(id) + + if c.GetLink() != link { + t.Errorf("GetLink should return %q, received %q", link, c.GetLink()) + } +} + +func TestCollection_GetType(t *testing.T) { + id := ID("test") + + c := CollectionNew(id) + + if c.GetType() != CollectionType { + t.Errorf("Collection Type should be %q, received %q", CollectionType, c.GetType()) + } +} + +func TestCollection_IsLink(t *testing.T) { + id := ID("test") + + c := CollectionNew(id) + + if c.IsLink() != false { + t.Errorf("Collection should not be a link, received %t", c.IsLink()) + } +} + +func TestCollection_IsObject(t *testing.T) { + id := ID("test") + + c := CollectionNew(id) + + if c.IsObject() != true { + t.Errorf("Collection should be an object, received %t", c.IsObject()) + } +} + +func TestCollection_UnmarshalJSON(t *testing.T) { + c := Collection{} + + dataEmpty := []byte("{}") + c.UnmarshalJSON(dataEmpty) + if c.ID != "" { + t.Errorf("Unmarshaled object should have empty ID, received %q", c.ID) + } + if c.Type != "" { + t.Errorf("Unmarshaled object should have empty Type, received %q", c.Type) + } + if c.AttributedTo != nil { + t.Errorf("Unmarshaled object should have empty AttributedTo, received %q", c.AttributedTo) + } + if len(c.Name) != 0 { + t.Errorf("Unmarshaled object should have empty Name, received %q", c.Name) + } + if len(c.Summary) != 0 { + t.Errorf("Unmarshaled object should have empty Summary, received %q", c.Summary) + } + if len(c.Content) != 0 { + t.Errorf("Unmarshaled object should have empty Content, received %q", c.Content) + } + if c.TotalItems != 0 { + t.Errorf("Unmarshaled object should have empty TotalItems, received %d", c.TotalItems) + } + if len(c.Items) > 0 { + t.Errorf("Unmarshaled object should have empty Items, received %v", c.Items) + } + if c.URL != nil { + t.Errorf("Unmarshaled object should have empty URL, received %v", c.URL) + } + if !c.Published.IsZero() { + t.Errorf("Unmarshaled object should have empty Published, received %q", c.Published) + } + if !c.StartTime.IsZero() { + t.Errorf("Unmarshaled object should have empty StartTime, received %q", c.StartTime) + } + if !c.Updated.IsZero() { + t.Errorf("Unmarshaled object should have empty Updated, received %q", c.Updated) + } +} + +func TestCollection_Count(t *testing.T) { + id := ID("test") + + c := CollectionNew(id) + + if c.TotalItems != 0 { + t.Errorf("Empty object should have empty TotalItems, received %d", c.TotalItems) + } + if len(c.Items) > 0 { + t.Errorf("Empty object should have empty Items, received %v", c.Items) + } + if c.Count() != uint(len(c.Items)) { + t.Errorf("%T.Count() returned %d, expected %d", c, c.Count(), len(c.Items)) + } + + c.Append(IRI("test")) + if c.TotalItems != 0 { + t.Errorf("Empty object should have empty TotalItems, received %d", c.TotalItems) + } + if c.Count() != uint(len(c.Items)) { + t.Errorf("%T.Count() returned %d, expected %d", c, c.Count(), len(c.Items)) + } +} + +func TestCollection_Contains(t *testing.T) { + t.Skipf("TODO") +} + +func TestCollection_IsCollection(t *testing.T) { + t.Skipf("TODO") +} + +func TestFollowersNew(t *testing.T) { + t.Skipf("TODO") +} + +func TestFollowingNew(t *testing.T) { + t.Skipf("TODO") +} + +func TestCollection_MarshalJSON(t *testing.T) { + t.Skipf("TODO") +} + +func TestCollection_ItemMatches(t *testing.T) { + t.Skipf("TODO") +} + +func TestToCollection(t *testing.T) { + //err := func(it Item) error { return ErrorInvalidType[Collection](it) } + tests := map[string]struct { + it Item + want *Collection + wantErr error + }{ + "Collection": { + it: new(Collection), + want: new(Collection), + wantErr: nil, + }, + "CollectionPage": { + it: new(CollectionPage), + want: new(Collection), + wantErr: nil, + }, + "OrderedCollectionPage": { + it: new(OrderedCollectionPage), + want: new(Collection), + wantErr: nil, + }, + "OrderedCollection": { + it: new(OrderedCollection), + want: new(Collection), + wantErr: nil, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := ToCollection(tt.it) + if tt.wantErr != nil && err == nil { + t.Errorf("ToCollection() no error returned, wanted error = [%T]%s", tt.wantErr, tt.wantErr) + return + } + if err != nil { + if tt.wantErr == nil { + t.Errorf("ToCollection() returned unexpected error[%T]%s", err, err) + return + } + if !reflect.DeepEqual(err, tt.wantErr) { + t.Errorf("ToCollection() received error %v, wanted error %v", err, tt.wantErr) + return + } + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ToCollection() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCollection_Equals(t *testing.T) { + tests := []struct { + name string + fields Collection + item Item + want bool + }{ + { + name: "collection with two items", + fields: Collection{ + ID: "https://example.com/1", + Type: CollectionType, + First: IRI("https://example.com/1?first"), + Items: ItemCollection{ + Object{ID: "https://example.com/1/1", Type: NoteType}, + Object{ID: "https://example.com/1/3", Type: ImageType}, + }, + }, + item: &Collection{ + ID: "https://example.com/1", + Type: CollectionType, + First: IRI("https://example.com/1?first"), + Items: ItemCollection{ + Object{ID: "https://example.com/1/1", Type: NoteType}, + Object{ID: "https://example.com/1/3", Type: ImageType}, + }, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.fields.Equals(tt.item); got != tt.want { + t.Errorf("Equals() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/copy.go b/copy.go new file mode 100644 index 0000000..64a51cb --- /dev/null +++ b/copy.go @@ -0,0 +1,234 @@ +package activitypub + +import ( + "fmt" +) + +func CopyOrderedCollectionPageProperties(to, from *OrderedCollectionPage) (*OrderedCollectionPage, error) { + to.PartOf = replaceIfItem(to.PartOf, from.PartOf) + to.Next = replaceIfItem(to.Next, from.Next) + to.Prev = replaceIfItem(to.Prev, from.Prev) + oldCol, _ := ToOrderedCollection(to) + newCol, _ := ToOrderedCollection(from) + _, err := CopyOrderedCollectionProperties(oldCol, newCol) + if err != nil { + return to, err + } + return to, nil +} + +func CopyCollectionPageProperties(to, from *CollectionPage) (*CollectionPage, error) { + to.PartOf = replaceIfItem(to.PartOf, from.PartOf) + to.Next = replaceIfItem(to.Next, from.Next) + to.Prev = replaceIfItem(to.Prev, from.Prev) + toCol, _ := ToCollection(to) + fromCol, _ := ToCollection(from) + _, err := CopyCollectionProperties(toCol, fromCol) + return to, err +} + +func CopyOrderedCollectionProperties(to, from *OrderedCollection) (*OrderedCollection, error) { + to.First = replaceIfItem(to.First, from.First) + to.Last = replaceIfItem(to.Last, from.Last) + to.OrderedItems = replaceIfItemCollection(to.OrderedItems, from.OrderedItems) + if to.TotalItems == 0 { + to.TotalItems = from.TotalItems + } + oldOb, _ := ToObject(to) + newOb, _ := ToObject(from) + _, err := CopyObjectProperties(oldOb, newOb) + return to, err +} + +func CopyCollectionProperties(to, from *Collection) (*Collection, error) { + to.First = replaceIfItem(to.First, from.First) + to.Last = replaceIfItem(to.Last, from.Last) + to.Items = replaceIfItemCollection(to.Items, from.Items) + if to.TotalItems == 0 { + to.TotalItems = from.TotalItems + } + oldOb, _ := ToObject(to) + newOb, _ := ToObject(from) + _, err := CopyObjectProperties(oldOb, newOb) + return to, err +} + +// CopyObjectProperties updates the "old" object properties with the "new's" +// Including ID and Type +func CopyObjectProperties(to, from *Object) (*Object, error) { + to.ID = from.ID + to.Type = from.Type + to.Name = replaceIfNaturalLanguageValues(to.Name, from.Name) + to.Attachment = replaceIfItem(to.Attachment, from.Attachment) + to.AttributedTo = replaceIfItem(to.AttributedTo, from.AttributedTo) + to.Audience = replaceIfItemCollection(to.Audience, from.Audience) + to.Content = replaceIfNaturalLanguageValues(to.Content, from.Content) + to.Context = replaceIfItem(to.Context, from.Context) + if len(from.MediaType) > 0 { + to.MediaType = from.MediaType + } + if !from.EndTime.IsZero() { + to.EndTime = from.EndTime + } + to.Generator = replaceIfItem(to.Generator, from.Generator) + to.Icon = replaceIfItem(to.Icon, from.Icon) + to.Image = replaceIfItem(to.Image, from.Image) + to.InReplyTo = replaceIfItem(to.InReplyTo, from.InReplyTo) + to.Location = replaceIfItem(to.Location, from.Location) + to.Preview = replaceIfItem(to.Preview, from.Preview) + if to.Published.IsZero() && !from.Published.IsZero() { + to.Published = from.Published + } + if to.Updated.IsZero() && !from.Updated.IsZero() { + to.Updated = from.Updated + } + to.Replies = replaceIfItem(to.Replies, from.Replies) + if !from.StartTime.IsZero() { + to.StartTime = from.StartTime + } + to.Summary = replaceIfNaturalLanguageValues(to.Summary, from.Summary) + to.Tag = replaceIfItemCollection(to.Tag, from.Tag) + if from.URL != nil { + to.URL = from.URL + } + to.To = replaceIfItemCollection(to.To, from.To) + to.Bto = replaceIfItemCollection(to.Bto, from.Bto) + to.CC = replaceIfItemCollection(to.CC, from.CC) + to.BCC = replaceIfItemCollection(to.BCC, from.BCC) + if from.Duration == 0 { + to.Duration = from.Duration + } + to.Source = replaceIfSource(to.Source, from.Source) + return to, nil +} + +func copyAllItemProperties(to, from Item) (Item, error) { + if CollectionType == to.GetType() { + o, err := ToCollection(to) + if err != nil { + return o, err + } + n, err := ToCollection(from) + if err != nil { + return o, err + } + return CopyCollectionProperties(o, n) + } + if CollectionPageType == to.GetType() { + o, err := ToCollectionPage(to) + if err != nil { + return o, err + } + n, err := ToCollectionPage(from) + if err != nil { + return o, err + } + return CopyCollectionPageProperties(o, n) + } + if OrderedCollectionType == to.GetType() { + o, err := ToOrderedCollection(to) + if err != nil { + return o, err + } + n, err := ToOrderedCollection(from) + if err != nil { + return o, err + } + return CopyOrderedCollectionProperties(o, n) + } + if OrderedCollectionPageType == to.GetType() { + o, err := ToOrderedCollectionPage(to) + if err != nil { + return o, err + } + n, err := ToOrderedCollectionPage(from) + if err != nil { + return o, err + } + return CopyOrderedCollectionPageProperties(o, n) + } + if ActorTypes.Contains(to.GetType()) { + o, err := ToActor(to) + if err != nil { + return o, err + } + n, err := ToActor(from) + if err != nil { + return o, err + } + return UpdatePersonProperties(o, n) + } + if ObjectTypes.Contains(to.GetType()) || to.GetType() == "" { + o, err := ToObject(to) + if err != nil { + return o, err + } + n, err := ToObject(from) + if err != nil { + return o, err + } + return CopyObjectProperties(o, n) + } + return to, fmt.Errorf("could not process objects with type %s", to.GetType()) +} + +// CopyItemProperties delegates to the correct per type functions for copying +// properties between matching Activity Objects +func CopyItemProperties(to, from Item) (Item, error) { + if to == nil { + return to, fmt.Errorf("nil object to update") + } + if from == nil { + return to, fmt.Errorf("nil object for update") + } + if !to.GetLink().Equals(from.GetLink(), false) { + return to, fmt.Errorf("object IDs don't match") + } + if to.GetType() != "" && to.GetType() != from.GetType() { + return to, fmt.Errorf("invalid object types for update %s(old) and %s(new)", from.GetType(), to.GetType()) + } + return copyAllItemProperties(to, from) +} + +// UpdatePersonProperties +func UpdatePersonProperties(to, from *Actor) (*Actor, error) { + to.Inbox = replaceIfItem(to.Inbox, from.Inbox) + to.Outbox = replaceIfItem(to.Outbox, from.Outbox) + to.Following = replaceIfItem(to.Following, from.Following) + to.Followers = replaceIfItem(to.Followers, from.Followers) + to.Liked = replaceIfItem(to.Liked, from.Liked) + to.PreferredUsername = replaceIfNaturalLanguageValues(to.PreferredUsername, from.PreferredUsername) + oldOb, _ := ToObject(to) + newOb, _ := ToObject(from) + _, err := CopyObjectProperties(oldOb, newOb) + return to, err +} + +func replaceIfItem(old, new Item) Item { + if new == nil { + return old + } + return new +} + +func replaceIfItemCollection(old, new ItemCollection) ItemCollection { + if new == nil { + return old + } + return new +} + +func replaceIfNaturalLanguageValues(old, new NaturalLanguageValues) NaturalLanguageValues { + if new == nil { + return old + } + return new +} + +func replaceIfSource(to, from Source) Source { + if from.MediaType != to.MediaType { + return from + } + to.Content = replaceIfNaturalLanguageValues(to.Content, from.Content) + return to +} diff --git a/decoding_gob.go b/decoding_gob.go new file mode 100644 index 0000000..4568521 --- /dev/null +++ b/decoding_gob.go @@ -0,0 +1,722 @@ +package activitypub + +import ( + "bytes" + "encoding/gob" + "errors" + "time" +) + +func GobDecode(data []byte) (Item, error) { + return gobDecodeItem(data) +} + +func gobDecodeUint(i *uint, data []byte) error { + g := gob.NewDecoder(bytes.NewReader(data)) + return g.Decode(i) +} + +func gobDecodeFloat64(f *float64, data []byte) error { + g := gob.NewDecoder(bytes.NewReader(data)) + return g.Decode(f) +} + +func gobDecodeInt64(i *int64, data []byte) error { + g := gob.NewDecoder(bytes.NewReader(data)) + return g.Decode(i) +} + +func gobDecodeBool(b *bool, data []byte) error { + g := gob.NewDecoder(bytes.NewReader(data)) + return g.Decode(b) +} + +func unmapActorProperties(mm map[string][]byte, a *Actor) error { + err := OnObject(a, func(ob *Object) error { + return unmapObjectProperties(mm, ob) + }) + if err != nil { + return err + } + if raw, ok := mm["inbox"]; ok { + if a.Inbox, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["outbox"]; ok { + if a.Outbox, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["following"]; ok { + if a.Following, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["followers"]; ok { + if a.Followers, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["liked"]; ok { + if a.Liked, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["preferredUsername"]; ok { + if a.PreferredUsername, err = gobDecodeNaturalLanguageValues(raw); err != nil { + return err + } + } + if raw, ok := mm["endpoints"]; ok { + if err = a.Endpoints.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["streams"]; ok { + if a.Streams, err = gobDecodeItems(raw); err != nil { + return err + } + } + if raw, ok := mm["publicKey"]; ok { + if err = a.PublicKey.GobDecode(raw); err != nil { + return err + } + } + return nil +} + +func unmapIntransitiveActivityProperties(mm map[string][]byte, act *IntransitiveActivity) error { + err := OnObject(act, func(ob *Object) error { + return unmapObjectProperties(mm, ob) + }) + if err != nil { + return err + } + if raw, ok := mm["actor"]; ok { + if act.Actor, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["target"]; ok { + if act.Target, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["result"]; ok { + if act.Result, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["origin"]; ok { + if act.Origin, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["instrument"]; ok { + if act.Instrument, err = gobDecodeItem(raw); err != nil { + return err + } + } + return nil +} + +func unmapActivityProperties(mm map[string][]byte, act *Activity) error { + err := OnIntransitiveActivity(act, func(act *IntransitiveActivity) error { + return unmapIntransitiveActivityProperties(mm, act) + }) + if err != nil { + return err + } + if raw, ok := mm["object"]; ok { + if act.Object, err = gobDecodeItem(raw); err != nil { + return err + } + } + return nil +} + +func unmapLinkProperties(mm map[string][]byte, l *Link) error { + if raw, ok := mm["id"]; ok { + if err := l.ID.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["type"]; ok { + if err := l.Type.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["mediaType"]; ok { + if err := l.MediaType.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["href"]; ok { + if err := l.Href.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["hrefLang"]; ok { + if err := l.HrefLang.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["name"]; ok { + if err := l.Name.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["rel"]; ok { + if err := l.Rel.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["width"]; ok { + if err := gobDecodeUint(&l.Width, raw); err != nil { + return err + } + } + if raw, ok := mm["height"]; ok { + if err := gobDecodeUint(&l.Height, raw); err != nil { + return err + } + } + return nil +} + +func unmapObjectProperties(mm map[string][]byte, o *Object) error { + var err error + if raw, ok := mm["id"]; ok { + if err = o.ID.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["type"]; ok { + if err = o.Type.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["name"]; ok { + if err = o.Name.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["attachment"]; ok { + if o.Attachment, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["attributedTo"]; ok { + if o.AttributedTo, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["audience"]; ok { + if o.Audience, err = gobDecodeItems(raw); err != nil { + return err + } + } + if raw, ok := mm["content"]; ok { + if o.Content, err = gobDecodeNaturalLanguageValues(raw); err != nil { + return err + } + } + if raw, ok := mm["context"]; ok { + if o.Context, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["mediaType"]; ok { + if err = o.MediaType.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["endTime"]; ok { + if err = o.EndTime.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["generator"]; ok { + if o.Generator, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["icon"]; ok { + if o.Icon, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["image"]; ok { + if o.Image, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["inReplyTo"]; ok { + if o.InReplyTo, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["location"]; ok { + if o.Location, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["preview"]; ok { + if o.Preview, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["published"]; ok { + if err = o.Published.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["replies"]; ok { + if o.Replies, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["startTime"]; ok { + if err = o.StartTime.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["summary"]; ok { + if o.Summary, err = gobDecodeNaturalLanguageValues(raw); err != nil { + return err + } + } + if raw, ok := mm["tag"]; ok { + if o.Tag, err = gobDecodeItems(raw); err != nil { + return err + } + } + if raw, ok := mm["updated"]; ok { + if err = o.Updated.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["url"]; ok { + if o.URL, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["to"]; ok { + if o.To, err = gobDecodeItems(raw); err != nil { + return err + } + } + if raw, ok := mm["bto"]; ok { + if o.Bto, err = gobDecodeItems(raw); err != nil { + return err + } + } + if raw, ok := mm["cc"]; ok { + if o.CC, err = gobDecodeItems(raw); err != nil { + return err + } + } + if raw, ok := mm["bcc"]; ok { + if o.BCC, err = gobDecodeItems(raw); err != nil { + return err + } + } + if raw, ok := mm["duration"]; ok { + if o.Duration, err = gobDecodeDuration(raw); err != nil { + return err + } + } + if raw, ok := mm["likes"]; ok { + if o.Likes, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["shares"]; ok { + if o.Shares, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["source"]; ok { + if err := o.Source.GobDecode(raw); err != nil { + return err + } + } + return nil +} + +func tryDecodeItems(items *ItemCollection, data []byte) error { + tt := make([][]byte, 0) + g := gob.NewDecoder(bytes.NewReader(data)) + if err := g.Decode(&tt); err != nil { + return err + } + for _, it := range tt { + ob, err := gobDecodeItem(it) + if err != nil { + return err + } + *items = append(*items, ob) + } + return nil +} + +func tryDecodeIRIs(iris *IRIs, data []byte) error { + return iris.GobDecode(data) +} + +func tryDecodeIRI(iri *IRI, data []byte) error { + return iri.GobDecode(data) +} + +func gobDecodeDuration(data []byte) (time.Duration, error) { + var d time.Duration + err := gob.NewDecoder(bytes.NewReader(data)).Decode(&d) + return d, err +} + +func gobDecodeNaturalLanguageValues(data []byte) (NaturalLanguageValues, error) { + n := make(NaturalLanguageValues, 0) + err := n.GobDecode(data) + return n, err +} + +func gobDecodeItems(data []byte) (ItemCollection, error) { + items := make(ItemCollection, 0) + if err := tryDecodeItems(&items, data); err != nil { + return nil, err + } + return items, nil +} + +func gobDecodeItem(data []byte) (Item, error) { + items := make(ItemCollection, 0) + if err := tryDecodeItems(&items, data); err == nil { + return items, nil + } + iris := make(IRIs, 0) + if err := tryDecodeIRIs(&iris, data); err == nil { + return iris, nil + } + isObject := false + typ := ActivityVocabularyType("") + mm, err := gobDecodeObjectAsMap(data) + if err == nil { + var sTyp []byte + sTyp, isObject = mm["type"] + if isObject { + typ = ActivityVocabularyType(sTyp) + } else { + _, isObject = mm["id"] + } + } + if isObject { + it, err := ItemTyperFunc(typ) + if err != nil { + return nil, err + } + switch it.GetType() { + case IRIType: + case "", ObjectType, ArticleType, AudioType, DocumentType, EventType, ImageType, NoteType, PageType, VideoType: + err = OnObject(it, func(ob *Object) error { + return unmapObjectProperties(mm, ob) + }) + case LinkType, MentionType: + err = OnLink(it, func(l *Link) error { + return unmapLinkProperties(mm, l) + }) + case ActivityType, AcceptType, AddType, AnnounceType, BlockType, CreateType, DeleteType, DislikeType, + FlagType, FollowType, IgnoreType, InviteType, JoinType, LeaveType, LikeType, ListenType, MoveType, OfferType, + RejectType, ReadType, RemoveType, TentativeRejectType, TentativeAcceptType, UndoType, UpdateType, ViewType: + err = OnActivity(it, func(act *Activity) error { + return unmapActivityProperties(mm, act) + }) + case IntransitiveActivityType, ArriveType, TravelType: + err = OnIntransitiveActivity(it, func(act *IntransitiveActivity) error { + return unmapIntransitiveActivityProperties(mm, act) + }) + case ActorType, ApplicationType, GroupType, OrganizationType, PersonType, ServiceType: + err = OnActor(it, func(a *Actor) error { + return unmapActorProperties(mm, a) + }) + case CollectionType: + err = OnCollection(it, func(c *Collection) error { + return unmapCollectionProperties(mm, c) + }) + case OrderedCollectionType: + err = OnOrderedCollection(it, func(c *OrderedCollection) error { + return unmapOrderedCollectionProperties(mm, c) + }) + case CollectionPageType: + err = OnCollectionPage(it, func(p *CollectionPage) error { + return unmapCollectionPageProperties(mm, p) + }) + case OrderedCollectionPageType: + err = OnOrderedCollectionPage(it, func(p *OrderedCollectionPage) error { + return unmapOrderedCollectionPageProperties(mm, p) + }) + case PlaceType: + err = OnPlace(it, func(p *Place) error { + return unmapPlaceProperties(mm, p) + }) + case ProfileType: + err = OnProfile(it, func(p *Profile) error { + return unmapProfileProperties(mm, p) + }) + case RelationshipType: + err = OnRelationship(it, func(r *Relationship) error { + return unmapRelationshipProperties(mm, r) + }) + case TombstoneType: + err = OnTombstone(it, func(t *Tombstone) error { + return unmapTombstoneProperties(mm, t) + }) + case QuestionType: + err = OnQuestion(it, func(q *Question) error { + return unmapQuestionProperties(mm, q) + }) + } + return it, err + } + iri := IRI("") + if err := tryDecodeIRI(&iri, data); err == nil { + return iri, err + } + + return nil, errors.New("unable to gob decode to any known ActivityPub types") +} + +func gobDecodeObjectAsMap(data []byte) (map[string][]byte, error) { + mm := make(map[string][]byte) + g := gob.NewDecoder(bytes.NewReader(data)) + if err := g.Decode(&mm); err != nil { + return nil, err + } + return mm, nil +} + +func unmapIncompleteCollectionProperties(mm map[string][]byte, c *Collection) error { + err := OnObject(c, func(ob *Object) error { + return unmapObjectProperties(mm, ob) + }) + if err != nil { + return err + } + if raw, ok := mm["current"]; ok { + if c.Current, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["first"]; ok { + if c.First, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["last"]; ok { + if c.Last, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["totalItems"]; ok { + if err = gobDecodeUint(&c.TotalItems, raw); err != nil { + return err + } + } + return nil +} + +func unmapCollectionProperties(mm map[string][]byte, c *Collection) error { + err := unmapIncompleteCollectionProperties(mm, c) + if err != nil { + return err + } + if raw, ok := mm["items"]; ok { + if c.Items, err = gobDecodeItems(raw); err != nil { + return err + } + } + return err +} + +func unmapCollectionPageProperties(mm map[string][]byte, c *CollectionPage) error { + err := OnCollection(c, func(c *Collection) error { + return unmapCollectionProperties(mm, c) + }) + if err != nil { + return err + } + if raw, ok := mm["partOf"]; ok { + if c.PartOf, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["next"]; ok { + if c.Next, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["prev"]; ok { + if c.Prev, err = gobDecodeItem(raw); err != nil { + return err + } + } + return err +} + +func unmapOrderedCollectionProperties(mm map[string][]byte, o *OrderedCollection) error { + err := OnCollection(o, func(c *Collection) error { + return unmapIncompleteCollectionProperties(mm, c) + }) + if err != nil { + return err + } + if raw, ok := mm["orderedItems"]; ok { + if o.OrderedItems, err = gobDecodeItems(raw); err != nil { + return err + } + } + return err +} + +func unmapOrderedCollectionPageProperties(mm map[string][]byte, c *OrderedCollectionPage) error { + err := OnOrderedCollection(c, func(c *OrderedCollection) error { + return unmapOrderedCollectionProperties(mm, c) + }) + if err != nil { + return err + } + if raw, ok := mm["partOf"]; ok { + if c.PartOf, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["next"]; ok { + if c.Next, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["prev"]; ok { + if c.Prev, err = gobDecodeItem(raw); err != nil { + return err + } + } + return err +} + +func unmapPlaceProperties(mm map[string][]byte, p *Place) error { + err := OnObject(p, func(ob *Object) error { + return unmapObjectProperties(mm, ob) + }) + if err != nil { + return err + } + if raw, ok := mm["accuracy"]; ok { + if err = gobDecodeFloat64(&p.Accuracy, raw); err != nil { + return err + } + } + if raw, ok := mm["altitude"]; ok { + if err = gobDecodeFloat64(&p.Altitude, raw); err != nil { + return err + } + } + if raw, ok := mm["latitude"]; ok { + if err = gobDecodeFloat64(&p.Latitude, raw); err != nil { + return err + } + } + if raw, ok := mm["radius"]; ok { + if err = gobDecodeInt64(&p.Radius, raw); err != nil { + return err + } + } + if raw, ok := mm["units"]; ok { + p.Units = string(raw) + } + return nil +} + +func unmapProfileProperties(mm map[string][]byte, p *Profile) error { + err := OnObject(p, func(ob *Object) error { + return unmapObjectProperties(mm, ob) + }) + if err != nil { + return err + } + if raw, ok := mm["Describes"]; ok { + if p.Describes, err = gobDecodeItem(raw); err != nil { + return err + } + } + return nil +} + +func unmapRelationshipProperties(mm map[string][]byte, r *Relationship) error { + err := OnObject(r, func(ob *Object) error { + return unmapObjectProperties(mm, ob) + }) + if err != nil { + return err + } + if raw, ok := mm["subject"]; ok { + if r.Subject, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["object"]; ok { + if r.Object, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["relationship"]; ok { + if r.Relationship, err = gobDecodeItem(raw); err != nil { + return err + } + } + return nil +} + +func unmapTombstoneProperties(mm map[string][]byte, t *Tombstone) error { + err := OnObject(t, func(ob *Object) error { + return unmapObjectProperties(mm, ob) + }) + if err != nil { + return err + } + if raw, ok := mm["formerType"]; ok { + if err = t.FormerType.GobDecode(raw); err != nil { + return err + } + } + if raw, ok := mm["deleted"]; ok { + if err = t.Deleted.GobDecode(raw); err != nil { + return err + } + } + return nil +} + +func unmapQuestionProperties(mm map[string][]byte, q *Question) error { + err := OnIntransitiveActivity(q, func(act *IntransitiveActivity) error { + return unmapIntransitiveActivityProperties(mm, act) + }) + if err != nil { + return err + } + if raw, ok := mm["oneOf"]; ok { + if q.OneOf, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["anyOf"]; ok { + if q.AnyOf, err = gobDecodeItem(raw); err != nil { + return err + } + } + if raw, ok := mm["closed"]; ok { + if err = gobDecodeBool(&q.Closed, raw); err != nil { + return err + } + } + return nil +} diff --git a/decoding_gob_test.go b/decoding_gob_test.go new file mode 100644 index 0000000..bf7358b --- /dev/null +++ b/decoding_gob_test.go @@ -0,0 +1,60 @@ +package activitypub + +/* +func TestGobEncode(t *testing.T) { + type args struct { + it Item + } + tests := []struct { + name string + args args + want []byte + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GobEncode(tt.args.it) + if (err != nil) != tt.wantErr { + t.Errorf("GobEncode() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GobEncode() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestUnmarshalGob(t *testing.T) { + type args struct { + data []byte + } + tests := []struct { + name string + args args + want Item + wantErr bool + }{ + { + name: "empty", + args: args{}, + want: nil, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := UnmarshalGob(tt.args.data) + if (err != nil) != tt.wantErr { + t.Errorf("UnmarshalGob() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("UnmarshalGob() = %v, want %v", got, tt.want) + } + }) + } +} +*/ diff --git a/decoding_json.go b/decoding_json.go new file mode 100644 index 0000000..38bb1d3 --- /dev/null +++ b/decoding_json.go @@ -0,0 +1,662 @@ +package activitypub + +import ( + "encoding" + "encoding/json" + "fmt" + "net/url" + "reflect" + "strings" + "time" + + "github.com/valyala/fastjson" +) + +var ( + apUnmarshalerType = reflect.TypeOf(new(Item)).Elem() + unmarshalerType = reflect.TypeOf(new(json.Unmarshaler)).Elem() + textUnmarshalerType = reflect.TypeOf(new(encoding.TextUnmarshaler)).Elem() +) + +// ItemTyperFunc will return an instance of a struct that implements activitypub.Item +// The default for this package is GetItemByType but can be overwritten +var ItemTyperFunc TyperFn = GetItemByType + +// JSONItemUnmarshal can be set externally to populate a custom object based on its type +var JSONItemUnmarshal JSONUnmarshalerFn = nil + +// IsNotEmpty checks if an object is empty +var IsNotEmpty NotEmptyCheckerFn = NotEmpty + +// TyperFn is the type of the function which returns an Item struct instance +// for a specific ActivityVocabularyType +type TyperFn func(ActivityVocabularyType) (Item, error) + +// JSONUnmarshalerFn is the type of the function that will load the data from a fastjson.Value into an Item +// that the current package doesn't know about. +type JSONUnmarshalerFn func(ActivityVocabularyType, *fastjson.Value, Item) error + +// NotEmptyCheckerFn is the type of the function that checks if an object is empty +type NotEmptyCheckerFn func(Item) bool + +func JSONGetID(val *fastjson.Value) ID { + i := val.Get("id").GetStringBytes() + return ID(i) +} + +func JSONGetType(val *fastjson.Value) ActivityVocabularyType { + t := val.Get("type").GetStringBytes() + return ActivityVocabularyType(t) +} + +func JSONGetMimeType(val *fastjson.Value, prop string) MimeType { + if !val.Exists(prop) { + return "" + } + t := val.GetStringBytes(prop) + return MimeType(t) +} + +func JSONGetInt(val *fastjson.Value, prop string) int64 { + if !val.Exists(prop) { + return 0 + } + i := val.Get(prop).GetInt64() + return i +} + +func JSONGetFloat(val *fastjson.Value, prop string) float64 { + if !val.Exists(prop) { + return 0.0 + } + f := val.Get(prop).GetFloat64() + return f +} + +func JSONGetString(val *fastjson.Value, prop string) string { + if !val.Exists(prop) { + return "" + } + s := val.Get(prop).GetStringBytes() + return string(s) +} + +func JSONGetBytes(val *fastjson.Value, prop string) []byte { + if !val.Exists(prop) { + return nil + } + s := val.Get(prop).GetStringBytes() + return s +} + +func JSONGetBoolean(val *fastjson.Value, prop string) bool { + if !val.Exists(prop) { + return false + } + t, _ := val.Get(prop).Bool() + return t +} + +func JSONGetNaturalLanguageField(val *fastjson.Value, prop string) NaturalLanguageValues { + n := NaturalLanguageValues{} + if val == nil { + return n + } + v := val.Get(prop) + if v == nil { + return nil + } + switch v.Type() { + case fastjson.TypeObject: + ob, _ := v.Object() + ob.Visit(func(key []byte, v *fastjson.Value) { + l := LangRefValue{} + l.Ref = LangRef(key) + if err := l.Value.UnmarshalJSON(v.GetStringBytes()); err == nil { + if l.Ref != NilLangRef || len(l.Value) > 0 { + n = append(n, l) + } + } + }) + case fastjson.TypeString: + l := LangRefValue{} + if err := l.UnmarshalJSON(v.GetStringBytes()); err == nil { + n = append(n, l) + } + } + + return n +} + +func JSONGetTime(val *fastjson.Value, prop string) time.Time { + t := time.Time{} + if val == nil { + return t + } + + if str := val.Get(prop).GetStringBytes(); len(str) > 0 { + t.UnmarshalText(str) + return t.UTC() + } + return t +} + +func JSONGetDuration(val *fastjson.Value, prop string) time.Duration { + if str := val.Get(prop).GetStringBytes(); len(str) > 0 { + // TODO(marius): this needs to be replaced to be compatible with xsd:duration + d, _ := time.ParseDuration(string(str)) + return d + } + return 0 +} + +func JSONGetPublicKey(val *fastjson.Value, prop string) PublicKey { + key := PublicKey{} + if val == nil { + return key + } + val = val.Get(prop) + if val == nil { + return key + } + JSONLoadPublicKey(val, &key) + return key +} + +func JSONItemsFn(val *fastjson.Value) (Item, error) { + if val.Type() == fastjson.TypeArray { + it := val.GetArray() + items := make(ItemCollection, 0) + for _, v := range it { + if it, _ := JSONLoadItem(v); it != nil { + items.Append(it) + } + } + return items, nil + } + return JSONLoadItem(val) +} + +func JSONLoadItem(val *fastjson.Value) (Item, error) { + typ := JSONGetType(val) + if typ == "" && val.Type() == fastjson.TypeString { + // try to see if it's an IRI + if i, ok := asIRI(val); ok { + return i, nil + } + } + i, err := ItemTyperFunc(typ) + if err != nil || IsNil(i) { + return nil, nil + } + var empty = func(i Item) bool { return !IsNotEmpty(i) } + + switch typ { + case "": + // NOTE(marius): this handles Tags which usually don't have types + fallthrough + case ObjectType, ArticleType, AudioType, DocumentType, EventType, ImageType, NoteType, PageType, VideoType: + err = OnObject(i, func(ob *Object) error { + return JSONLoadObject(val, ob) + }) + case LinkType, MentionType: + err = OnLink(i, func(l *Link) error { + return JSONLoadLink(val, l) + }) + case ActivityType, AcceptType, AddType, AnnounceType, BlockType, CreateType, DeleteType, DislikeType, + FlagType, FollowType, IgnoreType, InviteType, JoinType, LeaveType, LikeType, ListenType, MoveType, OfferType, + RejectType, ReadType, RemoveType, TentativeRejectType, TentativeAcceptType, UndoType, UpdateType, ViewType: + err = OnActivity(i, func(act *Activity) error { + return JSONLoadActivity(val, act) + }) + case IntransitiveActivityType, ArriveType, TravelType: + err = OnIntransitiveActivity(i, func(act *IntransitiveActivity) error { + return JSONLoadIntransitiveActivity(val, act) + }) + case ActorType, ApplicationType, GroupType, OrganizationType, PersonType, ServiceType: + err = OnActor(i, func(a *Actor) error { + return JSONLoadActor(val, a) + }) + case CollectionType: + err = OnCollection(i, func(c *Collection) error { + return JSONLoadCollection(val, c) + }) + case OrderedCollectionType: + err = OnOrderedCollection(i, func(c *OrderedCollection) error { + return JSONLoadOrderedCollection(val, c) + }) + case CollectionPageType: + err = OnCollectionPage(i, func(p *CollectionPage) error { + return JSONLoadCollectionPage(val, p) + }) + case OrderedCollectionPageType: + err = OnOrderedCollectionPage(i, func(p *OrderedCollectionPage) error { + return JSONLoadOrderedCollectionPage(val, p) + }) + case PlaceType: + err = OnPlace(i, func(p *Place) error { + return JSONLoadPlace(val, p) + }) + case ProfileType: + err = OnProfile(i, func(p *Profile) error { + return JSONLoadProfile(val, p) + }) + case RelationshipType: + err = OnRelationship(i, func(r *Relationship) error { + return JSONLoadRelationship(val, r) + }) + case TombstoneType: + err = OnTombstone(i, func(t *Tombstone) error { + return JSONLoadTombstone(val, t) + }) + case QuestionType: + err = OnQuestion(i, func(q *Question) error { + return JSONLoadQuestion(val, q) + }) + default: + if JSONItemUnmarshal == nil { + return nil, fmt.Errorf("unable to unmarshal custom type %s, you need to set a correct function for JSONItemUnmarshal", typ) + } + err = JSONItemUnmarshal(typ, val, i) + } + if err != nil { + return nil, err + } + if empty(i) { + return nil, nil + } + + return i, nil +} + +func JSONUnmarshalToItem(val *fastjson.Value) Item { + var ( + i Item + err error + ) + switch val.Type() { + case fastjson.TypeArray: + i, err = JSONItemsFn(val) + case fastjson.TypeObject: + i, err = JSONLoadItem(val) + case fastjson.TypeString: + if iri, ok := asIRI(val); ok { + // try to see if it's an IRI + i = iri + } + } + if err != nil { + return nil + } + return i +} + +func asIRI(val *fastjson.Value) (IRI, bool) { + if val == nil { + return NilIRI, true + } + s := strings.Trim(val.String(), `"`) + u, err := url.ParseRequestURI(s) + if err == nil && len(u.Scheme) > 0 && len(u.Host) > 0 { + // try to see if it's an IRI + return IRI(s), true + } + return EmptyIRI, false +} + +func JSONGetItem(val *fastjson.Value, prop string) Item { + if val == nil { + return nil + } + if val = val.Get(prop); val == nil { + return nil + } + switch val.Type() { + case fastjson.TypeString: + if i, ok := asIRI(val); ok { + // try to see if it's an IRI + return i + } + case fastjson.TypeArray: + it, _ := JSONItemsFn(val) + return it + case fastjson.TypeObject: + it, _ := JSONLoadItem(val) + return it + case fastjson.TypeNumber: + fallthrough + case fastjson.TypeNull: + fallthrough + default: + return nil + } + return nil +} + +func JSONGetURIItem(val *fastjson.Value, prop string) Item { + if val == nil { + return nil + } + if val = val.Get(prop); val == nil { + return nil + } + switch val.Type() { + case fastjson.TypeObject: + if it, _ := JSONLoadItem(val); it != nil { + return it + } + case fastjson.TypeArray: + if it, _ := JSONItemsFn(val); it != nil { + return it + } + case fastjson.TypeString: + return IRI(val.GetStringBytes()) + } + + return nil +} + +func JSONGetItems(val *fastjson.Value, prop string) ItemCollection { + if val == nil { + return nil + } + if val = val.Get(prop); val == nil { + return nil + } + + it := make(ItemCollection, 0) + switch val.Type() { + case fastjson.TypeArray: + for _, v := range val.GetArray() { + if i, _ := JSONLoadItem(v); i != nil { + it.Append(i) + } + } + case fastjson.TypeObject: + if i := JSONGetItem(val, prop); i != nil { + it.Append(i) + } + case fastjson.TypeString: + if iri := val.GetStringBytes(); len(iri) > 0 { + it.Append(IRI(iri)) + } + } + if len(it) == 0 { + return nil + } + return it +} + +func JSONGetLangRefField(val *fastjson.Value, prop string) LangRef { + s := val.Get(prop).GetStringBytes() + return LangRef(s) +} + +func JSONGetIRI(val *fastjson.Value, prop string) IRI { + s := val.Get(prop).GetStringBytes() + return IRI(s) +} + +// UnmarshalJSON tries to detect the type of the object in the json data and then outputs a matching +// ActivityStreams object, if possible +func UnmarshalJSON(data []byte) (Item, error) { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return nil, err + } + return JSONUnmarshalToItem(val), nil +} + +func GetItemByType(typ ActivityVocabularyType) (Item, error) { + switch typ { + case ObjectType, ArticleType, AudioType, DocumentType, EventType, ImageType, NoteType, PageType, VideoType: + return ObjectNew(typ), nil + case LinkType, MentionType: + return &Link{Type: typ}, nil + case ActivityType, AcceptType, AddType, AnnounceType, BlockType, CreateType, DeleteType, DislikeType, + FlagType, FollowType, IgnoreType, InviteType, JoinType, LeaveType, LikeType, ListenType, MoveType, OfferType, + RejectType, ReadType, RemoveType, TentativeRejectType, TentativeAcceptType, UndoType, UpdateType, ViewType: + return &Activity{Type: typ}, nil + case IntransitiveActivityType, ArriveType, TravelType: + return &IntransitiveActivity{Type: typ}, nil + case ActorType, ApplicationType, GroupType, OrganizationType, PersonType, ServiceType: + return &Actor{Type: typ}, nil + case CollectionType: + return &Collection{Type: typ}, nil + case OrderedCollectionType: + return &OrderedCollection{Type: typ}, nil + case CollectionPageType: + return &CollectionPage{Type: typ}, nil + case OrderedCollectionPageType: + return &OrderedCollectionPage{Type: typ}, nil + case PlaceType: + return &Place{Type: typ}, nil + case ProfileType: + return &Profile{Type: typ}, nil + case RelationshipType: + return &Relationship{Type: typ}, nil + case TombstoneType: + return &Tombstone{Type: typ}, nil + case QuestionType: + return &Question{Type: typ}, nil + case "": + fallthrough + default: + // when no type is available use a plain Object + return &Object{}, nil + } + return nil, fmt.Errorf("empty ActivityStreams type") +} + +func JSONGetActorEndpoints(val *fastjson.Value, prop string) *Endpoints { + if val == nil { + return nil + } + if val = val.Get(prop); val == nil { + return nil + } + + e := Endpoints{} + e.UploadMedia = JSONGetURIItem(val, "uploadMedia") + e.OauthAuthorizationEndpoint = JSONGetURIItem(val, "oauthAuthorizationEndpoint") + e.OauthTokenEndpoint = JSONGetURIItem(val, "oauthTokenEndpoint") + e.SharedInbox = JSONGetURIItem(val, "sharedInbox") + e.ProvideClientKey = JSONGetURIItem(val, "provideClientKey") + e.SignClientKey = JSONGetURIItem(val, "signClientKey") + + return &e +} + +func JSONLoadObject(val *fastjson.Value, o *Object) error { + o.ID = JSONGetID(val) + o.Type = JSONGetType(val) + o.Name = JSONGetNaturalLanguageField(val, "name") + o.Content = JSONGetNaturalLanguageField(val, "content") + o.Summary = JSONGetNaturalLanguageField(val, "summary") + o.Context = JSONGetItem(val, "context") + o.URL = JSONGetURIItem(val, "url") + o.MediaType = JSONGetMimeType(val, "mediaType") + o.Generator = JSONGetItem(val, "generator") + o.AttributedTo = JSONGetItem(val, "attributedTo") + o.Attachment = JSONGetItem(val, "attachment") + o.Location = JSONGetItem(val, "location") + o.Published = JSONGetTime(val, "published") + o.StartTime = JSONGetTime(val, "startTime") + o.EndTime = JSONGetTime(val, "endTime") + o.Duration = JSONGetDuration(val, "duration") + o.Icon = JSONGetItem(val, "icon") + o.Preview = JSONGetItem(val, "preview") + o.Image = JSONGetItem(val, "image") + o.Updated = JSONGetTime(val, "updated") + o.InReplyTo = JSONGetItem(val, "inReplyTo") + o.To = JSONGetItems(val, "to") + o.Audience = JSONGetItems(val, "audience") + o.Bto = JSONGetItems(val, "bto") + o.CC = JSONGetItems(val, "cc") + o.BCC = JSONGetItems(val, "bcc") + o.Replies = JSONGetItem(val, "replies") + o.Tag = JSONGetItems(val, "tag") + o.Likes = JSONGetItem(val, "likes") + o.Shares = JSONGetItem(val, "shares") + o.Source = GetAPSource(val) + return nil +} + +func JSONLoadIntransitiveActivity(val *fastjson.Value, i *IntransitiveActivity) error { + i.Actor = JSONGetItem(val, "actor") + i.Target = JSONGetItem(val, "target") + i.Result = JSONGetItem(val, "result") + i.Origin = JSONGetItem(val, "origin") + i.Instrument = JSONGetItem(val, "instrument") + return OnObject(i, func(o *Object) error { + return JSONLoadObject(val, o) + }) +} + +func JSONLoadActivity(val *fastjson.Value, a *Activity) error { + a.Object = JSONGetItem(val, "object") + return OnIntransitiveActivity(a, func(i *IntransitiveActivity) error { + return JSONLoadIntransitiveActivity(val, i) + }) +} + +func JSONLoadQuestion(val *fastjson.Value, q *Question) error { + q.OneOf = JSONGetItem(val, "oneOf") + q.AnyOf = JSONGetItem(val, "anyOf") + q.Closed = JSONGetBoolean(val, "closed") + return OnIntransitiveActivity(q, func(i *IntransitiveActivity) error { + return JSONLoadIntransitiveActivity(val, i) + }) +} + +func JSONLoadActor(val *fastjson.Value, a *Actor) error { + a.PreferredUsername = JSONGetNaturalLanguageField(val, "preferredUsername") + a.Followers = JSONGetItem(val, "followers") + a.Following = JSONGetItem(val, "following") + a.Inbox = JSONGetItem(val, "inbox") + a.Outbox = JSONGetItem(val, "outbox") + a.Liked = JSONGetItem(val, "liked") + a.Endpoints = JSONGetActorEndpoints(val, "endpoints") + a.Streams = JSONGetItems(val, "streams") + a.PublicKey = JSONGetPublicKey(val, "publicKey") + return OnObject(a, func(o *Object) error { + return JSONLoadObject(val, o) + }) +} + +func JSONLoadCollection(val *fastjson.Value, c *Collection) error { + c.Current = JSONGetItem(val, "current") + c.First = JSONGetItem(val, "first") + c.Last = JSONGetItem(val, "last") + c.TotalItems = uint(JSONGetInt(val, "totalItems")) + c.Items = JSONGetItems(val, "items") + return OnObject(c, func(o *Object) error { + return JSONLoadObject(val, o) + }) +} + +func JSONLoadCollectionPage(val *fastjson.Value, c *CollectionPage) error { + c.Next = JSONGetItem(val, "next") + c.Prev = JSONGetItem(val, "prev") + c.PartOf = JSONGetItem(val, "partOf") + return OnCollection(c, func(c *Collection) error { + return JSONLoadCollection(val, c) + }) +} + +func JSONLoadOrderedCollection(val *fastjson.Value, c *OrderedCollection) error { + c.Current = JSONGetItem(val, "current") + c.First = JSONGetItem(val, "first") + c.Last = JSONGetItem(val, "last") + c.TotalItems = uint(JSONGetInt(val, "totalItems")) + c.OrderedItems = JSONGetItems(val, "orderedItems") + return OnObject(c, func(o *Object) error { + return JSONLoadObject(val, o) + }) +} + +func JSONLoadOrderedCollectionPage(val *fastjson.Value, c *OrderedCollectionPage) error { + c.Next = JSONGetItem(val, "next") + c.Prev = JSONGetItem(val, "prev") + c.PartOf = JSONGetItem(val, "partOf") + c.StartIndex = uint(JSONGetInt(val, "startIndex")) + return OnOrderedCollection(c, func(c *OrderedCollection) error { + return JSONLoadOrderedCollection(val, c) + }) +} + +func JSONLoadPlace(val *fastjson.Value, p *Place) error { + p.Accuracy = JSONGetFloat(val, "accuracy") + p.Altitude = JSONGetFloat(val, "altitude") + p.Latitude = JSONGetFloat(val, "latitude") + p.Longitude = JSONGetFloat(val, "longitude") + p.Radius = JSONGetInt(val, "radius") + p.Units = JSONGetString(val, "units") + return OnObject(p, func(o *Object) error { + return JSONLoadObject(val, o) + }) +} + +func JSONLoadProfile(val *fastjson.Value, p *Profile) error { + p.Describes = JSONGetItem(val, "describes") + return OnObject(p, func(o *Object) error { + return JSONLoadObject(val, o) + }) +} + +func JSONLoadRelationship(val *fastjson.Value, r *Relationship) error { + r.Subject = JSONGetItem(val, "subject") + r.Object = JSONGetItem(val, "object") + r.Relationship = JSONGetItem(val, "relationship") + return OnObject(r, func(o *Object) error { + return JSONLoadObject(val, o) + }) +} + +func JSONLoadTombstone(val *fastjson.Value, t *Tombstone) error { + t.FormerType = ActivityVocabularyType(JSONGetString(val, "formerType")) + t.Deleted = JSONGetTime(val, "deleted") + return OnObject(t, func(o *Object) error { + return JSONLoadObject(val, o) + }) +} + +func JSONLoadLink(val *fastjson.Value, l *Link) error { + l.ID = JSONGetID(val) + l.Type = JSONGetType(val) + l.MediaType = JSONGetMimeType(val, "mediaType") + l.Preview = JSONGetItem(val, "preview") + if h := JSONGetInt(val, "height"); h != 0 { + l.Height = uint(h) + } + if w := JSONGetInt(val, "width"); w != 0 { + l.Width = uint(w) + } + l.Name = JSONGetNaturalLanguageField(val, "name") + if hrefLang := JSONGetLangRefField(val, "hrefLang"); len(hrefLang) > 0 { + l.HrefLang = hrefLang + } + if href := JSONGetURIItem(val, "href"); href != nil { + ll := href.GetLink() + if len(ll) > 0 { + l.Href = ll + } + } + if rel := JSONGetURIItem(val, "rel"); rel != nil { + rr := rel.GetLink() + if len(rr) > 0 { + l.Rel = rr + } + } + return nil +} + +func JSONLoadPublicKey(val *fastjson.Value, p *PublicKey) error { + p.ID = JSONGetID(val) + p.Owner = JSONGetIRI(val, "owner") + if pub := val.GetStringBytes("publicKeyPem"); len(pub) > 0 { + p.PublicKeyPem = string(pub) + } + return nil +} diff --git a/decoding_json_test.go b/decoding_json_test.go new file mode 100644 index 0000000..b1e334b --- /dev/null +++ b/decoding_json_test.go @@ -0,0 +1,487 @@ +package activitypub + +import ( + "errors" + "fmt" + "reflect" + "testing" + "time" + "unsafe" +) + +type visit struct { + a1 unsafe.Pointer + a2 unsafe.Pointer + typ reflect.Type +} + +type canErrorFunc func(format string, args ...interface{}) + +// See reflect.DeepEqual +func assertDeepEquals(t canErrorFunc, x, y interface{}) bool { + if x == nil || y == nil { + return x == y + } + v1 := reflect.ValueOf(x) + v2 := reflect.ValueOf(y) + if v1.Type() != v2.Type() { + t("%T != %T", x, y) + return false + } + return deepValueEqual(t, v1, v2, make(map[visit]bool), 0) +} + +// See reflect.deepValueEqual +func deepValueEqual(t canErrorFunc, v1, v2 reflect.Value, visited map[visit]bool, depth int) bool { + if !v1.IsValid() || !v2.IsValid() { + return v1.IsValid() == v2.IsValid() + } + if v1.Type() != v2.Type() { + t("types differ %s != %s", v1.Type().Name(), v2.Type().Name()) + return false + } + + hard := func(v1, v2 reflect.Value) bool { + switch v1.Kind() { + case reflect.Ptr: + return false + case reflect.Map, reflect.Slice, reflect.Interface: + // Nil pointers cannot be cyclic. Avoid putting them in the visited map. + return !v1.IsNil() && !v2.IsNil() + } + return false + } + + if hard(v1, v2) { + var addr1, addr2 unsafe.Pointer + if v1.CanAddr() { + addr1 = unsafe.Pointer(v1.UnsafeAddr()) + } else { + addr1 = unsafe.Pointer(v1.Pointer()) + } + if v2.CanAddr() { + addr2 = unsafe.Pointer(v2.UnsafeAddr()) + } else { + addr2 = unsafe.Pointer(v2.Pointer()) + } + if uintptr(addr1) > uintptr(addr2) { + // Canonicalize order to reduce number of entries in visited. + // Assumes non-moving garbage collector. + addr1, addr2 = addr2, addr1 + } + // Short circuit if references are already seen. + typ := v1.Type() + v := visit{addr1, addr2, typ} + if visited[v] { + return true + } + + // Remember for later. + visited[v] = true + } + + switch v1.Kind() { + case reflect.Array: + for i := 0; i < v1.Len(); i++ { + if !deepValueEqual(t, v1.Index(i), v2.Index(i), visited, depth+1) { + t("Arrays not equal at index %d %s %s", i, v1.Index(i), v2.Index(i)) + return false + } + } + return true + case reflect.Slice: + if v1.IsNil() != v2.IsNil() { + t("One of the slices is not nil %s[%d] vs %s[%d]", v1.Type().Name(), v1.Len(), v2.Type().Name(), v2.Len()) + return false + } + if v1.Len() != v2.Len() { + t("Slices lengths are different %s[%d] vs %s[%d]", v1.Type().Name(), v1.Len(), v2.Type().Name(), v2.Len()) + return false + } + if v1.Pointer() == v2.Pointer() { + return true + } + for i := 0; i < v1.Len(); i++ { + if !deepValueEqual(t, v1.Index(i), v2.Index(i), visited, depth+1) { + t("Slices elements at pos %d are not equal %#v vs %#v", i, v1.Index(i), v2.Index(i)) + return false + } + } + return true + case reflect.Interface: + if v1.IsNil() || v2.IsNil() { + if v1.IsNil() == v2.IsNil() { + return true + } + var isNil1, isNil2 string + if v1.IsNil() { + isNil1 = "is" + } else { + isNil1 = "is not" + } + if v2.IsNil() { + isNil2 = "is" + } else { + isNil2 = "is not" + } + t("Interface '%s' %s nil and '%s' %s nil", v1.Type().Name(), isNil1, v2.Type().Name(), isNil2) + return false + } + return deepValueEqual(t, v1.Elem(), v2.Elem(), visited, depth+1) + case reflect.Ptr: + if v1.Pointer() == v2.Pointer() { + return true + } + return deepValueEqual(t, v1.Elem(), v2.Elem(), visited, depth+1) + case reflect.Struct: + for i, n := 0, v1.NumField(); i < n; i++ { + var ( + f1 = v1.Field(i) + f2 = v2.Field(i) + n1 = v1.Type().Field(i).Name + n2 = v2.Type().Field(i).Name + t1 = f1.Type().Name() + t2 = f2.Type().Name() + ) + if !deepValueEqual(t, v1.Field(i), v2.Field(i), visited, depth+1) { + t("Struct fields at pos %d %s[%s] and %s[%s] are not deeply equal", i, n1, t1, n2, t2) + if f1.CanInterface() && f2.CanInterface() { + t(" Values: %#v - %#v", v1.Field(i).Interface(), v2.Field(i).Interface()) + } + return false + } + } + return true + case reflect.Map: + if v1.IsNil() != v2.IsNil() { + t("Maps are not nil", v1.Type().Name(), v2.Type().Name()) + return false + } + if v1.Len() != v2.Len() { + t("Maps don't have the same length %d vs %d", v1.Len(), v2.Len()) + return false + } + if v1.Pointer() == v2.Pointer() { + return true + } + for _, k := range v1.MapKeys() { + val1 := v1.MapIndex(k) + val2 := v2.MapIndex(k) + if !val1.IsValid() || !val2.IsValid() || !deepValueEqual(t, v1.MapIndex(k), v2.MapIndex(k), visited, depth+1) { + t("Maps values at index %s are not equal", k.String()) + return false + } + } + return true + case reflect.Func: + if v1.IsNil() && v2.IsNil() { + return true + } + // Can't do better than this: + return false + case reflect.String: + return v1.String() == v2.String() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v1.Int() == v2.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v1.Uint() == v2.Uint() + case reflect.Float32, reflect.Float64: + return v1.Float() == v2.Float() + case reflect.Bool: + return v1.Bool() == v2.Bool() + case reflect.Complex64, reflect.Complex128: + return v1.Complex() == v2.Complex() + } + return false +} + +type testPairs map[ActivityVocabularyType]reflect.Type + +var ( + objectPtrType = reflect.TypeOf(new(*Object)).Elem() + tombstoneType = reflect.TypeOf(new(*Tombstone)).Elem() + profileType = reflect.TypeOf(new(*Profile)).Elem() + placeType = reflect.TypeOf(new(*Place)).Elem() + relationshipType = reflect.TypeOf(new(*Relationship)).Elem() + linkPtrType = reflect.TypeOf(new(*Link)).Elem() + mentionPtrType = reflect.TypeOf(new(*Mention)).Elem() + activityPtrType = reflect.TypeOf(new(*Activity)).Elem() + intransitiveActivityPtrType = reflect.TypeOf(new(*IntransitiveActivity)).Elem() + collectionPtrType = reflect.TypeOf(new(*Collection)).Elem() + collectionPagePtrType = reflect.TypeOf(new(*CollectionPage)).Elem() + orderedCollectionPtrType = reflect.TypeOf(new(*OrderedCollection)).Elem() + orderedCollectionPagePtrType = reflect.TypeOf(new(*OrderedCollectionPage)).Elem() + actorPtrType = reflect.TypeOf(new(*Actor)).Elem() + applicationPtrType = reflect.TypeOf(new(*Application)).Elem() + servicePtrType = reflect.TypeOf(new(*Service)).Elem() + personPtrType = reflect.TypeOf(new(*Person)).Elem() + groupPtrType = reflect.TypeOf(new(*Group)).Elem() + organizationPtrType = reflect.TypeOf(new(*Organization)).Elem() + acceptPtrType = reflect.TypeOf(new(*Accept)).Elem() + addPtrType = reflect.TypeOf(new(*Add)).Elem() + announcePtrType = reflect.TypeOf(new(*Announce)).Elem() + arrivePtrType = reflect.TypeOf(new(*Arrive)).Elem() + blockPtrType = reflect.TypeOf(new(*Block)).Elem() + createPtrType = reflect.TypeOf(new(*Create)).Elem() + deletePtrType = reflect.TypeOf(new(*Delete)).Elem() + dislikePtrType = reflect.TypeOf(new(*Dislike)).Elem() + flagPtrType = reflect.TypeOf(new(*Flag)).Elem() + followPtrType = reflect.TypeOf(new(*Follow)).Elem() + ignorePtrType = reflect.TypeOf(new(*Ignore)).Elem() + invitePtrType = reflect.TypeOf(new(*Invite)).Elem() + joinPtrType = reflect.TypeOf(new(*Join)).Elem() + leavePtrType = reflect.TypeOf(new(*Leave)).Elem() + likePtrType = reflect.TypeOf(new(*Like)).Elem() + listenPtrType = reflect.TypeOf(new(*Listen)).Elem() + movePtrType = reflect.TypeOf(new(*Move)).Elem() + offerPtrType = reflect.TypeOf(new(*Offer)).Elem() + questionPtrType = reflect.TypeOf(new(*Question)).Elem() + rejectPtrType = reflect.TypeOf(new(*Reject)).Elem() + readPtrType = reflect.TypeOf(new(*Read)).Elem() + removePtrType = reflect.TypeOf(new(*Remove)).Elem() + tentativeRejectPtrType = reflect.TypeOf(new(*TentativeReject)).Elem() + tentativeAcceptPtrType = reflect.TypeOf(new(*TentativeAccept)).Elem() + travelPtrType = reflect.TypeOf(new(*Travel)).Elem() + undoPtrType = reflect.TypeOf(new(*Undo)).Elem() + updatePtrType = reflect.TypeOf(new(*Update)).Elem() + viewPtrType = reflect.TypeOf(new(*View)).Elem() +) + +var tests = testPairs{ + ObjectType: objectPtrType, + ArticleType: objectPtrType, + AudioType: objectPtrType, + DocumentType: objectPtrType, + ImageType: objectPtrType, + NoteType: objectPtrType, + PageType: objectPtrType, + PlaceType: placeType, + ProfileType: profileType, + RelationshipType: relationshipType, + TombstoneType: tombstoneType, + VideoType: objectPtrType, + LinkType: linkPtrType, + MentionType: mentionPtrType, + CollectionType: collectionPtrType, + CollectionPageType: collectionPagePtrType, + OrderedCollectionType: orderedCollectionPtrType, + OrderedCollectionPageType: orderedCollectionPagePtrType, + ActorType: actorPtrType, + ApplicationType: applicationPtrType, + ServiceType: servicePtrType, + PersonType: personPtrType, + GroupType: groupPtrType, + OrganizationType: organizationPtrType, + ActivityType: activityPtrType, + IntransitiveActivityType: intransitiveActivityPtrType, + AcceptType: acceptPtrType, + AddType: addPtrType, + AnnounceType: announcePtrType, + ArriveType: arrivePtrType, + BlockType: blockPtrType, + CreateType: createPtrType, + DeleteType: deletePtrType, + DislikeType: dislikePtrType, + FlagType: flagPtrType, + FollowType: followPtrType, + IgnoreType: ignorePtrType, + InviteType: invitePtrType, + JoinType: joinPtrType, + LeaveType: leavePtrType, + LikeType: likePtrType, + ListenType: listenPtrType, + MoveType: movePtrType, + OfferType: offerPtrType, + QuestionType: questionPtrType, + RejectType: rejectPtrType, + ReadType: readPtrType, + RemoveType: removePtrType, + TentativeRejectType: tentativeRejectPtrType, + TentativeAcceptType: tentativeAcceptPtrType, + TravelType: travelPtrType, + UndoType: undoPtrType, + UpdateType: updatePtrType, + ViewType: viewPtrType, +} + +func TestJSONGetItemByType(t *testing.T) { + for typ, test := range tests { + t.Run(string(typ), func(t *testing.T) { + v, err := GetItemByType(typ) + if err != nil { + t.Error(err) + } + if reflect.TypeOf(v) != test { + t.Errorf("Invalid type returned %T, expected %s", v, test.String()) + } + }) + } +} + +func TestUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + data []byte + want Item + err error + }{ + { + name: "empty", + data: []byte{'{', '}'}, + want: nil, + err: nil, + }, + { + name: "IRI", + data: []byte(`"http://example.com"`), + want: IRI("http://example.com"), + err: nil, + }, + { + name: "IRIs", + data: []byte(fmt.Sprintf("[%q, %q]", "http://example.com", "http://example.net")), + want: ItemCollection{ + IRI("http://example.com"), + IRI("http://example.net"), + }, + err: nil, + }, + { + name: "object", + data: []byte(`{"type":"Note"}`), + want: &Object{Type: NoteType}, + err: nil, + }, + { + name: "activity", + data: []byte(`{"type":"Like"}`), + want: &Activity{Type: LikeType}, + err: nil, + }, + { + name: "collection-2-items", + data: []byte(`{ "@context": "https://www.w3.org/ns/activitystreams", "id": "https://federated.git/inbox", "type": "OrderedCollection", "updated": "2021-08-08T16:09:05Z", "first": "https://federated.git/inbox?maxItems=100", "totalItems": 2, "orderedItems": [ { "id": "https://federated.git/activities/07440c39-64b2-4492-89cf-f5c2872cf4ff", "type": "Create", "attributedTo": "https://federated.git/actors/b1757243-080a-49dc-b832-42905d554b91", "to": [ "https://www.w3.org/ns/activitystreams#Public" ], "cc": [ "https://federated.git/actors/b1757243-080a-49dc-b832-42905d554b91/followers" ], "published": "2021-08-08T16:09:05Z", "actor": "https://federated.git/actors/b1757243-080a-49dc-b832-42905d554b91", "object": "https://federated.git/objects/3eb69f77-3b08-4bf1-8760-c7333e2900c4" }, { "id": "https://federated.git/activities/ab9a5511-cdb5-4585-8a48-775d1bf20121", "type": "Like", "attributedTo": "https://federated.git/actors/b1757243-080a-49dc-b832-42905d554b91", "to": [ "https://www.w3.org/ns/activitystreams#Public", "https://federated.git/actors/b1757243-080a-49dc-b832-42905d554b91" ], "published": "2021-08-08T16:09:05Z", "actor": "https://federated.git/actors/b1757243-080a-49dc-b832-42905d554b91", "object": "https://federated.git/objects/3eb69f77-3b08-4bf1-8760-c7333e2900c4" }]}`), + want: &OrderedCollection{ + ID: "https://federated.git/inbox", + Type: OrderedCollectionType, + Updated: time.Date(2021, 8, 8, 16, 9, 5, 0, time.UTC), + First: IRI("https://federated.git/inbox?maxItems=100"), + OrderedItems: ItemCollection{ + &Activity{ + ID: "https://federated.git/activities/07440c39-64b2-4492-89cf-f5c2872cf4ff", + Type: CreateType, + AttributedTo: IRI("https://federated.git/actors/b1757243-080a-49dc-b832-42905d554b91"), + To: ItemCollection{PublicNS}, + CC: ItemCollection{IRI("https://federated.git/actors/b1757243-080a-49dc-b832-42905d554b91/followers")}, + Published: time.Date(2021, 8, 8, 16, 9, 5, 0, time.UTC), + Actor: IRI("https://federated.git/actors/b1757243-080a-49dc-b832-42905d554b91"), + Object: IRI("https://federated.git/objects/3eb69f77-3b08-4bf1-8760-c7333e2900c4"), + }, + &Activity{ + ID: "https://federated.git/activities/ab9a5511-cdb5-4585-8a48-775d1bf20121", + Type: LikeType, + AttributedTo: IRI("https://federated.git/actors/b1757243-080a-49dc-b832-42905d554b91"), + To: ItemCollection{PublicNS, IRI("https://federated.git/actors/b1757243-080a-49dc-b832-42905d554b91")}, + Published: time.Date(2021, 8, 8, 16, 9, 5, 0, time.UTC), + Actor: IRI("https://federated.git/actors/b1757243-080a-49dc-b832-42905d554b91"), + Object: IRI("https://federated.git/objects/3eb69f77-3b08-4bf1-8760-c7333e2900c4"), + }, + }, + TotalItems: 2, + }, + err: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := UnmarshalJSON(tt.data) + if (err != nil && tt.err == nil) || (err == nil && tt.err != nil) { + if !errors.Is(err, tt.err) { + t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.err) + } + return + } + if !assertDeepEquals(t.Errorf, got, tt.want) { + t.Errorf("UnmarshalJSON() got = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestJSONGetDuration(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetInt(t *testing.T) { +} + +func TestJSONGetIRI(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetItem(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetItems(t *testing.T) { +} + +func TestJSONGetLangRefField(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetMimeType(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetID(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetNaturalLanguageField(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetString(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetTime(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetType(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetURIItem(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONUnmarshalToItem(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetActorEndpoints(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetBoolean(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetBytes(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetFloat(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetPublicKey(t *testing.T) { + t.Skipf("TODO") +} + +func TestJSONGetStreams(t *testing.T) { + t.Skipf("TODO") +} diff --git a/encoding_gob.go b/encoding_gob.go new file mode 100644 index 0000000..fca6d2d --- /dev/null +++ b/encoding_gob.go @@ -0,0 +1,792 @@ +package activitypub + +import ( + "bytes" + "encoding/gob" +) + +func GobEncode(it Item) ([]byte, error) { + return gobEncodeItem(it) +} + +// TODO(marius): when migrating to go1.18, use a numeric constraint for this +func gobEncodeInt64(i int64) ([]byte, error) { + b := bytes.Buffer{} + gg := gob.NewEncoder(&b) + if err := gg.Encode(i); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +// TODO(marius): when migrating to go1.18, use a numeric constraint for this +func gobEncodeUint(i uint) ([]byte, error) { + b := bytes.Buffer{} + gg := gob.NewEncoder(&b) + if err := gg.Encode(i); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +func gobEncodeFloat64(f float64) ([]byte, error) { + b := bytes.Buffer{} + gg := gob.NewEncoder(&b) + if err := gg.Encode(f); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +func gobEncodeBool(t bool) ([]byte, error) { + b := bytes.Buffer{} + gg := gob.NewEncoder(&b) + if err := gg.Encode(t); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +func gobEncodeStringLikeType(g *gob.Encoder, s []byte) error { + if err := g.Encode(s); err != nil { + return err + } + return nil +} + +func gobEncodeItems(col ItemCollection) ([]byte, error) { + b := bytes.Buffer{} + tt := make([][]byte, 0) + for _, it := range col.Collection() { + single, err := gobEncodeItem(it) + if err != nil { + return nil, err + } + tt = append(tt, single) + } + err := gob.NewEncoder(&b).Encode(tt) + return b.Bytes(), err +} + +func gobEncodeIRIs(col IRIs) ([]byte, error) { + b := bytes.Buffer{} + err := gob.NewEncoder(&b).Encode(col) + return b.Bytes(), err +} + +func gobEncodeItemOrLink(it LinkOrIRI) ([]byte, error) { + if ob, ok := it.(Item); ok { + return gobEncodeItem(ob) + } + b := bytes.Buffer{} + err := OnLink(it, func(l *Link) error { + bytes, err := l.GobEncode() + b.Write(bytes) + return err + }) + return b.Bytes(), err +} + +func gobEncodeItem(it Item) ([]byte, error) { + if IsIRI(it) { + if i, ok := it.(IRI); ok { + return []byte(i), nil + } + return []byte{}, nil + } + b := bytes.Buffer{} + var err error + if IsIRIs(it) { + err = OnIRIs(it, func(iris *IRIs) error { + bytes, err := gobEncodeIRIs(*iris) + b.Write(bytes) + return err + }) + } + if IsItemCollection(it) { + err = OnItemCollection(it, func(col *ItemCollection) error { + bytes, err := gobEncodeItems(*col) + b.Write(bytes) + return err + }) + } + if IsObject(it) { + switch it.GetType() { + case IRIType: + var bytes []byte + bytes, err = it.(IRI).GobEncode() + b.Write(bytes) + case "", ObjectType, ArticleType, AudioType, DocumentType, EventType, ImageType, NoteType, PageType, VideoType: + err = OnObject(it, func(ob *Object) error { + bytes, err := ob.GobEncode() + b.Write(bytes) + return err + }) + case LinkType, MentionType: + // TODO(marius): this shouldn't work, as Link does not implement Item? (or rather, should not) + err = OnLink(it, func(l *Link) error { + bytes, err := l.GobEncode() + b.Write(bytes) + return err + }) + case ActivityType, AcceptType, AddType, AnnounceType, BlockType, CreateType, DeleteType, DislikeType, + FlagType, FollowType, IgnoreType, InviteType, JoinType, LeaveType, LikeType, ListenType, MoveType, OfferType, + RejectType, ReadType, RemoveType, TentativeRejectType, TentativeAcceptType, UndoType, UpdateType, ViewType: + err = OnActivity(it, func(act *Activity) error { + bytes, err := act.GobEncode() + b.Write(bytes) + return err + }) + case IntransitiveActivityType, ArriveType, TravelType: + err = OnIntransitiveActivity(it, func(act *IntransitiveActivity) error { + bytes, err := act.GobEncode() + b.Write(bytes) + return err + }) + case ActorType, ApplicationType, GroupType, OrganizationType, PersonType, ServiceType: + err = OnActor(it, func(a *Actor) error { + bytes, err := a.GobEncode() + b.Write(bytes) + return err + }) + case CollectionType: + err = OnCollection(it, func(c *Collection) error { + bytes, err := c.GobEncode() + b.Write(bytes) + return err + }) + case OrderedCollectionType: + err = OnOrderedCollection(it, func(c *OrderedCollection) error { + bytes, err := c.GobEncode() + b.Write(bytes) + return err + }) + case CollectionPageType: + err = OnCollectionPage(it, func(p *CollectionPage) error { + bytes, err := p.GobEncode() + b.Write(bytes) + return err + }) + case OrderedCollectionPageType: + err = OnOrderedCollectionPage(it, func(p *OrderedCollectionPage) error { + bytes, err := p.GobEncode() + b.Write(bytes) + return err + }) + case PlaceType: + err = OnPlace(it, func(p *Place) error { + bytes, err := p.GobEncode() + b.Write(bytes) + return err + }) + case ProfileType: + err = OnProfile(it, func(p *Profile) error { + bytes, err := p.GobEncode() + b.Write(bytes) + return err + }) + case RelationshipType: + err = OnRelationship(it, func(r *Relationship) error { + bytes, err := r.GobEncode() + b.Write(bytes) + return err + }) + case TombstoneType: + err = OnTombstone(it, func(t *Tombstone) error { + bytes, err := t.GobEncode() + b.Write(bytes) + return err + }) + case QuestionType: + err = OnQuestion(it, func(q *Question) error { + bytes, err := q.GobEncode() + b.Write(bytes) + return err + }) + } + } + return b.Bytes(), err +} + +func mapObjectProperties(mm map[string][]byte, o *Object) (hasData bool, err error) { + if len(o.ID) > 0 { + if mm["id"], err = o.ID.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if len(o.Type) > 0 { + if mm["type"], err = o.Type.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if len(o.MediaType) > 0 { + if mm["mediaType"], err = o.MediaType.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if len(o.Name) > 0 { + if mm["name"], err = o.Name.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if o.Attachment != nil { + if mm["attachment"], err = gobEncodeItem(o.Attachment); err != nil { + return hasData, err + } + hasData = true + } + if o.AttributedTo != nil { + if mm["attributedTo"], err = gobEncodeItem(o.AttributedTo); err != nil { + return hasData, err + } + hasData = true + } + if o.Audience != nil { + if mm["audience"], err = gobEncodeItem(o.Audience); err != nil { + return hasData, err + } + hasData = true + } + if o.Content != nil { + if mm["content"], err = o.Content.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if o.Context != nil { + if mm["context"], err = gobEncodeItem(o.Context); err != nil { + return hasData, err + } + hasData = true + } + if len(o.MediaType) > 0 { + if mm["mediaType"], err = o.MediaType.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if !o.EndTime.IsZero() { + if mm["endTime"], err = o.EndTime.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if o.Generator != nil { + if mm["generator"], err = gobEncodeItem(o.Generator); err != nil { + return hasData, err + } + hasData = true + } + if o.Icon != nil { + if mm["icon"], err = gobEncodeItem(o.Icon); err != nil { + return hasData, err + } + hasData = true + } + if o.Image != nil { + if mm["image"], err = gobEncodeItem(o.Image); err != nil { + return hasData, err + } + hasData = true + } + if o.InReplyTo != nil { + if mm["inReplyTo"], err = gobEncodeItem(o.InReplyTo); err != nil { + return hasData, err + } + hasData = true + } + if o.Location != nil { + if mm["location"], err = gobEncodeItem(o.Location); err != nil { + return hasData, err + } + hasData = true + } + if o.Preview != nil { + if mm["preview"], err = gobEncodeItem(o.Preview); err != nil { + return hasData, err + } + hasData = true + } + if !o.Published.IsZero() { + if mm["published"], err = o.Published.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if o.Replies != nil { + if mm["replies"], err = gobEncodeItem(o.Replies); err != nil { + return hasData, err + } + hasData = true + } + if !o.StartTime.IsZero() { + if mm["startTime"], err = o.StartTime.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if len(o.Summary) > 0 { + if mm["summary"], err = o.Summary.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if o.Tag != nil { + if mm["tag"], err = gobEncodeItem(o.Tag); err != nil { + return hasData, err + } + hasData = true + } + if !o.Updated.IsZero() { + if mm["updated"], err = o.Updated.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if o.Tag != nil { + if mm["tag"], err = gobEncodeItem(o.Tag); err != nil { + return hasData, err + } + hasData = true + } + if !o.Updated.IsZero() { + if mm["updated"], err = o.Updated.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if o.URL != nil { + if mm["url"], err = gobEncodeItemOrLink(o.URL); err != nil { + return hasData, err + } + hasData = true + } + if o.To != nil { + if mm["to"], err = gobEncodeItem(o.To); err != nil { + return hasData, err + } + hasData = true + } + if o.Bto != nil { + if mm["bto"], err = gobEncodeItem(o.Bto); err != nil { + return hasData, err + } + hasData = true + } + if o.CC != nil { + if mm["cc"], err = gobEncodeItem(o.CC); err != nil { + return hasData, err + } + hasData = true + } + if o.BCC != nil { + if mm["bcc"], err = gobEncodeItem(o.BCC); err != nil { + return hasData, err + } + hasData = true + } + if o.Duration > 0 { + if mm["duration"], err = gobEncodeInt64(int64(o.Duration)); err != nil { + return hasData, err + } + hasData = true + } + if o.Likes != nil { + if mm["likes"], err = gobEncodeItem(o.Likes); err != nil { + return hasData, err + } + hasData = true + } + if o.Shares != nil { + if mm["shares"], err = gobEncodeItem(o.Shares); err != nil { + return hasData, err + } + hasData = true + } + if o.Shares != nil { + if mm["shares"], err = gobEncodeItem(o.Shares); err != nil { + return hasData, err + } + hasData = true + } + if len(o.Source.MediaType)+len(o.Source.Content) > 0 { + if mm["source"], err = o.Source.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + + return hasData, nil +} + +func mapActorProperties(mm map[string][]byte, a *Actor) (hasData bool, err error) { + err = OnObject(a, func(o *Object) error { + hasData, err = mapObjectProperties(mm, o) + return err + }) + if a.Inbox != nil { + if mm["inbox"], err = gobEncodeItem(a.Inbox); err != nil { + return hasData, err + } + hasData = true + } + if a.Inbox != nil { + if mm["inbox"], err = gobEncodeItem(a.Inbox); err != nil { + return hasData, err + } + hasData = true + } + if a.Outbox != nil { + if mm["outbox"], err = gobEncodeItem(a.Outbox); err != nil { + return hasData, err + } + hasData = true + } + if a.Following != nil { + if mm["following"], err = gobEncodeItem(a.Following); err != nil { + return hasData, err + } + hasData = true + } + if a.Followers != nil { + if mm["followers"], err = gobEncodeItem(a.Followers); err != nil { + return hasData, err + } + hasData = true + } + if a.Liked != nil { + if mm["liked"], err = gobEncodeItem(a.Liked); err != nil { + return hasData, err + } + hasData = true + } + if len(a.PreferredUsername) > 0 { + if mm["preferredUsername"], err = a.PreferredUsername.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if a.Endpoints != nil { + if mm["endpoints"], err = a.Endpoints.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if len(a.Streams) > 0 { + if mm["streams"], err = gobEncodeItems(a.Streams); err != nil { + return hasData, err + } + hasData = true + } + if len(a.PublicKey.PublicKeyPem)+len(a.PublicKey.ID) > 0 { + if mm["publicKey"], err = a.PublicKey.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + return hasData, err +} + +func mapIncompleteCollectionProperties(mm map[string][]byte, c Collection) (hasData bool, err error) { + err = OnObject(c, func(o *Object) error { + hasData, err = mapObjectProperties(mm, o) + return err + }) + if c.Current != nil { + if mm["current"], err = gobEncodeItem(c.Current); err != nil { + return hasData, err + } + hasData = true + } + if c.First != nil { + if mm["first"], err = gobEncodeItem(c.First); err != nil { + return hasData, err + } + hasData = true + } + if c.Last != nil { + if mm["last"], err = gobEncodeItem(c.Last); err != nil { + return hasData, err + } + hasData = true + } + if c.TotalItems > 0 { + hasData = true + } + if mm["totalItems"], err = gobEncodeUint(c.TotalItems); err != nil { + return hasData, err + } + return +} + +func mapCollectionProperties(mm map[string][]byte, c Collection) (hasData bool, err error) { + hasData, err = mapIncompleteCollectionProperties(mm, c) + if err != nil { + return hasData, err + } + if c.Items != nil { + if mm["items"], err = gobEncodeItems(c.Items); err != nil { + return hasData, err + } + hasData = true + } + return +} + +func mapOrderedCollectionProperties(mm map[string][]byte, c OrderedCollection) (hasData bool, err error) { + err = OnCollection(c, func(c *Collection) error { + hasData, err = mapIncompleteCollectionProperties(mm, *c) + return err + }) + if c.OrderedItems != nil { + if mm["orderedItems"], err = gobEncodeItems(c.OrderedItems); err != nil { + return hasData, err + } + hasData = true + } + return +} + +func mapCollectionPageProperties(mm map[string][]byte, c CollectionPage) (hasData bool, err error) { + err = OnCollection(c, func(c *Collection) error { + hasData, err = mapCollectionProperties(mm, *c) + return err + }) + if c.PartOf != nil { + if mm["partOf"], err = gobEncodeItem(c.PartOf); err != nil { + return hasData, err + } + hasData = true + } + if c.Next != nil { + if mm["next"], err = gobEncodeItem(c.Next); err != nil { + return hasData, err + } + hasData = true + } + if c.Prev != nil { + if mm["prev"], err = gobEncodeItem(c.Prev); err != nil { + return hasData, err + } + hasData = true + } + return +} + +func mapOrderedCollectionPageProperties(mm map[string][]byte, c OrderedCollectionPage) (hasData bool, err error) { + err = OnOrderedCollection(c, func(c *OrderedCollection) error { + hasData, err = mapOrderedCollectionProperties(mm, *c) + return err + }) + if c.PartOf != nil { + if mm["partOf"], err = gobEncodeItem(c.PartOf); err != nil { + return hasData, err + } + hasData = true + } + if c.Next != nil { + if mm["next"], err = gobEncodeItem(c.Next); err != nil { + return hasData, err + } + hasData = true + } + if c.Prev != nil { + if mm["prev"], err = gobEncodeItem(c.Prev); err != nil { + return hasData, err + } + hasData = true + } + return +} + +func mapLinkProperties(mm map[string][]byte, l Link) (hasData bool, err error) { + if len(l.ID) > 0 { + if mm["id"], err = l.ID.GobEncode(); err != nil { + return + } + hasData = true + } + if len(l.Type) > 0 { + if mm["type"], err = l.Type.GobEncode(); err != nil { + return + } + hasData = true + } + if len(l.MediaType) > 0 { + if mm["mediaType"], err = l.MediaType.GobEncode(); err != nil { + return + } + hasData = true + } + if len(l.Href) > 0 { + if mm["href"], err = l.Href.GobEncode(); err != nil { + return + } + hasData = true + } + if len(l.HrefLang) > 0 { + if mm["hrefLang"], err = l.HrefLang.GobEncode(); err != nil { + return + } + hasData = true + } + if len(l.Name) > 0 { + if mm["name"], err = l.Name.GobEncode(); err != nil { + return + } + hasData = true + } + if len(l.Rel) > 0 { + if mm["rel"], err = l.Rel.GobEncode(); err != nil { + return + } + hasData = true + } + if l.Width > 0 { + if mm["width"], err = gobEncodeUint(l.Width); err != nil { + return + } + hasData = true + } + if l.Height > 0 { + if mm["height"], err = gobEncodeUint(l.Height); err != nil { + return + } + hasData = true + } + return +} + +func mapPlaceProperties(mm map[string][]byte, p Place) (hasData bool, err error) { + err = OnObject(p, func(o *Object) error { + hasData, err = mapObjectProperties(mm, o) + return err + }) + if p.Accuracy > 0 { + if mm["accuracy"], err = gobEncodeFloat64(p.Accuracy); err != nil { + return + } + hasData = true + } + if p.Altitude > 0 { + if mm["altitude"], err = gobEncodeFloat64(p.Altitude); err != nil { + return + } + hasData = true + } + if p.Latitude > 0 { + if mm["latitude"], err = gobEncodeFloat64(p.Latitude); err != nil { + return + } + hasData = true + } + if p.Longitude > 0 { + if mm["longitude"], err = gobEncodeFloat64(p.Longitude); err != nil { + return + } + hasData = true + } + if p.Radius > 0 { + if mm["radius"], err = gobEncodeInt64(p.Radius); err != nil { + return + } + hasData = true + } + if len(p.Units) > 0 { + mm["units"] = []byte(p.Units) + hasData = true + } + return +} + +func mapProfileProperties(mm map[string][]byte, p Profile) (hasData bool, err error) { + err = OnObject(p, func(o *Object) error { + hasData, err = mapObjectProperties(mm, o) + return err + }) + if p.Describes != nil { + if mm["describes"], err = gobEncodeItem(p.Describes); err != nil { + return + } + hasData = true + } + return +} + +func mapRelationshipProperties(mm map[string][]byte, r Relationship) (hasData bool, err error) { + err = OnObject(r, func(o *Object) error { + hasData, err = mapObjectProperties(mm, o) + return err + }) + if r.Subject != nil { + if mm["subject"], err = gobEncodeItem(r.Subject); err != nil { + return + } + hasData = true + } + if r.Object != nil { + if mm["object"], err = gobEncodeItem(r.Object); err != nil { + return + } + hasData = true + } + if r.Relationship != nil { + if mm["relationship"], err = gobEncodeItem(r.Relationship); err != nil { + return + } + hasData = true + } + return +} + +func mapTombstoneProperties(mm map[string][]byte, t Tombstone) (hasData bool, err error) { + err = OnObject(t, func(o *Object) error { + hasData, err = mapObjectProperties(mm, o) + return err + }) + if len(t.FormerType) > 0 { + if mm["formerType"], err = t.FormerType.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + if !t.Deleted.IsZero() { + if mm["deleted"], err = t.Deleted.GobEncode(); err != nil { + return hasData, err + } + hasData = true + } + return +} + +func mapQuestionProperties(mm map[string][]byte, q Question) (hasData bool, err error) { + err = OnIntransitiveActivity(q, func(i *IntransitiveActivity) error { + hasData, err = mapIntransitiveActivityProperties(mm, i) + return err + }) + if q.OneOf != nil { + if mm["oneOf"], err = gobEncodeItem(q.OneOf); err != nil { + return + } + hasData = true + } + if q.AnyOf != nil { + if mm["anyOf"], err = gobEncodeItem(q.AnyOf); err != nil { + return + } + hasData = true + } + if q.Closed { + hasData = true + } + if hasData { + if mm["closed"], err = gobEncodeBool(q.Closed); err != nil { + return + } + } + return +} diff --git a/encoding_gob_test.go b/encoding_gob_test.go new file mode 100644 index 0000000..a3ae58f --- /dev/null +++ b/encoding_gob_test.go @@ -0,0 +1,43 @@ +package activitypub + +/* +func TestMarshalGob(t *testing.T) { + tests := []struct { + name string + it Item + want []byte + wantErr error + }{ + { + name: "empty object", + it: &Object{ + ID: "test", + }, + want: []byte{}, + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := bytes.NewBuffer(make([]byte, 0)) + err := gob.NewEncoder(buf).Encode(tt.it) + + if !errors.Is(err, tt.wantErr) { + t.Errorf("MarshalGob() error = %s, wantErr %v", err, tt.wantErr) + return + } + + it := new(Object) + got := buf.Bytes() + if err := gob.NewDecoder(bytes.NewReader(got)).Decode(it); err != nil { + t.Errorf("Gob Decoding failed for previously generated output %v", err) + } + if tt.wantErr == nil { + if !assertDeepEquals(t.Errorf, it, tt.it) { + t.Errorf("Gob Decoded value is different got = %#v, want %#v", it, tt.it) + } + } + }) + } +} +*/ diff --git a/encoding_json.go b/encoding_json.go new file mode 100644 index 0000000..151e884 --- /dev/null +++ b/encoding_json.go @@ -0,0 +1,405 @@ +package activitypub + +import ( + "encoding/json" + "fmt" + "time" + + "git.sr.ht/~mariusor/go-xsd-duration" + "github.com/go-ap/jsonld" +) + +func JSONWriteComma(b *[]byte) { + if len(*b) > 1 && (*b)[len(*b)-1] != ',' { + *b = append(*b, ',') + } +} + +func JSONWriteProp(b *[]byte, name string, val []byte) (notEmpty bool) { + if len(val) == 0 { + return false + } + JSONWriteComma(b) + success := JSONWritePropName(b, name) && JSONWriteValue(b, val) + if !success { + *b = (*b)[:len(*b)-1] + } + return success +} + +func JSONWrite(b *[]byte, c ...byte) { + *b = append(*b, c...) +} + +func JSONWriteS(b *[]byte, s string) { + *b = append(*b, s...) +} + +func JSONWritePropName(b *[]byte, s string) (notEmpty bool) { + if len(s) == 0 { + return false + } + JSONWrite(b, '"') + JSONWriteS(b, s) + JSONWrite(b, '"', ':') + return true +} + +func JSONWriteValue(b *[]byte, s []byte) (notEmpty bool) { + if len(s) == 0 { + return false + } + JSONWrite(b, s...) + return true +} + +func JSONWriteNaturalLanguageProp(b *[]byte, n string, nl NaturalLanguageValues) (notEmpty bool) { + l := nl.Count() + if l > 1 { + n += "Map" + } + if v, err := nl.MarshalJSON(); err == nil && len(v) > 0 { + return JSONWriteProp(b, n, v) + } + return false +} + +func JSONWriteStringProp(b *[]byte, n string, s string) (notEmpty bool) { + return JSONWriteProp(b, n, []byte(fmt.Sprintf(`"%s"`, s))) +} + +func JSONWriteBoolProp(b *[]byte, n string, t bool) (notEmpty bool) { + return JSONWriteProp(b, n, []byte(fmt.Sprintf(`"%t"`, t))) +} + +func JSONWriteIntProp(b *[]byte, n string, d int64) (notEmpty bool) { + return JSONWriteProp(b, n, []byte(fmt.Sprintf("%d", d))) +} + +func JSONWriteFloatProp(b *[]byte, n string, f float64) (notEmpty bool) { + return JSONWriteProp(b, n, []byte(fmt.Sprintf("%f", f))) +} + +func JSONWriteTimeProp(b *[]byte, n string, t time.Time) (notEmpty bool) { + var tb []byte + JSONWrite(&tb, '"') + JSONWriteS(&tb, t.UTC().Format(time.RFC3339)) + JSONWrite(&tb, '"') + return JSONWriteProp(b, n, tb) +} + +func JSONWriteDurationProp(b *[]byte, n string, d time.Duration) (notEmpty bool) { + var tb []byte + if v, err := xsd.Marshal(d); err == nil { + JSONWrite(&tb, '"') + JSONWrite(&tb, v...) + JSONWrite(&tb, '"') + } + return JSONWriteProp(b, n, tb) +} + +func JSONWriteIRIProp(b *[]byte, n string, i LinkOrIRI) (notEmpty bool) { + url := i.GetLink().String() + if len(url) == 0 { + return false + } + JSONWriteStringProp(b, n, url) + return true +} + +func JSONWriteItemProp(b *[]byte, n string, i 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 JSONWriteProp(b, n, v) + } + return notEmpty +} + +func byteInsertAt(raw []byte, b byte, p int) []byte { + return append(raw[:p], append([]byte{b}, raw[p:]...)...) +} + +func escapeQuote(s string) string { + raw := []byte(s) + end := len(s) + for i := 0; i < end; i++ { + c := raw[i] + if c == '"' && (i > 0 && s[i-1] != '\\') { + raw = byteInsertAt(raw, '\\', i) + i++ + end++ + } + } + return string(raw) +} + +func JSONWriteStringValue(b *[]byte, s string) (notEmpty bool) { + if len(s) == 0 { + return false + } + JSONWrite(b, '"') + JSONWriteS(b, escapeQuote(s)) + JSONWrite(b, '"') + return true +} + +func JSONWriteItemCollectionValue(b *[]byte, col ItemCollection, compact bool) (notEmpty bool) { + if len(col) == 0 { + return notEmpty + } + if len(col) == 1 && compact { + it := col[0] + im, ok := it.(json.Marshaler) + if !ok { + return false + } + v, err := im.MarshalJSON() + if err != nil { + return false + } + if len(v) == 0 { + return false + } + JSONWrite(b, v...) + return true + } + writeCommaIfNotEmpty := func(notEmpty bool) { + if notEmpty { + JSONWrite(b, ',') + } + } + JSONWrite(b, '[') + skipComma := true + for _, it := range col { + im, ok := it.(json.Marshaler) + if !ok { + continue + } + v, err := im.MarshalJSON() + if err != nil { + return false + } + if len(v) == 0 { + continue + } + writeCommaIfNotEmpty(!skipComma) + JSONWrite(b, v...) + skipComma = false + } + JSONWrite(b, ']') + return true +} + +func JSONWriteItemCollectionProp(b *[]byte, n string, col ItemCollection, compact bool) (notEmpty bool) { + if len(col) == 0 { + return notEmpty + } + JSONWriteComma(b) + success := JSONWritePropName(b, n) && JSONWriteItemCollectionValue(b, col, compact) + if !success { + *b = (*b)[:len(*b)-1] + } + return success +} + +func JSONWriteObjectValue(b *[]byte, o Object) (notEmpty bool) { + if v, err := o.ID.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = JSONWriteProp(b, "id", v) + } + if v, err := o.Type.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = JSONWriteProp(b, "type", v) || notEmpty + } + if v, err := o.MediaType.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = JSONWriteProp(b, "mediaType", v) || notEmpty + } + if len(o.Name) > 0 { + notEmpty = JSONWriteNaturalLanguageProp(b, "name", o.Name) || notEmpty + } + if len(o.Summary) > 0 { + notEmpty = JSONWriteNaturalLanguageProp(b, "summary", o.Summary) || notEmpty + } + if len(o.Content) > 0 { + notEmpty = JSONWriteNaturalLanguageProp(b, "content", o.Content) || notEmpty + } + if o.Attachment != nil { + notEmpty = JSONWriteItemProp(b, "attachment", o.Attachment) || notEmpty + } + if o.AttributedTo != nil { + notEmpty = JSONWriteItemProp(b, "attributedTo", o.AttributedTo) || notEmpty + } + if o.Audience != nil { + notEmpty = JSONWriteItemProp(b, "audience", o.Audience) || notEmpty + } + if o.Context != nil { + notEmpty = JSONWriteItemProp(b, "context", o.Context) || notEmpty + } + if o.Generator != nil { + notEmpty = JSONWriteItemProp(b, "generator", o.Generator) || notEmpty + } + if o.Icon != nil { + notEmpty = JSONWriteItemProp(b, "icon", o.Icon) || notEmpty + } + if o.Image != nil { + notEmpty = JSONWriteItemProp(b, "image", o.Image) || notEmpty + } + if o.InReplyTo != nil { + notEmpty = JSONWriteItemProp(b, "inReplyTo", o.InReplyTo) || notEmpty + } + if o.Location != nil { + notEmpty = JSONWriteItemProp(b, "location", o.Location) || notEmpty + } + if o.Preview != nil { + notEmpty = JSONWriteItemProp(b, "preview", o.Preview) || notEmpty + } + if o.Replies != nil { + notEmpty = JSONWriteItemProp(b, "replies", o.Replies) || notEmpty + } + if o.Tag != nil { + notEmpty = JSONWriteItemCollectionProp(b, "tag", o.Tag, false) || notEmpty + } + if o.URL != nil { + notEmpty = JSONWriteItemProp(b, "url", o.URL) || notEmpty + } + if o.To != nil { + notEmpty = JSONWriteItemCollectionProp(b, "to", o.To, false) || notEmpty + } + if o.Bto != nil { + notEmpty = JSONWriteItemCollectionProp(b, "bto", o.Bto, false) || notEmpty + } + if o.CC != nil { + notEmpty = JSONWriteItemCollectionProp(b, "cc", o.CC, false) || notEmpty + } + if o.BCC != nil { + notEmpty = JSONWriteItemCollectionProp(b, "bcc", o.BCC, false) || notEmpty + } + if !o.Published.IsZero() { + notEmpty = JSONWriteTimeProp(b, "published", o.Published) || notEmpty + } + if !o.Updated.IsZero() { + notEmpty = JSONWriteTimeProp(b, "updated", o.Updated) || notEmpty + } + if !o.StartTime.IsZero() { + notEmpty = JSONWriteTimeProp(b, "startTime", o.StartTime) || notEmpty + } + if !o.EndTime.IsZero() { + notEmpty = JSONWriteTimeProp(b, "endTime", o.EndTime) || notEmpty + } + if o.Duration != 0 { + // TODO(marius): maybe don't use 0 as a nil value for Object types + // which can have a valid duration of 0 - (Video, Audio, etc) + notEmpty = JSONWriteDurationProp(b, "duration", o.Duration) || notEmpty + } + if o.Likes != nil { + notEmpty = JSONWriteItemProp(b, "likes", o.Likes) || notEmpty + } + if o.Shares != nil { + notEmpty = JSONWriteItemProp(b, "shares", o.Shares) || notEmpty + } + if v, err := o.Source.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = JSONWriteProp(b, "source", v) || notEmpty + } + return notEmpty +} + +func JSONWriteActivityValue(b *[]byte, a Activity) (notEmpty bool) { + _ = OnIntransitiveActivity(a, func(i *IntransitiveActivity) error { + if i == nil { + return nil + } + notEmpty = JSONWriteIntransitiveActivityValue(b, *i) || notEmpty + return nil + }) + if a.Object != nil { + notEmpty = JSONWriteItemProp(b, "object", a.Object) || notEmpty + } + return notEmpty +} + +func JSONWriteIntransitiveActivityValue(b *[]byte, i IntransitiveActivity) (notEmpty bool) { + _ = OnObject(i, func(o *Object) error { + if o == nil { + return nil + } + notEmpty = JSONWriteObjectValue(b, *o) || notEmpty + return nil + }) + if i.Actor != nil { + notEmpty = JSONWriteItemProp(b, "actor", i.Actor) || notEmpty + } + if i.Target != nil { + notEmpty = JSONWriteItemProp(b, "target", i.Target) || notEmpty + } + if i.Result != nil { + notEmpty = JSONWriteItemProp(b, "result", i.Result) || notEmpty + } + if i.Origin != nil { + notEmpty = JSONWriteItemProp(b, "origin", i.Origin) || notEmpty + } + if i.Instrument != nil { + notEmpty = JSONWriteItemProp(b, "instrument", i.Instrument) || notEmpty + } + return notEmpty +} + +func JSONWriteQuestionValue(b *[]byte, q Question) (notEmpty bool) { + _ = OnIntransitiveActivity(q, func(i *IntransitiveActivity) error { + if i == nil { + return nil + } + notEmpty = JSONWriteIntransitiveActivityValue(b, *i) || notEmpty + return nil + }) + if q.OneOf != nil { + notEmpty = JSONWriteItemProp(b, "oneOf", q.OneOf) || notEmpty + } + if q.AnyOf != nil { + notEmpty = JSONWriteItemProp(b, "anyOf", q.AnyOf) || notEmpty + } + notEmpty = JSONWriteBoolProp(b, "closed", q.Closed) || notEmpty + return notEmpty +} + +func JSONWriteLinkValue(b *[]byte, l Link) (notEmpty bool) { + if v, err := l.ID.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = JSONWriteProp(b, "id", v) + } + if v, err := l.Type.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = JSONWriteProp(b, "type", v) || notEmpty + } + if v, err := l.MediaType.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = JSONWriteProp(b, "mediaType", v) || notEmpty + } + if len(l.Name) > 0 { + notEmpty = JSONWriteNaturalLanguageProp(b, "name", l.Name) || notEmpty + } + if v, err := l.Rel.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = JSONWriteProp(b, "rel", v) || notEmpty + } + if l.Height > 0 { + notEmpty = JSONWriteIntProp(b, "height", int64(l.Height)) + } + if l.Width > 0 { + notEmpty = JSONWriteIntProp(b, "width", int64(l.Width)) + } + if l.Preview != nil { + notEmpty = JSONWriteItemProp(b, "rel", l.Preview) || notEmpty + } + if v, err := l.Href.MarshalJSON(); err == nil && len(v) > 0 { + notEmpty = JSONWriteProp(b, "href", v) || notEmpty + } + if len(l.HrefLang) > 0 { + notEmpty = JSONWriteStringProp(b, "hrefLang", string(l.HrefLang)) || notEmpty + } + return notEmpty +} + +// MarshalJSON represents just a wrapper for the jsonld.Marshal function +func MarshalJSON(it LinkOrIRI) ([]byte, error) { + return jsonld.Marshal(it) +} diff --git a/encoding_json_test.go b/encoding_json_test.go new file mode 100644 index 0000000..4c59205 --- /dev/null +++ b/encoding_json_test.go @@ -0,0 +1,620 @@ +package activitypub + +import ( + "bytes" + "reflect" + "testing" + "time" +) + +func Test_JSONWrite(t *testing.T) { + type args struct { + b *[]byte + c []byte + } + tests := []struct { + name string + args args + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + }) + } +} + +func Test_JSONWriteActivity(t *testing.T) { + type args struct { + b *[]byte + a Activity + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteActivityValue(tt.args.b, tt.args.a); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteActivityValue() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteBoolProp(t *testing.T) { + type args struct { + b *[]byte + n string + t bool + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteBoolProp(tt.args.b, tt.args.n, tt.args.t); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteBoolProp() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteComma(t *testing.T) { + type args struct { + b *[]byte + } + tests := []struct { + name string + args args + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + }) + } +} + +func Test_JSONWriteDurationProp(t *testing.T) { + type args struct { + b *[]byte + n string + d time.Duration + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteDurationProp(tt.args.b, tt.args.n, tt.args.d); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteDurationProp() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteFloatProp(t *testing.T) { + type args struct { + b *[]byte + n string + f float64 + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteFloatProp(tt.args.b, tt.args.n, tt.args.f); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteFloatProp() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteIRIProp(t *testing.T) { + type args struct { + b *[]byte + n string + i LinkOrIRI + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteIRIProp(tt.args.b, tt.args.n, tt.args.i); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteIRIProp() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteIntProp(t *testing.T) { + type args struct { + b *[]byte + n string + d int64 + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteIntProp(tt.args.b, tt.args.n, tt.args.d); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteIntProp() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteIntransitiveActivity(t *testing.T) { + type args struct { + b *[]byte + i IntransitiveActivity + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteIntransitiveActivityValue(tt.args.b, tt.args.i); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteIntransitiveActivityValue() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteItemCollection(t *testing.T) { + type args struct { + b *[]byte + col ItemCollection + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteItemCollectionValue(tt.args.b, tt.args.col, true); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteItemCollectionValue() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteItemCollectionProp(t *testing.T) { + type args struct { + b *[]byte + n string + col ItemCollection + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteItemCollectionProp(tt.args.b, tt.args.n, tt.args.col, true); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteItemCollectionProp() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteItemProp(t *testing.T) { + type args struct { + b *[]byte + n string + i Item + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteItemProp(tt.args.b, tt.args.n, tt.args.i); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteItemProp() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteNaturalLanguageProp(t *testing.T) { + type args struct { + b *[]byte + n string + nl NaturalLanguageValues + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteNaturalLanguageProp(tt.args.b, tt.args.n, tt.args.nl); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteNaturalLanguageProp() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteObjectValue(t *testing.T) { + type args struct { + b *[]byte + o Object + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteObjectValue(tt.args.b, tt.args.o); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteObjectValue() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteProp(t *testing.T) { + type args struct { + b *[]byte + name string + val []byte + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteProp(tt.args.b, tt.args.name, tt.args.val); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteProp() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWritePropName(t *testing.T) { + type args struct { + b *[]byte + s string + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWritePropName(tt.args.b, tt.args.s); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWritePropName() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteQuestionValue(t *testing.T) { + type args struct { + b *[]byte + q Question + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteQuestionValue(tt.args.b, tt.args.q); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteQuestionValue() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteStringValue(t *testing.T) { + type args struct { + b *[]byte + s string + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteStringValue(tt.args.b, tt.args.s); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteStringValue() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteStringProp(t *testing.T) { + type args struct { + b *[]byte + n string + s string + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteStringProp(tt.args.b, tt.args.n, tt.args.s); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteStringProp() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteTimeProp(t *testing.T) { + type args struct { + b *[]byte + n string + t time.Time + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteTimeProp(tt.args.b, tt.args.n, tt.args.t); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteTimeProp() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func Test_JSONWriteValue(t *testing.T) { + type args struct { + b *[]byte + s []byte + } + tests := []struct { + name string + args args + wantNotEmpty bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotNotEmpty := JSONWriteValue(tt.args.b, tt.args.s); gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteValue() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + }) + } +} + +func mockOb(id IRI, typ ActivityVocabularyType) LinkOrIRI { + ob := ObjectNew(typ) + ob.ID = id + return ob +} + +func TestMarshalJSON(t *testing.T) { + tests := []struct { + name string + arg LinkOrIRI + want []byte + wantErr bool + }{ + { + name: "empty", + arg: nil, + want: []byte("null"), + }, + { + name: "Link to example.com", + arg: LinkNew("https://example.com", MentionType), + want: []byte(`{"id":"https://example.com","type":"Mention"}`), + }, + { + name: "Note", + arg: mockOb("https://example.com", NoteType), + want: []byte(`{"id":"https://example.com","type":"Note"}`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MarshalJSON(tt.arg) + if (err != nil) != tt.wantErr { + t.Errorf("MarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("MarshalJSON() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestJSONWriteValue(t *testing.T) { + buff := func(s int) *[]byte { + b := make([]byte, 0, s) + return &b + } + type args struct { + b *[]byte + s []byte + } + tests := []struct { + name string + args args + want []byte + wantNotEmpty bool + }{ + { + name: "empty", + args: args{ + b: buff(0), + }, + wantNotEmpty: false, + }, + { + name: "do not escape quotes at start-end", + args: args{ + b: buff(20), + s: []byte(`"https://example.com"`), + }, + want: []byte(`"https://example.com"`), + wantNotEmpty: true, + }, + { + name: "escape quotes inside string", + args: args{ + b: buff(80), + s: []byte(`"application/ld+json; profile="https://www.w3.org/ns/activitystreams""`), + }, + want: []byte(`"application/ld+json; profile="https://www.w3.org/ns/activitystreams""`), + wantNotEmpty: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotNotEmpty := JSONWriteValue(tt.args.b, tt.args.s) + if gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteStringValue() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + if tt.wantNotEmpty && !bytes.Equal(*tt.args.b, tt.want) { + t.Errorf("JSONWriteValue() = %s, want %s", *tt.args.b, tt.want) + } + }) + } +} + +func buff(l int) *[]byte { + b := make([]byte, 0, l) + return &b +} + +func TestJSONWriteStringValue(t *testing.T) { + type args struct { + b *[]byte + s string + } + tests := []struct { + name string + args args + want string + wantNotEmpty bool + }{ + { + name: "empty", + args: args{}, + want: "", + wantNotEmpty: false, + }, + { + name: "escaped quote", + args: args{ + b: buff(10), + s: `ana"are`, + }, + want: `"ana\"are"`, + wantNotEmpty: true, + }, + { + name: "already escaped quote", + args: args{ + b: buff(10), + s: `ana\"are`, + }, + want: `"ana\"are"`, + wantNotEmpty: true, + }, + { + name: "already escaped quote and multiple other quotes", + args: args{ + b: buff(10), + s: `ana\"""are`, + }, + want: `"ana\"\"\"are"`, + wantNotEmpty: true, + }, + { + name: "quote at the end", + args: args{ + b: buff(10), + s: `anaare"`, + }, + want: `"anaare\""`, + wantNotEmpty: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotNotEmpty := JSONWriteStringValue(tt.args.b, tt.args.s) + if gotNotEmpty != tt.wantNotEmpty { + t.Errorf("JSONWriteStringValue() = %v, want %v", gotNotEmpty, tt.wantNotEmpty) + } + if tt.wantNotEmpty && tt.want != string(*tt.args.b) { + t.Errorf("JSONWriteStringValue() = %s, want %s", *tt.args.b, tt.want) + } + }) + } +} diff --git a/extractors.go b/extractors.go new file mode 100644 index 0000000..70faff5 --- /dev/null +++ b/extractors.go @@ -0,0 +1,60 @@ +package activitypub + +func PreferredNameOf(it Item) string { + var cont string + if IsObject(it) { + _ = OnActor(it, func(act *Actor) error { + if act.PreferredUsername != nil { + cont = act.PreferredUsername.First().String() + } + return nil + }) + } + return cont +} + +func ContentOf(it Item) string { + var cont string + if IsObject(it) { + _ = OnObject(it, func(ob *Object) error { + if ob.Content != nil { + cont = ob.Content.First().String() + } + return nil + }) + } + return cont +} + +func SummaryOf(it Item) string { + var cont string + if IsObject(it) { + _ = OnObject(it, func(ob *Object) error { + if ob.Summary != nil { + cont = ob.Summary.First().String() + } + return nil + }) + } + return cont +} + +func NameOf(it Item) string { + var name string + if IsLink(it) { + _ = OnLink(it, func(lnk *Link) error { + if lnk.Name != nil { + name = lnk.Name.First().String() + } + return nil + }) + } else { + _ = OnObject(it, func(ob *Object) error { + if ob.Name != nil { + name = ob.Name.First().String() + } + return nil + }) + } + return name +} diff --git a/extractors_test.go b/extractors_test.go new file mode 100644 index 0000000..ed4ed54 --- /dev/null +++ b/extractors_test.go @@ -0,0 +1,87 @@ +package activitypub + +import "testing" + +func TestContentOf(t *testing.T) { + tests := []struct { + name string + arg Item + want string + }{ + { + name: "empty", + arg: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ContentOf(tt.arg); got != tt.want { + t.Errorf("ContentOf() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNameOf(t *testing.T) { + tests := []struct { + name string + arg Item + want string + }{ + { + name: "empty", + arg: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NameOf(tt.arg); got != tt.want { + t.Errorf("NameOf() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPreferredNameOf(t *testing.T) { + tests := []struct { + name string + arg Item + want string + }{ + { + name: "empty", + arg: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := PreferredNameOf(tt.arg); got != tt.want { + t.Errorf("PreferredNameOf() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSummaryOf(t *testing.T) { + tests := []struct { + name string + arg Item + want string + }{ + { + name: "empty", + arg: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SummaryOf(tt.arg); got != tt.want { + t.Errorf("SummaryOf() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/flatten.go b/flatten.go new file mode 100644 index 0000000..29c1b30 --- /dev/null +++ b/flatten.go @@ -0,0 +1,142 @@ +package activitypub + +// FlattenActivityProperties flattens the Activity's properties from Object type to IRI +func FlattenActivityProperties(act *Activity) *Activity { + if act == nil { + return nil + } + _ = OnIntransitiveActivity(act, func(in *IntransitiveActivity) error { + FlattenIntransitiveActivityProperties(in) + return nil + }) + act.Object = FlattenToIRI(act.Object) + return act +} + +// FlattenIntransitiveActivityProperties flattens the Activity's properties from Object type to IRI +func FlattenIntransitiveActivityProperties(act *IntransitiveActivity) *IntransitiveActivity { + if act == nil { + return nil + } + act.Actor = FlattenToIRI(act.Actor) + act.Target = FlattenToIRI(act.Target) + act.Result = FlattenToIRI(act.Result) + act.Origin = FlattenToIRI(act.Origin) + act.Result = FlattenToIRI(act.Result) + act.Instrument = FlattenToIRI(act.Instrument) + _ = OnObject(act, func(o *Object) error { + FlattenObjectProperties(o) + return nil + }) + return act +} + +// FlattenItemCollection flattens an Item Collection to their respective IRIs +func FlattenItemCollection(col ItemCollection) ItemCollection { + if col == nil { + return col + } + for k, it := range ItemCollectionDeduplication(&col) { + if iri := it.GetLink(); iri != "" { + col[k] = iri + } + } + return col +} + +// FlattenCollection flattens a Collection's objects to their respective IRIs +func FlattenCollection(col *Collection) *Collection { + if col == nil { + return col + } + col.Items = FlattenItemCollection(col.Items) + + return col +} + +// FlattenOrderedCollection flattens an OrderedCollection's objects to their respective IRIs +func FlattenOrderedCollection(col *OrderedCollection) *OrderedCollection { + if col == nil { + return col + } + col.OrderedItems = FlattenItemCollection(col.OrderedItems) + + return col +} + +// FlattenActorProperties flattens the Actor's properties from Object types to IRI +func FlattenActorProperties(a *Actor) *Actor { + if a == nil { + return nil + } + OnObject(a, func(o *Object) error { + FlattenObjectProperties(o) + return nil + }) + return a +} + +// FlattenObjectProperties flattens the Object's properties from Object types to IRI +func FlattenObjectProperties(o *Object) *Object { + if o == nil { + return nil + } + o.Replies = Flatten(o.Replies) + o.Shares = Flatten(o.Shares) + o.Likes = Flatten(o.Likes) + o.AttributedTo = Flatten(o.AttributedTo) + o.To = FlattenItemCollection(o.To) + o.Bto = FlattenItemCollection(o.Bto) + o.CC = FlattenItemCollection(o.CC) + o.BCC = FlattenItemCollection(o.BCC) + o.Audience = FlattenItemCollection(o.Audience) + // o.Tag = FlattenItemCollection(o.Tag) + return o +} + +// FlattenProperties flattens the Item's properties from Object types to IRI +func FlattenProperties(it Item) Item { + if IsNil(it) { + return nil + } + typ := it.GetType() + if IntransitiveActivityTypes.Contains(typ) { + _ = OnIntransitiveActivity(it, func(a *IntransitiveActivity) error { + FlattenIntransitiveActivityProperties(a) + return nil + }) + } else if ActivityTypes.Contains(typ) { + _ = OnActivity(it, func(a *Activity) error { + FlattenActivityProperties(a) + return nil + }) + } + if ActorTypes.Contains(typ) { + OnActor(it, func(a *Actor) error { + FlattenActorProperties(a) + return nil + }) + } + if ObjectTypes.Contains(typ) { + OnObject(it, func(o *Object) error { + FlattenObjectProperties(o) + return nil + }) + } + return it +} + +// Flatten checks if Item can be flattened to an IRI or array of IRIs and returns it if so +func Flatten(it Item) Item { + if IsNil(it) { + return nil + } + if it.IsCollection() { + OnCollectionIntf(it, func(c CollectionInterface) error { + it = FlattenItemCollection(c.Collection()).Normalize() + return nil + }) + return it + } + return it.GetLink() +} diff --git a/flatten_test.go b/flatten_test.go new file mode 100644 index 0000000..5bcfc10 --- /dev/null +++ b/flatten_test.go @@ -0,0 +1,89 @@ +package activitypub + +import ( + "reflect" + "testing" +) + +func TestFlattenPersonProperties(t *testing.T) { + t.Skipf("TODO") +} + +func TestFlattenProperties(t *testing.T) { + t.Skipf("TODO") +} + +func TestFlattenItemCollection(t *testing.T) { + t.Skipf("TODO") +} + +func TestFlattenCollection(t *testing.T) { + t.Skipf("TODO") +} + +func TestFlattenOrderedCollection(t *testing.T) { + t.Skipf("TODO") +} + +func TestFlattenIntransitiveActivityProperties(t *testing.T) { + type args struct { + act *IntransitiveActivity + } + tests := []struct { + name string + args args + want *IntransitiveActivity + }{ + { + name: "blank", + args: args{&IntransitiveActivity{}}, + want: &IntransitiveActivity{}, + }, + { + name: "flatten-actor", + args: args{&IntransitiveActivity{Actor: &Actor{ID: "example-actor-iri"}}}, + want: &IntransitiveActivity{Actor: IRI("example-actor-iri")}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FlattenIntransitiveActivityProperties(tt.args.act); !reflect.DeepEqual(got, tt.want) { + t.Errorf("FlattenIntransitiveActivityProperties() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFlattenActivityProperties(t *testing.T) { + type args struct { + act *Activity + } + tests := []struct { + name string + args args + want *Activity + }{ + { + name: "blank", + args: args{&Activity{}}, + want: &Activity{}, + }, + { + name: "flatten-actor", + args: args{&Activity{Actor: &Actor{ID: "example-actor-iri"}}}, + want: &Activity{Actor: IRI("example-actor-iri")}, + }, + { + name: "flatten-object", + args: args{&Activity{Object: &Object{ID: "example-actor-iri"}}}, + want: &Activity{Object: IRI("example-actor-iri")}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FlattenActivityProperties(tt.args.act); !reflect.DeepEqual(got, tt.want) { + t.Errorf("FlattenActivityProperties() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e666310 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/go-ap/activitypub + +go 1.18 + +require ( + git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 + github.com/go-ap/errors v0.0.0-20250501090840-cd50c6a0a4e6 + github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 + github.com/valyala/fastjson v1.6.4 +) diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..64dd825 --- /dev/null +++ b/helpers.go @@ -0,0 +1,571 @@ +package activitypub + +import ( + "fmt" + "time" +) + +// WithLinkFn represents a function type that can be used as a parameter for OnLink helper function +type WithLinkFn func(*Link) error + +// WithObjectFn represents a function type that can be used as a parameter for OnObject helper function +type WithObjectFn func(*Object) error + +// WithActivityFn represents a function type that can be used as a parameter for OnActivity helper function +type WithActivityFn func(*Activity) error + +// WithIntransitiveActivityFn represents a function type that can be used as a parameter for OnIntransitiveActivity helper function +type WithIntransitiveActivityFn func(*IntransitiveActivity) error + +// WithQuestionFn represents a function type that can be used as a parameter for OnQuestion helper function +type WithQuestionFn func(*Question) error + +// WithActorFn represents a function type that can be used as a parameter for OnActor helper function +type WithActorFn func(*Actor) error + +// WithCollectionInterfaceFn represents a function type that can be used as a parameter for OnCollectionIntf helper function +type WithCollectionInterfaceFn func(CollectionInterface) error + +// WithCollectionFn represents a function type that can be used as a parameter for OnCollection helper function +type WithCollectionFn func(*Collection) error + +// WithCollectionPageFn represents a function type that can be used as a parameter for OnCollectionPage helper function +type WithCollectionPageFn func(*CollectionPage) error + +// WithOrderedCollectionFn represents a function type that can be used as a parameter for OnOrderedCollection helper function +type WithOrderedCollectionFn func(*OrderedCollection) error + +// WithOrderedCollectionPageFn represents a function type that can be used as a parameter for OnOrderedCollectionPage helper function +type WithOrderedCollectionPageFn func(*OrderedCollectionPage) error + +// WithItemCollectionFn represents a function type that can be used as a parameter for OnItemCollection helper function +type WithItemCollectionFn func(*ItemCollection) error + +// WithIRIsFn represents a function type that can be used as a parameter for OnIRIs helper function +type WithIRIsFn func(*IRIs) error + +// OnLink calls function fn on it Item if it can be asserted to type *Link +// +// This function should be safe to use for all types with a structure compatible +// with the Link type +func OnLink(it LinkOrIRI, fn WithLinkFn) error { + if it == nil { + return nil + } + ob, err := ToLink(it) + if err != nil { + return err + } + return fn(ob) +} + +func To[T Item](it Item) (*T, error) { + if ob, ok := it.(T); ok { + return &ob, nil + } + return nil, fmt.Errorf("invalid cast for object %T", it) +} + +// On handles in a generic way the call to fn(*T) if the "it" Item can be asserted to one of the Objects type. +// It also covers the case where "it" is a collection of items that match the assertion. +func On[T Item](it Item, fn func(*T) error) error { + if !IsItemCollection(it) { + ob, err := To[T](it) + if err != nil { + return err + } + return fn(ob) + } + return OnItemCollection(it, func(col *ItemCollection) error { + for _, it := range *col { + if err := On(it, fn); err != nil { + return err + } + } + return nil + }) +} + +// OnObject calls function fn on it Item if it can be asserted to type *Object +// +// This function should be safe to be called for all types with a structure compatible +// to the Object type. +func OnObject(it Item, fn WithObjectFn) error { + if it == nil { + return nil + } + if IsItemCollection(it) { + return OnItemCollection(it, func(col *ItemCollection) error { + for _, it := range *col { + if IsLink(it) { + continue + } + if err := OnObject(it, fn); err != nil { + return err + } + } + return nil + }) + } + ob, err := ToObject(it) + if err != nil { + return err + } + return fn(ob) +} + +// OnActivity calls function fn on it Item if it can be asserted to type *Activity +// +// This function should be called if trying to access the Activity specific properties +// like "object", for the other properties OnObject, or OnIntransitiveActivity +// should be used instead. +func OnActivity(it Item, fn WithActivityFn) error { + if it == nil { + return nil + } + if IsItemCollection(it) { + return OnItemCollection(it, func(col *ItemCollection) error { + for _, it := range *col { + if IsLink(it) { + continue + } + if err := OnActivity(it, fn); err != nil { + return err + } + } + return nil + }) + } + act, err := ToActivity(it) + if err != nil { + return err + } + return fn(act) +} + +// OnIntransitiveActivity calls function fn on it Item if it can be asserted +// to type *IntransitiveActivity +// +// This function should be called if trying to access the IntransitiveActivity +// specific properties like "actor", for the other properties OnObject +// should be used instead. +func OnIntransitiveActivity(it Item, fn WithIntransitiveActivityFn) error { + if it == nil { + return nil + } + if IsItemCollection(it) { + return OnItemCollection(it, func(col *ItemCollection) error { + for _, it := range *col { + if err := OnIntransitiveActivity(it, fn); err != nil { + return err + } + } + return nil + }) + } + act, err := ToIntransitiveActivity(it) + if err != nil { + return err + } + return fn(act) +} + +// OnQuestion calls function fn on it Item if it can be asserted to type Question +// +// This function should be called if trying to access the Questions specific +// properties like "anyOf", "oneOf", "closed", etc. For the other properties +// OnObject or OnIntransitiveActivity should be used instead. +func OnQuestion(it Item, fn WithQuestionFn) error { + if it == nil { + return nil + } + if IsItemCollection(it) { + return OnItemCollection(it, func(col *ItemCollection) error { + for _, it := range *col { + if err := OnQuestion(it, fn); err != nil { + return err + } + } + return nil + }) + } + act, err := ToQuestion(it) + if err != nil { + return err + } + return fn(act) +} + +// OnActor calls function fn on it Item if it can be asserted to type *Actor +// +// This function should be called if trying to access the Actor specific +// properties like "preferredName", "publicKey", etc. For the other properties +// OnObject should be used instead. +func OnActor(it Item, fn WithActorFn) error { + if it == nil { + return nil + } + if IsItemCollection(it) { + return OnItemCollection(it, func(col *ItemCollection) error { + for _, it := range *col { + if IsLink(it) { + continue + } + if err := OnActor(it, fn); err != nil { + return err + } + } + return nil + }) + } + act, err := ToActor(it) + if err != nil { + return err + } + return fn(act) +} + +// OnItemCollection calls function fn on it Item if it can be asserted to type ItemCollection +// +// It should be used when Item represents an Item collection and it's usually used as a way +// to wrap functionality for other functions that will be called on each item in the collection. +func OnItemCollection(it Item, fn WithItemCollectionFn) error { + if it == nil { + return nil + } + col, err := ToItemCollection(it) + if err != nil { + return err + } + return fn(col) +} + +// OnIRIs calls function fn on it Item if it can be asserted to type IRIs +// +// It should be used when Item represents an IRI slice. +func OnIRIs(it Item, fn WithIRIsFn) error { + if it == nil { + return nil + } + col, err := ToIRIs(it) + if err != nil { + return err + } + return fn(col) +} + +// OnCollectionIntf calls function fn on it Item if it can be asserted to a type +// that implements the CollectionInterface +// +// This function should be called if Item represents a collection of ActivityPub +// objects. It basically wraps functionality for the different collection types +// supported by the package. +func OnCollectionIntf(it Item, fn WithCollectionInterfaceFn) error { + if it == nil { + return nil + } + switch it.GetType() { + case CollectionOfItems: + col, err := ToItemCollection(it) + if err != nil { + return err + } + return fn(col) + case CollectionOfIRIs: + col, err := ToIRIs(it) + if err != nil { + return err + } + itCol := col.Collection() + return fn(&itCol) + case CollectionType: + col, err := ToCollection(it) + if err != nil { + return err + } + return fn(col) + case CollectionPageType: + return OnCollectionPage(it, func(p *CollectionPage) error { + col, err := ToCollectionPage(p) + if err != nil { + return err + } + return fn(col) + }) + case OrderedCollectionType: + col, err := ToOrderedCollection(it) + if err != nil { + return err + } + return fn(col) + case OrderedCollectionPageType: + return OnOrderedCollectionPage(it, func(p *OrderedCollectionPage) error { + col, err := ToOrderedCollectionPage(p) + if err != nil { + return err + } + return fn(col) + }) + default: + return fmt.Errorf("%T[%s] can't be converted to a Collection type", it, it.GetType()) + } +} + +// OnCollection calls function fn on it Item if it can be asserted to type *Collection +// +// This function should be called if trying to access the Collection specific +// properties like "totalItems", "items", etc. For the other properties +// OnObject should be used instead. +func OnCollection(it Item, fn WithCollectionFn) error { + if it == nil { + return nil + } + col, err := ToCollection(it) + if err != nil { + return err + } + return fn(col) +} + +// OnCollectionPage calls function fn on it Item if it can be asserted to +// type *CollectionPage +// +// This function should be called if trying to access the CollectionPage specific +// properties like "partOf", "next", "perv". For the other properties +// OnObject or OnCollection should be used instead. +func OnCollectionPage(it Item, fn WithCollectionPageFn) error { + if it == nil { + return nil + } + col, err := ToCollectionPage(it) + if err != nil { + return err + } + return fn(col) +} + +// OnOrderedCollection calls function fn on it Item if it can be asserted +// to type *OrderedCollection +// +// This function should be called if trying to access the Collection specific +// properties like "totalItems", "orderedItems", etc. For the other properties +// OnObject should be used instead. +func OnOrderedCollection(it Item, fn WithOrderedCollectionFn) error { + if it == nil { + return nil + } + col, err := ToOrderedCollection(it) + if err != nil { + return err + } + return fn(col) +} + +// OnOrderedCollectionPage calls function fn on it Item if it can be asserted +// to type *OrderedCollectionPage +// +// This function should be called if trying to access the OrderedCollectionPage specific +// properties like "partOf", "next", "perv". For the other properties +// OnObject or OnOrderedCollection should be used instead. +func OnOrderedCollectionPage(it Item, fn WithOrderedCollectionPageFn) error { + if it == nil { + return nil + } + col, err := ToOrderedCollectionPage(it) + if err != nil { + return err + } + return fn(col) +} + +// ItemOrderTimestamp is used for ordering a ItemCollection slice using the slice.Sort function +// It orders i1 and i2 based on their Published and Updated timestamps, whichever is later. +func ItemOrderTimestamp(i1, i2 LinkOrIRI) bool { + if IsNil(i1) { + return !IsNil(i2) + } else if IsNil(i2) { + return false + } + + var t1 time.Time + var t2 time.Time + if IsObject(i1) { + o1, e1 := ToObject(i1) + if e1 != nil { + return false + } + t1 = o1.Published + if o1.Updated.After(t1) { + t1 = o1.Updated + } + } + if IsObject(i2) { + o2, e2 := ToObject(i2) + if e2 != nil { + return false + } + t2 = o2.Published + if o2.Updated.After(t2) { + t2 = o2.Updated + } + } + return t1.After(t2) +} + +func notEmptyLink(l *Link) bool { + return len(l.ID) > 0 || + LinkTypes.Contains(l.Type) || + len(l.MediaType) > 0 || + l.Preview != nil || + l.Name != nil || + len(l.Href) > 0 || + len(l.Rel) > 0 || + len(l.HrefLang) > 0 || + l.Height > 0 || + l.Width > 0 +} + +func notEmptyObject(o *Object) bool { + if o == nil { + return false + } + return len(o.ID) > 0 || + len(o.Type) > 0 || + ActivityTypes.Contains(o.Type) || + o.Content != nil || + o.Attachment != nil || + o.AttributedTo != nil || + o.Audience != nil || + o.BCC != nil || + o.Bto != nil || + o.CC != nil || + o.Context != nil || + o.Duration > 0 || + !o.EndTime.IsZero() || + o.Generator != nil || + o.Icon != nil || + o.Image != nil || + o.InReplyTo != nil || + o.Likes != nil || + o.Location != nil || + len(o.MediaType) > 0 || + o.Name != nil || + o.Preview != nil || + !o.Published.IsZero() || + o.Replies != nil || + o.Shares != nil || + o.Source.MediaType != "" || + o.Source.Content != nil || + !o.StartTime.IsZero() || + o.Summary != nil || + o.Tag != nil || + o.To != nil || + !o.Updated.IsZero() || + o.URL != nil +} + +func notEmptyInstransitiveActivity(i *IntransitiveActivity) bool { + notEmpty := i.Actor != nil || + i.Target != nil || + i.Result != nil || + i.Origin != nil || + i.Instrument != nil + if notEmpty { + return true + } + OnObject(i, func(ob *Object) error { + notEmpty = notEmptyObject(ob) + return nil + }) + return notEmpty +} + +func notEmptyActivity(a *Activity) bool { + var notEmpty bool + OnIntransitiveActivity(a, func(i *IntransitiveActivity) error { + notEmpty = notEmptyInstransitiveActivity(i) + return nil + }) + return notEmpty || a.Object != nil +} + +func notEmptyActor(a *Actor) bool { + var notEmpty bool + OnObject(a, func(o *Object) error { + notEmpty = notEmptyObject(o) + return nil + }) + return notEmpty || + a.Inbox != nil || + a.Outbox != nil || + a.Following != nil || + a.Followers != nil || + a.Liked != nil || + a.PreferredUsername != nil || + a.Endpoints != nil || + a.Streams != nil || + len(a.PublicKey.ID)+len(a.PublicKey.Owner)+len(a.PublicKey.PublicKeyPem) > 0 +} + +// NotEmpty tells us if a Item interface value has a non nil value for various types +// that implement +func NotEmpty(i Item) bool { + if IsNil(i) { + return false + } + var notEmpty bool + if IsIRI(i) { + notEmpty = len(i.GetLink()) > 0 + } + if i.IsCollection() { + OnCollectionIntf(i, func(c CollectionInterface) error { + notEmpty = c != nil || len(c.Collection()) > 0 + return nil + }) + } + if ActivityTypes.Contains(i.GetType()) { + OnActivity(i, func(a *Activity) error { + notEmpty = notEmptyActivity(a) + return nil + }) + } else if ActorTypes.Contains(i.GetType()) { + OnActor(i, func(a *Actor) error { + notEmpty = notEmptyActor(a) + return nil + }) + } else if i.IsLink() { + OnLink(i, func(l *Link) error { + notEmpty = notEmptyLink(l) + return nil + }) + } else { + OnObject(i, func(o *Object) error { + notEmpty = notEmptyObject(o) + return nil + }) + } + return notEmpty +} + +// DerefItem dereferences +func DerefItem(it Item) ItemCollection { + if IsNil(it) { + return nil + } + + var items ItemCollection + if IsIRIs(it) { + _ = OnIRIs(it, func(col *IRIs) error { + items = col.Collection() + return nil + }) + } else if IsItemCollection(it) { + _ = OnItemCollection(it, func(col *ItemCollection) error { + items = col.Collection() + return nil + }) + } else { + items = ItemCollection{it} + } + return items +} diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000..7fe5db1 --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,679 @@ +package activitypub + +import ( + "fmt" + "reflect" + "testing" + "time" +) + +func assertObjectWithTesting(fn canErrorFunc, expected Item) WithObjectFn { + return func(p *Object) error { + if !assertDeepEquals(fn, p, expected) { + return fmt.Errorf("not equal") + } + return nil + } +} + +func TestOnObject(t *testing.T) { + testObject := Object{ + ID: "https://example.com", + } + type args struct { + it Item + fn func(canErrorFunc, Item) WithObjectFn + } + tests := []struct { + name string + args args + expected Item + wantErr bool + }{ + { + name: "single", + args: args{testObject, assertObjectWithTesting}, + expected: &testObject, + wantErr: false, + }, + { + name: "single fails", + args: args{Object{ID: "https://not-equals"}, assertObjectWithTesting}, + expected: &testObject, + wantErr: true, + }, + { + name: "collectionOfObjects", + args: args{ItemCollection{testObject, testObject}, assertObjectWithTesting}, + expected: &testObject, + wantErr: false, + }, + { + name: "collectionOfObjects fails", + args: args{ItemCollection{testObject, Object{ID: "https://not-equals"}}, assertObjectWithTesting}, + expected: &testObject, + wantErr: true, + }, + } + for _, tt := range tests { + var logFn canErrorFunc + if tt.wantErr { + logFn = t.Logf + } else { + logFn = t.Errorf + } + t.Run(tt.name, func(t *testing.T) { + if err := OnObject(tt.args.it, tt.args.fn(logFn, tt.expected)); (err != nil) != tt.wantErr { + t.Errorf("OnObject() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func assertActivityWithTesting(fn canErrorFunc, expected Item) WithActivityFn { + return func(p *Activity) error { + if !assertDeepEquals(fn, p, expected) { + return fmt.Errorf("not equal") + } + return nil + } +} + +func TestOnActivity(t *testing.T) { + testActivity := Activity{ + ID: "https://example.com", + } + type args struct { + it Item + fn func(canErrorFunc, Item) WithActivityFn + } + tests := []struct { + name string + args args + expected Item + wantErr bool + }{ + { + name: "single", + args: args{testActivity, assertActivityWithTesting}, + expected: &testActivity, + wantErr: false, + }, + { + name: "single fails", + args: args{Activity{ID: "https://not-equals"}, assertActivityWithTesting}, + expected: &testActivity, + wantErr: true, + }, + { + name: "collectionOfActivitys", + args: args{ItemCollection{testActivity, testActivity}, assertActivityWithTesting}, + expected: &testActivity, + wantErr: false, + }, + { + name: "collectionOfActivitys fails", + args: args{ItemCollection{testActivity, Activity{ID: "https://not-equals"}}, assertActivityWithTesting}, + expected: &testActivity, + wantErr: true, + }, + } + for _, tt := range tests { + var logFn canErrorFunc + if tt.wantErr { + logFn = t.Logf + } else { + logFn = t.Errorf + } + t.Run(tt.name, func(t *testing.T) { + if err := OnActivity(tt.args.it, tt.args.fn(logFn, tt.expected)); (err != nil) != tt.wantErr { + t.Errorf("OnActivity() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func assertIntransitiveActivityWithTesting(fn canErrorFunc, expected Item) WithIntransitiveActivityFn { + return func(p *IntransitiveActivity) error { + if !assertDeepEquals(fn, p, expected) { + return fmt.Errorf("not equal") + } + return nil + } +} + +func TestOnIntransitiveActivity(t *testing.T) { + testIntransitiveActivity := IntransitiveActivity{ + ID: "https://example.com", + } + type args struct { + it Item + fn func(canErrorFunc, Item) WithIntransitiveActivityFn + } + tests := []struct { + name string + args args + expected Item + wantErr bool + }{ + { + name: "single", + args: args{testIntransitiveActivity, assertIntransitiveActivityWithTesting}, + expected: &testIntransitiveActivity, + wantErr: false, + }, + { + name: "single fails", + args: args{IntransitiveActivity{ID: "https://not-equals"}, assertIntransitiveActivityWithTesting}, + expected: &testIntransitiveActivity, + wantErr: true, + }, + { + name: "collectionOfIntransitiveActivitys", + args: args{ItemCollection{testIntransitiveActivity, testIntransitiveActivity}, assertIntransitiveActivityWithTesting}, + expected: &testIntransitiveActivity, + wantErr: false, + }, + { + name: "collectionOfIntransitiveActivitys fails", + args: args{ItemCollection{testIntransitiveActivity, IntransitiveActivity{ID: "https://not-equals"}}, assertIntransitiveActivityWithTesting}, + expected: &testIntransitiveActivity, + wantErr: true, + }, + } + for _, tt := range tests { + var logFn canErrorFunc + if tt.wantErr { + logFn = t.Logf + } else { + logFn = t.Errorf + } + t.Run(tt.name, func(t *testing.T) { + if err := OnIntransitiveActivity(tt.args.it, tt.args.fn(logFn, tt.expected)); (err != nil) != tt.wantErr { + t.Errorf("OnIntransitiveActivity() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func assertQuestionWithTesting(fn canErrorFunc, expected Item) WithQuestionFn { + return func(p *Question) error { + if !assertDeepEquals(fn, p, expected) { + return fmt.Errorf("not equal") + } + return nil + } +} + +func TestOnQuestion(t *testing.T) { + testQuestion := Question{ + ID: "https://example.com", + } + type args struct { + it Item + fn func(canErrorFunc, Item) WithQuestionFn + } + tests := []struct { + name string + args args + expected Item + wantErr bool + }{ + { + name: "single", + args: args{testQuestion, assertQuestionWithTesting}, + expected: &testQuestion, + wantErr: false, + }, + { + name: "single fails", + args: args{Question{ID: "https://not-equals"}, assertQuestionWithTesting}, + expected: &testQuestion, + wantErr: true, + }, + { + name: "collectionOfQuestions", + args: args{ItemCollection{testQuestion, testQuestion}, assertQuestionWithTesting}, + expected: &testQuestion, + wantErr: false, + }, + { + name: "collectionOfQuestions fails", + args: args{ItemCollection{testQuestion, Question{ID: "https://not-equals"}}, assertQuestionWithTesting}, + expected: &testQuestion, + wantErr: true, + }, + } + for _, tt := range tests { + var logFn canErrorFunc + if tt.wantErr { + logFn = t.Logf + } else { + logFn = t.Errorf + } + t.Run(tt.name, func(t *testing.T) { + if err := OnQuestion(tt.args.it, tt.args.fn(logFn, tt.expected)); (err != nil) != tt.wantErr { + t.Errorf("OnQuestion() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestOnCollection(t *testing.T) { + t.Skipf("TODO") +} + +func TestOnCollectionPage(t *testing.T) { + t.Skipf("TODO") +} + +func TestOnOrderedCollectionPage(t *testing.T) { + t.Skipf("TODO") +} + +type args[T Objects] struct { + it T + fn func(fn canErrorFunc, expected T) func(*T) error +} + +type testPair[T Objects] struct { + name string + args args[T] + expected T + wantErr bool +} + +func assert[T Objects](fn canErrorFunc, expected T) func(*T) error { + return func(p *T) error { + if !assertDeepEquals(fn, *p, expected) { + return fmt.Errorf("not equal") + } + return nil + } +} + +func TestOn(t *testing.T) { + var tests = []testPair[Object]{ + { + name: "single object", + args: args[Object]{Object{ID: "https://example.com"}, assert[Object]}, + expected: Object{ID: "https://example.com"}, + wantErr: false, + }, + { + name: "single image", + args: args[Image]{Image{ID: "http://example.com"}, assert[Image]}, + expected: Image{ID: "http://example.com"}, + wantErr: false, + }, + } + for _, tt := range tests { + var logFn canErrorFunc + if tt.wantErr { + logFn = t.Logf + } else { + logFn = t.Errorf + } + t.Run(tt.name, func(t *testing.T) { + if err := On(tt.args.it, tt.args.fn(logFn, tt.expected)); (err != nil) != tt.wantErr { + t.Errorf("On[%T]() error = %v, wantErr %v", tt.args.it, err, tt.wantErr) + } + }) + } +} + +var ( + emptyPrintFn = func(string, ...any) {} + + fnPrintObj = func(printFn func(string, ...any)) func(_ *Object) error { + return func(o *Object) error { + printFn("%v", o) + return nil + } + } + + fnObj = func(_ *Object) error { return nil } + fnAct = func(_ *Actor) error { return nil } + fnA = func(_ *Activity) error { return nil } + fnIA = func(_ *IntransitiveActivity) error { return nil } + + maybeObject Item = new(Object) + notObject Item = new(Activity) + maybeActor Item = new(Actor) + maybeActivity Item = new(Activity) + notIntransitiveActivity Item = new(Activity) + maybeIntransitiveActivity Item = new(IntransitiveActivity) + colOfObjects Item = ItemCollection{Object{ID: "unum"}, Object{ID: "duo"}} + colOfNotObjects Item = ItemCollection{Activity{ID: "unum"}, Activity{ID: "duo"}} +) + +func Benchmark_ToObject(b *testing.B) { + for i := 0; i < b.N; i++ { + ToObject(maybeObject) + } +} + +func Benchmark_To_T_Object(b *testing.B) { + for i := 0; i < b.N; i++ { + To[Object](maybeObject) + } +} + +func Benchmark_ToActor(b *testing.B) { + for i := 0; i < b.N; i++ { + ToActor(maybeActor) + } +} + +func Benchmark_To_T_Actor(b *testing.B) { + for i := 0; i < b.N; i++ { + To[Actor](maybeActor) + } +} + +func Benchmark_ToActivity(b *testing.B) { + for i := 0; i < b.N; i++ { + ToActivity(maybeActivity) + } +} + +func Benchmark_To_T_Activity(b *testing.B) { + for i := 0; i < b.N; i++ { + To[Activity](maybeActivity) + } +} + +func Benchmark_ToIntransitiveActivityHappy(b *testing.B) { + for i := 0; i < b.N; i++ { + ToIntransitiveActivity(maybeIntransitiveActivity) + } +} + +func Benchmark_To_T_IntransitiveActivityHappy(b *testing.B) { + for i := 0; i < b.N; i++ { + To[IntransitiveActivity](maybeIntransitiveActivity) + } +} + +func Benchmark_ToIntransitiveActivityNotHappy(b *testing.B) { + for i := 0; i < b.N; i++ { + ToIntransitiveActivity(notIntransitiveActivity) + } +} + +func Benchmark_To_T_IntransitiveActivityNotHappy(b *testing.B) { + for i := 0; i < b.N; i++ { + To[IntransitiveActivity](notIntransitiveActivity) + } +} + +func Benchmark_OnObject(b *testing.B) { + for i := 0; i < b.N; i++ { + OnObject(maybeObject, fnObj) + } +} + +func Benchmark_On_T_Object(b *testing.B) { + for i := 0; i < b.N; i++ { + On[Object](maybeObject, fnObj) + } +} +func Benchmark_OnActor(b *testing.B) { + for i := 0; i < b.N; i++ { + OnActor(maybeObject, fnAct) + } +} +func Benchmark_On_T_Actor(b *testing.B) { + for i := 0; i < b.N; i++ { + On[Actor](maybeObject, fnAct) + } +} +func Benchmark_OnActivity(b *testing.B) { + for i := 0; i < b.N; i++ { + OnActivity(maybeObject, fnA) + } +} + +func Benchmark_On_T_Activity(b *testing.B) { + for i := 0; i < b.N; i++ { + On[Activity](maybeObject, fnA) + } +} +func Benchmark_OnIntransitiveActivity(b *testing.B) { + for i := 0; i < b.N; i++ { + OnIntransitiveActivity(maybeObject, fnIA) + } +} + +func Benchmark_On_T_IntransitiveActivity(b *testing.B) { + for i := 0; i < b.N; i++ { + On[IntransitiveActivity](maybeObject, fnIA) + } +} +func Benchmark_OnObjectNotHappy(b *testing.B) { + for i := 0; i < b.N; i++ { + OnObject(notObject, fnObj) + } +} + +func Benchmark_On_T_ObjectNotHappy(b *testing.B) { + for i := 0; i < b.N; i++ { + On[Object](notObject, fnObj) + } +} + +func Benchmark_OnObjectHappyCol(b *testing.B) { + for i := 0; i < b.N; i++ { + OnObject(colOfObjects, fnObj) + } +} + +func Benchmark_On_T_ObjectHappyCol(b *testing.B) { + for i := 0; i < b.N; i++ { + On[Object](colOfObjects, fnObj) + } +} + +func Benchmark_OnObjectNotHappyCol(b *testing.B) { + for i := 0; i < b.N; i++ { + OnObject(colOfNotObjects, fnObj) + } +} + +func Benchmark_On_T_ObjectNotHappyCol(b *testing.B) { + for i := 0; i < b.N; i++ { + On[Object](colOfNotObjects, fnObj) + } +} + +func TestDerefItem(t *testing.T) { + tests := []struct { + name string + arg Item + want ItemCollection + }{ + { + name: "empty", + }, + { + name: "simple object", + arg: &Object{ID: "https://example.com"}, + want: ItemCollection{&Object{ID: "https://example.com"}}, + }, + { + name: "simple IRI", + arg: IRI("https://example.com"), + want: ItemCollection{IRI("https://example.com")}, + }, + { + name: "IRI collection", + arg: IRIs{IRI("https://example.com"), IRI("https://example.com/~jdoe")}, + want: ItemCollection{IRI("https://example.com"), IRI("https://example.com/~jdoe")}, + }, + { + name: "Item collection", + arg: ItemCollection{ + &Object{ID: "https://example.com"}, + &Actor{ID: "https://example.com/~jdoe"}, + }, + want: ItemCollection{ + &Object{ID: "https://example.com"}, + &Actor{ID: "https://example.com/~jdoe"}, + }, + }, + { + name: "mixed item collection", + arg: ItemCollection{ + &Object{ID: "https://example.com"}, + IRI("https://example.com/666"), + &Actor{ID: "https://example.com/~jdoe"}, + }, + want: ItemCollection{ + &Object{ID: "https://example.com"}, + IRI("https://example.com/666"), + &Actor{ID: "https://example.com/~jdoe"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := DerefItem(tt.arg); !reflect.DeepEqual(got, tt.want) { + t.Errorf("DerefItem() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestItemOrderTimestamp(t *testing.T) { + early1 := time.Date(2001, 6, 6, 6, 0, 0, 0, time.UTC) + early2 := time.Date(2001, 6, 6, 6, 0, 1, 0, time.UTC) + late1 := time.Date(2001, 6, 6, 7, 0, 0, 0, time.UTC) + late2 := time.Date(2001, 6, 6, 7, 0, 1, 0, time.UTC) + type args struct { + i1 Item + i2 Item + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "empty", + args: args{}, + want: false, + }, + { + name: "first empty", + args: args{ + i1: nil, + i2: &Object{}, + }, + want: true, + }, + { + name: "second empty", + args: args{ + i1: &Object{}, + i2: nil, + }, + want: false, + }, + { + name: "empty published/updated", + args: args{ + i1: &Object{}, + i2: &Object{}, + }, + want: false, + }, + { + name: "first has empty published/updated", + args: args{ + i1: &Object{}, + i2: &Object{Published: early1}, + }, + want: false, + }, + { + name: "check published equals", + args: args{ + i1: &Object{Published: early1}, + i2: &Object{Published: early1}, + }, + want: false, + }, + { + name: "check published/updated equals", + args: args{ + i1: &Object{Published: early2}, + i2: &Object{Updated: early2}, + }, + want: false, + }, + { + name: "check updated/published equals", + args: args{ + i1: &Object{Updated: late1}, + i2: &Object{Published: late1}, + }, + want: false, + }, + { + name: "check first published earlier", + args: args{ + i1: &Object{Published: early1}, + i2: &Object{Published: late1}, + }, + want: false, + }, + { + name: "check second published earlier", + args: args{ + i1: &Object{Published: late1}, + i2: &Object{Published: early1}, + }, + want: true, + }, + { + name: "check first updated earlier", + args: args{ + i1: &Object{Updated: early1}, + i2: &Object{Updated: late1}, + }, + want: false, + }, + { + name: "check second updated earlier", + args: args{ + i1: &Object{Updated: late1}, + i2: &Object{Updated: early1}, + }, + want: true, + }, + + { + name: "check first earlier", + args: args{ + i1: &Object{Published: early1, Updated: late1}, + i2: &Object{Published: early1, Updated: late2}, + }, + want: false, + }, + { + name: "check second earlier", + args: args{ + i1: &Object{Published: early1, Updated: late2}, + i2: &Object{Published: early1, Updated: late1}, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ItemOrderTimestamp(tt.args.i1, tt.args.i2); got != tt.want { + t.Errorf("ItemOrderTimestamp() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/intransitive_activity.go b/intransitive_activity.go new file mode 100644 index 0000000..a197ba8 --- /dev/null +++ b/intransitive_activity.go @@ -0,0 +1,377 @@ +package activitypub + +import ( + "bytes" + "encoding/gob" + "fmt" + "io" + "time" + "unsafe" + + "github.com/valyala/fastjson" +) + +type IntransitiveActivities interface { + IntransitiveActivity | Question +} + +// IntransitiveActivity Instances of IntransitiveActivity are a subtype of Activity representing intransitive actions. +// The object property is therefore inappropriate for these activities. +type IntransitiveActivity struct { + // ID provides the globally unique identifier for anActivity Pub Object or Link. + ID ID `jsonld:"id,omitempty"` + // Type identifies the Activity Pub Object or Link type. Multiple values may be specified. + Type ActivityVocabularyType `jsonld:"type,omitempty"` + // Name a simple, human-readable, plain-text name for the object. + // HTML markup MUST NOT be included. The name MAY be expressed using multiple language-tagged values. + Name NaturalLanguageValues `jsonld:"name,omitempty,collapsible"` + // Attachment identifies a resource attached or related to an object that potentially requires special handling. + // The intent is to provide a model that is at least semantically similar to attachments in email. + Attachment Item `jsonld:"attachment,omitempty"` + // AttributedTo identifies one or more entities to which this object is attributed. The attributed entities might not be Actors. + // For instance, an object might be attributed to the completion of another activity. + AttributedTo Item `jsonld:"attributedTo,omitempty"` + // Audience identifies one or more entities that represent the total population of entities + // for which the object can considered to be relevant. + Audience ItemCollection `jsonld:"audience,omitempty"` + // Content or textual representation of the Activity Pub Object encoded as a JSON string. + // By default, the value of content is HTML. + // The mediaType property can be used in the object to indicate a different content type. + // (The content MAY be expressed using multiple language-tagged values.) + Content NaturalLanguageValues `jsonld:"content,omitempty,collapsible"` + // Context identifies the context within which the object exists or an activity was performed. + // The notion of "context" used is intentionally vague. + // The intended function is to serve as a means of grouping objects and activities that share a + // common originating context or purpose. An example could be all activities relating to a common project or event. + Context Item `jsonld:"context,omitempty"` + // MediaType when used on an Object, identifies the MIME media type of the value of the content property. + // If not specified, the content property is assumed to contain text/html content. + MediaType MimeType `jsonld:"mediaType,omitempty"` + // EndTime the date and time describing the actual or expected ending time of the object. + // When used with an Activity object, for instance, the endTime property specifies the moment + // the activity concluded or is expected to conclude. + EndTime time.Time `jsonld:"endTime,omitempty"` + // Generator identifies the entity (e.g. an application) that generated the object. + Generator Item `jsonld:"generator,omitempty"` + // Icon indicates an entity that describes an icon for this object. + // The image should have an aspect ratio of one (horizontal) to one (vertical) + // and should be suitable for presentation at a small size. + Icon Item `jsonld:"icon,omitempty"` + // Image indicates an entity that describes an image for this object. + // Unlike the icon property, there are no aspect ratio or display size limitations assumed. + Image Item `jsonld:"image,omitempty"` + // InReplyTo indicates one or more entities for which this object is considered a response. + InReplyTo Item `jsonld:"inReplyTo,omitempty"` + // Location indicates one or more physical or logical locations associated with the object. + Location Item `jsonld:"location,omitempty"` + // Preview identifies an entity that provides a preview of this object. + Preview Item `jsonld:"preview,omitempty"` + // Published the date and time at which the object was published + Published time.Time `jsonld:"published,omitempty"` + // Replies identifies a Collection containing objects considered to be responses to this object. + Replies Item `jsonld:"replies,omitempty"` + // StartTime the date and time describing the actual or expected starting time of the object. + // When used with an Activity object, for instance, the startTime property specifies + // the moment the activity began or is scheduled to begin. + StartTime time.Time `jsonld:"startTime,omitempty"` + // Summary a natural language summarization of the object encoded as HTML. + // *Multiple language tagged summaries may be provided.) + Summary NaturalLanguageValues `jsonld:"summary,omitempty,collapsible"` + // Tag one or more "tags" that have been associated with an objects. A tag can be any kind of Activity Pub Object. + // The key difference between attachment and tag is that the former implies association by inclusion, + // while the latter implies associated by reference. + Tag ItemCollection `jsonld:"tag,omitempty"` + // Updated the date and time at which the object was updated + Updated time.Time `jsonld:"updated,omitempty"` + // URL identifies one or more links to representations of the object + URL Item `jsonld:"url,omitempty"` + // To identifies an entity considered to be part of the public primary audience of an Activity Pub Object + To ItemCollection `jsonld:"to,omitempty"` + // Bto identifies anActivity Pub Object that is part of the private primary audience of this Activity Pub Object. + Bto ItemCollection `jsonld:"bto,omitempty"` + // CC identifies anActivity Pub Object that is part of the public secondary audience of this Activity Pub Object. + CC ItemCollection `jsonld:"cc,omitempty"` + // BCC identifies one or more Objects that are part of the private secondary audience of this Activity Pub Object. + BCC ItemCollection `jsonld:"bcc,omitempty"` + // Duration when the object describes a time-bound resource, such as an audio or video, a meeting, etc, + // the duration property indicates the object's approximate duration. + // The value must be expressed as an xsd:duration as defined by [ xmlschema11-2], + // section 3.3.6 (e.g. a period of 5 seconds is represented as "PT5S"). + Duration time.Duration `jsonld:"duration,omitempty"` + // This is a list of all Like activities with this object as the object property, added as a side effect. + // The likes collection MUST be either an OrderedCollection or a Collection and MAY be filtered on privileges + // of an authenticated user or as appropriate when no authentication is given. + Likes Item `jsonld:"likes,omitempty"` + // This is a list of all Announce activities with this object as the object property, added as a side effect. + // The shares collection MUST be either an OrderedCollection or a Collection and MAY be filtered on privileges + // of an authenticated user or as appropriate when no authentication is given. + Shares Item `jsonld:"shares,omitempty"` + // Source property is intended to convey some sort of source from which the content markup was derived, + // as a form of provenance, or to support future editing by clients. + // In general, clients do the conversion from source to content, not the other way around. + Source Source `jsonld:"source,omitempty"` + // CanReceiveActivities describes one or more entities that either performed or are expected to perform the activity. + // Any single activity can have multiple actors. The actor may be specified using an indirect Link. + Actor CanReceiveActivities `jsonld:"actor,omitempty"` + // Target describes the indirect object, or target, of the activity. + // The precise meaning of the target is largely dependent on the type of action being described + // but will often be the object of the English preposition "to". + // For instance, in the activity "John added a movie to his wishlist", + // the target of the activity is John's wishlist. An activity can have more than one target. + Target Item `jsonld:"target,omitempty"` + // Result describes the result of the activity. For instance, if a particular action results in the creation + // of a new resource, the result property can be used to describe that new resource. + Result Item `jsonld:"result,omitempty"` + // Origin describes an indirect object of the activity from which the activity is directed. + // The precise meaning of the origin is the object of the English preposition "from". + // For instance, in the activity "John moved an item to List B from List A", the origin of the activity is "List A". + Origin Item `jsonld:"origin,omitempty"` + // Instrument identifies one or more objects used (or to be used) in the completion of an Activity. + Instrument Item `jsonld:"instrument,omitempty"` +} + +type ( + // Arrive is an IntransitiveActivity that indicates that the actor has arrived at the location. + // The origin can be used to identify the context from which the actor originated. + // The target typically has no defined meaning. + Arrive = IntransitiveActivity + + // Travel indicates that the actor is traveling to target from origin. + // Travel is an IntransitiveObject whose actor specifies the direct object. + // If the target or origin are not specified, either can be determined by context. + Travel = IntransitiveActivity +) + +// Recipients performs recipient de-duplication on the IntransitiveActivity's To, Bto, CC and BCC properties +func (i *IntransitiveActivity) Recipients() ItemCollection { + aud := i.Audience + return ItemCollectionDeduplication(&i.To, &i.CC, &i.Bto, &i.BCC, &ItemCollection{i.Actor}, &aud) +} + +// Clean removes Bto and BCC properties +func (i *IntransitiveActivity) Clean() { + _ = OnObject(i, func(o *Object) error { + o.Clean() + return nil + }) +} + +// GetType returns the ActivityVocabulary type of the current Intransitive Activity +func (i IntransitiveActivity) GetType() ActivityVocabularyType { + return i.Type +} + +// IsLink returns false for Activity objects +func (i IntransitiveActivity) IsLink() bool { + return false +} + +// GetID returns the ID corresponding to the IntransitiveActivity object +func (i IntransitiveActivity) GetID() ID { + return i.ID +} + +// GetLink returns the IRI corresponding to the IntransitiveActivity object +func (i IntransitiveActivity) GetLink() IRI { + return IRI(i.ID) +} + +// IsObject returns true for IntransitiveActivity objects +func (i IntransitiveActivity) IsObject() bool { + return true +} + +// IsCollection returns false for IntransitiveActivity objects +func (i IntransitiveActivity) IsCollection() bool { + return false +} + +// UnmarshalJSON decodes an incoming JSON document into the receiver object. +func (i *IntransitiveActivity) UnmarshalJSON(data []byte) error { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + return JSONLoadIntransitiveActivity(val, i) +} + +// MarshalJSON encodes the receiver object to a JSON document. +func (i IntransitiveActivity) MarshalJSON() ([]byte, error) { + b := make([]byte, 0) + JSONWrite(&b, '{') + + if !JSONWriteIntransitiveActivityValue(&b, i) { + return nil, nil + } + JSONWrite(&b, '}') + return b, nil +} + +// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. +func (i *IntransitiveActivity) UnmarshalBinary(data []byte) error { + return i.GobDecode(data) +} + +// MarshalBinary implements the encoding.BinaryMarshaler interface. +func (i IntransitiveActivity) MarshalBinary() ([]byte, error) { + return i.GobEncode() +} + +func (i IntransitiveActivity) GobEncode() ([]byte, error) { + mm := make(map[string][]byte) + hasData, err := mapIntransitiveActivityProperties(mm, &i) + if err != nil { + return nil, err + } + if !hasData { + return []byte{}, nil + } + bb := bytes.Buffer{} + g := gob.NewEncoder(&bb) + if err := g.Encode(mm); err != nil { + return nil, err + } + return bb.Bytes(), nil +} + +func (i *IntransitiveActivity) GobDecode(data []byte) error { + if len(data) == 0 { + return nil + } + mm, err := gobDecodeObjectAsMap(data) + if err != nil { + return err + } + return unmapIntransitiveActivityProperties(mm, i) +} + +// IntransitiveActivityNew initializes a intransitive activity +func IntransitiveActivityNew(id ID, typ ActivityVocabularyType) *IntransitiveActivity { + if !IntransitiveActivityTypes.Contains(typ) { + typ = IntransitiveActivityType + } + i := IntransitiveActivity{ID: id, Type: typ} + i.Name = NaturalLanguageValuesNew() + i.Content = NaturalLanguageValuesNew() + + return &i +} + +// ToIntransitiveActivity tries to convert it Item to an IntransitiveActivity object +func ToIntransitiveActivity(it Item) (*IntransitiveActivity, error) { + switch i := it.(type) { + case *IntransitiveActivity: + return i, nil + case IntransitiveActivity: + return &i, nil + case *Question: + return (*IntransitiveActivity)(unsafe.Pointer(i)), nil + case Question: + return (*IntransitiveActivity)(unsafe.Pointer(&i)), nil + case *Activity: + return (*IntransitiveActivity)(unsafe.Pointer(i)), nil + case Activity: + return (*IntransitiveActivity)(unsafe.Pointer(&i)), nil + default: + return reflectItemToType[IntransitiveActivity](it) + } +} + +// ArriveNew initializes an Arrive activity +func ArriveNew(id ID) *Arrive { + a := IntransitiveActivityNew(id, ArriveType) + o := Arrive(*a) + return &o +} + +// TravelNew initializes a Travel activity +func TravelNew(id ID) *Travel { + a := IntransitiveActivityNew(id, TravelType) + o := Travel(*a) + return &o +} + +// Equals verifies if our receiver Object is equals with the "with" Object +func (i IntransitiveActivity) Equals(with Item) bool { + result := true + err := OnIntransitiveActivity(with, func(w *IntransitiveActivity) error { + _ = OnObject(i, func(oa *Object) error { + result = oa.Equals(w) + return nil + }) + if w.Actor != nil { + if !ItemsEqual(i.Actor, w.Actor) { + result = false + return nil + } + } + if w.Target != nil { + if !ItemsEqual(i.Target, w.Target) { + result = false + return nil + } + } + if w.Result != nil { + if !ItemsEqual(i.Result, w.Result) { + result = false + return nil + } + } + if w.Origin != nil { + if !ItemsEqual(i.Origin, w.Origin) { + result = false + return nil + } + } + if w.Instrument != nil { + if !ItemsEqual(i.Instrument, w.Instrument) { + result = false + return nil + } + } + return nil + }) + if err != nil { + result = false + } + return result +} + +func (i IntransitiveActivity) Format(s fmt.State, verb rune) { + switch verb { + case 's': + if i.Type != "" && i.ID != "" { + _, _ = fmt.Fprintf(s, "%T[%s]( %s )", i, i.Type, i.ID) + } else if i.ID != "" { + _, _ = fmt.Fprintf(s, "%T( %s )", i, i.ID) + } else { + _, _ = fmt.Fprintf(s, "%T[%p]", i, &i) + } + case 'v': + _, _ = fmt.Fprintf(s, "%T[%s] {", i, i.Type) + _ = fmtIntransitiveActivityProps(s)(&i) + _, _ = io.WriteString(s, " }") + } +} + +func fmtIntransitiveActivityProps(w io.Writer) func(*IntransitiveActivity) error { + return func(ia *IntransitiveActivity) error { + if !IsNil(ia.Actor) { + _, _ = fmt.Fprintf(w, " actor: %s", ia.Actor) + } + if !IsNil(ia.Target) { + _, _ = fmt.Fprintf(w, " target: %s", ia.Target) + } + if !IsNil(ia.Result) { + _, _ = fmt.Fprintf(w, " result: %s", ia.Result) + } + if !IsNil(ia.Origin) { + _, _ = fmt.Fprintf(w, " origin: %s", ia.Origin) + } + if !IsNil(ia.Instrument) { + _, _ = fmt.Fprintf(w, " instrument: %s", ia.Instrument) + } + return OnObject(ia, fmtObjectProps(w)) + } +} diff --git a/intransitive_activity_test.go b/intransitive_activity_test.go new file mode 100644 index 0000000..b62b515 --- /dev/null +++ b/intransitive_activity_test.go @@ -0,0 +1,391 @@ +package activitypub + +import ( + "testing" + "time" +) + +func TestIntransitiveActivityNew(t *testing.T) { + testValue := ID("test") + var testType ActivityVocabularyType = "Arrive" + + a := IntransitiveActivityNew(testValue, testType) + + if a.ID != testValue { + t.Errorf("IntransitiveActivity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != testType { + t.Errorf("IntransitiveActivity Type '%v' different than expected '%v'", a.Type, testType) + } + + g := IntransitiveActivityNew(testValue, "") + + if g.ID != testValue { + t.Errorf("IntransitiveActivity Id '%v' different than expected '%v'", g.ID, testValue) + } + if g.Type != IntransitiveActivityType { + t.Errorf("IntransitiveActivity Type '%v' different than expected '%v'", g.Type, IntransitiveActivityType) + } +} + +func TestIntransitiveActivityRecipients(t *testing.T) { + bob := PersonNew("bob") + alice := PersonNew("alice") + foo := OrganizationNew("foo") + bar := GroupNew("bar") + + a := IntransitiveActivityNew("test", "t") + + a.To.Append(bob) + a.To.Append(alice) + a.To.Append(foo) + a.To.Append(bar) + if len(a.To) != 4 { + t.Errorf("%T.To should have exactly 4(four) elements, not %d", a, len(a.To)) + } + + a.To.Append(bar) + a.To.Append(alice) + a.To.Append(foo) + a.To.Append(bob) + if len(a.To) != 4 { + t.Errorf("%T.To should have exactly 4(eight) elements, not %d", a, len(a.To)) + } + + a.Recipients() + if len(a.To) != 4 { + t.Errorf("%T.To should have exactly 4(four) elements, not %d", a, len(a.To)) + } + + b := ActivityNew("t", "test", nil) + + b.To.Append(bar) + b.To.Append(alice) + b.To.Append(foo) + b.To.Append(bob) + b.Bto.Append(bar) + b.Bto.Append(alice) + b.Bto.Append(foo) + b.Bto.Append(bob) + b.CC.Append(bar) + b.CC.Append(alice) + b.CC.Append(foo) + b.CC.Append(bob) + b.BCC.Append(bar) + b.BCC.Append(alice) + b.BCC.Append(foo) + b.BCC.Append(bob) + + b.Recipients() + if len(b.To) != 4 { + t.Errorf("%T.To should have exactly 4(four) elements, not %d", b, len(b.To)) + } + if len(b.Bto) != 0 { + t.Errorf("%T.Bto should have exactly 0(zero) elements, not %d", b, len(b.Bto)) + } + if len(b.CC) != 0 { + t.Errorf("%T.CC should have exactly 0(zero) elements, not %d", b, len(b.CC)) + } + if len(b.BCC) != 0 { + t.Errorf("%T.BCC should have exactly 0(zero) elements, not %d", b, len(b.BCC)) + } + var err error + recIds := make([]ID, 0) + err = checkDedup(b.To, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(b.Bto, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(b.CC, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(b.BCC, &recIds) + if err != nil { + t.Error(err) + } +} + +func TestIntransitiveActivity_GetLink(t *testing.T) { + i := IntransitiveActivityNew("test", QuestionType) + + if i.GetID() != "test" { + t.Errorf("%T should return an empty %T object. Received %#v", i, i, i) + } +} + +func TestIntransitiveActivity_GetObject(t *testing.T) { + i := IntransitiveActivityNew("test", QuestionType) + + if i.GetID() != "test" || i.GetType() != QuestionType { + t.Errorf("%T should not return an empty %T object. Received %#v", i, i, i) + } +} + +func TestIntransitiveActivity_IsLink(t *testing.T) { + i := IntransitiveActivityNew("test", QuestionType) + + if i.IsLink() { + t.Errorf("%T should not respond true to IsLink", i) + } +} + +func TestIntransitiveActivity_IsObject(t *testing.T) { + i := IntransitiveActivityNew("test", ActivityType) + + if !i.IsObject() { + t.Errorf("%T should respond true to IsObject", i) + } +} + +func TestIntransitiveActivity_Recipients(t *testing.T) { + to := PersonNew("bob") + o := ObjectNew(ArticleType) + cc := PersonNew("alice") + + o.ID = "something" + + c := IntransitiveActivityNew("act", IntransitiveActivityType) + c.To.Append(to) + c.CC.Append(cc) + c.BCC.Append(cc) + + c.Recipients() + + var err error + recIds := make([]ID, 0) + err = checkDedup(c.To, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(c.Bto, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(c.CC, &recIds) + if err != nil { + t.Error(err) + } + err = checkDedup(c.BCC, &recIds) + if err != nil { + t.Error(err) + } +} + +func TestIntransitiveActivity_GetID(t *testing.T) { + a := IntransitiveActivityNew("test", IntransitiveActivityType) + + if a.GetID() != "test" { + t.Errorf("%T should return an empty %T object. Received %#v", a, a.GetID(), a.GetID()) + } +} + +func TestIntransitiveActivity_GetType(t *testing.T) { + { + a := IntransitiveActivityNew("test", IntransitiveActivityType) + if a.GetType() != IntransitiveActivityType { + t.Errorf("GetType should return %q for %T, received %q", IntransitiveActivityType, a, a.GetType()) + } + } + { + a := IntransitiveActivityNew("test", ArriveType) + if a.GetType() != ArriveType { + t.Errorf("GetType should return %q for %T, received %q", ArriveType, a, a.GetType()) + } + } + { + a := IntransitiveActivityNew("test", QuestionType) + if a.GetType() != QuestionType { + t.Errorf("GetType should return %q for %T, received %q", QuestionType, a, a.GetType()) + } + } +} + +func TestToIntransitiveActivity(t *testing.T) { + var it Item + act := IntransitiveActivityNew("test", TravelType) + it = act + + a, err := ToIntransitiveActivity(it) + if err != nil { + t.Error(err) + } + if a != act { + t.Errorf("Invalid activity returned by ToActivity #%v", a) + } + + ob := ObjectNew(ArticleType) + it = ob + + o, err := ToIntransitiveActivity(it) + if err == nil { + t.Errorf("Error returned when calling ToActivity with object should not be nil") + } + if o != nil { + t.Errorf("Invalid return by ToActivity #%v, should have been nil", o) + } +} + +func TestIntransitiveActivity_Clean(t *testing.T) { + t.Skipf("TODO") +} + +func TestIntransitiveActivity_IsCollection(t *testing.T) { + t.Skipf("TODO") +} + +func TestIntransitiveActivity_UnmarshalJSON(t *testing.T) { + t.Skipf("TODO") +} + +func TestArriveNew(t *testing.T) { + testValue := ID("test") + + a := ArriveNew(testValue) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != ArriveType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, ArriveType) + } +} + +func TestTravelNew(t *testing.T) { + testValue := ID("test") + + a := TravelNew(testValue) + + if a.ID != testValue { + t.Errorf("Activity Id '%v' different than expected '%v'", a.ID, testValue) + } + if a.Type != TravelType { + t.Errorf("Activity Type '%v' different than expected '%v'", a.Type, TravelType) + } +} + +func TestIntransitiveActivity_Equals(t *testing.T) { + type fields struct { + ID ID + Type ActivityVocabularyType + Name NaturalLanguageValues + Attachment Item + AttributedTo Item + Audience ItemCollection + Content NaturalLanguageValues + Context Item + MediaType MimeType + EndTime time.Time + Generator Item + Icon Item + Image Item + InReplyTo Item + Location Item + Preview Item + Published time.Time + Replies Item + StartTime time.Time + Summary NaturalLanguageValues + Tag ItemCollection + Updated time.Time + URL Item + To ItemCollection + Bto ItemCollection + CC ItemCollection + BCC ItemCollection + Duration time.Duration + Likes Item + Shares Item + Source Source + Actor Item + Target Item + Result Item + Origin Item + Instrument Item + } + tests := []struct { + name string + fields fields + arg Item + want bool + }{ + { + name: "equal-empty-intransitive-activity", + fields: fields{}, + arg: IntransitiveActivity{}, + want: true, + }, + { + name: "equal-intransitive-activity-just-id", + fields: fields{ID: "test"}, + arg: IntransitiveActivity{ID: "test"}, + want: true, + }, + { + name: "equal-intransitive-activity-id", + fields: fields{ID: "test", URL: IRI("example.com")}, + arg: IntransitiveActivity{ID: "test"}, + want: true, + }, + { + name: "equal-false-with-id-and-url", + fields: fields{ID: "test"}, + arg: IntransitiveActivity{ID: "test", URL: IRI("example.com")}, + want: false, + }, + { + name: "not a valid intransitive-activity", + fields: fields{ID: "http://example.com"}, + arg: Link{ID: "http://example.com"}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := IntransitiveActivity{ + ID: tt.fields.ID, + Type: tt.fields.Type, + Name: tt.fields.Name, + Attachment: tt.fields.Attachment, + AttributedTo: tt.fields.AttributedTo, + Audience: tt.fields.Audience, + Content: tt.fields.Content, + Context: tt.fields.Context, + MediaType: tt.fields.MediaType, + EndTime: tt.fields.EndTime, + Generator: tt.fields.Generator, + Icon: tt.fields.Icon, + Image: tt.fields.Image, + InReplyTo: tt.fields.InReplyTo, + Location: tt.fields.Location, + Preview: tt.fields.Preview, + Published: tt.fields.Published, + Replies: tt.fields.Replies, + StartTime: tt.fields.StartTime, + Summary: tt.fields.Summary, + Tag: tt.fields.Tag, + Updated: tt.fields.Updated, + URL: tt.fields.URL, + To: tt.fields.To, + Bto: tt.fields.Bto, + CC: tt.fields.CC, + BCC: tt.fields.BCC, + Duration: tt.fields.Duration, + Likes: tt.fields.Likes, + Shares: tt.fields.Shares, + Source: tt.fields.Source, + Actor: tt.fields.Actor, + Target: tt.fields.Target, + Result: tt.fields.Result, + Origin: tt.fields.Origin, + Instrument: tt.fields.Instrument, + } + if got := a.Equals(tt.arg); got != tt.want { + t.Errorf("Equals() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/iri.go b/iri.go new file mode 100644 index 0000000..2af74bd --- /dev/null +++ b/iri.go @@ -0,0 +1,452 @@ +package activitypub + +import ( + "bytes" + "encoding/gob" + "errors" + "fmt" + "io" + "net/url" + "path/filepath" + "strings" + + "github.com/valyala/fastjson" +) + +const ( + // ActivityBaseURI the URI for the ActivityStreams namespace + ActivityBaseURI = IRI("https://www.w3.org/ns/activitystreams") + // SecurityContextURI the URI for the security namespace (for an Actor's PublicKey) + SecurityContextURI = IRI("https://w3id.org/security/v1") + // PublicNS is the reference to the Public entity in the ActivityStreams namespace. + // + // Public Addressing + // + // https://www.w3.org/TR/activitypub/#public-addressing + // + // In addition to [ActivityStreams] collections and objects, Activities may additionally be addressed to the + // special "public" collection, with the identifier https://www.w3.org/ns/activitystreams#Public. For example: + // + // { + // "@context": "https://www.w3.org/ns/activitystreams", + // "id": "https://www.w3.org/ns/activitystreams#Public", + // "type": "Collection" + // } + // Activities addressed to this special URI shall be accessible to all users, without authentication. + // Implementations MUST NOT deliver to the "public" special collection; it is not capable of receiving + // actual activities. However, actors MAY have a sharedInbox endpoint which is available for efficient + // shared delivery of public posts (as well as posts to followers-only); see 7.1.3 Shared Inbox Delivery. + // + // NOTE + // Compacting an ActivityStreams object using the ActivityStreams JSON-LD context might result in + // https://www.w3.org/ns/activitystreams#Public being represented as simply Public or as:Public which are valid + // representations of the Public collection. Implementations which treat ActivityStreams objects as simply JSON + // rather than converting an incoming activity over to a local context using JSON-LD tooling should be aware + // of this and should be prepared to accept all three representations. + PublicNS = ActivityBaseURI + "#Public" +) + +// JsonLDContext is a slice of IRIs that form the default context for the objects in the +// GoActivitypub vocabulary. +// It does not represent just the default ActivityStreams public namespace, but it also +// has the W3 Permanent Identifier Community Group's Security namespace, which appears +// in the Actor type objects, which contain public key related data. +var JsonLDContext = []IRI{ + ActivityBaseURI, + SecurityContextURI, +} + +type ( + // IRI is a Internationalized Resource Identifiers (IRIs) RFC3987 + IRI string + IRIs []IRI +) + +func (i IRI) Format(s fmt.State, verb rune) { + switch verb { + case 's', 'v': + _, _ = io.WriteString(s, i.String()) + } +} + +// String returns the String value of the IRI object +func (i IRI) String() string { + return string(i) +} + +// GetLink +func (i IRI) GetLink() IRI { + return i +} + +// URL +func (i IRI) URL() (*url.URL, error) { + if i == "" { + return nil, errors.New("empty IRI") + } + return url.Parse(string(i)) +} + +// UnmarshalJSON decodes an incoming JSON document into the receiver object. +func (i *IRI) UnmarshalJSON(s []byte) error { + *i = IRI(strings.Trim(string(s), "\"")) + return nil +} + +// MarshalJSON encodes the receiver object to a JSON document. +func (i IRI) MarshalJSON() ([]byte, error) { + if i == "" { + return nil, nil + } + b := make([]byte, 0) + JSONWrite(&b, '"') + JSONWriteS(&b, i.String()) + JSONWrite(&b, '"') + return b, nil +} + +// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. +func (i *IRI) UnmarshalBinary(data []byte) error { + return i.GobDecode(data) +} + +// MarshalBinary implements the encoding.BinaryMarshaler interface. +func (i IRI) MarshalBinary() ([]byte, error) { + return i.GobEncode() +} + +// GobEncode +func (i IRI) GobEncode() ([]byte, error) { + return []byte(i), nil +} + +// GobEncode +func (i IRIs) GobEncode() ([]byte, error) { + if len(i) == 0 { + return []byte{}, nil + } + b := bytes.Buffer{} + gg := gob.NewEncoder(&b) + bb := make([][]byte, 0) + for _, iri := range i { + bb = append(bb, []byte(iri)) + } + if err := gg.Encode(bb); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +// GobDecode +func (i *IRI) GobDecode(data []byte) error { + *i = IRI(data) + return nil +} + +func (i *IRIs) GobDecode(data []byte) error { + if len(data) == 0 { + // NOTE(marius): this behaviour diverges from vanilla gob package + return nil + } + err := gob.NewDecoder(bytes.NewReader(data)).Decode(i) + if err == nil { + return nil + } + bb := make([][]byte, 0) + err = gob.NewDecoder(bytes.NewReader(data)).Decode(&bb) + if err != nil { + return err + } + for _, b := range bb { + *i = append(*i, IRI(b)) + } + return nil +} + +// AddPath concatenates el elements as a path to i +func (i IRI) AddPath(el ...string) IRI { + iri := strings.TrimRight(i.String(), "/") + return IRI(iri + filepath.Clean(filepath.Join("/", filepath.Join(el...)))) +} + +// GetID +func (i IRI) GetID() ID { + return i +} + +// GetType +func (i IRI) GetType() ActivityVocabularyType { + return IRIType +} + +// IsLink +func (i IRI) IsLink() bool { + return true +} + +// IsObject +func (i IRI) IsObject() bool { + return false +} + +// IsCollection returns false for IRI objects +func (i IRI) IsCollection() bool { + return false +} + +// FlattenToIRI checks if Item can be flatten to an IRI and returns it if so +func FlattenToIRI(it Item) Item { + if !IsNil(it) && it.IsObject() && len(it.GetLink()) > 0 { + return it.GetLink() + } + return it +} + +func (i IRIs) MarshalJSON() ([]byte, error) { + if len(i) == 0 { + return []byte{'[', ']'}, nil + } + b := make([]byte, 0) + writeCommaIfNotEmpty := func(notEmpty bool) { + if notEmpty { + JSONWriteS(&b, ",") + } + } + JSONWrite(&b, '[') + for k, iri := range i { + writeCommaIfNotEmpty(k > 0) + JSONWrite(&b, '"') + JSONWriteS(&b, iri.String()) + JSONWrite(&b, '"') + } + JSONWrite(&b, ']') + return b, nil +} + +func (i *IRIs) UnmarshalJSON(data []byte) error { + if i == nil { + return nil + } + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + switch val.Type() { + case fastjson.TypeString: + if iri, ok := asIRI(val); ok && len(iri) > 0 { + *i = append(*i, iri) + } + case fastjson.TypeArray: + for _, v := range val.GetArray() { + if iri, ok := asIRI(v); ok && len(iri) > 0 { + *i = append(*i, iri) + } + } + } + return nil +} + +// GetID returns the ID corresponding to ItemCollection +func (i IRIs) GetID() ID { + return EmptyID +} + +// GetLink returns the empty IRI +func (i IRIs) GetLink() IRI { + return EmptyIRI +} + +// GetType returns the ItemCollection's type +func (i IRIs) GetType() ActivityVocabularyType { + return CollectionOfIRIs +} + +// IsLink returns false for an ItemCollection object +func (i IRIs) IsLink() bool { + return false +} + +// IsObject returns true for a ItemCollection object +func (i IRIs) IsObject() bool { + return false +} + +// IsCollection returns true for IRI slices +func (i IRIs) IsCollection() bool { + return true +} + +// Append facilitates adding elements to IRI slices +// and ensures IRIs implements the Collection interface +func (i *IRIs) Append(it ...Item) error { + for _, ob := range it { + if (*i).Contains(ob.GetLink()) { + continue + } + *i = append(*i, ob.GetLink()) + } + return nil +} + +func (i *IRIs) Collection() ItemCollection { + res := make(ItemCollection, len(*i)) + for k, iri := range *i { + res[k] = iri + } + return res +} + +func (i *IRIs) Count() uint { + return uint(len(*i)) +} + +// Contains verifies if IRIs array contains the received one +func (i IRIs) Contains(r Item) bool { + if len(i) == 0 { + return false + } + for _, iri := range i { + if r.GetLink().Equals(iri, false) { + return true + } + } + return false +} + +func validURL(u *url.URL, checkScheme bool) bool { + if u == nil { + return false + } + if len(u.Host) == 0 { + return false + } + if checkScheme { + return len(u.Scheme) > 0 + } + return true +} + +func stripFragment(u string) string { + p := strings.Index(u, "#") + if p <= 0 { + p = len(u) + } + return u[:p] +} + +func stripScheme(u string) string { + p := strings.Index(u, "://") + if p < 0 { + p = 0 + } + return u[p:] +} + +func irisEqual(i1, i2 IRI, checkScheme bool) bool { + u, e := i1.URL() + uw, ew := i2.URL() + if e != nil || ew != nil || !validURL(u, checkScheme) || !validURL(uw, checkScheme) { + return strings.EqualFold(i1.String(), i2.String()) + } + if checkScheme { + if !strings.EqualFold(u.Scheme, uw.Scheme) { + return false + } + } + if !strings.EqualFold(u.Host, uw.Host) { + return false + } + if !(u.Path == "/" && uw.Path == "" || u.Path == "" && uw.Path == "/") && + !strings.EqualFold(filepath.Clean(u.Path), filepath.Clean(uw.Path)) { + return false + } + uq := u.Query() + uwq := uw.Query() + if len(uq) != len(uwq) { + return false + } + for k, uqv := range uq { + uwqv, ok := uwq[k] + if !ok { + return false + } + if len(uqv) != len(uwqv) { + return false + } + for _, uqvv := range uqv { + eq := false + for _, uwqvv := range uwqv { + if uwqvv == uqvv { + eq = true + continue + } + } + if !eq { + return false + } + } + } + return true +} + +// Equals verifies if our receiver IRI is equals with the "with" IRI +func (i IRI) Equals(with IRI, checkScheme bool) bool { + is := stripFragment(string(i)) + ws := stripFragment(string(with)) + if !checkScheme { + is = stripScheme(is) + ws = stripScheme(ws) + } + if strings.EqualFold(is, ws) { + return true + } + return irisEqual(i, with, checkScheme) +} + +func hostSplit(h string) (string, string) { + pieces := strings.Split(h, ":") + if len(pieces) == 0 { + return "", "" + } + if len(pieces) == 1 { + return pieces[0], "" + } + return pieces[0], pieces[1] +} + +func (i IRI) Contains(what IRI, checkScheme bool) bool { + u, e := i.URL() + uw, ew := what.URL() + if e != nil || ew != nil { + return strings.Contains(i.String(), what.String()) + } + if checkScheme { + if u.Scheme != uw.Scheme { + return false + } + } + uHost, _ := hostSplit(u.Host) + uwHost, _ := hostSplit(uw.Host) + if uHost != uwHost { + return false + } + p := u.Path + if p != "" { + p = filepath.Clean(p) + } + pw := uw.Path + if pw != "" { + pw = filepath.Clean(pw) + } + return strings.Contains(p, pw) +} + +func (i IRI) ItemsMatch(col ...Item) bool { + for _, it := range col { + if match := it.GetLink().Contains(i, false); !match { + return false + } + } + return true +} diff --git a/iri_test.go b/iri_test.go new file mode 100644 index 0000000..6493e5d --- /dev/null +++ b/iri_test.go @@ -0,0 +1,405 @@ +package activitypub + +import ( + "errors" + "fmt" + "net/url" + "reflect" + "testing" +) + +func TestIRI_GetLink(t *testing.T) { + val := "http://example.com" + u := IRI(val) + if u.GetLink() != IRI(val) { + t.Errorf("IRI %q should equal %q", u, val) + } +} + +func TestIRI_String(t *testing.T) { + val := "http://example.com" + u := IRI(val) + if u.String() != val { + t.Errorf("IRI %q should equal %q", u, val) + } +} + +func TestIRI_GetID(t *testing.T) { + i := IRI("http://example.com") + if id := i.GetID(); !id.IsValid() || id != ID(i) { + t.Errorf("ID %q (%T) should equal %q (%T)", id, id, i, ID(i)) + } +} + +func TestIRI_GetType(t *testing.T) { + i := IRI("http://example.com") + if i.GetType() != IRIType { + t.Errorf("Invalid type for %T object %s, expected %s", i, i.GetType(), IRIType) + } +} + +func TestIRI_IsLink(t *testing.T) { + i := IRI("http://example.com") + if i.IsLink() != true { + t.Errorf("%T.IsLink() returned %t, expected %t", i, i.IsLink(), true) + } +} + +func TestIRI_IsObject(t *testing.T) { + i := IRI("http://example.com") + if i.IsObject() { + t.Errorf("%T.IsObject() returned %t, expected %t", i, i.IsObject(), false) + } + ii := IRI([]byte("https://example.com")) + if ii.IsObject() { + t.Errorf("%T.IsObject() returned %t, expected %t", ii, ii.IsObject(), false) + } + iii := &ii + if iii.IsObject() { + t.Errorf("%T.IsObject() returned %t, expected %t", iii, iii.IsObject(), false) + } +} + +func TestIRI_UnmarshalJSON(t *testing.T) { + val := "http://example.com" + i := IRI("") + + err := i.UnmarshalJSON([]byte(val)) + if err != nil { + t.Error(err) + } + if val != i.String() { + t.Errorf("%T invalid value after Unmarshal %q, expected %q", i, i, val) + } +} + +func TestIRI_MarshalJSON(t *testing.T) { + value := []byte("http://example.com") + i := IRI(value) + + v, err := i.MarshalJSON() + if err != nil { + t.Error(err) + } + expected := fmt.Sprintf("%q", value) + if expected != string(v) { + t.Errorf("Invalid value after MarshalJSON: %s, expected %s", v, expected) + } +} + +func TestFlattenToIRI(t *testing.T) { + t.Skipf("TODO") +} + +func TestIRI_URL(t *testing.T) { + tests := []struct { + name string + i IRI + want *url.URL + wantErr bool + }{ + { + name: "empty", + i: "", + want: nil, + wantErr: true, // empty IRI + }, + { + name: "example with fragment", + i: "https://example.com/#fragment", + want: &url.URL{ + Scheme: "https", + Host: "example.com", + Path: "/", + Fragment: "fragment", + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.i.URL() + if (err != nil) != tt.wantErr { + t.Errorf("URL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("URL() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIRIs_Contains(t *testing.T) { + t.Skipf("TODO") +} + +func TestIRI_Contains(t *testing.T) { + t.Skip("TODO") +} + +func TestIRI_IsCollection(t *testing.T) { + t.Skip("TODO") +} + +func TestIRIs_UnmarshalJSON(t *testing.T) { + type args struct { + d []byte + } + tests := []struct { + name string + args args + obj IRIs + want IRIs + err error + }{ + { + name: "empty", + args: args{[]byte{'{', '}'}}, + want: nil, + err: nil, + }, + { + name: "IRI", + args: args{[]byte("\"http://example.com\"")}, + want: IRIs{IRI("http://example.com")}, + err: nil, + }, + { + name: "IRIs", + args: args{[]byte(fmt.Sprintf("[%q, %q, %q]", "http://example.com", "http://example.net", "http://example.org"))}, + want: IRIs{ + IRI("http://example.com"), + IRI("http://example.net"), + IRI("http://example.org"), + }, + err: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.obj.UnmarshalJSON(tt.args.d) + if (err != nil && tt.err == nil) || (err == nil && tt.err != nil) { + if !errors.Is(err, tt.err) { + t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.err) + } + return + } + if !assertDeepEquals(t.Errorf, tt.obj, tt.want) { + t.Errorf("UnmarshalJSON() got = %#v, want %#v", tt.obj, tt.want) + } + }) + } +} + +func TestIRIs_MarshalJSON(t *testing.T) { + value1 := []byte("http://example.com") + value2 := []byte("http://example.net") + value3 := []byte("http://example.org") + i := IRIs{ + IRI(value1), + IRI(value2), + IRI(value3), + } + + v, err := i.MarshalJSON() + if err != nil { + t.Error(err) + } + expected := fmt.Sprintf("[%q, %q, %q]", value1, value2, value3) + if expected == string(v) { + t.Errorf("Invalid value after MarshalJSON: %s, expected %s", v, expected) + } +} + +func TestIRI_AddPath(t *testing.T) { + t.Skip("TODO") +} + +func TestIRI_ItemMatches(t *testing.T) { + t.Skip("TODO") +} + +func TestIRI_GobDecode(t *testing.T) { + tests := []struct { + name string + i IRI + data []byte + wantErr bool + }{ + { + name: "empty", + i: "", + data: []byte{}, + wantErr: false, + }, + { + name: "some iri", + i: "https://example.com", + data: gobValue([]byte("https://example.com")), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.i.GobDecode(tt.data); (err != nil) != tt.wantErr { + t.Errorf("GobDecode() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestIRI_GobEncode(t *testing.T) { + tests := []struct { + name string + i IRI + want []byte + wantErr bool + }{ + { + name: "empty", + i: "", + want: []byte{}, + wantErr: false, + }, + { + name: "some iri", + i: "https://example.com", + want: []byte("https://example.com"), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.i.GobEncode() + if (err != nil) != tt.wantErr { + t.Errorf("GobEncode() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GobEncode() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIRI_Equals(t *testing.T) { + type args struct { + with IRI + check bool + } + tests := []struct { + name string + i IRI + args args + want bool + }{ + { + name: "just host", + i: "http://example.com", + args: args{ + with: IRI("http://example.com"), + check: true, + }, + want: true, + }, + { + name: "host and path", + i: "http://example.com/ana/are/mere", + args: args{ + with: IRI("http://example.com/ana/are/mere"), + check: true, + }, + want: true, + }, + { + name: "different schemes check scheme", + i: "https://example.com/ana/are/mere", + args: args{ + with: IRI("http://example.com/ana/are/mere"), + check: true, + }, + want: false, + }, + { + name: "different schemes, don't check scheme", + i: "https://example.com/ana/are/mere", + args: args{ + with: IRI("http://example.com/ana/are/mere"), + check: false, + }, + want: true, + }, + { + name: "same host different scheme, same query - different order", + i: "https://example.com?ana=mere&foo=bar", + args: args{ + with: "http://example.com?foo=bar&ana=mere", + check: false, + }, + want: true, + }, + { + name: "same host, different scheme and same path, same query different order", + i: "http://example.com/ana/are/mere?foo=bar&ana=mere", + args: args{ + with: "https://example.com/ana/are/mere?ana=mere&foo=bar", + check: false, + }, + want: true, + }, + { + name: "same host different scheme, same query", + i: "https://example.com?ana=mere", + args: args{ + with: "http://example.com?ana=mere", + check: false, + }, + want: true, + }, + { + name: "different host same scheme", + i: "http://example1.com", + args: args{ + with: "http://example.com", + check: true, + }, + want: false, + }, + { + name: "same host, same scheme and different path", + i: "same host, same scheme and different path", + args: args{ + with: "http://example.com/ana/are/mere", + check: true, + }, + want: false, + }, + { + name: "same host same scheme, different query key", + i: "http://example.com?ana1=mere", + args: args{ + with: "http://example.com?ana=mere", + check: false, + }, + want: false, + }, + { + name: "same host same scheme, different query value", + i: "http://example.com?ana=mere", + args: args{ + with: "http://example.com?ana=mere1", + check: false, + }, + // This was true in the url.Parse implementation + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.i.Equals(tt.args.with, tt.args.check); got != tt.want { + t.Errorf("Equals() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/item.go b/item.go new file mode 100644 index 0000000..9e40abf --- /dev/null +++ b/item.go @@ -0,0 +1,212 @@ +package activitypub + +import ( + "fmt" + "reflect" + "strings" +) + +// Item struct +type Item = ObjectOrLink + +const ( + // EmptyIRI represents a zero length IRI + EmptyIRI IRI = "" + // NilIRI represents by convention an IRI which is nil + // Its use is mostly to check if a property of an ActivityPub Item is nil + NilIRI IRI = "-" + + // EmptyID represents a zero length ID + EmptyID = EmptyIRI + // NilID represents by convention an ID which is nil, see details of NilIRI + NilID = NilIRI +) + +func itemsNeedSwapping(i1, i2 Item) bool { + if IsIRI(i1) && !IsIRI(i2) { + return true + } + t1 := i1.GetType() + t2 := i2.GetType() + if ObjectTypes.Contains(t2) { + return !ObjectTypes.Contains(t1) + } + return false +} + +// ItemsEqual checks if it and with Items are equal +func ItemsEqual(it, with Item) bool { + if IsNil(it) || IsNil(with) { + return IsNil(with) && IsNil(it) + } + if itemsNeedSwapping(it, with) { + return ItemsEqual(with, it) + } + result := false + if IsIRI(with) || IsIRI(it) { + // NOTE(marius): I'm not sure this logic is sound: + // if only one item is an IRI it should not be equal to the other even if it has the same ID + result = it.GetLink().Equals(with.GetLink(), false) + } else if IsItemCollection(it) { + if !IsItemCollection(with) { + return false + } + _ = OnItemCollection(it, func(c *ItemCollection) error { + result = c.Equals(with) + return nil + }) + } else if IsObject(it) { + _ = OnObject(it, func(i *Object) error { + result = i.Equals(with) + return nil + }) + if ActivityTypes.Contains(with.GetType()) { + _ = OnActivity(it, func(i *Activity) error { + result = i.Equals(with) + return nil + }) + } else if ActorTypes.Contains(with.GetType()) { + _ = OnActor(it, func(i *Actor) error { + result = i.Equals(with) + return nil + }) + } else if it.IsCollection() { + if it.GetType() == CollectionType { + _ = OnCollection(it, func(c *Collection) error { + result = c.Equals(with) + return nil + }) + } + if it.GetType() == OrderedCollectionType { + _ = OnOrderedCollection(it, func(c *OrderedCollection) error { + result = c.Equals(with) + return nil + }) + } + if it.GetType() == CollectionPageType { + _ = OnCollectionPage(it, func(c *CollectionPage) error { + result = c.Equals(with) + return nil + }) + } + if it.GetType() == OrderedCollectionPageType { + _ = OnOrderedCollectionPage(it, func(c *OrderedCollectionPage) error { + result = c.Equals(with) + return nil + }) + } + } + } + return result +} + +// IsItemCollection returns if the current Item interface holds a Collection +func IsItemCollection(it LinkOrIRI) bool { + _, ok := it.(ItemCollection) + _, okP := it.(*ItemCollection) + return ok || okP || IsIRIs(it) +} + +// IsIRI returns if the current Item interface holds an IRI +func IsIRI(it LinkOrIRI) bool { + _, okV := it.(IRI) + _, okP := it.(*IRI) + return okV || okP +} + +// IsIRIs returns if the current Item interface holds an IRI slice +func IsIRIs(it LinkOrIRI) bool { + _, okV := it.(IRIs) + _, okP := it.(*IRIs) + return okV || okP +} + +// IsLink returns if the current Item interface holds a Link +func IsLink(it LinkOrIRI) bool { + _, okV := it.(Link) + _, okP := it.(*Link) + return okV || okP +} + +// IsObject returns if the current Item interface holds an Object +func IsObject(it LinkOrIRI) bool { + switch ob := it.(type) { + case Actor, *Actor, + Object, *Object, Profile, *Profile, Place, *Place, Relationship, *Relationship, Tombstone, *Tombstone, + Activity, *Activity, IntransitiveActivity, *IntransitiveActivity, Question, *Question, + Collection, *Collection, CollectionPage, *CollectionPage, + OrderedCollection, *OrderedCollection, OrderedCollectionPage, *OrderedCollectionPage: + return ob != nil + default: + return false + } +} + +// IsNil checks if the object matching an ObjectOrLink interface is nil +func IsNil(it LinkOrIRI) bool { + if it == nil { + return true + } + // This is the default if the argument can't be cast to Object, as is the case for an ItemCollection + isNil := false + if IsIRI(it) { + isNil = len(it.GetLink()) == 0 || strings.EqualFold(it.GetLink().String(), NilIRI.String()) + } else if IsItemCollection(it) { + if v, ok := it.(ItemCollection); ok { + return v == nil + } + if v, ok := it.(*ItemCollection); ok { + return v == nil + } + if v, ok := it.(IRIs); ok { + return v == nil + } + if v, ok := it.(*IRIs); ok { + return v == nil + } + } else if IsObject(it) { + if ob, ok := it.(Item); ok { + _ = OnObject(ob, func(o *Object) error { + isNil = o == nil + return nil + }) + } + } else if IsLink(it) { + _ = OnLink(it, func(l *Link) error { + isNil = l == nil + return nil + }) + } else { + // NOTE(marius): we're not dealing with a type that we know about, so we use slow reflection + // as we still care about the result + v := reflect.ValueOf(it) + isNil = v.Kind() == reflect.Pointer && v.IsNil() + } + return isNil +} + +func ErrorInvalidType[T Objects | Links | IRIs](received LinkOrIRI) error { + return fmt.Errorf("unable to convert %T to %T", received, new(T)) +} + +// OnItem runs function "fn" on the Item "it", with the benefit of destructuring "it" to individual +// items if it's actually an ItemCollection or an object holding an ItemCollection +// +// It is expected that the caller handles the logic of dealing with different Item implementations +// internally in "fn". +func OnItem(it Item, fn func(Item) error) error { + if it == nil { + return nil + } + if !IsItemCollection(it) { + return fn(it) + } + return OnItemCollection(it, func(col *ItemCollection) error { + for _, it := range *col { + if err := OnItem(it, fn); err != nil { + return err + } + } + return nil + }) +} diff --git a/item_collection.go b/item_collection.go new file mode 100644 index 0000000..c3f510b --- /dev/null +++ b/item_collection.go @@ -0,0 +1,301 @@ +package activitypub + +import ( + "sort" +) + +// ItemCollection represents an array of items +type ItemCollection []Item + +// GetID returns the ID corresponding to ItemCollection +func (i ItemCollection) GetID() ID { + return EmptyID +} + +// GetLink returns the empty IRI +func (i ItemCollection) GetLink() IRI { + return EmptyIRI +} + +// GetType returns the ItemCollection's type +func (i ItemCollection) GetType() ActivityVocabularyType { + return CollectionOfItems +} + +// IsLink returns false for an ItemCollection object +func (i ItemCollection) IsLink() bool { + return false +} + +// IsObject returns true for a ItemCollection object +func (i ItemCollection) IsObject() bool { + return false +} + +func (i ItemCollection) IRIs() IRIs { + if i == nil { + return nil + } + + iris := make(IRIs, 0, len(i)) + for _, it := range i { + iris = append(iris, it.GetLink()) + } + return iris +} + +// MarshalJSON encodes the receiver object to a JSON document. +func (i ItemCollection) MarshalJSON() ([]byte, error) { + if i == nil { + return nil, nil + } + b := make([]byte, 0) + JSONWriteItemCollectionValue(&b, i, true) + return b, nil +} + +// Append facilitates adding elements to Item arrays +// and ensures ItemCollection implements the Collection interface +func (i *ItemCollection) Append(it ...Item) error { + for _, ob := range it { + if i.Contains(ob) { + continue + } + *i = append(*i, ob) + } + return nil +} + +// Count returns the length of Items in the item collection +func (i *ItemCollection) Count() uint { + if i == nil { + return 0 + } + return uint(len(*i)) +} + +// First returns the ID corresponding to ItemCollection +func (i ItemCollection) First() Item { + if len(i) == 0 { + return nil + } + return i[0] +} + +// Normalize returns the first item if the collection contains only one, +// the full collection if the collection contains more than one item, +// or nil +func (i ItemCollection) Normalize() Item { + if len(i) == 0 { + return nil + } + if len(i) == 1 { + return i[0] + } + return i +} + +// Collection returns the current object as collection interface +func (i *ItemCollection) Collection() ItemCollection { + return *i +} + +// IsCollection returns true for ItemCollection arrays +func (i ItemCollection) IsCollection() bool { + return true +} + +// Contains verifies if IRIs array contains the received one +func (i ItemCollection) Contains(r Item) bool { + if len(i) == 0 { + return false + } + for _, it := range i { + if ItemsEqual(it, r) { + return true + } + } + return false +} + +// Remove removes the r Item from the i ItemCollection if it contains it +func (i *ItemCollection) Remove(r Item) { + li := len(*i) + if li == 0 { + return + } + if r == nil { + return + } + remIdx := -1 + for idx, it := range *i { + if ItemsEqual(it, r) { + remIdx = idx + } + } + if remIdx == -1 { + return + } + if remIdx < li-1 { + *i = append((*i)[:remIdx], (*i)[remIdx+1:]...) + } else { + *i = (*i)[:remIdx] + } +} + +// ItemCollectionDeduplication normalizes the received arguments lists into a single unified one +func ItemCollectionDeduplication(recCols ...*ItemCollection) ItemCollection { + rec := make(ItemCollection, 0) + + for _, recCol := range recCols { + if recCol == nil { + continue + } + + toRemove := make([]int, 0) + for i, cur := range *recCol { + save := true + if cur == nil { + continue + } + var testIt IRI + if cur.IsObject() { + testIt = cur.GetID() + } else if cur.IsLink() { + testIt = cur.GetLink() + } else { + continue + } + for _, it := range rec { + if testIt.Equals(it.GetID(), false) { + // mark the element for removal + toRemove = append(toRemove, i) + save = false + } + } + if save { + rec = append(rec, testIt) + } + } + + sort.Sort(sort.Reverse(sort.IntSlice(toRemove))) + for _, idx := range toRemove { + *recCol = append((*recCol)[:idx], (*recCol)[idx+1:]...) + } + } + return rec +} + +// ToItemCollection returns the item collection contained as part of OrderedCollection, OrderedCollectionPage, +// Collection and CollectionPage. +// It also converts an IRI slice into an equivalent ItemCollection. +func ToItemCollection(it Item) (*ItemCollection, error) { + switch i := it.(type) { + case *ItemCollection: + return i, nil + case ItemCollection: + return &i, nil + case *OrderedCollection: + return &i.OrderedItems, nil + case *OrderedCollectionPage: + return &i.OrderedItems, nil + case *Collection: + return &i.Items, nil + case *CollectionPage: + return &i.Items, nil + case IRIs: + iris := make(ItemCollection, len(i)) + for j, ob := range i { + iris[j] = ob + } + return &iris, nil + case *IRIs: + iris := make(ItemCollection, len(*i)) + for j, ob := range *i { + iris[j] = ob + } + return &iris, nil + default: + return reflectItemToType[ItemCollection](it) + } + return nil, ErrorInvalidType[ItemCollection](it) +} + +// ToIRIs +func ToIRIs(it Item) (*IRIs, error) { + switch i := it.(type) { + case *IRIs: + return i, nil + case IRIs: + return &i, nil + case ItemCollection: + iris := i.IRIs() + return &iris, nil + case *ItemCollection: + iris := make(IRIs, len(*i)) + for j, ob := range *i { + iris[j] = ob.GetLink() + } + return &iris, nil + default: + return reflectItemToType[IRIs](it) + } + return nil, ErrorInvalidType[IRIs](it) +} + +// ItemsMatch +func (i ItemCollection) ItemsMatch(col ...Item) bool { + for _, it := range col { + if match := i.Contains(it); !match { + return false + } + } + return true +} + +// Equals +func (i ItemCollection) Equals(with Item) bool { + if IsNil(with) { + return IsNil(i) || len(i) == 0 + } + if !with.IsCollection() { + return false + } + if with.GetType() != CollectionOfItems { + return false + } + result := true + _ = OnItemCollection(with, func(w *ItemCollection) error { + if w.Count() != i.Count() { + result = false + return nil + } + for _, it := range i { + if !w.Contains(it.GetLink()) { + result = false + return nil + } + } + return nil + }) + return result +} + +// Clean removes Bto and BCC properties on all the members of the collection +func (i ItemCollection) Clean() { + for j, it := range i { + i[j] = CleanRecipients(it) + } +} + +func (i ItemCollection) Recipients() ItemCollection { + all := make(ItemCollection, 0) + for _, it := range i { + _ = OnObject(it, func(ob *Object) error { + aud := ob.Audience + _ = all.Append(ItemCollectionDeduplication(&ob.To, &ob.CC, &ob.Bto, &ob.BCC, &aud)...) + return nil + }) + } + return ItemCollectionDeduplication(&all) +} diff --git a/item_collection_test.go b/item_collection_test.go new file mode 100644 index 0000000..0c01f4e --- /dev/null +++ b/item_collection_test.go @@ -0,0 +1,415 @@ +package activitypub + +import ( + "reflect" + "testing" +) + +func TestItemCollection_Append(t *testing.T) { + t.Skipf("TODO") +} + +func TestItemCollection_Collection(t *testing.T) { + t.Skipf("TODO") +} + +func TestItemCollection_GetID(t *testing.T) { + t.Skipf("TODO") +} + +func TestItemCollection_GetLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestItemCollection_GetType(t *testing.T) { + t.Skipf("TODO") +} + +func TestItemCollection_IsLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestItemCollection_IsObject(t *testing.T) { + t.Skipf("TODO") +} + +func TestItemCollection_First(t *testing.T) { + t.Skipf("TODO") +} + +func TestItemCollection_Count(t *testing.T) { + t.Skipf("TODO") +} + +func TestItemCollection_Contains(t *testing.T) { + t.Skipf("TODO") +} + +func TestItemCollection_IsCollection(t *testing.T) { + t.Skipf("TODO") +} + +func TestToItemCollection(t *testing.T) { + t.Skipf("TODO") +} + +func TestItemCollection_Remove(t *testing.T) { + tests := []struct { + name string + i ItemCollection + arg Item + }{ + { + name: "empty_collection_nil_item", + i: ItemCollection{}, + arg: nil, + }, + { + name: "empty_collection_non_nil_item", + i: ItemCollection{}, + arg: &Object{}, + }, + { + name: "non_empty_collection_nil_item", + i: ItemCollection{ + &Object{ID: "test"}, + }, + arg: nil, + }, + { + name: "non_empty_collection_non_contained_item_empty_ID", + i: ItemCollection{ + &Object{ID: "test"}, + }, + arg: &Object{}, + }, + { + name: "non_empty_collection_non_contained_item", + i: ItemCollection{ + &Object{ID: "test"}, + }, + arg: &Object{ID: "test123"}, + }, + { + name: "non_empty_collection_just_contained_item", + i: ItemCollection{ + &Object{ID: "test"}, + }, + arg: &Object{ID: "test"}, + }, + { + name: "non_empty_collection_contained_item_first_pos", + i: ItemCollection{ + &Object{ID: "test"}, + &Object{ID: "test123"}, + }, + arg: &Object{ID: "test"}, + }, + { + name: "non_empty_collection_contained_item_not_first_pos", + i: ItemCollection{ + &Object{ID: "test123"}, + &Object{ID: "test"}, + &Object{ID: "test321"}, + }, + arg: &Object{ID: "test"}, + }, + { + name: "non_empty_collection_contained_item_last_pos", + i: ItemCollection{ + &Object{ID: "test123"}, + &Object{ID: "test"}, + }, + arg: &Object{ID: "test"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + origContains := tt.i.Contains(tt.arg) + origLen := tt.i.Count() + should := "" + does := "n't" + if origContains { + should = "n't" + does = "" + } + + tt.i.Remove(tt.arg) + if tt.i.Contains(tt.arg) { + t.Errorf("%T should%s contain %T, but it does%s: %#v", tt.i, should, tt.arg, does, tt.i) + } + if origContains { + if tt.i.Count() > origLen-1 { + t.Errorf("%T should have a count lower than %d, got %d", tt.i, origLen, tt.i.Count()) + } + } else { + if tt.i.Count() != origLen { + t.Errorf("%T should have a count equal to %d, got %d", tt.i, origLen, tt.i.Count()) + } + } + }) + } +} + +func TestItemCollectionDeduplication(t *testing.T) { + tests := []struct { + name string + args []*ItemCollection + want ItemCollection + remaining []*ItemCollection + }{ + { + name: "empty", + }, + { + name: "no-overlap", + args: []*ItemCollection{ + { + IRI("https://example.com"), + IRI("https://example.com/2"), + }, + { + IRI("https://example.com/1"), + }, + }, + want: ItemCollection{ + IRI("https://example.com"), + IRI("https://example.com/2"), + IRI("https://example.com/1"), + }, + remaining: []*ItemCollection{ + { + IRI("https://example.com"), + IRI("https://example.com/2"), + }, + { + IRI("https://example.com/1"), + }, + }, + }, + { + name: "some-overlap", + args: []*ItemCollection{ + { + IRI("https://example.com"), + IRI("https://example.com/2"), + }, + { + IRI("https://example.com/1"), + IRI("https://example.com/2"), + }, + }, + want: ItemCollection{ + IRI("https://example.com"), + IRI("https://example.com/2"), + IRI("https://example.com/1"), + }, + remaining: []*ItemCollection{ + { + IRI("https://example.com"), + IRI("https://example.com/2"), + }, + { + IRI("https://example.com/1"), + }, + }, + }, + { + name: "test from spammy", + args: []*ItemCollection{ + { + IRI("https://example.dev/a801139a-0d9a-4703-b0a5-9d14ae1438e4/followers"), + IRI("https://www.w3.org/ns/activitystreams#Public"), + }, + { + IRI("https://example.dev/a801139a-0d9a-4703-b0a5-9d14ae1438e4"), + }, + { + IRI("https://example.dev"), + IRI("https://example.dev/a801139a-0d9a-4703-b0a5-9d14ae1438e4"), + IRI("https://www.w3.org/ns/activitystreams#Public"), + }, + }, + want: ItemCollection{ + IRI("https://example.dev/a801139a-0d9a-4703-b0a5-9d14ae1438e4/followers"), + IRI("https://www.w3.org/ns/activitystreams#Public"), + IRI("https://example.dev/a801139a-0d9a-4703-b0a5-9d14ae1438e4"), + IRI("https://example.dev"), + }, + remaining: []*ItemCollection{ + { + IRI("https://example.dev/a801139a-0d9a-4703-b0a5-9d14ae1438e4/followers"), + IRI("https://www.w3.org/ns/activitystreams#Public"), + }, + { + IRI("https://example.dev/a801139a-0d9a-4703-b0a5-9d14ae1438e4"), + }, + { + IRI("https://example.dev"), + }, + }, + }, + { + name: "different order for spammy test", + args: []*ItemCollection{ + { + IRI("https://example.dev/a801139a-0d9a-4703-b0a5-9d14ae1438e4/followers"), + IRI("https://www.w3.org/ns/activitystreams#Public"), + }, + { + IRI("https://example.dev"), + IRI("https://example.dev/a801139a-0d9a-4703-b0a5-9d14ae1438e4"), + IRI("https://www.w3.org/ns/activitystreams#Public"), + }, + { + IRI("https://example.dev/a801139a-0d9a-4703-b0a5-9d14ae1438e4"), + }, + }, + want: ItemCollection{ + IRI("https://example.dev/a801139a-0d9a-4703-b0a5-9d14ae1438e4/followers"), + IRI("https://www.w3.org/ns/activitystreams#Public"), + IRI("https://example.dev/a801139a-0d9a-4703-b0a5-9d14ae1438e4"), + IRI("https://example.dev"), + }, + remaining: []*ItemCollection{ + { + IRI("https://example.dev/a801139a-0d9a-4703-b0a5-9d14ae1438e4/followers"), + IRI("https://www.w3.org/ns/activitystreams#Public"), + }, + { + IRI("https://example.dev"), + IRI("https://example.dev/a801139a-0d9a-4703-b0a5-9d14ae1438e4"), + }, + {}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ItemCollectionDeduplication(tt.args...); !tt.want.Equals(got) { + t.Errorf("ItemCollectionDeduplication() = %v, want %v", got, tt.want) + } + if len(tt.remaining) != len(tt.args) { + t.Errorf("ItemCollectionDeduplication() arguments count %d, want %d", len(tt.args), len(tt.remaining)) + } + for i, remArg := range tt.remaining { + arg := tt.args[i] + if !remArg.Equals(arg) { + t.Errorf("ItemCollectionDeduplication() argument at pos %d = %v, want %v", i, arg, remArg) + } + } + }) + } +} + +func TestToItemCollection1(t *testing.T) { + tests := []struct { + name string + it Item + want *ItemCollection + wantErr bool + }{ + { + name: "empty", + }, + { + name: "IRIs to ItemCollection", + it: IRIs{"https://example.com", "https://example.com/example"}, + want: &ItemCollection{IRI("https://example.com"), IRI("https://example.com/example")}, + wantErr: false, + }, + { + name: "ItemCollection to ItemCollection", + it: ItemCollection{IRI("https://example.com"), IRI("https://example.com/example")}, + want: &ItemCollection{IRI("https://example.com"), IRI("https://example.com/example")}, + wantErr: false, + }, + { + name: "*ItemCollection to ItemCollection", + it: &ItemCollection{IRI("https://example.com"), IRI("https://example.com/example")}, + want: &ItemCollection{IRI("https://example.com"), IRI("https://example.com/example")}, + wantErr: false, + }, + { + name: "Collection to ItemCollection", + it: &Collection{Items: ItemCollection{IRI("https://example.com"), IRI("https://example.com/example")}}, + want: &ItemCollection{IRI("https://example.com"), IRI("https://example.com/example")}, + wantErr: false, + }, + { + name: "CollectionPage to ItemCollection", + it: &CollectionPage{Items: ItemCollection{IRI("https://example.com"), IRI("https://example.com/example")}}, + want: &ItemCollection{IRI("https://example.com"), IRI("https://example.com/example")}, + wantErr: false, + }, + { + name: "OrderedCollection to ItemCollection", + it: &OrderedCollection{OrderedItems: ItemCollection{IRI("https://example.com"), IRI("https://example.com/example")}}, + want: &ItemCollection{IRI("https://example.com"), IRI("https://example.com/example")}, + wantErr: false, + }, + { + name: "OrderedCollectionPage to ItemOrderedCollection", + it: &OrderedCollectionPage{OrderedItems: ItemCollection{IRI("https://example.com"), IRI("https://example.com/example")}}, + want: &ItemCollection{IRI("https://example.com"), IRI("https://example.com/example")}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ToItemCollection(tt.it) + if (err != nil) != tt.wantErr { + t.Errorf("ToItemCollection() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ToItemCollection() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestItemCollection_IRIs(t *testing.T) { + tests := []struct { + name string + i ItemCollection + want IRIs + }{ + { + name: "empty", + i: nil, + want: nil, + }, + { + name: "one item", + i: ItemCollection{ + &Object{ID: "https://example.com"}, + }, + want: IRIs{"https://example.com"}, + }, + { + name: "two items", + i: ItemCollection{ + &Object{ID: "https://example.com"}, + &Actor{ID: "https://example.com/~jdoe"}, + }, + want: IRIs{"https://example.com", "https://example.com/~jdoe"}, + }, + { + name: "mixed items", + i: ItemCollection{ + &Object{ID: "https://example.com"}, + IRI("https://example.com/666"), + &Actor{ID: "https://example.com/~jdoe"}, + }, + want: IRIs{"https://example.com", "https://example.com/666", "https://example.com/~jdoe"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.i.IRIs(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("IRIs() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/item_test.go b/item_test.go new file mode 100644 index 0000000..1462976 --- /dev/null +++ b/item_test.go @@ -0,0 +1,392 @@ +package activitypub + +import "testing" + +func TestFlatten(t *testing.T) { + t.Skipf("TODO") +} + +func TestItemsEqual(t *testing.T) { + type args struct { + it Item + with Item + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "nil_items_equal", + args: args{nil, nil}, + want: true, + }, + { + name: "nil_item_with_object", + args: args{nil, &Object{}}, + want: false, + }, + { + name: "nil_item_with_object#1", + args: args{&Object{}, nil}, + want: false, + }, + { + name: "empty_objects", + args: args{&Object{}, &Object{}}, + want: true, + }, + { + name: "empty_objects_different_alias_type", + args: args{&Activity{}, &Object{}}, + want: true, + }, + { + name: "empty_objects_different_alias_type#1", + args: args{&Actor{}, &Object{}}, + want: true, + }, + { + name: "same_id_object", + args: args{&Object{ID: "test"}, &Object{ID: "test"}}, + want: true, + }, + { + name: "same_id_object_different_alias", + args: args{&Activity{ID: "test"}, &Object{ID: "test"}}, + want: true, + }, + { + name: "same_id_object_different_alias#1", + args: args{&Activity{ID: "test"}, &Actor{ID: "test"}}, + want: true, + }, + { + name: "different_id_objects", + args: args{&Object{ID: "test1"}, &Object{ID: "test"}}, + want: false, + }, + { + name: "different_id_types", + args: args{&Object{ID: "test", Type: NoteType}, &Object{ID: "test", Type: ArticleType}}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ItemsEqual(tt.args.it, tt.args.with); got != tt.want { + t.Errorf("ItemsEqual() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsNil(t *testing.T) { + type args struct { + it Item + } + var ( + o *Object + col *ItemCollection + iris *IRIs + obNil Item = o + colNil Item = col + itIRIs Item = iris + ) + tests := []struct { + name string + args args + want bool + }{ + { + name: "nil is nil", + args: args{ + it: nil, + }, + want: true, + }, + { + name: "Item is nil", + args: args{ + it: Item(nil), + }, + want: true, + }, + { + name: "Object nil", + args: args{ + it: obNil, + }, + want: true, + }, + { + name: "IRIs nil", + args: args{ + it: iris, + }, + want: true, + }, + { + name: "IRIs as Item nil", + args: args{ + it: itIRIs, + }, + want: true, + }, + { + name: "IRIs not nil", + args: args{ + it: IRIs{}, + }, + want: false, + }, + { + name: "IRIs as Item not nil", + args: args{ + it: Item(IRIs{}), + }, + want: false, + }, + { + name: "ItemCollection nil", + args: args{ + it: col, + }, + want: true, + }, + { + name: "ItemCollection as Item nil", + args: args{ + it: colNil, + }, + want: true, + }, + { + name: "ItemCollection not nil", + args: args{ + it: ItemCollection{}, + }, + want: false, + }, + { + name: "object-not-nil", + args: args{ + it: &Object{}, + }, + want: false, + }, + { + name: "place-not-nil", + args: args{ + it: &Place{}, + }, + want: false, + }, + { + name: "tombstone-not-nil", + args: args{ + it: &Tombstone{}, + }, + want: false, + }, + { + name: "collection-not-nil", + args: args{ + it: &Collection{}, + }, + want: false, + }, + { + name: "activity-not-nil", + args: args{ + it: &Activity{}, + }, + want: false, + }, + { + name: "intransitive-activity-not-nil", + args: args{ + it: &IntransitiveActivity{}, + }, + want: false, + }, + { + name: "actor-not-nil", + args: args{ + it: &Actor{}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsNil(tt.args.it); got != tt.want { + t.Errorf("IsNil() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestItemsEqual1(t *testing.T) { + type args struct { + it Item + with Item + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "nil", + args: args{}, + want: true, + }, + { + name: "equal empty items", + args: args{ + it: &Object{}, + with: &Actor{}, + }, + want: true, + }, + { + name: "equal same ID items", + args: args{ + it: &Object{ID: "example-1"}, + with: &Object{ID: "example-1"}, + }, + want: true, + }, + { + name: "different IDs", + args: args{ + it: &Object{ID: "example-1"}, + with: &Object{ID: "example-2"}, + }, + want: false, + }, + { + name: "different properties", + args: args{ + it: &Object{ID: "example-1"}, + with: &Object{Type: ArticleType}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ItemsEqual(tt.args.it, tt.args.with); got != tt.want { + t.Errorf("ItemsEqual() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsObject(t *testing.T) { + type args struct { + it Item + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "nil", + args: args{}, + want: false, + }, + { + name: "interface with nil value", + args: args{Item(nil)}, + want: false, + }, + { + name: "empty object", + args: args{Object{}}, + want: true, + }, + { + name: "pointer to empty object", + args: args{&Object{}}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsObject(tt.args.it); got != tt.want { + t.Errorf("IsObject() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestItemsEqual2(t *testing.T) { + type args struct { + it Item + with Item + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "nil vs nil", + args: args{ + it: nil, + with: nil, + }, + want: true, + }, + { + name: "nil vs object", + args: args{ + it: nil, + with: Object{}, + }, + want: false, + }, + { + name: "object vs nil", + args: args{ + it: Object{}, + with: nil, + }, + want: false, + }, + { + name: "empty object vs empty object", + args: args{ + it: Object{}, + with: Object{}, + }, + want: true, + }, + { + name: "object-id vs empty object", + args: args{ + it: Object{ID: "https://example.com"}, + with: Object{}, + }, + want: false, + }, + { + name: "empty object vs object-id", + args: args{ + it: Object{}, + with: Object{ID: "https://example.com"}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ItemsEqual(tt.args.it, tt.args.with); got != tt.want { + t.Errorf("ItemsEqual() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/link.go b/link.go new file mode 100644 index 0000000..96cf0ce --- /dev/null +++ b/link.go @@ -0,0 +1,166 @@ +package activitypub + +import ( + "bytes" + "encoding/gob" + "fmt" + + "github.com/valyala/fastjson" +) + +// LinkTypes represent the valid values for a Link object +var LinkTypes = ActivityVocabularyTypes{ + LinkType, + MentionType, +} + +type Links interface { + Link | IRI +} + +// A Link is an indirect, qualified reference to a resource identified by a URL. +// The fundamental model for links is established by [ RFC5988]. +// Many of the properties defined by the Activity Vocabulary allow values that are either instances of APObject or Link. +// When a Link is used, it establishes a qualified relation connecting the subject +// (the containing object) to the resource identified by the href. +// Properties of the Link are properties of the reference as opposed to properties of the resource. +type Link struct { + // Provides the globally unique identifier for an APObject or Link. + ID ID `jsonld:"id,omitempty"` + // Identifies the APObject or Link type. Multiple values may be specified. + Type ActivityVocabularyType `jsonld:"type,omitempty"` + // A simple, human-readable, plain-text name for the object. + // HTML markup MUST NOT be included. The name MAY be expressed using multiple language-tagged values. + Name NaturalLanguageValues `jsonld:"name,omitempty,collapsible"` + // A link relation associated with a Link. The value must conform to both the [HTML5] and + // [RFC5988](https://tools.ietf.org/html/rfc5988) "link relation" definitions. + // In the [HTML5], any string not containing the "space" U+0020, "tab" (U+0009), "LF" (U+000A), + // "FF" (U+000C), "CR" (U+000D) or "," (U+002C) characters can be used as a valid link relation. + Rel IRI `jsonld:"rel,omitempty"` + // When used on a Link, identifies the MIME media type of the referenced resource. + MediaType MimeType `jsonld:"mediaType,omitempty"` + // On a Link, specifies a hint as to the rendering height in device-independent pixels of the linked resource. + Height uint `jsonld:"height,omitempty"` + // On a Link, specifies a hint as to the rendering width in device-independent pixels of the linked resource. + Width uint `jsonld:"width,omitempty"` + // Identifies an entity that provides a preview of this object. + Preview Item `jsonld:"preview,omitempty"` + // The target resource pointed to by a Link. + Href IRI `jsonld:"href,omitempty"` + // Hints as to the language used by the target resource. + // Value must be a [BCP47](https://tools.ietf.org/html/bcp47) Language-Tag. + HrefLang LangRef `jsonld:"hrefLang,omitempty"` +} + +// Mention is a specialized Link that represents an @mention. +type Mention = Link + +// LinkNew initializes a new Link +func LinkNew(id ID, typ ActivityVocabularyType) *Link { + if !LinkTypes.Contains(typ) { + typ = LinkType + } + return &Link{ID: id, Type: typ} +} + +// MentionNew initializes a new Mention +func MentionNew(id ID) *Mention { + return &Mention{ID: id, Type: MentionType} +} + +// IsLink validates if current Link is a Link +func (l Link) IsLink() bool { + return l.Type == LinkType || LinkTypes.Contains(l.Type) +} + +// IsObject validates if current Link is an GetID +func (l Link) IsObject() bool { + return l.Type == ObjectType || ObjectTypes.Contains(l.Type) +} + +// IsCollection returns false for Link objects +func (l Link) IsCollection() bool { + return false +} + +// GetID returns the ID corresponding to the Link object +func (l Link) GetID() ID { + return l.ID +} + +// GetLink returns the IRI corresponding to the current Link +func (l Link) GetLink() IRI { + return IRI(l.ID) +} + +// GetType returns the Type corresponding to the Mention object +func (l Link) GetType() ActivityVocabularyType { + return l.Type +} + +// MarshalJSON encodes the receiver object to a JSON document. +func (l Link) MarshalJSON() ([]byte, error) { + b := make([]byte, 0) + JSONWrite(&b, '{') + + if JSONWriteLinkValue(&b, l) { + JSONWrite(&b, '}') + return b, nil + } + return nil, nil +} + +// UnmarshalJSON decodes an incoming JSON document into the receiver object. +func (l *Link) UnmarshalJSON(data []byte) error { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + return JSONLoadLink(val, l) +} + +// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. +func (l *Link) UnmarshalBinary(data []byte) error { + return l.GobDecode(data) +} + +// MarshalBinary implements the encoding.BinaryMarshaler interface. +func (l Link) MarshalBinary() ([]byte, error) { + return l.GobEncode() +} + +func (l Link) GobEncode() ([]byte, error) { + mm := make(map[string][]byte) + hasData, err := mapLinkProperties(mm, l) + if err != nil { + return nil, err + } + if !hasData { + return []byte{}, nil + } + bb := bytes.Buffer{} + g := gob.NewEncoder(&bb) + if err := g.Encode(mm); err != nil { + return nil, err + } + return bb.Bytes(), nil +} + +func (l *Link) GobDecode(data []byte) error { + if len(data) == 0 { + return nil + } + mm, err := gobDecodeObjectAsMap(data) + if err != nil { + return err + } + return unmapLinkProperties(mm, l) +} + +func (l Link) Format(s fmt.State, verb rune) { + switch verb { + case 's', 'v': + _, _ = fmt.Fprintf(s, "%T[%s] { }", l, l.Type) + } +} diff --git a/link_test.go b/link_test.go new file mode 100644 index 0000000..04e1166 --- /dev/null +++ b/link_test.go @@ -0,0 +1,165 @@ +package activitypub + +import ( + "reflect" + "testing" +) + +func TestLinkNew(t *testing.T) { + testValue := ID("test") + var testType ActivityVocabularyType + + l := LinkNew(testValue, testType) + + if l.ID != testValue { + t.Errorf("APObject Id '%v' different than expected '%v'", l.ID, testValue) + } + if l.Type != LinkType { + t.Errorf("APObject Type '%v' different than expected '%v'", l.Type, LinkType) + } +} + +func TestLink_IsLink(t *testing.T) { + l := LinkNew("test", LinkType) + if !l.IsLink() { + t.Errorf("%#v should be a valid link", l.Type) + } + m := LinkNew("test", MentionType) + if !m.IsLink() { + t.Errorf("%#v should be a valid link", m.Type) + } +} + +func TestLink_IsObject(t *testing.T) { + l := LinkNew("test", LinkType) + if l.IsObject() { + t.Errorf("%#v should not be a valid object", l.Type) + } + m := LinkNew("test", MentionType) + if m.IsObject() { + t.Errorf("%#v should not be a valid object", m.Type) + } +} + +func TestLink_GetID(t *testing.T) { + t.Skipf("TODO") +} + +func TestLink_GetLink(t *testing.T) { + t.Skipf("TODO") +} + +func TestLink_GetType(t *testing.T) { + t.Skipf("TODO") +} + +func TestLink_UnmarshalJSON(t *testing.T) { + t.Skipf("TODO") +} + +func TestMentionNew(t *testing.T) { + t.Skipf("TODO") +} + +func TestLink_IsCollection(t *testing.T) { + t.Skipf("TODO") +} + +func TestLink_GobEncode(t *testing.T) { + type fields struct { + ID ID + Type ActivityVocabularyType + Name NaturalLanguageValues + Rel IRI + MediaType MimeType + Height uint + Width uint + Preview Item + Href IRI + HrefLang LangRef + } + tests := []struct { + name string + fields fields + want []byte + wantErr bool + }{ + { + name: "empty", + fields: fields{}, + want: []byte{}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := Link{ + ID: tt.fields.ID, + Type: tt.fields.Type, + Name: tt.fields.Name, + Rel: tt.fields.Rel, + MediaType: tt.fields.MediaType, + Height: tt.fields.Height, + Width: tt.fields.Width, + Preview: tt.fields.Preview, + Href: tt.fields.Href, + HrefLang: tt.fields.HrefLang, + } + got, err := l.GobEncode() + if (err != nil) != tt.wantErr { + t.Errorf("GobEncode() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GobEncode() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLink_GobDecode(t *testing.T) { + type fields struct { + ID ID + Type ActivityVocabularyType + Name NaturalLanguageValues + Rel IRI + MediaType MimeType + Height uint + Width uint + Preview Item + Href IRI + HrefLang LangRef + } + tests := []struct { + name string + fields fields + data []byte + wantErr bool + }{ + { + name: "empty", + fields: fields{}, + data: []byte{}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := &Link{ + ID: tt.fields.ID, + Type: tt.fields.Type, + Name: tt.fields.Name, + Rel: tt.fields.Rel, + MediaType: tt.fields.MediaType, + Height: tt.fields.Height, + Width: tt.fields.Width, + Preview: tt.fields.Preview, + Href: tt.fields.Href, + HrefLang: tt.fields.HrefLang, + } + if err := l.GobDecode(tt.data); (err != nil) != tt.wantErr { + t.Errorf("GobDecode() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/natural_language_values.go b/natural_language_values.go new file mode 100644 index 0000000..62d58c7 --- /dev/null +++ b/natural_language_values.go @@ -0,0 +1,812 @@ +package activitypub + +import ( + "bytes" + "encoding/gob" + "fmt" + "io" + "strings" + "unicode/utf8" + + "github.com/valyala/fastjson" +) + +// NilLangRef represents a convention for a nil language reference. +// It is used for LangRefValue objects without an explicit language key. +const NilLangRef LangRef = "-" + +// DefaultLang represents the default language reference used when using the convenience content generation. +var DefaultLang = NilLangRef + +type ( + // LangRef is the type for a language reference code, should be an ISO639-1 language specifier. + LangRef string + Content []byte + + // LangRefValue is a type for storing per language values + LangRefValue struct { + Ref LangRef + Value Content + } + // NaturalLanguageValues is a mapping for multiple language values + NaturalLanguageValues []LangRefValue +) + +func NaturalLanguageValuesNew(values ...LangRefValue) NaturalLanguageValues { + n := make(NaturalLanguageValues, len(values)) + for i, val := range values { + n[i] = val + } + return n +} + +func DefaultNaturalLanguageValue(content string) NaturalLanguageValues { + return NaturalLanguageValuesNew(DefaultLangRef(content)) +} + +func (n NaturalLanguageValues) String() string { + cnt := len(n) + if cnt == 1 { + return n[0].String() + } + s := strings.Builder{} + s.Write([]byte{'['}) + for k, v := range n { + s.WriteString(v.String()) + if k != cnt-1 { + s.Write([]byte{','}) + } + } + s.Write([]byte{']'}) + return s.String() +} + +func (n NaturalLanguageValues) Get(ref LangRef) Content { + for _, val := range n { + if val.Ref == ref { + return val.Value + } + } + return nil +} + +// Set sets a language, value pair in a NaturalLanguageValues array +func (n *NaturalLanguageValues) Set(ref LangRef, v Content) error { + found := false + for k, vv := range *n { + if vv.Ref == ref { + (*n)[k] = LangRefValue{ref, v} + found = true + } + } + if !found { + n.Append(ref, v) + } + return nil +} + +func (n *NaturalLanguageValues) Add(ref LangRefValue) { + *n = append(*n, ref) +} + +var hex = "0123456789abcdef" + +// safeSet holds the value true if the ASCII character with the given array +// position can be represented inside a JSON string without any further +// escaping. +// +// All values are true except for the ASCII control characters (0-31), the +// double quote ("), and the backslash character ("\"). +var safeSet = [utf8.RuneSelf]bool{ + ' ': true, + '!': true, + '"': false, + '#': true, + '$': true, + '%': true, + '&': true, + '\'': true, + '(': true, + ')': true, + '*': true, + '+': true, + ',': true, + '-': true, + '.': true, + '/': true, + '0': true, + '1': true, + '2': true, + '3': true, + '4': true, + '5': true, + '6': true, + '7': true, + '8': true, + '9': true, + ':': true, + ';': true, + '<': true, + '=': true, + '>': true, + '?': true, + '@': true, + 'A': true, + 'B': true, + 'C': true, + 'D': true, + 'E': true, + 'F': true, + 'G': true, + 'H': true, + 'I': true, + 'J': true, + 'K': true, + 'L': true, + 'M': true, + 'N': true, + 'O': true, + 'P': true, + 'Q': true, + 'R': true, + 'S': true, + 'T': true, + 'U': true, + 'V': true, + 'W': true, + 'X': true, + 'Y': true, + 'Z': true, + '[': true, + '\\': false, + ']': true, + '^': true, + '_': true, + '`': true, + 'a': true, + 'b': true, + 'c': true, + 'd': true, + 'e': true, + 'f': true, + 'g': true, + 'h': true, + 'i': true, + 'j': true, + 'k': true, + 'l': true, + 'm': true, + 'n': true, + 'o': true, + 'p': true, + 'q': true, + 'r': true, + 's': true, + 't': true, + 'u': true, + 'v': true, + 'w': true, + 'x': true, + 'y': true, + 'z': true, + '{': true, + '|': true, + '}': true, + '~': true, + '\u007f': true, +} + +// htmlSafeSet holds the value true if the ASCII character with the given +// array position can be safely represented inside a JSON string, embedded +// inside of HTML