├── .github └── workflows │ ├── docs.yml │ └── rust.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── activitypub.iml └── src ├── core ├── activity.rs ├── actor.rs ├── collection.rs ├── mod.rs └── object.rs └── lib.rs /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | jobs: 8 | docs: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | with: 13 | persist-credentials: false 14 | 15 | - name: generate docs 16 | run: cargo doc --verbose --no-deps --all-features 17 | 18 | - name: finalize docs 19 | run: | 20 | CRATE_NAME=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]' | cut -f2 -d"/") 21 | echo "" > target/doc/index.html 22 | touch target/doc/.nojekyll 23 | 24 | - name: upload docs 25 | uses: actions/upload-artifact@v2 26 | with: 27 | name: docs 28 | path: target/doc 29 | 30 | - name: deploy docs 31 | uses: JamesIves/github-pages-deploy-action@v4 32 | with: 33 | branch: gh-pages 34 | folder: target/doc 35 | clean: true 36 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: build and test 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: check 18 | run: cargo check --verbose 19 | - name: build 20 | run: cargo build --verbose 21 | - name: test 22 | run: cargo test --verbose 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | /.idea/ 12 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rustypub" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | http-serde = "1.1.2" 10 | chrono = { version = "0.4.19", default-features = false, features = ["serde"] } 11 | http = "0.2.8" 12 | rsa = "0.7.2" 13 | serde = { version = "1.0.143", features = ["derive"] } 14 | serde_json = "1.0.83" 15 | serde_tuple = "0.5.0" 16 | derive_builder = "0.12.0" 17 | 18 | [dev-dependencies] 19 | pretty_assertions = "1" 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # activitypub 2 | 3 | https://www.w3.org/TR/activitystreams-core/ 4 | 5 | Types: 6 | 7 | From https://www.w3.org/TR/activitystreams-vocabulary/#types 8 | 9 | # Core Types 10 | - [x] Object 11 | - [x] Link 12 | - [x] Activity 13 | - [x] IntransitiveActivity 14 | - [x] Collection 15 | - [x] OrderedCollection 16 | - [x] CollectionPage 17 | - [x] OrderedCollectionPage 18 | 19 | # Extended Types 20 | 21 | ## Activity Types 22 | 23 | - [ ] Accept 24 | - [ ] Add 25 | - [ ] Announce 26 | - [ ] Arrive 27 | - [ ] Block 28 | - [ ] Create 29 | - [ ] Delete 30 | - [ ] Dislike 31 | - [ ] Flag 32 | - [ ] Follow 33 | - [ ] Ignore 34 | - [ ] Invite 35 | - [ ] Join 36 | - [ ] Leave 37 | - [ ] Like 38 | - [ ] Listen 39 | - [ ] Move 40 | - [ ] Offer 41 | - [ ] Question 42 | - [ ] Reject 43 | - [ ] Read 44 | - [ ] Remove 45 | - [ ] TentativeReject 46 | - [ ] TentativeAccept 47 | - [ ] Travel 48 | - [ ] Undo 49 | - [ ] Update 50 | - [ ] View 51 | 52 | 53 | ## Actor Types 54 | 55 | - [ ] Application 56 | - [ ] Group 57 | - [ ] Organization 58 | - [ ] Person 59 | - [ ] Service 60 | 61 | ## Object Types 62 | 63 | - [ ] Article 64 | - [ ] Audio 65 | - [ ] Document 66 | - [ ] Event 67 | - [ ] Image 68 | - [ ] Note 69 | - [ ] Page 70 | - [ ] Place 71 | - [ ] Profile 72 | - [ ] Relationship 73 | - [ ] Tombstone 74 | - [ ] Video 75 | 76 | Link type includes Mention 77 | 78 | - [ ] Mention -------------------------------------------------------------------------------- /activitypub.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/core/activity.rs: -------------------------------------------------------------------------------- 1 | use crate::core::actor::{Actor, ActorBuilder}; 2 | use crate::core::object::{Object, ObjectBuilder}; 3 | use derive_builder::Builder; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | /////////////////////////////// 7 | // Activity 8 | // //////////////////////////// 9 | // One of the major sub-types of object 10 | // Activity also has a number of subtypes itself 11 | 12 | /// An [Activity] is a subtype of [Object] that describes some form of action 13 | /// that may happen, is currently happening, or has already happened. The 14 | /// [Activity] type itself serves as an abstract base type for all types of 15 | /// activities. It is important to note that the [Activity] type itself does 16 | /// not carry any specific semantics about the kind of action being taken. 17 | #[derive(Serialize, Default, Deserialize, Debug, Clone, Builder)] 18 | #[builder(default)] 19 | pub struct Activity { 20 | #[serde(flatten)] 21 | pub base: Object, 22 | 23 | #[serde(skip_serializing_if = "Option::is_none")] 24 | pub actor: Option, 25 | #[serde(skip_serializing_if = "Option::is_none")] 26 | pub object: Option, 27 | #[serde(skip_serializing_if = "Option::is_none")] 28 | pub target: Option, 29 | #[serde(skip_serializing_if = "Option::is_none")] 30 | pub result: Option, 31 | 32 | #[serde(skip_serializing_if = "Option::is_none")] 33 | pub to: Option>, 34 | 35 | #[serde(skip_serializing_if = "Option::is_none")] 36 | pub origin: Option, // TODO: Origin 37 | #[serde(skip_serializing_if = "Option::is_none")] 38 | pub instrument: Option, // TODO: Instrument 39 | } 40 | 41 | impl ActivityBuilder { 42 | // TODO: macro 43 | pub fn with_base(&mut self, build_fn: F) -> &mut Self 44 | where 45 | F: FnOnce(&mut ObjectBuilder) -> &mut ObjectBuilder, 46 | { 47 | let mut base_builder = ObjectBuilder::default(); 48 | self.base(build_fn(&mut base_builder).build().unwrap()) 49 | } 50 | 51 | pub fn with_object(&mut self, build_fn: F) -> &mut Self 52 | where 53 | F: FnOnce(&mut ObjectBuilder) -> &mut ObjectBuilder, 54 | { 55 | let mut base_builder = ObjectBuilder::default(); 56 | self.object(Some(build_fn(&mut base_builder).build().unwrap())) 57 | } 58 | 59 | pub fn with_actor(&mut self, build_fn: F) -> &mut Self 60 | where 61 | F: FnOnce(&mut ActorBuilder) -> &mut ActorBuilder, 62 | { 63 | let mut base_builder = ActorBuilder::default(); 64 | self.actor(Some(build_fn(&mut base_builder).build().unwrap())) 65 | } 66 | 67 | pub fn with_target(&mut self, build_fn: F) -> &mut Self 68 | where 69 | F: FnOnce(&mut ObjectBuilder) -> &mut ObjectBuilder, 70 | { 71 | let mut base_builder = ObjectBuilder::default(); 72 | self.target(Some(build_fn(&mut base_builder).build().unwrap())) 73 | } 74 | /// Instances of [IntransitiveActivity] are a subtype of [Activity] representing 75 | /// intransitive actions. The object property is therefore inappropriate for 76 | /// these activities. 77 | pub fn intransitive_activity(object_type: String) -> Self { 78 | let obj = ObjectBuilder::default() 79 | .object_type(Some(object_type)) 80 | .build() 81 | .unwrap(); 82 | ActivityBuilder::default().base(obj).object(None).to_owned() 83 | } 84 | } 85 | 86 | #[cfg(test)] 87 | mod tests { 88 | use super::*; 89 | use crate::core::{ContextBuilder, Document}; 90 | use pretty_assertions::assert_eq; 91 | use serde_json::json; 92 | 93 | #[test] 94 | fn serialize_activity() { 95 | let activity = ActivityBuilder::default() 96 | .with_base(|builder| { 97 | builder 98 | .object_type(Some("Activity".into())) 99 | .summary(Some("Sally did something to a note".into())) 100 | }) 101 | .with_object(|builder| { 102 | builder 103 | .object_type(Some("Note".into())) 104 | .name(Some("A Note".into())) 105 | }) 106 | .with_actor(|actor| { 107 | actor.with_base(|builder| { 108 | builder 109 | .object_type(Some("Person".into())) 110 | .name(Some("Sally".into())) 111 | }) 112 | }) 113 | .build() 114 | .unwrap(); 115 | let actual = Document::new(ContextBuilder::default().build().unwrap(), activity); 116 | let expected = json!({ 117 | "@context": { 118 | "@vocab": "https://www.w3.org/ns/activitystreams" 119 | }, 120 | "type": "Activity", 121 | "summary": "Sally did something to a note", 122 | "actor": { 123 | "type": "Person", 124 | "name": "Sally" 125 | }, 126 | "object": { 127 | "type": "Note", 128 | "name": "A Note" 129 | } 130 | }); 131 | assert_eq!(serde_json::to_value(actual).unwrap(), expected); 132 | } 133 | 134 | #[test] 135 | fn deserialize_activity() { 136 | let actual = json!({ 137 | "@context": { 138 | "@vocab": "https://www.w3.org/ns/activitystreams" 139 | }, 140 | "type": "Activity", 141 | "summary": "Sally did something to a note", 142 | "actor": { 143 | "type": "Person", 144 | "name": "Sally" 145 | }, 146 | "object": { 147 | "type": "Note", 148 | "name": "A Note" 149 | } 150 | }) 151 | .to_string(); 152 | let document: Document = Document::deserialize_string(actual).unwrap(); 153 | let activity = document.object as Activity; 154 | assert_eq!(activity.base.object_type, Some("Activity".into())); 155 | assert_eq!( 156 | activity.base.summary, 157 | Some("Sally did something to a note".into()) 158 | ); 159 | 160 | assert!(activity.actor.is_some()); 161 | let actor = activity.actor.unwrap(); 162 | assert_eq!(actor.base.object_type, Some("Person".into())); 163 | assert_eq!(actor.base.name, Some("Sally".into())); 164 | 165 | assert!(activity.object.is_some()); 166 | let object = activity.object.as_ref().unwrap(); 167 | assert_eq!(object.object_type, Some("Note".into())); 168 | assert_eq!(object.name, Some("A Note".into())); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/core/actor.rs: -------------------------------------------------------------------------------- 1 | use crate::core::object::{Object, ObjectBuilder}; 2 | use derive_builder::Builder; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | // TODO: expand to actor types: https://www.w3.org/TR/activitystreams-vocabulary/#actor-types 6 | #[derive(Serialize, Deserialize, Default, Debug, Clone, Builder)] 7 | #[builder(default)] 8 | pub struct Actor { 9 | #[serde(flatten)] 10 | pub base: Object, 11 | 12 | #[serde(rename = "preferredUsername")] 13 | #[serde(skip_serializing_if = "Option::is_none")] 14 | pub preferred_username: Option, 15 | 16 | // TODO: spec says MUST have 17 | #[serde(skip_serializing_if = "Option::is_none")] 18 | pub inbox: Option, 19 | #[serde(skip_serializing_if = "Option::is_none")] 20 | pub outbox: Option, 21 | #[serde(skip_serializing_if = "Option::is_none")] 22 | pub followers: Option, 23 | #[serde(skip_serializing_if = "Option::is_none")] 24 | pub following: Option, 25 | #[serde(skip_serializing_if = "Option::is_none")] 26 | pub liked: Option, 27 | 28 | #[serde(rename = "publicKey", skip_serializing_if = "Option::is_none")] 29 | pub public_key_info: Option, 30 | } 31 | 32 | impl ActorBuilder { 33 | pub fn with_base(&mut self, build_fn: F) -> &mut Self 34 | where 35 | F: FnOnce(&mut ObjectBuilder) -> &mut ObjectBuilder, 36 | { 37 | let mut base_builder = ObjectBuilder::default(); 38 | self.base(build_fn(&mut base_builder).build().unwrap()) 39 | } 40 | } 41 | 42 | #[derive(Debug, Clone, Serialize, Deserialize)] 43 | #[serde(rename_all = "camelCase")] 44 | pub struct PublicKeyInfo { 45 | pub id: String, 46 | pub owner: String, 47 | pub public_key_pem: String, 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use super::*; 53 | use crate::core::{ContextBuilder, Document}; 54 | use http::Uri; 55 | use pretty_assertions::assert_eq; 56 | use serde_json::json; 57 | 58 | #[test] 59 | fn serialize_actor() { 60 | let person = ActorBuilder::default() 61 | .with_base(|base| { 62 | base.object_type(Some("Person".into())) 63 | .id(Some( 64 | "https://example.com/person/1234".parse::().unwrap(), 65 | )) 66 | .name(Some("name".into())) 67 | }) 68 | .preferred_username(Some("dma".into())) 69 | .build() 70 | .unwrap(); 71 | let context = ContextBuilder::new().build().unwrap(); 72 | let actual = Document::new(context, person); 73 | let expected = json!({ 74 | "@context": { 75 | "@vocab": "https://www.w3.org/ns/activitystreams" 76 | }, 77 | "type": "Person", 78 | "id": "https://example.com/person/1234", 79 | "name": "name", 80 | "preferredUsername": "dma" 81 | }); 82 | 83 | let v = serde_json::to_value(&actual); 84 | assert_eq!(v.unwrap(), expected) 85 | } 86 | 87 | #[test] 88 | fn deserialize_actor() { 89 | let actual = json!({ 90 | "@context": { 91 | "@vocab": "https://www.w3.org/ns/activitystreams" 92 | }, 93 | "type": "Person", 94 | "id": "https://example.com/person/1234", 95 | "name": "name", 96 | "preferredUsername": "dma" 97 | }) 98 | .to_string(); 99 | let document: Document = Document::deserialize_string(actual).unwrap(); 100 | let actor = document.object; 101 | assert_eq!(actor.base.object_type, Some("Person".into())); 102 | assert_eq!( 103 | actor.base.id, 104 | Some("https://example.com/person/1234".parse::().unwrap()) 105 | ); 106 | assert_eq!(actor.base.name, Some("name".into())); 107 | assert_eq!(actor.preferred_username, Some("dma".into())); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/core/collection.rs: -------------------------------------------------------------------------------- 1 | use serde::{ Deserialize, Serialize }; 2 | use derive_builder::Builder; 3 | use super::object::{ Object, ObjectBuilder }; 4 | 5 | /// A [Collection] is a subtype of [Object] that represents ordered or unordered 6 | /// sets of [Object] or [Link] instances. Refer to the Activity Streams 2.0 Core 7 | /// specification for a complete description of the [Collection] type. 8 | #[derive(Serialize, Deserialize, Debug, Clone, Builder)] 9 | pub struct Collection { // TODO: can we avoid need for Clone? 10 | #[serde(flatten)] 11 | pub base: Object, 12 | 13 | #[serde(rename = "totalItems", skip_serializing_if = "Option::is_none")] 14 | pub total_items: Option, 15 | 16 | #[serde(skip_serializing_if = "Vec::is_empty")] 17 | pub items: Vec, 18 | } 19 | 20 | impl CollectionBuilder { 21 | 22 | pub fn with_base(&mut self, build_fn: F) -> &mut Self 23 | where F: FnOnce(&mut ObjectBuilder) -> &mut ObjectBuilder 24 | { 25 | let mut base_builder = ObjectBuilder::default(); 26 | self.base(build_fn(&mut base_builder).build().unwrap()) 27 | } 28 | 29 | } 30 | 31 | /// A subtype of [Collection] in which members of the logical collection are 32 | /// assumed to always be strictly ordered. 33 | #[derive(Serialize, Deserialize, Debug, Clone, Builder)] 34 | pub struct OrderedCollection { 35 | #[serde(flatten)] 36 | pub base: Object, 37 | 38 | #[serde(rename = "totalItems", skip_serializing_if = "Option::is_none")] 39 | pub total_items: Option, 40 | 41 | #[serde(skip_serializing_if = "Vec::is_empty")] 42 | #[serde(rename = "orderedItems")] 43 | pub ordered_items: Vec, 44 | } 45 | 46 | /// Used to represent distinct subsets of items from a [Collection]. Refer to 47 | /// the Activity Streams 2.0 Core for a complete description of the 48 | /// [CollectionPage] object. 49 | #[derive(Serialize, Deserialize, Debug, Builder)] 50 | pub struct CollectionPage { 51 | #[serde(flatten)] 52 | pub base: Collection, 53 | 54 | #[serde(rename = "partOf")] 55 | pub part_of: String, 56 | 57 | pub next: Option, 58 | 59 | pub prev: Option, 60 | } 61 | 62 | /// Used to represent ordered subsets of items from an [OrderedCollection]. 63 | /// Refer to the Activity Streams 2.0 Core for a complete description of 64 | /// the [OrderedCollectionPage] object. 65 | #[derive(Serialize, Deserialize, Debug, Builder)] 66 | pub struct OrderedCollectionPage { 67 | #[serde(flatten)] 68 | pub base: OrderedCollection, 69 | 70 | #[serde(rename = "partOf")] 71 | pub part_of: String, 72 | 73 | pub next: Option, 74 | 75 | pub prev: Option, 76 | } 77 | -------------------------------------------------------------------------------- /src/core/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod activity; 2 | pub mod actor; 3 | pub mod collection; 4 | pub mod object; 5 | 6 | pub use object::*; 7 | 8 | use derive_builder::Builder; 9 | use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize}; 10 | 11 | // TODO: rename to something else as there's a [Document] in the Activity 12 | // Streams spec. 13 | /// Outer object for serialization and deserialization. Not an Activity Streams 14 | /// 2.0 object. 15 | #[derive(Serialize, Deserialize, Debug, Clone, Builder)] 16 | pub struct Document { 17 | #[serde(rename = "@context", deserialize_with = "context_deserializer")] 18 | pub context: Context, 19 | 20 | #[serde(flatten)] 21 | pub object: T, 22 | } 23 | 24 | impl Document { 25 | pub fn new(context: Context, object: T) -> Self { 26 | Document { context, object } 27 | } 28 | 29 | pub fn serialize_pretty(&self) -> serde_json::Result { 30 | let serialized = serde_json::to_string_pretty(&self); 31 | println!("serialized = {:?}", serialized); 32 | serialized 33 | } 34 | } 35 | 36 | impl Document { 37 | pub fn deserialize_string(json: String) -> serde_json::Result> { 38 | serde_json::from_str(json.as_str()) 39 | } 40 | } 41 | 42 | /////////////////////////// 43 | // Context 44 | /////////////////////////// 45 | /// JSON-LD uses the special @context property to define the processing context. 46 | /// The value of the @context property is defined by the [JSON-LD] 47 | /// specification. Implementations producing Activity Streams 2.0 documents 48 | /// should include a @context property with a value that includes a reference to 49 | /// the normative Activity Streams 2.0 JSON-LD @context definition using the URL 50 | /// "https://www.w3.org/ns/activitystreams". Implementations may use the 51 | /// alternative URL "http://www.w3.org/ns/activitystreams" instead. This can be 52 | /// done using a string, object, or array. 53 | /// 54 | 55 | const NAMESPACE: &str = "https://www.w3.org/ns/activitystreams"; 56 | 57 | #[derive(Serialize, Deserialize, Debug, Clone, Builder)] 58 | #[builder(default)] 59 | pub struct Context { 60 | #[serde(rename = "@vocab")] 61 | namespace: String, 62 | 63 | #[serde(skip_serializing_if = "Option::is_none", rename = "@language")] 64 | language: Option, 65 | } 66 | 67 | impl Context { 68 | pub fn new() -> Self { 69 | Context { 70 | namespace: NAMESPACE.to_string(), 71 | language: None, 72 | } 73 | } 74 | } 75 | impl Default for Context { 76 | fn default() -> Self { 77 | Context { 78 | namespace: NAMESPACE.to_string(), 79 | language: None, 80 | } 81 | } 82 | } 83 | 84 | fn context_deserializer<'de, D>(deserializer: D) -> Result 85 | where 86 | D: Deserializer<'de>, 87 | { 88 | #[derive(Deserialize, Debug)] 89 | #[serde(untagged)] 90 | enum ContextType<'a> { 91 | Context(Context), 92 | Str(&'a str), 93 | } 94 | 95 | Ok(match ContextType::deserialize(deserializer)? { 96 | ContextType::Str(_x) => Context::new(), 97 | ContextType::Context(x) => x, 98 | }) 99 | } 100 | 101 | impl ContextBuilder { 102 | pub fn new() -> Self { 103 | ContextBuilder::default() 104 | } 105 | } 106 | 107 | #[cfg(test)] 108 | mod tests { 109 | use serde_json::json; 110 | 111 | use super::*; 112 | 113 | #[test] 114 | fn serialize_context() { 115 | let ctx: Context = ContextBuilder::default() 116 | .language(Some("en".into())) 117 | .build() 118 | .unwrap(); 119 | 120 | let expected = json!({ 121 | "@vocab": "https://www.w3.org/ns/activitystreams", 122 | "@language": "en" 123 | }); 124 | let value = serde_json::to_value(&ctx); 125 | assert_eq!(value.unwrap(), expected) 126 | } 127 | 128 | #[test] 129 | fn deserialize_context() { 130 | let actual = json!( 131 | { 132 | "@vocab": "https://www.w3.org/ns/activitystreams", 133 | "@language": "en" 134 | }) 135 | .to_string(); 136 | let ctx: Context = serde_json::from_str(&actual).unwrap(); 137 | assert_eq!(ctx.language, Some("en".into())); 138 | assert_eq!( 139 | ctx.namespace, 140 | "https://www.w3.org/ns/activitystreams".to_string() 141 | ); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/core/object.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use derive_builder::Builder; 3 | use http::Uri; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | /////////////////////////// 7 | // Object 8 | /////////////////////////// 9 | /// The [Object] is the primary base type for the Activity Streams vocabulary. 10 | /// In addition to having a global identifier (expressed as an absolute IRI 11 | /// using the id property) and an "object type" (expressed using the type 12 | /// property), all instances of the Object type share a common set of 13 | /// properties normatively defined by the Activity Vocabulary. These 14 | /// include: attachment | attributedTo | audience | content | context | 15 | /// contentMap | name | nameMap | endTime | generator | icon | image | 16 | /// inReplyTo | location | preview | published | replies | startTime | 17 | /// summary | summaryMap | tag | updated | url | to | bto | cc | bcc | 18 | /// mediaType | duration 19 | /// All properties are optional (including the id and type). 20 | #[derive(Serialize, Deserialize, Default, Debug, Clone, Builder)] 21 | #[builder(default)] 22 | pub struct Object { 23 | #[serde(rename = "type", skip_serializing_if = "Option::is_none")] 24 | pub object_type: Option, 25 | 26 | #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_uri")] 27 | pub id: Option, 28 | 29 | #[serde(skip_serializing_if = "Option::is_none")] 30 | pub name: Option, 31 | 32 | #[serde(skip_serializing_if = "Option::is_none")] 33 | // TODO: actually an IRI: consider https://docs.rs/iref/latest/iref/ 34 | pub url: Option, 35 | 36 | #[serde(skip_serializing_if = "Option::is_none")] 37 | pub published: Option>, 38 | 39 | #[serde(skip_serializing_if = "Option::is_none")] 40 | pub image: Option, 41 | 42 | #[serde( 43 | rename = "attributedTo", 44 | skip_serializing_if = "Vec::is_empty", 45 | default = "Vec::new" 46 | )] 47 | pub attributed_to: Vec, 48 | 49 | #[serde(skip_serializing_if = "Option::is_none")] 50 | pub audience: Option>, 51 | 52 | #[serde(skip_serializing_if = "Option::is_none")] 53 | pub content: Option, 54 | 55 | #[serde(skip_serializing_if = "Option::is_none")] 56 | pub summary: Option, 57 | 58 | #[serde(skip_serializing_if = "Option::is_none")] 59 | pub duration: Option, 60 | 61 | #[serde(skip_serializing_if = "Option::is_none")] 62 | pub preview: Option>, 63 | } 64 | 65 | #[derive(Serialize, Deserialize, Default, Debug, Clone)] 66 | pub struct Application(Object); 67 | 68 | #[derive(Serialize, Deserialize, Default, Debug, Clone)] 69 | pub struct Group(Object); 70 | 71 | #[derive(Serialize, Deserialize, Default, Debug, Clone)] 72 | pub struct Organization(Object); 73 | 74 | #[derive(Serialize, Deserialize, Default, Debug, Clone)] 75 | pub struct Person(Object); 76 | 77 | #[derive(Serialize, Deserialize, Default, Debug, Clone)] 78 | pub struct Service(Object); 79 | 80 | #[derive(Serialize, Deserialize, Debug, Clone)] 81 | #[serde(untagged)] 82 | pub enum AttributedTo { 83 | Object(Object), 84 | Link(Link), 85 | } 86 | 87 | impl ObjectBuilder { 88 | pub fn new() -> Self { 89 | ObjectBuilder::default() 90 | } 91 | 92 | pub fn of_object_type(t: String) -> Self { 93 | ObjectBuilder::default().object_type(Some(t)).to_owned() 94 | } 95 | 96 | pub fn note(name: String, content: String) -> Self { 97 | ObjectBuilder::of_object_type("Note".into()) 98 | .name(Some(name)) 99 | .content(Some(content)) 100 | .to_owned() 101 | } 102 | } 103 | 104 | // a maintainer of Serde gives a workaround for Option + with: 105 | // https://github.com/serde-rs/serde/issues/1301#issuecomment-394108486 106 | // don't forget to default or you will get: 107 | // panicked at 'called `Result::unwrap()` on an `Err` value: Error("missing field `id`" 108 | // https://github.com/serde-rs/serde/issues/723#issuecomment-423299411 109 | mod opt_uri { 110 | use http::uri::Uri; 111 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 112 | 113 | pub fn serialize(value: &Option, serializer: S) -> Result 114 | where 115 | S: Serializer, 116 | { 117 | #[derive(Serialize)] 118 | struct Helper<'a>(#[serde(with = "http_serde::uri")] &'a Uri); 119 | 120 | value.as_ref().map(Helper).serialize(serializer) 121 | } 122 | 123 | pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> 124 | where 125 | D: Deserializer<'de>, 126 | { 127 | #[derive(Deserialize)] 128 | struct Helper(#[serde(with = "http_serde::uri")] Uri); 129 | 130 | let helper = Option::deserialize(deserializer)?; 131 | Ok(helper.map(|Helper(external)| external)) 132 | } 133 | } 134 | 135 | /////////////////////////// 136 | // Link 137 | /////////////////////////// 138 | /// A [Link] is an indirect, qualified reference to a resource identified by a 139 | /// URL. The fundamental model for links is established by 140 | /// [RFC5988](https://www.w3.org/TR/activitystreams-vocabulary/#bib-RFC5988). 141 | /// Many of the properties defined by the Activity Vocabulary allow values that 142 | /// are either instances of [Object] or [Link]. When a [Link] is used, it 143 | /// establishes a qualified relation connecting the subject (the containing 144 | /// object) to the resource identified by the href. Properties of the [Link] 145 | /// are properties of the reference as opposed to properties of the resource. 146 | 147 | #[derive(Serialize, Deserialize, Debug, Default, Clone, Builder)] 148 | #[builder(default)] 149 | pub struct Link { 150 | #[serde(rename = "type", skip_serializing_if = "Option::is_none")] 151 | pub link_type: Option, 152 | 153 | #[serde(with = "http_serde::uri")] 154 | pub href: Uri, 155 | 156 | #[serde(skip_serializing_if = "Vec::is_empty", default = "Vec::new")] 157 | pub rel: Vec, // TODO: RFC5988 validation 158 | 159 | #[serde(rename = "mediaType", skip_serializing_if = "Option::is_none")] 160 | pub media_type: Option, 161 | 162 | #[serde(skip_serializing_if = "Option::is_none")] 163 | pub name: Option, 164 | 165 | #[serde(skip_serializing_if = "Option::is_none")] 166 | pub hreflang: Option, // TODO: BCP47 language tag 167 | 168 | #[serde(skip_serializing_if = "Option::is_none")] 169 | pub height: Option, 170 | 171 | #[serde(skip_serializing_if = "Option::is_none")] 172 | pub width: Option, 173 | 174 | #[serde(skip_serializing_if = "Option::is_none")] 175 | pub preview: Option, 176 | } 177 | 178 | impl Link { 179 | pub fn new(uri: String, media_type: String) -> Self { 180 | Link { 181 | link_type: Some("Link".into()), 182 | href: uri.parse().unwrap(), 183 | rel: vec![], 184 | media_type: Some(media_type), 185 | name: None, 186 | hreflang: None, 187 | height: None, 188 | width: None, 189 | preview: None, 190 | } 191 | } 192 | } 193 | 194 | impl LinkBuilder { 195 | pub fn new() -> Self { 196 | LinkBuilder::default() 197 | } 198 | } 199 | 200 | /////////////////////////// 201 | // Preview 202 | /////////////////////////// 203 | /// Identifies an entity that provides a preview of this object. 204 | #[derive(Serialize, Deserialize, Debug, Clone, Builder)] 205 | #[builder(default)] 206 | pub struct Preview { 207 | #[serde(rename = "type", skip_serializing_if = "Option::is_none")] 208 | pub object_type: Option, 209 | 210 | #[serde(skip_serializing_if = "Option::is_none")] 211 | pub name: Option, 212 | 213 | #[serde(skip_serializing_if = "Option::is_none")] 214 | pub duration: Option, 215 | 216 | #[serde(skip_serializing_if = "Option::is_none")] 217 | pub url: Option>, 218 | } 219 | 220 | impl Default for Preview { 221 | fn default() -> Self { 222 | Preview { 223 | object_type: Some("Preview".into()), 224 | name: None, 225 | duration: None, 226 | url: None, 227 | } 228 | } 229 | } 230 | 231 | #[cfg(test)] 232 | mod tests { 233 | use super::*; 234 | use crate::core::{Context, ContextBuilder, Document, DocumentBuilder}; 235 | use pretty_assertions::assert_eq; 236 | use serde_json::{json, Result}; 237 | 238 | #[test] 239 | fn serialize_object() { 240 | let object: Object = ObjectBuilder::default() 241 | .name(Some("name".into())) 242 | .build() 243 | .unwrap(); 244 | let context: Context = ContextBuilder::new() 245 | .language(Some("en".into())) 246 | .build() 247 | .unwrap(); 248 | let actual = DocumentBuilder::default() 249 | .object(Some(object)) 250 | .context(context) 251 | .build() 252 | .unwrap(); 253 | 254 | let expected = json!({ 255 | "@context": { 256 | "@vocab": "https://www.w3.org/ns/activitystreams", 257 | "@language": "en" 258 | }, 259 | "name": "name" 260 | }); 261 | assert_eq!(serde_json::to_value(&actual).unwrap(), expected) 262 | } 263 | 264 | #[test] 265 | fn deserialize_object() { 266 | let actual = json!({ 267 | "@context": { 268 | "@vocab": "https://www.w3.org/ns/activitystreams", 269 | "@language": "en" 270 | }, 271 | "name": "name" 272 | }) 273 | .to_string(); 274 | let document: Document = Document::deserialize_string(actual).unwrap(); 275 | assert_eq!(document.context.language, Some("en".into())); 276 | let object = document.object as Object; 277 | assert_eq!(object.name, Some("name".into())); 278 | } 279 | 280 | #[test] 281 | fn deserialize_object_malformed() { 282 | let actual = String::from( 283 | r#"{ 284 | "@context": { 285 | "@vocab": "https://www.w3.org/ns/activitystreams", 286 | "@language": "en" 287 | }, 288 | }"#, 289 | ); 290 | let result: Result> = Document::deserialize_string(actual); 291 | assert!(result.is_err()); 292 | } 293 | 294 | #[test] 295 | fn serialize_link() { 296 | let href = "http://example.org/abc".parse::().unwrap(); 297 | let actual = Document::new( 298 | ContextBuilder::new().build().unwrap(), 299 | LinkBuilder::new() 300 | .href(href) 301 | .name(Some("An example link".into())) 302 | .hreflang(Some("en".into())) 303 | .link_type(Some("Link".into())) 304 | .media_type(Some("text/html".into())) 305 | .build() 306 | .unwrap(), 307 | ); 308 | let expected = json!({ 309 | "@context": { 310 | "@vocab": "https://www.w3.org/ns/activitystreams" 311 | }, 312 | "type": "Link", 313 | "href": "http://example.org/abc", 314 | "mediaType": "text/html", 315 | "name": "An example link", 316 | "hreflang": "en" 317 | }); 318 | assert_eq!(serde_json::to_value(actual).unwrap(), expected); 319 | } 320 | 321 | #[test] 322 | fn deserialize_link() { 323 | let actual = json!({ 324 | "@context": { 325 | "@vocab": "https://www.w3.org/ns/activitystreams" 326 | }, 327 | "type": "Link", 328 | "href": "http://example.org/abc", 329 | "name": "An example link", 330 | "hreflang": "en" 331 | }) 332 | .to_string(); 333 | let document: Document = Document::deserialize_string(actual).unwrap(); 334 | let link = document.object as Link; 335 | assert_eq!(link.link_type, Some("Link".into())); 336 | assert_eq!(link.href, "http://example.org/abc"); 337 | assert_eq!(link.name, Some("An example link".into())); 338 | assert_eq!(link.hreflang, Some("en".into())); 339 | } 340 | 341 | #[test] 342 | fn serialize_preview() { 343 | let trailer_preview = 344 | Link::new("http://example.org/trailer.mkv".into(), "video/mkv".into()); 345 | let preview = PreviewBuilder::default() 346 | .duration(Some("PT1M".into())) 347 | .object_type(Some("Video".into())) 348 | .url(Some(Box::new(trailer_preview))) 349 | .name(Some("Trailer".into())) 350 | .build() 351 | .unwrap(); 352 | 353 | let object = ObjectBuilder::default() 354 | .duration(Some("PT2H30M".into())) 355 | .name(Some("Cool New Movie".into())) 356 | .preview(Some(Box::new(preview))) 357 | .object_type(Some("Video".into())) 358 | .build() 359 | .unwrap(); 360 | let context = ContextBuilder::new().build().unwrap(); 361 | let actual = Document::new(context, object); 362 | let expected = json!({ 363 | "@context": { 364 | "@vocab": "https://www.w3.org/ns/activitystreams" 365 | }, 366 | "type": "Video", 367 | "name": "Cool New Movie", 368 | "duration": "PT2H30M", 369 | "preview": { 370 | "type": "Video", 371 | "name": "Trailer", 372 | "duration": "PT1M", 373 | "url": { 374 | "type": "Link", 375 | "href": "http://example.org/trailer.mkv", 376 | "mediaType": "video/mkv" 377 | } 378 | } 379 | }); 380 | assert_eq!(serde_json::to_value(actual).unwrap(), expected); 381 | } 382 | 383 | #[test] 384 | fn deserialize_preview() { 385 | let actual = json!({ 386 | "@context": { 387 | "@vocab": "https://www.w3.org/ns/activitystreams" 388 | }, 389 | "type": "Video", 390 | "name": "Cool New Movie", 391 | "duration": "PT2H30M", 392 | "preview": { 393 | "type": "Video", 394 | "name": "Trailer", 395 | "duration": "PT1M", 396 | "url": { 397 | "href": "http://example.org/trailer.mkv", 398 | "mediaType": "video/mkv" 399 | } 400 | } 401 | }) 402 | .to_string(); 403 | let document: Document = Document::deserialize_string(actual).unwrap(); 404 | let object = document.object; 405 | assert_eq!(object.object_type, Some("Video".into())); 406 | assert_eq!(object.name, Some("Cool New Movie".into())); 407 | assert_eq!(object.duration, Some("PT2H30M".into())); 408 | 409 | let preview = object.preview.unwrap(); 410 | assert_eq!(preview.object_type, Some("Video".into())); 411 | assert_eq!(preview.name, Some("Trailer".into())); 412 | assert_eq!(preview.duration, Some("PT1M".into())); 413 | 414 | let url = preview.url.as_ref().unwrap(); 415 | assert_eq!(url.media_type, Some("video/mkv".into())); 416 | assert_eq!( 417 | url.href, 418 | "http://example.org/trailer.mkv" 419 | .parse::() 420 | .unwrap() 421 | ); 422 | } 423 | 424 | #[test] 425 | fn serialize_note() { 426 | let context = ContextBuilder::new().build().unwrap(); 427 | let note = ObjectBuilder::note("Name".into(), "Content".into()) 428 | .build() 429 | .unwrap(); 430 | let document: Document = Document::new(context, note); 431 | let expected = json!({ 432 | "@context": { 433 | "@vocab": "https://www.w3.org/ns/activitystreams" 434 | }, 435 | "type": "Note", 436 | "name": "Name", 437 | "content": "Content" 438 | }); 439 | 440 | assert_eq!(serde_json::to_value(document).unwrap(), expected) 441 | } 442 | 443 | #[test] 444 | fn deserialize_note() { 445 | let actual = json!({ 446 | "@context": { 447 | "@vocab": "https://www.w3.org/ns/activitystreams" 448 | }, 449 | "type": "Note", 450 | "name": "Name", 451 | "content": "Content" 452 | }) 453 | .to_string(); 454 | let document: Document = Document::deserialize_string(actual).unwrap(); 455 | let note: Object = document.object; 456 | assert_eq!(note.object_type, Some("Note".into())); 457 | assert_eq!(note.name, Some("Name".into())); 458 | assert_eq!(note.content, Some("Content".into())); 459 | } 460 | } 461 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod core; 2 | 3 | extern crate derive_builder; 4 | extern crate serde; 5 | 6 | #[cfg(test)] 7 | mod tests { 8 | use chrono::{DateTime, NaiveDate, Utc}; 9 | use http::Uri; 10 | use pretty_assertions::assert_eq; 11 | use serde_json::json; 12 | 13 | use crate::core::{ 14 | activity::{Activity, ActivityBuilder}, 15 | collection::{Collection, CollectionPage, OrderedCollection, OrderedCollectionPage}, 16 | object::{AttributedTo, Object, ObjectBuilder}, 17 | ContextBuilder, Document, Link, 18 | }; 19 | 20 | // A set of tests from https://www.w3.org/TR/activitystreams-vocabulary examples 21 | #[test] 22 | fn example_1() { 23 | let listing = json!({ 24 | "@context": { "@vocab": "https://www.w3.org/ns/activitystreams" }, 25 | "type": "Object", 26 | "id": "http://www.test.example/object/1", 27 | "name": "A Simple, non-specific object" 28 | }) 29 | .to_string(); 30 | let object: Object = Document::deserialize_string(listing).unwrap().object; 31 | assert_eq!(object.object_type, Some("Object".into())); 32 | assert_eq!( 33 | object.id, 34 | Some("http://www.test.example/object/1".parse::().unwrap()) 35 | ); 36 | assert_eq!(object.name, Some("A Simple, non-specific object".into())); 37 | } 38 | 39 | #[test] 40 | fn example_2() { 41 | let listing = json!({ 42 | "@context": {"@vocab": "https://www.w3.org/ns/activitystreams"}, 43 | "type": "Link", 44 | "href": "http://example.org/abc", 45 | "hreflang": "en", 46 | "mediaType": "text/html", 47 | "name": "An example link" 48 | }) 49 | .to_string(); 50 | 51 | let link: Link = Document::deserialize_string(listing).unwrap().object; 52 | assert_eq!(link.link_type, Some("Link".into())); 53 | assert_eq!( 54 | link.href, 55 | "http://example.org/abc".parse::().unwrap() 56 | ); 57 | assert_eq!(link.hreflang, Some("en".into())); 58 | assert_eq!(link.media_type, Some("text/html".into())); 59 | assert_eq!(link.name, Some("An example link".into())); 60 | } 61 | 62 | #[test] 63 | fn example_3() { 64 | let listing = json!({ 65 | "@context": { "@vocab": "https://www.w3.org/ns/activitystreams" }, 66 | "type": "Activity", 67 | "summary": "Sally did something to a note", 68 | "actor": { 69 | "type": "Person", 70 | "name": "Sally" 71 | }, 72 | "object": { 73 | "type": "Note", 74 | "name": "A Note" 75 | } 76 | }) 77 | .to_string(); 78 | 79 | let activity: Activity = Document::deserialize_string(listing).unwrap().object; 80 | assert_eq!(activity.base.object_type, Some("Activity".into())); 81 | assert_eq!( 82 | activity.base.summary, 83 | Some("Sally did something to a note".into()) 84 | ); 85 | 86 | assert!(activity.actor.is_some()); 87 | let actor = activity.actor.unwrap(); 88 | assert_eq!(actor.base.object_type, Some("Person".into())); 89 | assert_eq!(actor.base.name, Some("Sally".into())); 90 | 91 | assert!(activity.object.is_some()); 92 | let object = activity.object.unwrap(); 93 | assert_eq!(object.object_type, Some("Note".into())); 94 | assert_eq!(object.name, Some("A Note".into())); 95 | } 96 | 97 | #[test] 98 | fn example_4() { 99 | let listing = json!({ 100 | "@context": { "@vocab": "https://www.w3.org/ns/activitystreams" }, 101 | "type": "Travel", 102 | "summary": "Sally went to work", 103 | "actor": { 104 | "type": "Person", 105 | "name": "Sally" 106 | }, 107 | "target": { 108 | "type": "Place", 109 | "name": "Work" 110 | } 111 | }) 112 | .to_string(); 113 | 114 | let activity: Activity = Document::deserialize_string(listing).unwrap().object; 115 | assert_eq!(activity.base.object_type, Some("Travel".into())); 116 | assert_eq!(activity.base.summary, Some("Sally went to work".into())); 117 | assert!(activity.object.is_none()); 118 | 119 | assert!(activity.actor.is_some()); 120 | let actor = activity.actor.as_ref().unwrap(); 121 | assert_eq!(actor.base.object_type, Some("Person".into())); 122 | assert_eq!(actor.base.name, Some("Sally".into())); 123 | 124 | assert!(activity.target.is_some()); 125 | let target = activity.target.as_ref().unwrap(); 126 | assert_eq!(target.object_type, Some("Place".into())); 127 | assert_eq!(target.name, Some("Work".into())); 128 | } 129 | 130 | #[test] 131 | fn example_5() { 132 | let listing = json!({ 133 | "@context": { "@vocab": "https://www.w3.org/ns/activitystreams" }, 134 | "summary": "Sally's notes", 135 | "type": "Collection", 136 | "totalItems": 2, 137 | "items": [ 138 | { 139 | "type": "Note", 140 | "name": "A Simple Note" 141 | }, 142 | { 143 | "type": "Note", 144 | "name": "Another Simple Note" 145 | } 146 | ] 147 | }) 148 | .to_string(); 149 | 150 | let collection: Collection = Document::deserialize_string(listing).unwrap().object; 151 | assert_eq!(collection.base.object_type, Some("Collection".into())); 152 | assert_eq!(collection.base.summary, Some("Sally's notes".into())); 153 | assert_eq!(collection.total_items, Some(2)); 154 | 155 | let items = &collection.items; 156 | assert_eq!(items.len(), collection.total_items.unwrap()); 157 | assert_eq!(items[0].object_type, Some("Note".into())); 158 | assert_eq!(items[0].name, Some("A Simple Note".into())); 159 | assert_eq!(items[1].object_type, Some("Note".into())); 160 | assert_eq!(items[1].name, Some("Another Simple Note".into())); 161 | } 162 | 163 | #[test] 164 | fn example_6() { 165 | let listing = json!({ 166 | "@context": { "@vocab": "https://www.w3.org/ns/activitystreams" }, 167 | "summary": "Sally's notes", 168 | "type": "OrderedCollection", 169 | "totalItems": 2, 170 | "orderedItems": [ 171 | { 172 | "type": "Note", 173 | "name": "A Simple Note" 174 | }, 175 | { 176 | "type": "Note", 177 | "name": "Another Simple Note" 178 | } 179 | ] 180 | }) 181 | .to_string(); 182 | 183 | let collection: OrderedCollection = 184 | Document::deserialize_string(listing).unwrap().object; 185 | assert_eq!( 186 | collection.base.object_type, 187 | Some("OrderedCollection".into()) 188 | ); 189 | assert_eq!(collection.base.summary, Some("Sally's notes".into())); 190 | assert_eq!(collection.total_items, Some(2)); 191 | 192 | let items = &collection.ordered_items; 193 | assert_eq!(items.len(), collection.total_items.unwrap()); 194 | assert_eq!(items[0].object_type, Some("Note".into())); 195 | assert_eq!(items[0].name, Some("A Simple Note".into())); 196 | assert_eq!(items[1].object_type, Some("Note".into())); 197 | assert_eq!(items[1].name, Some("Another Simple Note".into())); 198 | } 199 | 200 | #[test] 201 | fn example_7() { 202 | let listing = json!({ 203 | "@context": { "@vocab": "https://www.w3.org/ns/activitystreams" }, 204 | "summary": "Page 1 of Sally's notes", 205 | "type": "CollectionPage", 206 | "id": "http://example.org/foo?page=1", 207 | "partOf": "http://example.org/foo", 208 | "items": [ 209 | { 210 | "type": "Note", 211 | "name": "A Simple Note" 212 | }, 213 | { 214 | "type": "Note", 215 | "name": "Another Simple Note" 216 | } 217 | ] 218 | }) 219 | .to_string(); 220 | 221 | let collection_page: CollectionPage = 222 | Document::deserialize_string(listing).unwrap().object; 223 | assert_eq!( 224 | collection_page.base.base.object_type, 225 | Some("CollectionPage".into()) 226 | ); 227 | assert_eq!( 228 | collection_page.base.base.id, 229 | Some("http://example.org/foo?page=1".parse::().unwrap()) 230 | ); 231 | assert_eq!( 232 | collection_page.base.base.summary, 233 | Some("Page 1 of Sally's notes".into()) 234 | ); 235 | assert_eq!( 236 | collection_page.part_of, 237 | "http://example.org/foo".to_string() 238 | ); 239 | assert_eq!(collection_page.base.total_items, None); 240 | 241 | let items = &collection_page.base.items; 242 | assert_eq!(items[0].object_type, Some("Note".into())); 243 | assert_eq!(items[0].name, Some("A Simple Note".into())); 244 | assert_eq!(items[1].object_type, Some("Note".into())); 245 | assert_eq!(items[1].name, Some("Another Simple Note".into())); 246 | } 247 | 248 | #[test] 249 | fn example_8() { 250 | let listing = json!({ 251 | "@context": {"@vocab": "https://www.w3.org/ns/activitystreams"}, 252 | "summary": "Page 1 of Sally's notes", 253 | "type": "OrderedCollectionPage", 254 | "id": "http://example.org/foo?page=1", 255 | "partOf": "http://example.org/foo", 256 | "orderedItems": [ 257 | { 258 | "type": "Note", 259 | "name": "A Simple Note" 260 | }, 261 | { 262 | "type": "Note", 263 | "name": "Another Simple Note" 264 | } 265 | ] 266 | }) 267 | .to_string(); 268 | 269 | let collection_page: OrderedCollectionPage = 270 | Document::deserialize_string(listing).unwrap().object; 271 | assert_eq!( 272 | collection_page.base.base.object_type, 273 | Some("OrderedCollectionPage".into()) 274 | ); 275 | assert_eq!( 276 | collection_page.base.base.id, 277 | Some("http://example.org/foo?page=1".parse::().unwrap()) 278 | ); 279 | assert_eq!( 280 | collection_page.base.base.summary, 281 | Some("Page 1 of Sally's notes".into()) 282 | ); 283 | assert_eq!( 284 | collection_page.part_of, 285 | "http://example.org/foo".to_string() 286 | ); 287 | assert_eq!(collection_page.base.total_items, None); 288 | 289 | let items = &collection_page.base.ordered_items; 290 | assert_eq!(items[0].object_type, Some("Note".into())); 291 | assert_eq!(items[0].name, Some("A Simple Note".into())); 292 | assert_eq!(items[1].object_type, Some("Note".into())); 293 | assert_eq!(items[1].name, Some("Another Simple Note".into())); 294 | } 295 | 296 | #[test] 297 | fn example_53() { 298 | let listing = json!({ 299 | "@context": { "@vocab": "https://www.w3.org/ns/activitystreams" }, 300 | "type": "Note", 301 | "name": "A Word of Warning", 302 | "content": "Looks like it is going to rain today. Bring an umbrella!" 303 | }) 304 | .to_string(); 305 | 306 | let document: Document = Document::deserialize_string(listing).unwrap(); 307 | let note = document.object; 308 | assert_eq!(note.object_type, Some("Note".into())); 309 | assert_eq!(note.name, Some("A Word of Warning".into())); 310 | assert_eq!( 311 | note.content, 312 | Some(String::from( 313 | "Looks like it is going to rain today. Bring an umbrella!" 314 | )) 315 | ); 316 | } 317 | 318 | #[test] 319 | fn example_69() { 320 | let listing = json!({ 321 | "@context": { 322 | "@vocab": "https://www.w3.org/ns/activitystreams" 323 | }, 324 | "name": "Holiday announcement", 325 | "type": "Note", 326 | "content": "Thursday will be a company-wide holiday. Enjoy your day off!", 327 | "audience": { 328 | "type": "http://example.org/Organization", 329 | "name": "ExampleCo LLC" 330 | } 331 | }) 332 | .to_string(); 333 | 334 | let document: Document = Document::deserialize_string(listing).unwrap(); 335 | let object = document.object; 336 | assert_eq!(object.name, Some("Holiday announcement".into())); 337 | assert_eq!(object.object_type, Some("Note".into())); 338 | assert_eq!( 339 | object.content, 340 | Some(String::from( 341 | "Thursday will be a company-wide holiday. Enjoy your day off!" 342 | )) 343 | ); 344 | assert!(object.audience.is_some()); 345 | let audience = object.audience.unwrap(); 346 | assert_eq!( 347 | audience.object_type, 348 | Some("http://example.org/Organization".into()) 349 | ); 350 | assert_eq!(audience.name, Some("ExampleCo LLC".into())); 351 | } 352 | 353 | #[test] 354 | fn example_114() { 355 | let listing = json!({ 356 | "@context": { 357 | "@vocab": "https://www.w3.org/ns/activitystreams" 358 | }, 359 | "summary": "A simple note", 360 | "type": "Note", 361 | "content": "A simple note" 362 | }) 363 | .to_string(); 364 | 365 | let document: Document = Document::deserialize_string(listing).unwrap(); 366 | let object = document.object; 367 | assert_eq!(object.summary, Some("A simple note".into())); 368 | assert_eq!(object.object_type, Some("Note".into())); 369 | assert_eq!(object.content, Some("A simple note".into())); 370 | } 371 | 372 | #[test] 373 | fn example_133() { 374 | let listing = json!({ 375 | "@context": { 376 | "@vocab": "https://www.w3.org/ns/activitystreams" 377 | }, 378 | "name": "Cane Sugar Processing", 379 | "type": "Note", 380 | "summary": "A simple note" 381 | }) 382 | .to_string(); 383 | 384 | let document: Document = Document::deserialize_string(listing).unwrap(); 385 | let object = document.object; 386 | assert_eq!(object.summary, Some("A simple note".into())); 387 | assert_eq!(object.object_type, Some("Note".into())); 388 | assert_eq!(object.name, Some("Cane Sugar Processing".into())); 389 | } 390 | 391 | // A set of tests from https://www.w3.org/TR/activitystreams-core/ examples 392 | #[test] 393 | fn minimal_activity_3_1() { 394 | let activity = ActivityBuilder::default() 395 | .with_base(|builder| { 396 | builder 397 | .object_type(Some("Create".into())) 398 | .summary(Some("Martin created an image".into())) 399 | }) 400 | .with_actor(|actor| { 401 | actor.with_base(|base_builder| { 402 | base_builder 403 | .object_type(Some("Person".into())) 404 | .id(Some("http://www.test.example/martin".parse().unwrap())) 405 | }) 406 | }) 407 | .with_object(|builder| builder.id(Some("http://example.org/foo.jpg".parse().unwrap()))) 408 | .build() 409 | .unwrap(); 410 | let actual = Document::new(ContextBuilder::new().build().unwrap(), activity); 411 | let expected = json!({ 412 | "@context": { 413 | "@vocab": "https://www.w3.org/ns/activitystreams" 414 | }, 415 | "type": "Create", 416 | "summary": "Martin created an image", 417 | "actor": { 418 | "type": "Person", 419 | "id": "http://www.test.example/martin" 420 | }, 421 | "object": { 422 | "id": "http://example.org/foo.jpg" 423 | } 424 | }); 425 | assert_eq!(serde_json::to_value(actual).unwrap(), expected); 426 | } 427 | 428 | #[test] 429 | fn basic_activity_with_additional_detail_3_2() { 430 | let activity = ActivityBuilder::default() 431 | .with_base(|b| { 432 | b.object_type(Some("Add".into())) 433 | .summary(Some("Martin added an article to his blog".into())) 434 | .published(Some(DateTime::::from_utc( 435 | NaiveDate::from_ymd_opt(2015, 2, 10) 436 | .unwrap() 437 | .and_hms_opt(15, 4, 55) 438 | .unwrap(), 439 | Utc, 440 | ))) 441 | }) 442 | .with_actor(|actor| { 443 | actor.with_base(|base_builder| { 444 | base_builder 445 | .object_type(Some("Person".into())) 446 | .id(Some( 447 | "http://www.test.example/martin".parse::().unwrap(), 448 | )) 449 | .name(Some("Martin Smith".into())) 450 | .image(Some(Link::new( 451 | "http://example.org/martin/image.jpg".into(), 452 | "image/jpeg".into(), 453 | ))) 454 | .url(Some("http://example.org/martin".into())) 455 | }) 456 | }) 457 | // TODO: figure out how to get a 'Z' on this. probably requires a time-zone (so not naive) 458 | .with_object(|builder| { 459 | builder 460 | .object_type(Some("Article".into())) 461 | .id(Some( 462 | "http://www.test.example/blog/abc123/xyz" 463 | .parse::() 464 | .unwrap(), 465 | )) 466 | .name(Some("Why I love Activity Streams".into())) 467 | .url(Some("http://example.org/blog/2011/02/entry".into())) 468 | }) 469 | .with_target(|target| { 470 | target 471 | .object_type(Some("OrderedCollection".into())) 472 | .id(Some("http://example.org/blog/".parse::().unwrap())) 473 | .name(Some("Martin's Blog".into())) 474 | }) 475 | .build() 476 | .unwrap(); 477 | 478 | let actual = Document::new(ContextBuilder::new().build().unwrap(), activity); 479 | let expected = json!({ 480 | "@context": { 481 | "@vocab": "https://www.w3.org/ns/activitystreams" 482 | }, 483 | "type": "Add", 484 | "published": "2015-02-10T15:04:55Z", 485 | "summary": "Martin added an article to his blog", 486 | "actor": { 487 | "type": "Person", 488 | "id": "http://www.test.example/martin", 489 | "name": "Martin Smith", 490 | "url": "http://example.org/martin", 491 | "image": { 492 | "type": "Link", 493 | "href": "http://example.org/martin/image.jpg", 494 | "mediaType": "image/jpeg" 495 | } 496 | }, 497 | "object": { 498 | "type": "Article", 499 | "id": "http://www.test.example/blog/abc123/xyz", 500 | "name": "Why I love Activity Streams", 501 | "url": "http://example.org/blog/2011/02/entry" 502 | }, 503 | "target": { 504 | "type": "OrderedCollection", 505 | "id": "http://example.org/blog/", 506 | "name": "Martin's Blog" 507 | } 508 | }); 509 | assert_eq!(serde_json::to_value(actual).unwrap(), expected); 510 | } 511 | 512 | #[test] 513 | fn object_4_1_7() { 514 | let subject = ObjectBuilder::default() 515 | .object_type(Some("Person".into())) 516 | .id(Some("http://joe.website.example/".parse::().unwrap())) 517 | .name(Some("Joe Smith".into())) 518 | .build() 519 | .unwrap(); 520 | let actual = Document::new( 521 | ContextBuilder::new().build().unwrap(), 522 | ObjectBuilder::new() 523 | .id(Some("http://example.org/foo".parse::().unwrap())) 524 | .object_type(Some("Note".into())) 525 | .name(Some("My favourite stew recipe".into())) 526 | .published(Some(DateTime::::from_utc( 527 | NaiveDate::from_ymd_opt(2014, 8, 21) 528 | .unwrap() 529 | .and_hms_opt(12, 34, 56) 530 | .unwrap(), 531 | Utc, 532 | ))) 533 | .attributed_to(vec![AttributedTo::Object(subject)]) 534 | .build() 535 | .unwrap(), 536 | ); 537 | 538 | let expected = json!({ 539 | "@context": { 540 | "@vocab": "https://www.w3.org/ns/activitystreams" 541 | }, 542 | "type": "Note", 543 | "id": "http://example.org/foo", 544 | "name": "My favourite stew recipe", 545 | "published": "2014-08-21T12:34:56Z", 546 | "attributedTo": [ 547 | { 548 | "type": "Person", 549 | "id": "http://joe.website.example/", 550 | "name": "Joe Smith" 551 | } 552 | ] 553 | }); 554 | assert_eq!(serde_json::to_value(actual).unwrap(), expected); 555 | } 556 | } 557 | --------------------------------------------------------------------------------