├── .gitignore ├── src ├── util │ ├── constructor.ts │ ├── content-map.ts │ └── is-not-empty-array.ts ├── interfaces │ ├── as-document.interface.ts │ ├── as-intransitive-activity.interface.ts │ ├── index.ts │ ├── as-link.interface.ts │ ├── as-collection-page.interface.ts │ ├── as-collection.interface.ts │ ├── as-object.interface.ts │ └── as-activity.interface.ts ├── decorator │ ├── is-required.ts │ ├── is-optional.ts │ ├── is-one-of-instance.ts │ └── is-one-of-instance-or-url.ts ├── index.ts ├── main.ts ├── actors.ts ├── links.ts ├── objects.ts ├── activities.ts └── activity-streams.ts ├── tsconfig.build.json ├── jest.config.js ├── examples ├── validation.ts ├── composite-classes.ts └── custom-classes.ts ├── LICENSE ├── package.json ├── tests ├── object.spec.ts └── main.spec.ts ├── README.md └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ -------------------------------------------------------------------------------- /src/util/constructor.ts: -------------------------------------------------------------------------------- 1 | export type Constructor = new (...args: any[]) => T; 2 | -------------------------------------------------------------------------------- /src/util/content-map.ts: -------------------------------------------------------------------------------- 1 | export type ContentMap = {[key: string]: string}[]; 2 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["**/*.spec.ts"] 4 | } -------------------------------------------------------------------------------- /src/interfaces/as-document.interface.ts: -------------------------------------------------------------------------------- 1 | import { ASObject } from "./as-object.interface"; 2 | 3 | export interface ASDocument extends ASObject { 4 | } -------------------------------------------------------------------------------- /src/interfaces/as-intransitive-activity.interface.ts: -------------------------------------------------------------------------------- 1 | import { ASActivity } from "./as-activity.interface"; 2 | 3 | export interface ASIntransitiveActivity extends ASActivity { 4 | } -------------------------------------------------------------------------------- /src/decorator/is-required.ts: -------------------------------------------------------------------------------- 1 | export const isRequiredMetadataKey = Symbol('activityStreamsIsRequired'); 2 | 3 | export function IsRequired() { 4 | return Reflect.metadata(isRequiredMetadataKey, true); 5 | } 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: {'^.+\\.ts?$': 'ts-jest'}, 3 | testEnvironment: 'node', 4 | testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$', 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 6 | }; -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './as-activity.interface'; 2 | export * from './as-collection-page.interface'; 3 | export * from './as-collection.interface'; 4 | export * from './as-document.interface'; 5 | export * from './as-link.interface'; 6 | export * from './as-object.interface'; 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './activity-streams'; 2 | export * from './objects'; 3 | export * from './activities'; 4 | export * from './actors'; 5 | export * from './links'; 6 | export * from './decorator/is-required'; 7 | export * from './interfaces'; 8 | 9 | export { IsNotEmptyArray } from './util/is-not-empty-array'; -------------------------------------------------------------------------------- /src/interfaces/as-link.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ASLink { 2 | '@context'?: string | string[]; 3 | type: string | string[]; 4 | href: string; 5 | id?: string; 6 | name?: string | string[]; 7 | hreflang?: string; 8 | mediaType?: string; 9 | rel?: string | string[]; 10 | height?: number; 11 | width?: number; 12 | } -------------------------------------------------------------------------------- /src/interfaces/as-collection-page.interface.ts: -------------------------------------------------------------------------------- 1 | import { ASCollection } from "./as-collection.interface"; 2 | import { ASLink } from "./as-link.interface"; 3 | 4 | export interface ASCollectionPage extends ASCollection { 5 | partOf?: ASLink | ASCollection | string; 6 | next?: ASLink | ASCollection | string; 7 | prev?: ASLink | ASCollection | string; 8 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { validate } from 'class-validator'; 3 | 4 | import { Actor } from './'; 5 | import { plainToClassFromExist } from 'class-transformer'; 6 | 7 | setTimeout(() => { 8 | // const actor = new Actor(); 9 | 10 | // actor.audience = [{ 11 | // name: 'The Audience Name', 12 | // type: 'Actor' 13 | // }, {name: 'Another audience', type: 'Actor'}]; 14 | 15 | // let newActor = plainToClassFromExist(Actor, actor); 16 | }, 20); -------------------------------------------------------------------------------- /src/interfaces/as-collection.interface.ts: -------------------------------------------------------------------------------- 1 | import { ASCollectionPage } from "./as-collection-page.interface"; 2 | import { ASLink } from "./as-link.interface"; 3 | import { ASObject } from "./as-object.interface"; 4 | 5 | export interface ASCollection extends ASObject { 6 | totalItems?: number; 7 | current?: ASCollectionPage | ASLink | string; 8 | first?: ASCollectionPage | ASLink | string; 9 | last?: ASCollectionPage | ASLink | string; 10 | items: (ASObject | ASLink | string)[]; 11 | } -------------------------------------------------------------------------------- /src/actors.ts: -------------------------------------------------------------------------------- 1 | import { ActivityStreams, Actor } from "."; 2 | import { Constructor } from "./util/constructor"; 3 | 4 | export class Application extends Actor { 5 | static readonly type = "Application"; 6 | } 7 | 8 | export class Group extends Actor { 9 | static readonly type = "Group"; 10 | } 11 | 12 | export class Organization extends Actor { 13 | static readonly type = "Organization"; 14 | } 15 | 16 | export class Person extends Actor { 17 | static readonly type = "Person"; 18 | } 19 | 20 | export class Service extends Actor { 21 | static readonly type = "Service"; 22 | } 23 | -------------------------------------------------------------------------------- /examples/validation.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { Note } from '../src'; 3 | import { validate } from 'class-validator'; 4 | 5 | const note = new Note(); 6 | 7 | async function validateNote() { 8 | let errors = await validate(note); 9 | 10 | if (errors.length > 0) { 11 | console.log('the note is invalid'); 12 | } 13 | else { 14 | console.log('the note is valid'); 15 | } 16 | } 17 | 18 | note.id = 'https://yuforium.com/users/chris/note-123'; 19 | 20 | validateNote(); // the note is valid 21 | 22 | note.id = 'invalid, id must be a valid URL'; 23 | 24 | validateNote(); // the note is invalid -------------------------------------------------------------------------------- /src/decorator/is-optional.ts: -------------------------------------------------------------------------------- 1 | import { ValidateIf, ValidationOptions } from "class-validator"; 2 | import { isRequiredMetadataKey } from "./is-required"; 3 | 4 | export const IS_OPTIONAL = 'activityStreamsIsRequired'; 5 | 6 | export function IsOptional(validationOptions?: ValidationOptions) { 7 | return function IsOptionalDecorator(prototype: Object, propertyKey: string | symbol) { 8 | ValidateIf((obj) => { 9 | if (Reflect.getMetadata(isRequiredMetadataKey, obj, propertyKey)) { 10 | return true; 11 | } 12 | return obj[propertyKey] !== null && obj[propertyKey] !== undefined; 13 | }, validationOptions)(prototype, propertyKey); 14 | } 15 | } -------------------------------------------------------------------------------- /examples/composite-classes.ts: -------------------------------------------------------------------------------- 1 | import { ActivityStreams } from '../src'; 2 | import 'reflect-metadata'; 3 | 4 | class Duck extends ActivityStreams.object('Duck') { 5 | public quack() { 6 | console.log('quack!'); 7 | } 8 | } 9 | 10 | class Yeti extends ActivityStreams.object('Yeti') { 11 | public roar() { 12 | console.log('roar!'); 13 | } 14 | } 15 | 16 | /** 17 | * Add the newly created classes to built-in ActivityStreams transformer. 18 | * 19 | * You can also create your own transformer and add the classes to it. 20 | */ 21 | ActivityStreams.transformer.add(Duck, Yeti); 22 | 23 | const duckYeti = ActivityStreams.transform({ 24 | type: ['Duck', 'Yeti'], 25 | id: 'https://yuforium.com/the-infamous-duck-yeti' 26 | }); 27 | 28 | duckYeti.quack(); // quack! 29 | duckYeti.roar(); // roar! -------------------------------------------------------------------------------- /src/util/is-not-empty-array.ts: -------------------------------------------------------------------------------- 1 | import { registerDecorator, ValidationOptions, ValidationDecoratorOptions } from "class-validator"; 2 | 3 | export function IsNotEmptyArray(validationOptions?: ValidationOptions) { 4 | return function (object: Object, propertyName: string) { 5 | registerDecorator({ 6 | name: 'isNotEmptyArray', 7 | target: object.constructor, 8 | propertyName, 9 | options: Object.assign({ 10 | message: `${propertyName} must have at least one element when specified as array`, 11 | validationOptions 12 | }), 13 | validator: { 14 | validate(value: any, args: any) { 15 | if (Array.isArray(value)) { 16 | return value.length > 0; 17 | } 18 | return true; 19 | } 20 | } 21 | } as ValidationDecoratorOptions); 22 | } 23 | } -------------------------------------------------------------------------------- /examples/custom-classes.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { IsString, validate } from 'class-validator'; 3 | import { ActivityStreams } from '../src'; 4 | import 'reflect-metadata'; 5 | 6 | 7 | // Creates CustomNote class as an Activity Streams Object 8 | class CustomNote extends ActivityStreams.object('CustomNote') { 9 | @Expose() 10 | @IsString({each: true}) 11 | public customField: string | string[]; 12 | }; 13 | 14 | // Add this to the built-in transformer 15 | ActivityStreams.transformer.add(CustomNote); 16 | 17 | // new instance of CustomNote 18 | const custom: CustomNote = ActivityStreams.transform({ 19 | type: 'CustomNote', 20 | customField: 5 // invalid, must be a string 21 | }); 22 | 23 | // will get error "each value in customField must be a string" 24 | validate(custom).then(errors => { 25 | errors.forEach(error => { console.log(error) }); 26 | }); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Chris Moser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@yuforium/activity-streams", 3 | "version": "0.1.3", 4 | "description": "Activity Streams definitions with validation using class-validator and class-transformer", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "sideEffects": false, 8 | "scripts": { 9 | "doc": "npx typedoc -out docs ./src/index.ts", 10 | "test": "npx jest", 11 | "coverage": "npx jest --coverage", 12 | "build": "npx tsc -p tsconfig.build.json", 13 | "build:watch": "npx tsc -p tsconfig.build.json --watch", 14 | "prepare": "npm run build" 15 | }, 16 | "author": "Chris Moser", 17 | "license": "MIT", 18 | "peerDependencies": { 19 | "class-transformer": "^0.5.1", 20 | "class-validator": "^0.14.0", 21 | "reflect-metadata": "^0.1.13" 22 | }, 23 | "devDependencies": { 24 | "@types/jest": "^27.5.0", 25 | "jest": "^27.5.1", 26 | "source-map-support": "^0.5.19", 27 | "ts-jest": "^27.1.4", 28 | "typedoc": "^0.23.20", 29 | "typescript": "^4.8.4" 30 | }, 31 | "directories": { 32 | "test": "tests" 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "git+https://github.com/yuforium/activity-streams.git" 37 | }, 38 | "keywords": [ 39 | "activity", 40 | "streams" 41 | ], 42 | "bugs": { 43 | "url": "https://github.com/yuforium/activity-streams-validator/issues" 44 | }, 45 | "homepage": "https://github.com/yuforium/activity-streams-validator#readme", 46 | "files": [ 47 | "dist", 48 | "src" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /src/interfaces/as-object.interface.ts: -------------------------------------------------------------------------------- 1 | import { ASCollection } from "./as-collection.interface"; 2 | import { ASLink } from "./as-link.interface"; 3 | 4 | export type ASObjectOrLink = ASObject | ASLink | string; 5 | 6 | export type ASContentMap = {[key: string]: string}[]; 7 | 8 | export interface ASObject { 9 | '@context'?: string | string[]; 10 | id?: string; 11 | type: string | string[]; 12 | attachment?: ASObjectOrLink | ASObjectOrLink[]; 13 | attributedTo?: ASObjectOrLink | ASObjectOrLink[]; 14 | audience?: ASObjectOrLink | ASObjectOrLink[]; 15 | content?: string | string[]; 16 | context?: ASObjectOrLink | ASObjectOrLink[]; 17 | contentMap?: ASContentMap; 18 | name?: string | string[]; 19 | nameMap?: ASContentMap|ASContentMap[]; 20 | endTime?: string; 21 | generator?: ASObjectOrLink | ASObjectOrLink[]; 22 | icon?: ASObjectOrLink | ASObjectOrLink[]; 23 | image?: ASObjectOrLink | ASObjectOrLink[]; 24 | inReplyTo?: ASObjectOrLink | ASObjectOrLink[]; 25 | location?: ASObjectOrLink | ASObjectOrLink[]; 26 | preview?: ASObjectOrLink | ASObjectOrLink[]; 27 | published?: string; 28 | replies?: ASCollection; 29 | startTime?: string; 30 | summary?: string | string[]; 31 | summaryMap?: ASContentMap|ASContentMap[]; 32 | tag?: ASObjectOrLink | ASObjectOrLink[]; 33 | updated?: string; 34 | url?: ASLink | string | (ASLink | string)[]; 35 | to?: ASObjectOrLink | ASObjectOrLink[]; 36 | bto?: ASObjectOrLink | ASObjectOrLink[]; 37 | cc?: ASObjectOrLink | ASObjectOrLink[]; 38 | bcc?: ASObjectOrLink | ASObjectOrLink[]; 39 | mediaType?: string; 40 | duration?: string; 41 | } -------------------------------------------------------------------------------- /src/decorator/is-one-of-instance.ts: -------------------------------------------------------------------------------- 1 | import { buildMessage, ValidateBy, ValidationOptions } from "class-validator"; 2 | import { Constructor } from "../util/constructor"; 3 | 4 | export const IS_ONE_OF_INSTANCE = 'isOneOfInstance'; 5 | 6 | /** 7 | * Checks if the value is an instance of the specified object. 8 | */ 9 | export function isOneOfInstance(object: unknown, targetTypeConstructors: Constructor[]): boolean { 10 | return targetTypeConstructors.some(targetTypeConstructor => { 11 | targetTypeConstructor && typeof targetTypeConstructor === 'function' && object instanceof targetTypeConstructor 12 | }); 13 | } 14 | 15 | /** 16 | * Checks if the value is an instance of the specified object. 17 | */ 18 | export function IsOneOfInstance( 19 | targetType: Constructor|Constructor[], 20 | validationOptions?: ValidationOptions 21 | ): PropertyDecorator { 22 | return ValidateBy( 23 | { 24 | name: IS_ONE_OF_INSTANCE, 25 | constraints: [targetType], 26 | validator: { 27 | validate: (value, args): boolean => isOneOfInstance(value, Array.isArray(args?.constraints[0]) ? args?.constraints[0] as Constructor[]: [args?.constraints[0]]), 28 | defaultMessage: buildMessage((eachPrefix, args) => { 29 | if (args?.constraints[0]) { 30 | return eachPrefix + `$property must be an instance of ${args?.constraints[0].name as string}`; 31 | } else { 32 | return eachPrefix + `${IS_ONE_OF_INSTANCE} decorator expects and object as value, but got falsy value.`; 33 | } 34 | }, validationOptions), 35 | }, 36 | }, 37 | validationOptions 38 | ); 39 | } -------------------------------------------------------------------------------- /src/decorator/is-one-of-instance-or-url.ts: -------------------------------------------------------------------------------- 1 | import { buildMessage, isURL, ValidateBy, ValidationOptions } from "class-validator"; 2 | import { Constructor } from "../util/constructor"; 3 | import { isOneOfInstance } from "./is-one-of-instance"; 4 | 5 | export const IS_ONE_OF_INSTANCE_OR_URL = 'isOneOfInstanceOrUrl'; 6 | 7 | export function isOneOfInstanceOrUrl(object: unknown, targetTypeConstructors: Constructor[]) { 8 | if (typeof object === 'string') { 9 | return isURL(object); 10 | } 11 | else { 12 | return isOneOfInstance(object, targetTypeConstructors); 13 | } 14 | } 15 | 16 | /** 17 | * Checks if the value is an instance of the specified object. 18 | */ 19 | export function IsOneOfInstanceOrUrl( 20 | targetType: Constructor|Constructor[], 21 | validationOptions?: ValidationOptions 22 | ): PropertyDecorator { 23 | const targetTypes = Array.isArray(targetType) ? targetType : [targetType]; 24 | return ValidateBy( 25 | { 26 | name: IS_ONE_OF_INSTANCE_OR_URL, 27 | constraints: targetTypes, 28 | validator: { 29 | validate: (value, args): boolean => { 30 | return isURL(value) || isOneOfInstance(value, Array.isArray(args?.constraints[0]) ? args?.constraints[0] as Constructor[]: [args?.constraints[0]]) 31 | }, 32 | defaultMessage: buildMessage((eachPrefix, args) => { 33 | if (args?.constraints[0]) { 34 | return eachPrefix + `$property must be an instance of ${targetTypes.map(t => t.name).join('|')}`; 35 | } else { 36 | return eachPrefix + `${IS_ONE_OF_INSTANCE_OR_URL} decorator expects and object as value, but got falsy value.`; 37 | } 38 | }, validationOptions), 39 | }, 40 | }, 41 | validationOptions 42 | ); 43 | } -------------------------------------------------------------------------------- /src/links.ts: -------------------------------------------------------------------------------- 1 | import { ActivityStreams } from "."; 2 | import { ASLink } from "./interfaces/as-link.interface"; 3 | import { Constructor } from "./util/constructor"; 4 | 5 | /** 6 | * A Link describes a qualified, indirect reference to another resource that is closely related to the conceptual model of Links as established in [RFC5988]. The properties of the Link object are not the properties of the referenced resource, but are provided as hints for rendering agents to understand how to make use of the resource. For example, height and width might represent the desired rendered size of a referenced image, rather than the actual pixel dimensions of the referenced image. 7 | * 8 | * The target URI of the Link is expressed using the required href property. In addition, all Link instances share the following common set of optional properties as normatively defined by the Activity Vocabulary: id | name | hreflang | mediaType | rel | height | width 9 | * 10 | * For example, all Objects can contain an image property whose value describes a graphical representation of the containing object. This property will typically be used to provide the URL to an image (e.g. JPEG, GIF or PNG) resource that can be displayed to the user. Any given object might have multiple such visual representations -- multiple screenshots, for instance, or the same image at different resolutions. In Activity Streams 2.0, there are essentially three ways of describing such references. 11 | * 12 | * https://www.w3.org/TR/activitystreams-core/#link 13 | */ 14 | export class Link extends ActivityStreams.link('Link') {}; 15 | 16 | /** 17 | * A specialized Link that represents an @mention. 18 | * 19 | * https://www.w3.org/ns/activitystreams#Mention 20 | */ 21 | export const Mention: Constructor = ActivityStreams.link('Mention', class Mention {}); 22 | -------------------------------------------------------------------------------- /src/interfaces/as-activity.interface.ts: -------------------------------------------------------------------------------- 1 | import { ASObject, ASObjectOrLink } from "./as-object.interface"; 2 | 3 | export interface ASActivity extends ASObject { 4 | /** 5 | * 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 Link}. 6 | */ 7 | actor?: ASObjectOrLink; 8 | 9 | /** 10 | * Describes an object of any kind. The Object type serves as the base type for most of the other kinds of objects defined in the Activity Vocabulary, including other Core types such as {@link Activity}, {@link IntransitiveActivity}, {@link Collection} and {@link OrderedCollection}. 11 | */ 12 | object?: ASObjectOrLink; 13 | 14 | /** 15 | * 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. 16 | */ 17 | target?: ASObjectOrLink; 18 | 19 | /** 20 | * 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. 21 | */ 22 | result?: ASObjectOrLink; 23 | 24 | /** 25 | * 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". 26 | */ 27 | origin?: ASObjectOrLink; 28 | 29 | /** 30 | * Identifies one or more objects used (or to be used) in the completion of an {@link Activity}. 31 | */ 32 | instrument?: ASObjectOrLink; 33 | } -------------------------------------------------------------------------------- /tests/object.spec.ts: -------------------------------------------------------------------------------- 1 | import { plainToClass } from 'class-transformer'; 2 | import { IsString, validate } from 'class-validator'; 3 | import 'reflect-metadata'; 4 | import { ActivityStreams, Note } from '../src'; 5 | 6 | // const toASObject = ActivityStreams.transform("Object"); 7 | 8 | describe('default object transformations', () => { 9 | it('should transform a simple object', () => { 10 | const obj = ActivityStreams.transform({ 11 | type: 'Note' 12 | }); 13 | 14 | expect(obj).toBeInstanceOf(Note); 15 | }); 16 | }); 17 | 18 | describe('object transformation', () => { 19 | it('plain object transforms actor', () => { 20 | let value: any = { 21 | type: 'Note' 22 | }; 23 | 24 | let obj = ActivityStreams.transform(value); 25 | 26 | expect(obj).toBeInstanceOf(Note); 27 | 28 | // json['attachment'] = [ 29 | // { 30 | // type: 'Actor', 31 | // name: 'Test Actor' 32 | // }, 33 | // { 34 | // type: 'Actor', 35 | // name: 'Another Actor' 36 | // } 37 | // ] 38 | 39 | // obj = ActivityStreams.transform(json); 40 | 41 | // console.log(obj); 42 | 43 | // expect(obj.attachment[0]).toBeInstanceOf(Actor); 44 | // expect(obj.attachment[1]).toBeInstanceOf(Actor); 45 | }); 46 | }); 47 | 48 | describe('dynamic composition', () => { 49 | const transform = ActivityStreams.transform; 50 | 51 | class TestClass extends ActivityStreams.object('TestClass') { 52 | @IsString({each: true}) 53 | testName: string | string[]; 54 | } 55 | ActivityStreams.transformer.add(TestClass); 56 | 57 | it('should transform a composite object', async () => { 58 | const vals = { 59 | type: ['Note', 'TestClass'], 60 | testName: 'test' 61 | }; 62 | 63 | const obj = transform({ 64 | type: ['Note', 'TestClass'], 65 | testName: 'test' 66 | }); 67 | 68 | let errs = await validate(obj); 69 | expect(errs).toHaveLength(0); 70 | 71 | Object.assign(obj, {id: 'an invalid id', testName: 31337}); 72 | 73 | errs = await validate(obj); 74 | expect(errs).toHaveLength(2); 75 | expect(errs.find(e => e.property === 'id')).toHaveProperty('constraints.isUrl'); 76 | expect(errs.find(e => e.property === 'testName')).toHaveProperty('constraints.isString'); 77 | }); 78 | }); -------------------------------------------------------------------------------- /tests/main.spec.ts: -------------------------------------------------------------------------------- 1 | import { validate, ValidationError } from 'class-validator'; 2 | import 'reflect-metadata'; 3 | import { ActivityStreams, Note } from "../src"; 4 | import { Constructor } from '../src/util/constructor'; 5 | 6 | describe('basic id validation', () => { 7 | class GenericObject extends ActivityStreams.object('Object') { }; 8 | 9 | it('should pass basic validation', async () => { 10 | let obj; 11 | let err: ValidationError | undefined; 12 | let errors: ValidationError[]; 13 | 14 | // test with a simple object 15 | obj = Object.assign(new GenericObject(),{id: 'https://yuforium.com/users/chris/note-123'}); 16 | expect(await validate(obj)).toHaveLength(0); 17 | 18 | // a new note should have a valid id 19 | obj.id = 'this is an invalid id'; 20 | errors = await validate(obj); 21 | expect(errors).toHaveLength(1); 22 | expect(errors[0].constraints?.isUrl).toBe('id must be an URL address'); 23 | 24 | // int should not be allowed for type 25 | Object.assign(obj, {id: 5}); 26 | err = (await validate(obj)).find(e => e.property === 'id'); 27 | expect(err?.constraints?.isUrl).toBe('id must be an URL address'); 28 | expect(err?.constraints?.isString).toBe('id must be a string'); 29 | 30 | // array should not be allowed for type 31 | Object.assign(obj, {id: ['https://yuforium.com/users/chris/note-123']}); 32 | err = (await validate(obj)).find(e => e.property === 'id'); 33 | expect(err?.constraints?.isUrl).toBe('id must be an URL address'); 34 | expect(err?.constraints?.isString).toBe('id must be a string'); 35 | }); 36 | }); 37 | 38 | describe('basic type validation', () => { 39 | class GenericObject extends ActivityStreams.object('GenericObject') { }; 40 | 41 | it('should pass basic validation', async () => { 42 | let obj; 43 | let errors: ValidationError[]; 44 | let err: ValidationError | undefined; 45 | 46 | obj = new GenericObject(); 47 | errors = await validate(obj); 48 | expect(errors).toHaveLength(0); 49 | 50 | obj.type = "Object"; 51 | errors = await validate(obj); 52 | expect(errors).toHaveLength(0); 53 | 54 | obj.type = ["Object", "test:Object"]; 55 | errors = await validate(obj); 56 | expect(errors).toHaveLength(0); 57 | 58 | obj.type = []; 59 | err = (await validate(obj)).find(e => e.property === 'type'); 60 | expect(err?.constraints?.isNotEmptyArray).toBe('type must have at least one element when specified as array'); 61 | }); 62 | }); -------------------------------------------------------------------------------- /src/objects.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { IsNumber, IsOptional, IsRFC3339, IsString, Max, Min } from 'class-validator'; 3 | import { ActivityStreams } from '.'; 4 | import { ASCollection } from './interfaces/as-collection.interface'; 5 | import { ASCollectionPage } from './interfaces/as-collection-page.interface'; 6 | import { ASObject, ASObjectOrLink } from './interfaces/as-object.interface'; 7 | import { Constructor } from './util/constructor'; 8 | 9 | /** 10 | * @category Core 11 | */ 12 | class BaseObject extends ActivityStreams.object('Object') {}; // registers this as type "Object" (name can't be used as class name however) 13 | 14 | export class Actor extends ActivityStreams.object('Actor') {}; 15 | export class Article extends ActivityStreams.object('Article') {}; 16 | export class Audio extends ActivityStreams.object('Audio') {}; 17 | export class Collection extends ActivityStreams.collection('Collection') implements ASCollection {}; 18 | export class OrderedCollection extends ActivityStreams.collection('OrderedCollection') implements ASCollection {}; 19 | export class CollectionPage extends ActivityStreams.collectionPage('CollectionPage') implements ASCollectionPage {}; 20 | export class OrderedCollectionPage extends ActivityStreams.collectionPage('OrderedCollectionPage') implements ASCollectionPage {}; 21 | export class Document extends ActivityStreams.document('Document') {}; 22 | export class Event extends ActivityStreams.object('Event') {}; 23 | export class Image extends ActivityStreams.document('Image') {}; 24 | export class Note extends ActivityStreams.object('Note') {}; 25 | export class Page extends ActivityStreams.document('Page') {}; 26 | ActivityStreams.transformer.add(Actor, Article, Audio, Collection, OrderedCollection, CollectionPage, OrderedCollectionPage, Document, Event, Image, Note, Page); 27 | 28 | class PlaceDef { 29 | @IsOptional() 30 | @IsNumber() 31 | @Min(0) 32 | @Max(100) 33 | accuracy?: number; 34 | 35 | @IsOptional() 36 | @IsNumber() 37 | altitude?: number; 38 | 39 | @IsOptional() 40 | @IsNumber() 41 | @Min(-90) 42 | @Max(90) 43 | latitude?: number; 44 | 45 | @IsOptional() 46 | @IsNumber() 47 | @Min(-180) 48 | @Max(180) 49 | longitude?: number; 50 | 51 | @IsOptional() 52 | @IsNumber() 53 | @Min(0) 54 | radius?: number; 55 | } 56 | export class Place extends ActivityStreams.object('Place', PlaceDef) {}; 57 | ActivityStreams.transformer.add(Place); 58 | 59 | class ProfileDef { 60 | @IsOptional() 61 | @IsString() 62 | @Expose({ name: 'describes' }) 63 | describes?: ASObjectOrLink; 64 | } 65 | export class Profile extends ActivityStreams.object('Profile', ProfileDef) {}; 66 | ActivityStreams.transformer.add(Profile); 67 | 68 | class RelationshipDef { 69 | @IsOptional() 70 | subject?: ASObjectOrLink; 71 | 72 | @IsOptional() 73 | object?: ASObjectOrLink; 74 | 75 | @IsOptional() 76 | relationship?: ASObjectOrLink; 77 | } 78 | export class Relationship extends ActivityStreams.object('Relationship', RelationshipDef) {}; 79 | ActivityStreams.transformer.add(Relationship); 80 | 81 | class TombstoneDef { 82 | @IsOptional() 83 | @IsString() 84 | formerType?: string; 85 | 86 | @IsOptional() 87 | @IsRFC3339() 88 | deleted?: string; 89 | } 90 | export class Tombstone extends ActivityStreams.object('Tombstone', TombstoneDef) {}; 91 | ActivityStreams.transformer.add(Tombstone); 92 | 93 | export class Video extends ActivityStreams.document('Video', class {}) {}; 94 | ActivityStreams.transformer.add(Video); 95 | 96 | export { BaseObject as Object } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @yuforium/activity-streams 2 | _Activity Streams Validator and Transformer_ 3 | 4 | ## Getting Started 5 | ```sh 6 | npm i --save \ 7 | @yuforium/activity-streams \ 8 | class-validator class-transformer \ 9 | reflect-metadata 10 | ``` 11 | 12 | ## Using Built-In Classes 13 | Use built in classes to do validation using class-validator: 14 | 15 | ```typescript 16 | import 'reflect-metadata'; 17 | import { Note } from '@yuforium/activity-streams'; 18 | import { validate } from 'class-validator'; 19 | 20 | const note = new Note(); 21 | 22 | async function validateNote() { 23 | let errors = await validate(note); 24 | 25 | if (errors.length > 0) { 26 | console.log('the note is invalid'); 27 | } 28 | else { 29 | console.log('the note is valid'); 30 | } 31 | } 32 | 33 | note.id = 'https://yuforium.com/users/chris/note-123'; 34 | 35 | validateNote(); // the note is valid 36 | 37 | note.id = 'invalid, id must be a valid URL'; 38 | 39 | validateNote(); // the note is invalid 40 | ``` 41 | 42 | ## Defining Custom Validation Rules 43 | You can define your own validation rules by extending the built in classes or initializing your own using one of several methods using a base type (such as a link, object, activity, or collection): 44 | 45 | ```typescript 46 | import { Expose } from 'class-transformer'; 47 | import { IsString, validate } from 'class-validator'; 48 | import { ActivityStreams } from '@yuforium/activity-streams'; 49 | import 'reflect-metadata'; 50 | 51 | 52 | // Creates a CustomNote type class as an Activity Streams Object 53 | class CustomNote extends ActivityStreams.object('CustomNote') { 54 | @Expose() 55 | @IsString({each: true}) 56 | public customField: string | string[]; 57 | }; 58 | 59 | // Add this to the built-in transformer 60 | ActivityStreams.transformer.add(CustomNote); 61 | 62 | // new instance of CustomNote 63 | const custom: CustomNote = ActivityStreams.transform({ 64 | type: 'CustomNote', 65 | customField: 5 // invalid, must be a string 66 | }); 67 | 68 | // will get error "each value in customField must be a string" 69 | validate(custom).then(errors => { 70 | errors.forEach(error => { console.log(error) }); 71 | }); 72 | ``` 73 | 74 | ## Composite Transformation 75 | In addition to supporting custom classes, multiple types may be defined and interpolated from the `transform()` method. 76 | 77 | ```typescript 78 | import { Expose } from 'class-transformer'; 79 | import { IsString, validate } from 'class-validator'; 80 | import { ActivityStreams } from '@yuforium/activity-streams'; 81 | import 'reflect-metadata'; 82 | 83 | 84 | // Creates CustomNote class as an Activity Streams Object 85 | class CustomNote extends ActivityStreams.object('CustomNote') { 86 | @Expose() 87 | @IsString({each: true}) 88 | public customField: string | string[]; 89 | }; 90 | 91 | // Add this to the built in transformer 92 | ActivityStreams.transformer.add(CustomNote); 93 | 94 | // new instance of CustomNote 95 | const custom = ActivityStreams.transform({ 96 | type: 'CustomNote', 97 | customField: 5 // invalid, must be a string 98 | }); 99 | 100 | // will get error "each value in customField must be a string" 101 | validate(custom).then(errors => { 102 | errors.forEach(error => { console.log(error) }); 103 | }); 104 | ``` 105 | 106 | ## Requiring Optional Fields 107 | Many fields in the Activity Streams specification are optional, but you may want to make them required your own validation purposes. 108 | 109 | Extend the classes you need and then use the `@IsRequired()` decorator for these fields. 110 | 111 | _my-note.ts_ 112 | ```typescript 113 | import { Note, IsRequired } from '@yuforium/activity-streams'; 114 | 115 | export class MyNote extends Note { 116 | // content field is now required 117 | @IsRequired() 118 | public content; 119 | } 120 | ``` 121 | _validate.ts_ 122 | ```typescript 123 | import { MyNote } from './my-note'; 124 | 125 | const note = new MyNote(); 126 | 127 | validate(note); // fails 128 | 129 | note.content = "If you can dodge a wrench, you can dodge a ball."; 130 | 131 | validate(note); // works 132 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "skipLibCheck": true, 4 | /* Basic Options */ 5 | // "incremental": true, /* Enable incremental compilation */ 6 | "target": "es2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 7 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 8 | "lib": ["es2017", "DOM"], /* Specify library files to be included in the compilation. */ 9 | // "allowJs": true, /* Allow javascript files to be compiled. */ 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 14 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | "outDir": "./dist", /* Redirect output structure to the directory. */ 17 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 18 | // "composite": true, /* Enable project compilation */ 19 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 20 | // "removeComments": true, /* Do not emit comments to output. */ 21 | "noEmit": false, /* Do not emit outputs. */ 22 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 23 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 24 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 25 | 26 | /* Strict Type-Checking Options */ 27 | "strict": true, /* Enable all strict type-checking options. */ 28 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 32 | "strictPropertyInitialization": false, /* Enable strict checking of property initialization in classes. */ 33 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 34 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 35 | 36 | /* Additional Checks */ 37 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 38 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 39 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 40 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 41 | 42 | /* Module Resolution Options */ 43 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 44 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 45 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 46 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 47 | "typeRoots": ["node_modules/@types"], /* List of folders to include type definitions from. */ 48 | "types": ["node", "jest"], /* Type declaration files to be included in compilation. */ 49 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 50 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 51 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 52 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 53 | 54 | /* Source Map Options */ 55 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 56 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 57 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 58 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 59 | 60 | /* Experimental Options */ 61 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 62 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 63 | 64 | /* Advanced Options */ 65 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 66 | }, 67 | 68 | "include": ["./src", "./tests"], 69 | "exclude": ["node_modules"] 70 | } 71 | -------------------------------------------------------------------------------- /src/activities.ts: -------------------------------------------------------------------------------- 1 | import { Expose, Transform, Type } from "class-transformer"; 2 | import { IsOptional } from "class-validator"; 3 | import { ActivityStreams } from "./activity-streams"; 4 | import { ASObjectOrLink } from "./interfaces"; 5 | 6 | /** 7 | * @category Core 8 | */ 9 | export class Activity extends ActivityStreams.activity('Activity') {}; 10 | 11 | /** 12 | * @category Core 13 | */ 14 | export class IntransitiveActivity extends ActivityStreams.intransitiveActivity('IntransitiveActivity') {}; 15 | 16 | 17 | /** 18 | * Indicates that the {@link ASActivity.actor | actor} accepts the object. The target property can be used in certain circumstances to indicate the context into which the object has been accepted. 19 | * @category Activities 20 | */ 21 | export class Accept extends ActivityStreams.activity('Accept') {}; 22 | 23 | /** 24 | * 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. 25 | * @category Activities 26 | */ 27 | export class Add extends ActivityStreams.activity('Add') {}; 28 | 29 | /** 30 | * Indicates that the actor is calling the target's attention the object. 31 | * The origin typically has no defined meaning. 32 | * @category Activities 33 | */ 34 | export class Announce extends ActivityStreams.activity('Announce') {}; 35 | 36 | /** 37 | * 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. 38 | * @category Activities 39 | */ 40 | export class Arrive extends ActivityStreams.intransitiveActivity('Arrive') {}; 41 | 42 | /** 43 | * @category Activities 44 | */ 45 | export class Ignore extends ActivityStreams.activity('Ignore') {}; 46 | 47 | /** 48 | * @category Activities 49 | */ 50 | export class Block extends Ignore {}; 51 | 52 | /** 53 | * @category Activities 54 | */ 55 | export class Create extends ActivityStreams.activity('Create') {}; 56 | 57 | /** 58 | * @category Activities 59 | */ 60 | export class Delete extends ActivityStreams.activity('Delete') {}; 61 | 62 | /** 63 | * @category Activities 64 | */ 65 | export class Dislike extends ActivityStreams.activity('Dislike') {}; 66 | 67 | /** 68 | * @category Activities 69 | */ 70 | export class Follow extends ActivityStreams.activity('Follow') {}; 71 | 72 | /** 73 | * @category Activities 74 | */ 75 | export class Offer extends ActivityStreams.activity('Offer') {}; 76 | 77 | /** 78 | * @category Activities 79 | */ 80 | export class Invite extends Offer {}; 81 | 82 | /** 83 | * @category Activities 84 | */ 85 | export class Join extends ActivityStreams.activity('Join') {}; 86 | 87 | /** 88 | * @category Activities 89 | */ 90 | export class Leave extends ActivityStreams.activity('Leave') {}; 91 | 92 | /** 93 | * @category Activities 94 | */ 95 | export class Like extends ActivityStreams.activity('Like') {}; 96 | 97 | /** 98 | * @category Activities 99 | */ 100 | export class Listen extends ActivityStreams.activity('Listen') {}; 101 | 102 | /** 103 | * @category Activities 104 | */ 105 | export class Move extends ActivityStreams.activity('Move') {}; 106 | 107 | 108 | class QuestionDef { 109 | @IsOptional() 110 | oneOf?: ASObjectOrLink | ASObjectOrLink[]; 111 | 112 | @IsOptional() 113 | anyOf?: ASObjectOrLink | ASObjectOrLink[]; 114 | 115 | @IsOptional() 116 | closed?: boolean | string | ASObjectOrLink | ASObjectOrLink[]; 117 | } 118 | /** 119 | * @category Activities 120 | */ 121 | export class Question extends ActivityStreams.intransitiveActivity('Question', QuestionDef) {}; 122 | 123 | /** 124 | * @category Activities 125 | */ 126 | export class Reject extends ActivityStreams.activity('Reject') {}; 127 | 128 | /** 129 | * Indicates that the actor has read the object. 130 | * @category Activities 131 | */ 132 | export class Read extends ActivityStreams.activity('Read') {}; 133 | 134 | /** 135 | * Indicates that the actor is removing the object. If specified, the origin indicates the context from which the object is being removed. 136 | * @category Activities 137 | */ 138 | export class Remove extends ActivityStreams.activity('Remove') {}; 139 | 140 | /** 141 | * A specialization of Reject in which the rejection is considered tentative. 142 | * @category Activities 143 | */ 144 | export class TentativeReject extends ActivityStreams.activity('TentativeReject') {}; 145 | 146 | /** 147 | * A specialization of Accept indicating that the acceptance is tentative. 148 | * @category Activities 149 | */ 150 | export class TentativeAccept extends ActivityStreams.activity('TentativeAccept') {}; 151 | 152 | /** 153 | * 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. 154 | * @category Activities 155 | */ 156 | export class Travel extends ActivityStreams.intransitiveActivity('Travel') {}; 157 | 158 | /** 159 | * 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). 160 | * 161 | * The target and origin typically have no defined meaning. 162 | * @category Activities 163 | */ 164 | export class Undo extends ActivityStreams.activity('Undo', class {}) {}; 165 | 166 | /** 167 | * 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. 168 | * 169 | * The target and origin typically have no defined meaning. 170 | * @category Activities 171 | */ 172 | export class Update extends ActivityStreams.activity('Update', class {}) {}; 173 | 174 | /** 175 | * Indicates that the actor has viewed the object. 176 | * @category Activities 177 | */ 178 | export class View extends ActivityStreams.activity('View', class {}) {}; 179 | 180 | // [Accept, Add, Announce, Arrive, Block, Create, Delete, Dislike, Follow, Ignore, Invite, Join, Leave, Like, Listen, Move, Offer, Question, Read, Reject, Remove, TentativeAccept, TentativeReject, Travel, Undo, Update, View] 181 | // .forEach(constructor => ActivityStreams.register(constructor)); -------------------------------------------------------------------------------- /src/activity-streams.ts: -------------------------------------------------------------------------------- 1 | import { ClassTransformOptions, Expose, plainToInstance, Transform } from "class-transformer"; 2 | import { registerDecorator, getMetadataStorage, IsInt, IsMimeType, IsNotEmpty, IsNumber, IsObject, IsPositive, IsRFC3339, IsString, IsUrl, Min, ValidateIf, ValidateNested } from "class-validator"; 3 | import { IsOptional } from "./decorator/is-optional"; 4 | import { ASLink } from "./interfaces/as-link.interface"; 5 | import { ASObject, ASObjectOrLink } from "./interfaces/as-object.interface"; 6 | import { ASCollection } from "./interfaces/as-collection.interface"; 7 | import { Constructor } from "./util/constructor"; 8 | import { ASActivity } from "./interfaces/as-activity.interface"; 9 | import { ASCollectionPage } from "./interfaces/as-collection-page.interface"; 10 | import { ASDocument } from "./interfaces/as-document.interface"; 11 | import { ASIntransitiveActivity } from "./interfaces/as-intransitive-activity.interface"; 12 | import { ContentMap } from "./util/content-map"; 13 | import { IsNotEmptyArray } from "./util/is-not-empty-array"; 14 | 15 | /** 16 | * Base collection of ActivityStreams objects. 17 | */ 18 | export namespace ActivityStreams { 19 | /** 20 | * Interface for any class that can be transformed into an ActivityStreams object. 21 | * At this time there are no requirements, but they may be added in the future. 22 | */ 23 | export interface ASTransformable { 24 | }; 25 | 26 | export interface ASConstructor extends Constructor { 27 | type: string | string[]; 28 | }; 29 | 30 | /** 31 | * Default registered types. When new types are added via the ActivityStreams.object() or ActivityStreams.link() methods, they are 32 | * added to this list of types which are used by ActivityStreams.transformer 33 | */ 34 | export const transformerTypes: {[k: string]: Constructor} = {}; 35 | 36 | export interface TransformerOptions { 37 | composeWithMissingConstructors?: boolean; 38 | enableCompositeTypes?: boolean, 39 | alwaysReturnValueOnTransform?: boolean; 40 | } 41 | 42 | export class Transformer { 43 | protected composites: {[k: symbol]: Constructor} = {}; 44 | protected options: TransformerOptions = { 45 | composeWithMissingConstructors: true, 46 | enableCompositeTypes: true, 47 | alwaysReturnValueOnTransform: false 48 | }; 49 | 50 | constructor(protected types: {[k: string]: Constructor} = {}, options?: TransformerOptions) { 51 | Object.assign(this.options, options); 52 | } 53 | 54 | add(...constructors: ASConstructor<{type: string | string[]}>[]) { 55 | constructors.forEach(ctor => this.types[ctor.type as string] = ctor); 56 | } 57 | 58 | transform({value, options}: {value: {type: string | string[], [k: string]: any}, options?: ClassTransformOptions}): any { 59 | options = Object.assign({excludeExtraneousValues: true, exposeUnsetFields: false}, options); 60 | 61 | if (Array.isArray(value)) { 62 | return value.map(v => this.transform({value: v, options})); 63 | } 64 | 65 | if (typeof value !== 'object') { 66 | return this.options.alwaysReturnValueOnTransform ? value : undefined; 67 | } 68 | 69 | if (typeof value.type === 'string') { 70 | if (this.types[value.type]) { 71 | return plainToInstance(this.types[value.type], value, options); 72 | } 73 | 74 | if (this.options.alwaysReturnValueOnTransform) { 75 | return value; 76 | } 77 | 78 | return undefined; 79 | } 80 | else if (Array.isArray(value.type) && this.options.enableCompositeTypes) { 81 | const types = value.type.filter(t => this.types[t]); 82 | const symbol = Symbol.for(types.join('-')); 83 | 84 | if (!types.length) { 85 | if (this.options.alwaysReturnValueOnTransform) { 86 | return value; 87 | } 88 | 89 | return undefined; 90 | } 91 | 92 | let ctor = this.composites[symbol]; 93 | 94 | if (ctor) { 95 | return plainToInstance(ctor, value, options); 96 | } 97 | else { 98 | const ctors = types.map((t) => {return this.types[t]}); 99 | const cls = this.composeClass(...ctors); 100 | 101 | this.composites[symbol] = cls; 102 | 103 | if (!this.options.composeWithMissingConstructors && ctors.length !== types.length) { 104 | if (this.options.alwaysReturnValueOnTransform) { 105 | return value; 106 | } 107 | 108 | return undefined; 109 | } 110 | 111 | return plainToInstance(cls, value, options); 112 | } 113 | } 114 | else { 115 | return this.options.alwaysReturnValueOnTransform ? value : undefined; 116 | } 117 | } 118 | 119 | protected composeClass(...constructors: Constructor[]) { 120 | return constructors.reduce((prev: Constructor, curr: Constructor) => { 121 | return this.mixinClass(prev, curr); 122 | }, class {}); 123 | } 124 | 125 | protected mixinClass(target: Constructor, source: Constructor): Constructor { 126 | const cls = class extends target { 127 | } 128 | 129 | Object.getOwnPropertyNames(source.prototype).forEach((name) => { 130 | Object.defineProperty( 131 | cls.prototype, 132 | name, 133 | Object.getOwnPropertyDescriptor(source.prototype, name) || Object.create(null) 134 | ); 135 | }); 136 | 137 | return cls; 138 | } 139 | } 140 | 141 | /** 142 | * The built in ActivityStreams transformer. This is used by the ActivityStreams.transform() method, and can be used to transform a plain object to any of the built-in ActivityStreams classes for validation. 143 | */ 144 | export const transformer = new Transformer(transformerTypes); 145 | 146 | /** 147 | * A built-in function that uses the {@link ActivityStreams.transformer} to transform a plain object to an ActivityStreams object. 148 | * @param value Object 149 | * @returns ASContructor 150 | */ 151 | export function transform(value: {type: string | string[], [k: string]: any}): any { 152 | return transformer.transform({value, options: {exposeUnsetFields: false}}); 153 | } 154 | 155 | /** 156 | * Create a new class based on the ActivityStreams Link type. 157 | * 158 | * @param namedType The name of the type to create, which will equal to the value of the type property. 159 | * @param Base Base class to derive from. Defaults to ASTransformable. 160 | * @returns ASConstructor 161 | */ 162 | export function link>(namedType: string, Base?: TBase | undefined): ASConstructor { 163 | if (Base === undefined) { 164 | Base = class {} as TBase; 165 | } 166 | 167 | class ActivityStreamsLink extends Base implements ASLink { 168 | static readonly type = namedType; 169 | 170 | @IsString({each: true}) 171 | @IsOptional() 172 | '@context'?: string | string[] = 'https://www.w3.org/ns/activitystreams'; 173 | 174 | @IsString() 175 | @IsNotEmpty() 176 | @Expose() 177 | type: string = namedType; 178 | 179 | @IsString() 180 | @IsUrl() 181 | href: string; 182 | 183 | @IsString() 184 | @IsOptional() 185 | id?: string; 186 | 187 | @IsString() 188 | @IsOptional() 189 | name?: string | string[]; 190 | 191 | @IsString() 192 | @IsOptional() 193 | hreflang?: string; 194 | 195 | @IsString() 196 | @IsOptional() 197 | @IsMimeType() 198 | mediaType?: string; 199 | 200 | @IsString() 201 | @IsOptional() 202 | rel?: string|string[]; 203 | 204 | @IsOptional() 205 | @IsNumber() 206 | @IsInt() 207 | @IsPositive() 208 | height?: number; 209 | 210 | @IsOptional() 211 | @IsNumber() 212 | @IsInt() 213 | @IsPositive() 214 | width?: number; 215 | } 216 | 217 | return ActivityStreamsLink; 218 | } 219 | 220 | /** 221 | * Create a new class based on the ActivityStreams Object type. 222 | * @param namedType The name of the type to create, which will equal to the value of the type property. 223 | * @param Base Base class to derive from. Defaults to ASTransformable. 224 | * @returns ASConstructor 225 | */ 226 | export function object = Constructor>(namedType: string, Base?: TBase | undefined): ASConstructor { 227 | if (Base === undefined) { 228 | Base = class {} as TBase; 229 | } 230 | 231 | class ActivityStreamsObject extends Base implements ASObject { 232 | static readonly type: string | string[] = namedType; 233 | 234 | @IsString() 235 | @IsOptional() 236 | '@context'?: string | string[] = 'https://www.w3.org/ns/activitystreams'; 237 | 238 | @IsString({each: true}) 239 | @IsNotEmpty() 240 | @IsNotEmptyArray() 241 | @Expose() 242 | type: string | string[] = namedType; 243 | 244 | @IsString() 245 | @IsUrl() 246 | @IsOptional() 247 | @IsNotEmpty() 248 | @Expose() 249 | id?: string; 250 | 251 | /** 252 | * 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. 253 | * https://www.w3.org/ns/activitystreams#attachment 254 | */ 255 | @IsOptional() 256 | @Expose() 257 | @Transform(params => transformer.transform(params)) 258 | public attachment?: ASObjectOrLink | ASObjectOrLink[]; 259 | 260 | /** 261 | * 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. 262 | * https://www.w3.org/ns/activitystreams#attributedTo 263 | */ 264 | @IsOptional() 265 | @Expose() 266 | public attributedTo?: ASObjectOrLink | ASObjectOrLink[]; 267 | 268 | /** 269 | * Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant. 270 | * 271 | * https://www.w3.org/ns/activitystreams#audience 272 | */ 273 | @IsOptional() 274 | @Expose() 275 | audience?: ASObjectOrLink | ASObjectOrLink[]; 276 | 277 | /** 278 | * The content or textual representation of the 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. 279 | * 280 | * The content may be expressed using multiple language-tagged values. 281 | * 282 | * https://www.w3.org/ns/activitystreams#content 283 | */ 284 | @IsString() 285 | @Expose() 286 | @IsOptional() 287 | content?: string | string[]; 288 | 289 | /** 290 | * Identifies the context within which the object exists or an activity was performed. 291 | * 292 | * 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. 293 | * 294 | * https://www.w3.org/ns/activitystreams#context 295 | */ 296 | @IsOptional() 297 | @Expose() 298 | context?: ASObjectOrLink | ASObjectOrLink[]; 299 | 300 | /** 301 | * The content or textual representation of the 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. 302 | * 303 | * The content may be expressed using multiple language-tagged values. 304 | * 305 | * https://www.w3.org/ns/activitystreams#content 306 | */ 307 | @IsObject() 308 | @IsOptional() 309 | @Expose() 310 | contentMap?: ContentMap; 311 | 312 | /** 313 | * 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. 314 | * 315 | * https://www.w3.org/ns/activitystreams#name 316 | */ 317 | @IsString() 318 | @IsOptional() 319 | @Expose() 320 | name?: string | string[]; 321 | 322 | /** 323 | * 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. 324 | * 325 | * https://www.w3.org/ns/activitystreams#name 326 | */ 327 | @IsObject() 328 | @IsOptional() 329 | @Expose() 330 | nameMap?: ContentMap | ContentMap[]; 331 | 332 | @IsOptional() 333 | @IsString() 334 | @IsRFC3339() 335 | @Expose() 336 | endTime?: string; 337 | 338 | @IsOptional() 339 | @Expose() 340 | generator?: ASObjectOrLink | ASObjectOrLink[]; 341 | 342 | @IsOptional() 343 | @Expose() 344 | icon?: ASObjectOrLink | ASObjectOrLink[]; 345 | 346 | @IsOptional() 347 | @Expose() 348 | image?: ASObjectOrLink | ASObjectOrLink[]; 349 | 350 | @IsOptional() 351 | @Expose() 352 | inReplyTo?: ASObjectOrLink | ASObjectOrLink[]; 353 | 354 | @IsOptional() 355 | @Expose() 356 | location?: ASObjectOrLink | ASObjectOrLink[];; 357 | 358 | @IsOptional() 359 | @Expose() 360 | preview?: ASObjectOrLink | ASObjectOrLink[]; 361 | 362 | /** 363 | * The date and time at which the object was published 364 | * 365 | * ```json 366 | * { 367 | * "@context": "https://www.w3.org/ns/activitystreams", 368 | * "summary": "A simple note", 369 | * "type": "Note", 370 | * "content": "Fish swim.", 371 | * "published": "2014-12-12T12:12:12Z" 372 | * } 373 | * ``` 374 | * 375 | * https://www.w3.org/ns/activitystreams#published 376 | */ 377 | @IsOptional() 378 | @IsString() 379 | @IsRFC3339() 380 | @Expose() 381 | published?: string; 382 | 383 | @IsOptional() 384 | @Expose() 385 | replies?: ASCollection; 386 | 387 | @IsOptional() 388 | @IsString() 389 | @IsRFC3339() 390 | @Expose() 391 | startTime?: string; 392 | 393 | @IsOptional() 394 | @Expose() 395 | summary?: string|string[]; 396 | 397 | @IsObject() 398 | @IsOptional() 399 | @Expose() 400 | summaryMap?: ContentMap|ContentMap[]; 401 | 402 | /** 403 | * One or more "tags" that have been associated with an objects. A tag can be any kind of Object. The key difference between attachment and tag is that the former implies association by inclusion, while the latter implies associated by reference. 404 | * 405 | * https://www.w3.org/ns/activitystreams#tag 406 | */ 407 | @IsOptional() 408 | @Expose() 409 | tag?: ASObjectOrLink | ASObjectOrLink[]; 410 | 411 | @IsOptional() 412 | @IsString() 413 | @IsRFC3339() 414 | @Expose() 415 | updated?: string; 416 | 417 | @IsOptional() 418 | @Expose() 419 | url?: ASLink | string | (ASLink | string)[]; 420 | 421 | @IsOptional() 422 | @Expose() 423 | to?: ASObjectOrLink | ASObjectOrLink[]; 424 | 425 | @IsOptional() 426 | @Expose() 427 | bto?: ASObjectOrLink | ASObjectOrLink[]; 428 | 429 | @IsOptional() 430 | @Expose() 431 | cc?: ASObjectOrLink | ASObjectOrLink[]; 432 | 433 | @IsOptional() 434 | @Expose() 435 | bcc?: ASObjectOrLink | ASObjectOrLink[]; 436 | 437 | @IsOptional() 438 | @IsString() 439 | @IsMimeType() 440 | @Expose() 441 | mediaType?: string; 442 | 443 | @IsOptional() 444 | @IsString() 445 | @Expose() 446 | duration?: string; 447 | }; 448 | 449 | return ActivityStreamsObject; 450 | } 451 | 452 | /** 453 | * Create a new class based on the ActivityStreams Document type. 454 | * 455 | * @param namedType The name of the type to create, which will equal to the value of the type property. 456 | * @param Base Base class to derive from. Defaults to ASTransformable. 457 | * @returns ASConstructor 458 | */ 459 | export function document>(namedType: string, Base?: TBase | undefined): ASConstructor { 460 | class ActivityStreamsDocument extends object(namedType, Base) implements ASDocument { 461 | } 462 | 463 | return ActivityStreamsDocument; 464 | } 465 | 466 | /** 467 | * Create a new class based on the ActivityStreams Activity type. 468 | * 469 | * @param namedType The name of the type to create, which will equal to the value of the type property. 470 | * @param Base Base class to derive from. Defaults to ASTransformable. 471 | * @returns ASConstructor 472 | */ 473 | export function activity>(namedType: string, Base?: TBase | undefined): ASConstructor { 474 | if (Base === undefined) { 475 | Base = class {} as TBase; 476 | } 477 | 478 | class ActivityStreamsActivity extends object(namedType, Base) implements ASActivity { 479 | @IsOptional() 480 | @Expose() 481 | actor?: ASObjectOrLink; 482 | 483 | @IsOptional() 484 | @Expose() 485 | object?: ASObjectOrLink; 486 | 487 | @IsOptional() 488 | @Expose() 489 | target?: ASObjectOrLink; 490 | 491 | @IsOptional() 492 | @Expose() 493 | result?: ASObjectOrLink; 494 | 495 | @IsOptional() 496 | @Expose() 497 | origin?: ASObjectOrLink; 498 | 499 | @IsOptional() 500 | @Expose() 501 | instrument?: ASObjectOrLink; 502 | } 503 | 504 | return ActivityStreamsActivity; 505 | } 506 | 507 | /** 508 | * Create a new class based on the ActivityStreams IntransitiveActivity type. 509 | * 510 | * @param namedType The name of the type to create, which will equal to the value of the type property. 511 | * @param Base Base class to derive from. Defaults to ASTransformable. 512 | * @returns ASConstructor 513 | */ 514 | export function intransitiveActivity>(namedType: string, Base?: TBase | undefined): ASConstructor { 515 | if (Base === undefined) { 516 | Base = class {} as TBase; 517 | } 518 | 519 | class ActivityStreamsIntransitiveActivity extends activity(namedType, Base) implements ASIntransitiveActivity { 520 | } 521 | 522 | return ActivityStreamsIntransitiveActivity; 523 | } 524 | 525 | /** 526 | * Create a new class based on the ActivityStreams Collection type. 527 | * 528 | * @param namedType The name of the type to create, which will equal to the value of the type property. 529 | * @param Base Base class to derive from. Defaults to ASTransformable. 530 | * @returns ASConstructor 531 | */ 532 | export function collection>(namedType: string, Base?: TBase | undefined): ASConstructor { 533 | class ActivityStreamsCollection extends object(namedType, Base) implements ASCollection { 534 | @Expose() 535 | @IsOptional() 536 | @IsNumber() 537 | @IsInt() 538 | @Min(0) 539 | totalItems?: number; 540 | 541 | @Expose() 542 | @IsOptional() 543 | current?: ASCollectionPage | ASLink | string 544 | 545 | @Expose() 546 | @IsOptional() 547 | first?: ASCollectionPage | ASLink | string 548 | 549 | @Expose() 550 | @IsOptional() 551 | last?: ASCollectionPage | ASLink | string 552 | 553 | @Expose() 554 | @IsOptional() 555 | items: ASObjectOrLink[]; 556 | } 557 | 558 | return ActivityStreamsCollection; 559 | } 560 | 561 | /** 562 | * Create a new class based on the ActivityStreams CollectionPage type. 563 | * 564 | * @param namedType The name of the type to create, which will equal to the value of the type property. 565 | * @param Base Base class to derive from. Defaults to ASTransformable. 566 | * @returns ASConstructor 567 | */ 568 | export function collectionPage>(namedType: string, Base?: TBase | undefined): ASConstructor { 569 | class ActivityStreamsCollectionPage extends collection(namedType, Base) { 570 | @Expose() 571 | @IsOptional() 572 | partOf?: ASCollection | ASLink; 573 | 574 | @Expose() 575 | @IsOptional() 576 | next?: ASCollectionPage | ASLink; 577 | 578 | @Expose() 579 | @IsOptional() 580 | prev?: ASCollectionPage | ASLink; 581 | } 582 | 583 | return ActivityStreamsCollectionPage; 584 | } 585 | } 586 | --------------------------------------------------------------------------------