From fe1f2a8ac8f7efd4dfb68c4d7536f28fdf802db2 Mon Sep 17 00:00:00 2001 From: dherges Date: Sat, 16 Feb 2019 09:24:33 +0100 Subject: [PATCH 1/5] feat(syndesi): introduce client for hypermedia-like JSON/HTTP --- angular.json | 42 +++++- docs/GREEK.md | 5 + libs/shared/src/lib/debug.ts | 1 + libs/syndesi/README.md | 3 + libs/syndesi/jest.config.js | 5 + libs/syndesi/src/index.ts | 5 + libs/syndesi/src/lib/call.ts | 146 +++++++++++++++++++ libs/syndesi/src/lib/client.service.ts | 29 ++++ libs/syndesi/src/lib/resources.spec.ts | 148 ++++++++++++++++++++ libs/syndesi/src/lib/resources.ts | 125 +++++++++++++++++ libs/syndesi/src/lib/syndesi.module.spec.ts | 14 ++ libs/syndesi/src/lib/syndesi.module.ts | 7 + libs/syndesi/src/lib/uri.ts | 22 +++ libs/syndesi/src/test-setup.ts | 1 + libs/syndesi/tsconfig.json | 7 + libs/syndesi/tsconfig.lib.json | 26 ++++ libs/syndesi/tsconfig.spec.json | 10 ++ libs/syndesi/tslint.json | 7 + nx.json | 3 +- tsconfig.json | 3 +- 20 files changed, 603 insertions(+), 6 deletions(-) create mode 100644 docs/GREEK.md create mode 100644 libs/syndesi/README.md create mode 100644 libs/syndesi/jest.config.js create mode 100644 libs/syndesi/src/index.ts create mode 100644 libs/syndesi/src/lib/call.ts create mode 100644 libs/syndesi/src/lib/client.service.ts create mode 100644 libs/syndesi/src/lib/resources.spec.ts create mode 100644 libs/syndesi/src/lib/resources.ts create mode 100644 libs/syndesi/src/lib/syndesi.module.spec.ts create mode 100644 libs/syndesi/src/lib/syndesi.module.ts create mode 100644 libs/syndesi/src/lib/uri.ts create mode 100644 libs/syndesi/src/test-setup.ts create mode 100644 libs/syndesi/tsconfig.json create mode 100644 libs/syndesi/tsconfig.lib.json create mode 100644 libs/syndesi/tsconfig.spec.json create mode 100644 libs/syndesi/tslint.json diff --git a/angular.json b/angular.json index 69621baa..2fe1baec 100644 --- a/angular.json +++ b/angular.json @@ -610,9 +610,40 @@ "styleext": "scss" } } + }, + "syndesi": { + "root": "libs/syndesi", + "sourceRoot": "libs/syndesi/src", + "projectType": "library", + "prefix": "sp", + "architect": { + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "libs/syndesi/tsconfig.lib.json", + "libs/syndesi/tsconfig.spec.json" + ], + "exclude": ["**/node_modules/**"] + } + }, + "test": { + "builder": "@nrwl/builders:jest", + "options": { + "jestConfig": "libs/syndesi/jest.config.js", + "tsConfig": "libs/syndesi/tsconfig.spec.json", + "setupFile": "libs/syndesi/src/test-setup.ts" + } + } + }, + "schematics": { + "@nrwl/schematics:component": { + "styleext": "scss" + } + } } }, - "defaultProject": "domain", + "defaultProject": "sparkles", "cli": { "warnings": { "typescriptMismatch": false @@ -621,13 +652,16 @@ "packageManager": "yarn" }, "schematics": { + "@nrwl/schematics:application": { + "unitTestRunner": "jest", + "e2eTestRunner": "cypress" + }, "@nrwl/schematics:library": { "unitTestRunner": "jest", "framework": "angular" }, - "@nrwl/schematics:application": { - "unitTestRunner": "jest", - "e2eTestRunner": "cypress" + "@nrwl/schematics:component": { + "styleext": "scss" }, "@nrwl/schematics:node-application": { "framework": "express" diff --git a/docs/GREEK.md b/docs/GREEK.md new file mode 100644 index 00000000..eae4fbc2 --- /dev/null +++ b/docs/GREEK.md @@ -0,0 +1,5 @@ + - sýndesi / σύνδεση + - https://en.wiktionary.org/wiki/%CF%83%CF%8D%CE%BD%CE%B4%CE%B5%CF%83%CE%B7 + - epafí / επαφή + - https://en.wiktionary.org/wiki/%CE%B5%CF%80%CE%B1%CF%86%CE%AE + diff --git a/libs/shared/src/lib/debug.ts b/libs/shared/src/lib/debug.ts index ace81463..37a33edc 100644 --- a/libs/shared/src/lib/debug.ts +++ b/libs/shared/src/lib/debug.ts @@ -43,6 +43,7 @@ import { unique } from './functional'; export class Debug { public environment: any = {}; + private enabled: string[] = []; public get isDevelop(): boolean { diff --git a/libs/syndesi/README.md b/libs/syndesi/README.md new file mode 100644 index 00000000..1e8a6a6c --- /dev/null +++ b/libs/syndesi/README.md @@ -0,0 +1,3 @@ +# Syndesi + +> A connected-media format. diff --git a/libs/syndesi/jest.config.js b/libs/syndesi/jest.config.js new file mode 100644 index 00000000..468f06bd --- /dev/null +++ b/libs/syndesi/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + name: 'syndesi', + preset: '../../jest.config.js', + coverageDirectory: '../../coverage/libs/syndesi' +}; diff --git a/libs/syndesi/src/index.ts b/libs/syndesi/src/index.ts new file mode 100644 index 00000000..8cbe8377 --- /dev/null +++ b/libs/syndesi/src/index.ts @@ -0,0 +1,5 @@ +export * from './lib/call'; +export * from './lib/client.service'; +export * from './lib/resources'; +export * from './lib/uri'; +export * from './lib/syndesi.module'; diff --git a/libs/syndesi/src/lib/call.ts b/libs/syndesi/src/lib/call.ts new file mode 100644 index 00000000..612a11b4 --- /dev/null +++ b/libs/syndesi/src/lib/call.ts @@ -0,0 +1,146 @@ +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Resource } from './resources'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { expand, UriParams } from './uri'; + +const nextCall = (call: Call) => { + + return function (res: T | HttpResponse) { + const json = res instanceof HttpResponse ? res.body : res; + const response = res instanceof HttpResponse ? res : undefined; + + return new Call(call['_http'], json, response); + }; +}; + +export class Call { + private _http: HttpClient; + private _uri: string; + private _method: string; + private _options = {}; + + constructor( + http: HttpClient | Call, + public resource: T, + public response?: HttpResponse + ) { + if (http instanceof HttpClient) { + this._http = http; + } else { + this._http = http._http; + } + } + + public delete(rel: string, params?: UriParams, body?: any): Call { + this.uri(rel, params); + this.method('delete'); + + return this; + } + + public get(rel: string, params?: UriParams): Call { + this.uri(rel, params); + this.method('get'); + + return this; + } + + public head(rel: string, params?: UriParams): Call { + this.uri(rel, params); + this.method('head'); + + return this; + } + + public options(rel: string, params?: UriParams): Call { + this.uri(rel, params); + this.method('options'); + + return this; + } + + public patch(rel: string, params?: UriParams): Call { + this.uri(rel, params); + this.method('patch'); + + return this; + } + + public post(rel: string, params?: UriParams, body?: any): Call { + this.uri(rel, params); + this.method('post'); + this.body(body); + + return this; + } + + public put(rel: string, params?: UriParams, body?: any): Call { + this.uri(rel, params); + this.method('put'); + this.body(body); + + return this; + } + + public uri(rel: string, params?: UriParams): Call { + this._uri = this.expandLink(rel, params); + + return this; + } + + public method(verb: string): Call { + this._method = verb; + + return this; + } + + public opts(opts: any): Call { + this._options = opts; + + return this; + } + + public body(body: any): Call { + this._options = { + ...this._options, + body + }; + + return this; + } + + public send(): Observable> { + if (!this._method) { + throw new Error('method is a required parameter! Please set it with method() or one of the short-hands like get().'); + } + if (!this._uri) { + throw new Error('uri is a required parameter! Please set it with uri() or one of the short-hands like get().'); + } + + return this._http + .request( + this._method, + this._uri, + { + ...this._options, + observe: 'response', + responseType: 'json' + }) + .pipe(map(nextCall(this))); + } + + private expandLink(rel: string, params?: UriParams): string { + const link = this.resource._links[rel]; + + if (!link) { + throw new Error(`Link with rel=${rel} does not exist`); + } + + if (link instanceof Array) { + throw new Error('Traversing arrays not implemented'); + } else { + return expand(link, params); + } + } +} diff --git a/libs/syndesi/src/lib/client.service.ts b/libs/syndesi/src/lib/client.service.ts new file mode 100644 index 00000000..ea989c63 --- /dev/null +++ b/libs/syndesi/src/lib/client.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Call } from './call'; +import { Resource } from './resources'; + +@Injectable({ providedIn: 'root' }) +export class HalClient { + + constructor( + private http: HttpClient + ) {} + + /** + * Get the index page of the API at given `url` + * + * @param url URL of index resource, e.g. `/foo/bar/api.json` + */ + public index (url: string): Observable> { + return this.http.get (url).pipe( + map(res => new Call(this.http, res))); + } + + public call (resource: T): Call { + return new Call(this.http, resource); + } + +} diff --git a/libs/syndesi/src/lib/resources.spec.ts b/libs/syndesi/src/lib/resources.spec.ts new file mode 100644 index 00000000..49570c51 --- /dev/null +++ b/libs/syndesi/src/lib/resources.spec.ts @@ -0,0 +1,148 @@ +import { Resource, Link, isLink, isLinks, link, links, embedded, embeddeds } from './resources'; + +describe(`Resource`, () => { + + it(`should extend existing interface types`, () => { + interface A { + b: number; + } + + const test: Resource = { + _links: { + self: { href: '/test' } + }, + b: 123 + }; + + expect(test.b).toEqual(123); + }); +}); + +describe(`isLink()`, () => { + + it(`should return true when property 'href' is string`, () => { + const foo = { href: '/bar' }; + expect(isLink(foo)).toBeTruthy(); + }); + + it(`should return false for undefined values`, () => { + expect(isLink(undefined)).toBeFalsy(); + expect(isLink(null)).toBeFalsy(); + }); + + it(`should return false for quirks`, () => { + expect(isLink([])).toBeFalsy(); + expect(isLink({})).toBeFalsy(); + }); + + it(`should be a type guard for Link`, () => { + const a = { + href: '/foo' + }; + + expect(isLink(a)).toBeTruthy(); + if (isLink(a)) { + expect(a.href).toEqual('/foo'); + } else { + fail('isLink() should return true for Link interface'); + } + }); +}); + +describe(`isLinks()`, () => { + + const foo = { href: '/foo' }; + const bar = { href: '/bar' }; + + it(`should return true for array of Link`, () => { + expect(isLinks([foo, bar])).toBeTruthy(); + }); +}); + +describe(`link()`, () => { + + const test: Resource = { + _links: { + self: { href: '/test' }, + foo: { href: '/bar' } + } + }; + + it(`should return Link for rel`, () => { + const l = link('self', test); + expect(l.href).toEqual('/test'); + }); +}); + +describe(`links()`, () => { + + const test: Resource = { + _links: { + self: { href: '/test' }, + foo: [ + { href: '/bar' }, + { href: '/bar1' } + ] + } + }; + + it(`should return Link for rel`, () => { + const l = links('foo', test); + expect(l.length).toEqual(2); + }); +}); + +describe(`embedded()`, () => { + + const test: Resource = { + _links: { + self: { href: '/test' } + }, + _embedded: { + foo: { + _links: { self: { href: '/foo'} }, + value: 'first' + }, + bar: { + _links: { self: { href: '/bar'} }, + value: 'second' + }, + } + }; + + it(`should return Resource for rel`, () => { + const r = embedded('foo', test); + expect(r).toBeTruthy(); + expect(r.value).toEqual('first'); + + const r2 = embedded('bar', test); + expect(r2).toBeTruthy(); + expect(r2.value).toEqual('second'); + }); +}); + +describe(`embeddeds()`, () => { + + const test: Resource = { + _links: { + self: { href: '/test' } + }, + _embedded: { + foo: [ + { + _links: { self: { href: '/foo'} }, + value: 'first' + }, + { + _links: { self: { href: '/bar'} }, + value: 'second' + } + ] + } + }; + + it(`should return Resource[] for rel`, () => { + const r = embeddeds('foo', test); + expect(r.length).toEqual(2); + }); +}); diff --git a/libs/syndesi/src/lib/resources.ts b/libs/syndesi/src/lib/resources.ts new file mode 100644 index 00000000..bad7430d --- /dev/null +++ b/libs/syndesi/src/lib/resources.ts @@ -0,0 +1,125 @@ +export interface Link { + href: string; + templated?: boolean; +} + +export type Resource = T & { + _links: { + self: Link; + [key: string]: Link | Link[]; + }; + _embedded?: { + [key: string]: Resource | Resource[]; + }; +} + +export type ResourceWithEmbedded = Resource & { + _embedded: { + [key: string]: Resource | Resource[]; + } +}; + +export interface ResourceCollection { + _embedded: { + content: Resource[]; + [key: string]: any; + } +} + +export type ResourcesCollection = Resource & { + _embedded: { + content: Resource[]; + [key: string]: any; + } +} + +export function hasLinks(value: any): value is Resource { + return value && value._links; +} + +export function isLink(value: any): value is Link { + return value && typeof value.href === 'string'; +} + +export function isLinks(value: any): value is Link[] { + return value instanceof Array && value.every(val => isLink(val)); +} + +export function link(rel: string, res: any, shouldThrow?: boolean): Link { + if (hasLinks(res)) { + const link = res._links[rel]; + + if (isLink(link)) { + return link; + } else if (shouldThrow) { + throw new Error(`rel=${rel} is not a Link on Resource ${res}`); + } + } else if (shouldThrow) { + throw new Error(`Object ${res} is not a Resource`); + } +} + +export function links(rel: string, res: any, shouldThrow?: boolean): Link[] { + if (hasLinks(res)) { + const link = res._links[rel]; + + if (isLinks(link)) { + return link; + } else if (shouldThrow) { + throw new Error(`rel=${rel} is not an Link[] on Resource ${res}`); + } + } else if (shouldThrow) { + throw new Error(`Object ${res} is not a Resource`); + } +} + +export function hasEmbedded(value: any): value is ResourceWithEmbedded { + return value && typeof value._embedded === 'object'; +} + +export function isResource(value: any): value is Resource { + return hasLinks(value) && isLink(value._links.self); +} + +export function isResources(value: any): value is Resource[] { + return value instanceof Array && value.every(val => isResource(val)); +} + +export type EmbeddedOpts = { + guard?: (value: any) => value is T; + shouldThrow?: boolean; +}; + +export function embedded(rel: string, res: any, opts?: EmbeddedOpts): Resource { + if (hasEmbedded(res)) { + const em = res._embedded[rel]; + const guard = opts && opts.guard ? opts.guard : (v: T): v is T => true; + + if (isResource(em) && guard(em)) { + return em; + } else if (opts && opts.shouldThrow) { + throw new Error(`rel=${rel} is not an embedded Resource on ${res}`); + } + } else if (opts && opts.shouldThrow) { + throw new Error(`${res} has no _embedded resources`); + } +} + +export function embeddeds(rel: string, res: any, opts?: EmbeddedOpts): Resource[] { + if (hasEmbedded(res)) { + const em = res._embedded[rel]; + const guard = opts && opts.guard ? opts.guard : (v: T): v is T => true; + + const isArrayOfT = (value: any): value is T[] => { + return value instanceof Array && value.every(val => guard(val)); + }; + + if (isResources(em) && isArrayOfT(em)) { + return em; + } else if (opts && opts.shouldThrow) { + throw new Error(`rel=${rel} is not an embedded Resource[] on ${res}`); + } + } else if (opts && opts.shouldThrow) { + throw new Error(`${res} has no _embedded resources`); + } +} diff --git a/libs/syndesi/src/lib/syndesi.module.spec.ts b/libs/syndesi/src/lib/syndesi.module.spec.ts new file mode 100644 index 00000000..5d99ed05 --- /dev/null +++ b/libs/syndesi/src/lib/syndesi.module.spec.ts @@ -0,0 +1,14 @@ +import { async, TestBed } from '@angular/core/testing'; +import { SyndesiModule } from './syndesi.module'; + +describe('SyndesiModule', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [SyndesiModule] + }).compileComponents(); + })); + + it('should create', () => { + expect(SyndesiModule).toBeDefined(); + }); +}); diff --git a/libs/syndesi/src/lib/syndesi.module.ts b/libs/syndesi/src/lib/syndesi.module.ts new file mode 100644 index 00000000..ae98c0b2 --- /dev/null +++ b/libs/syndesi/src/lib/syndesi.module.ts @@ -0,0 +1,7 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@NgModule({ + imports: [CommonModule] +}) +export class SyndesiModule {} diff --git a/libs/syndesi/src/lib/uri.ts b/libs/syndesi/src/lib/uri.ts new file mode 100644 index 00000000..2227cb8c --- /dev/null +++ b/libs/syndesi/src/lib/uri.ts @@ -0,0 +1,22 @@ +import { Link } from './resources'; + +export interface UriParams { + [key: string]: any +} + +export function expand(link: Link, params: UriParams): string { + let url: string; + if (link.templated) { + // TODO: expand uri template + // https://github.com/bramstein/url-template/blob/master/lib/url-template.js + // https://github.com/geraintluff/uri-templates + url = link.href; + Object.keys(params).forEach(key => { + url = url.replace(`{${key}}`, params[key]); + }); + } else { + url = link.href; + } + + return url; +} diff --git a/libs/syndesi/src/test-setup.ts b/libs/syndesi/src/test-setup.ts new file mode 100644 index 00000000..8d88704e --- /dev/null +++ b/libs/syndesi/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular'; diff --git a/libs/syndesi/tsconfig.json b/libs/syndesi/tsconfig.json new file mode 100644 index 00000000..e5decd5e --- /dev/null +++ b/libs/syndesi/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": ["**/*.ts"] +} diff --git a/libs/syndesi/tsconfig.lib.json b/libs/syndesi/tsconfig.lib.json new file mode 100644 index 00000000..3b81cfc3 --- /dev/null +++ b/libs/syndesi/tsconfig.lib.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc/libs/syndesi", + "target": "es2015", + "module": "es2015", + "moduleResolution": "node", + "declaration": true, + "sourceMap": true, + "inlineSources": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "importHelpers": true, + "types": [], + "lib": ["dom", "es2018"] + }, + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "fullTemplateTypeCheck": true, + "strictInjectionParameters": true, + "enableResourceInlining": true + }, + "exclude": ["src/test.ts", "**/*.spec.ts"] +} diff --git a/libs/syndesi/tsconfig.spec.json b/libs/syndesi/tsconfig.spec.json new file mode 100644 index 00000000..8e37b9f1 --- /dev/null +++ b/libs/syndesi/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc/libs/syndesi", + "module": "commonjs", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/libs/syndesi/tslint.json b/libs/syndesi/tslint.json new file mode 100644 index 00000000..a8eb2fb8 --- /dev/null +++ b/libs/syndesi/tslint.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tslint.json", + "rules": { + "directive-selector": [true, "attribute", "sparkles", "camelCase"], + "component-selector": [true, "element", "sparkles", "kebab-case"] + } +} diff --git a/nx.json b/nx.json index 87b4ac73..a96cc855 100644 --- a/nx.json +++ b/nx.json @@ -47,7 +47,8 @@ }, "demos-app": { "tags": [] - } + }, + "syndesi": { "tags": [] } }, "implicitDependencies": { "package.json": "*", diff --git a/tsconfig.json b/tsconfig.json index f9913105..05903a82 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,7 +32,8 @@ "@sparkles/demos/component-demos": [ "libs/demos/component-demos/src/index.ts" ], - "@sparkles/demos/app": ["libs/demos/app/src/index.ts"] + "@sparkles/demos/app": ["libs/demos/app/src/index.ts"], + "@sparkles/syndesi": ["libs/syndesi/src/index.ts"] }, "module": "es2015" }, From 165647e3bc800e31cc4296533cea8ffcd1814ea9 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 17 Feb 2019 19:29:52 +0100 Subject: [PATCH 2/5] docs(syndesi): add user guide --- libs/syndesi/README.md | 6 + libs/syndesi/src/lib/call.ts | 17 ++- libs/syndesi/src/lib/client.service.spec.ts | 15 ++ libs/syndesi/src/lib/client.service.ts | 15 +- libs/syndesi/src/lib/resources.spec.ts | 18 +++ libs/syndesi/src/lib/resources.ts | 144 +++++++++++++++++--- 6 files changed, 182 insertions(+), 33 deletions(-) create mode 100644 libs/syndesi/src/lib/client.service.spec.ts diff --git a/libs/syndesi/README.md b/libs/syndesi/README.md index 1e8a6a6c..971ee7ac 100644 --- a/libs/syndesi/README.md +++ b/libs/syndesi/README.md @@ -1,3 +1,9 @@ # Syndesi > A connected-media format. + +Syndesi is inspired by HAL, Siren, and so on. + +- http://stateless.co/hal_specification.html +- https://sookocheff.com/post/api/on-choosing-a-hypermedia-format/ +- https://tools.ietf.org/html/draft-kelly-json-hal-08 diff --git a/libs/syndesi/src/lib/call.ts b/libs/syndesi/src/lib/call.ts index 612a11b4..7bc605e0 100644 --- a/libs/syndesi/src/lib/call.ts +++ b/libs/syndesi/src/lib/call.ts @@ -4,9 +4,9 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { expand, UriParams } from './uri'; -const nextCall = (call: Call) => { +const nextCall = (call: Call) => { - return function (res: T | HttpResponse) { + return function (res: Resource | HttpResponse>) { const json = res instanceof HttpResponse ? res.body : res; const response = res instanceof HttpResponse ? res : undefined; @@ -14,7 +14,7 @@ const nextCall = (call: Call) => { }; }; -export class Call { +export class Call { private _http: HttpClient; private _uri: string; private _method: string; @@ -22,7 +22,7 @@ export class Call { constructor( http: HttpClient | Call, - public resource: T, + public resource: Resource, public response?: HttpResponse ) { if (http instanceof HttpClient) { @@ -110,7 +110,7 @@ export class Call { return this; } - public send(): Observable> { + public send(): Observable> { if (!this._method) { throw new Error('method is a required parameter! Please set it with method() or one of the short-hands like get().'); } @@ -118,16 +118,15 @@ export class Call { throw new Error('uri is a required parameter! Please set it with uri() or one of the short-hands like get().'); } - return this._http - .request( + return this._http.request>( this._method, this._uri, { ...this._options, observe: 'response', responseType: 'json' - }) - .pipe(map(nextCall(this))); + } + ).pipe(map(nextCall(this))); } private expandLink(rel: string, params?: UriParams): string { diff --git a/libs/syndesi/src/lib/client.service.spec.ts b/libs/syndesi/src/lib/client.service.spec.ts new file mode 100644 index 00000000..d941e46f --- /dev/null +++ b/libs/syndesi/src/lib/client.service.spec.ts @@ -0,0 +1,15 @@ +import { async, TestBed } from '@angular/core/testing'; +import { ClientService } from './client.service'; + +describe('SyndesiModule', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + providers: [ClientService] + }).compileComponents(); + })); + + it('should create', () => { + const service = TestBed.get(ClientService); + expect(service).toBeTruthy(); + }); +}); diff --git a/libs/syndesi/src/lib/client.service.ts b/libs/syndesi/src/lib/client.service.ts index ea989c63..34977854 100644 --- a/libs/syndesi/src/lib/client.service.ts +++ b/libs/syndesi/src/lib/client.service.ts @@ -6,7 +6,7 @@ import { Call } from './call'; import { Resource } from './resources'; @Injectable({ providedIn: 'root' }) -export class HalClient { +export class ClientService { constructor( private http: HttpClient @@ -16,13 +16,20 @@ export class HalClient { * Get the index page of the API at given `url` * * @param url URL of index resource, e.g. `/foo/bar/api.json` + * @return Emits a `Call` object for subsequent API calls */ - public index (url: string): Observable> { - return this.http.get (url).pipe( + public index (url: string): Observable> { + return this.http.get> (url).pipe( map(res => new Call(this.http, res))); } - public call (resource: T): Call { + /** + * Get a subsequent call from a resource obtained prior. + * + * @param resource + * @return A `Call` object for subsequent API calls + */ + public call (resource: Resource): Call { return new Call(this.http, resource); } diff --git a/libs/syndesi/src/lib/resources.spec.ts b/libs/syndesi/src/lib/resources.spec.ts index 49570c51..9ef3e1ea 100644 --- a/libs/syndesi/src/lib/resources.spec.ts +++ b/libs/syndesi/src/lib/resources.spec.ts @@ -16,6 +16,24 @@ describe(`Resource`, () => { expect(test.b).toEqual(123); }); + + it(`should allow to declare custom interface types`, () => { + interface A { + id: number; + } + + interface AResource extends Resource {} + + const test: AResource = { + id: 123, + _links: { + self: { href: '/user/123' } + } + }; + + expect(test.id).toEqual('123'); + expect(test._links.self.href).toEqual('/user/123'); + }); }); describe(`isLink()`, () => { diff --git a/libs/syndesi/src/lib/resources.ts b/libs/syndesi/src/lib/resources.ts index bad7430d..dbad80b8 100644 --- a/libs/syndesi/src/lib/resources.ts +++ b/libs/syndesi/src/lib/resources.ts @@ -1,8 +1,62 @@ +/** + * Link representation. + * + * @experimental + */ export interface Link { href: string; templated?: boolean; } +/** + * Base type for resource representations. + * + * A resource must be identifiable by a `self` URI. + * + * The generic type parameter may be used to extend existing type information, thus allows + * to add resource-capabilities to existing code in an unobstrusive way. + * + * ### How To Use + * + * The first approach is to declare a value with generic type information: + * + * ```ts + * export interface User { + * id: number; + * name: string; + * } + * + * const foo: Resource = { + * _links: { + * self: { href: '/user/123' } + * }, + * id: 123, + * name: 'Theo Test' + * } + * ``` + * + * The second approach is to extend existing type declarations: + * + * ```ts + * export interface User { + * id: number; + * name: string; + * } + * + * export interface UserResource extends Resource { + * } + * + * const foo: UserResource = { + * _links: { + * self: { href: '/user/123' } + * }, + * id: 123, + * name: 'Theo Test' + * } + * ``` + * + * @stable + */ export type Resource = T & { _links: { self: Link; @@ -13,22 +67,50 @@ export type Resource = T & { }; } +/** + * A resource with `_embedded` resources. + * + * @stable + */ export type ResourceWithEmbedded = Resource & { _embedded: { [key: string]: Resource | Resource[]; } }; -export interface ResourceCollection { - _embedded: { - content: Resource[]; - [key: string]: any; - } +/** + * Resource metadata for collections of resources. + * + * @stable + */ +export interface CollectionMetadata { + /** The number of items contained in this collection representation. */ + count: number; + /** The total count of items in the full collection. Optional. */ + totalCount?: number; + /** The index of this page. Optional, but required when paging is enabled. */ + page?: number; + /** The total count of pages. Optional, but required when paging is enabled. */ + totalPages?: number; + /** The number of items per each page. Optional, but required when paging is enabled. */ + pageSize?: number; } -export type ResourcesCollection = Resource & { +/** + * A collection of resources. + * + * The embedded `content` property must be an array of resources. + * Each resource must be indentifiable by a `self` URI. + * + * `ResourceCollection` itself is a resource and must be identifiable by `self` URI. + * It has metadata information from `CollectionMetadata`, such as the amount of items or paging + * information. + * + * @stable + */ +export type ResourceCollection = Resource & { _embedded: { - content: Resource[]; + content: Resource[]; [key: string]: any; } } @@ -45,30 +127,34 @@ export function isLinks(value: any): value is Link[] { return value instanceof Array && value.every(val => isLink(val)); } -export function link(rel: string, res: any, shouldThrow?: boolean): Link { +export interface LinkOpts { + shouldThrow?: boolean; +}; + +export function link(rel: string, res: any, opts?: LinkOpts): Link { if (hasLinks(res)) { - const link = res._links[rel]; + const linkForRel = res._links[rel]; - if (isLink(link)) { - return link; - } else if (shouldThrow) { + if (isLink(linkForRel)) { + return linkForRel; + } else if (opts && opts.shouldThrow) { throw new Error(`rel=${rel} is not a Link on Resource ${res}`); } - } else if (shouldThrow) { + } else if (opts && opts.shouldThrow) { throw new Error(`Object ${res} is not a Resource`); } } -export function links(rel: string, res: any, shouldThrow?: boolean): Link[] { +export function links(rel: string, res: any, opts?: LinkOpts): Link[] { if (hasLinks(res)) { - const link = res._links[rel]; + const linkForRel = res._links[rel]; - if (isLinks(link)) { - return link; - } else if (shouldThrow) { + if (isLinks(linkForRel)) { + return linkForRel; + } else if (opts && opts.shouldThrow) { throw new Error(`rel=${rel} is not an Link[] on Resource ${res}`); } - } else if (shouldThrow) { + } else if (opts && opts.shouldThrow) { throw new Error(`Object ${res} is not a Resource`); } } @@ -85,11 +171,20 @@ export function isResources(value: any): value is Resource[] { return value instanceof Array && value.every(val => isResource(val)); } -export type EmbeddedOpts = { +export interface EmbeddedOpts { guard?: (value: any) => value is T; shouldThrow?: boolean; }; +/** + * Return the embedded resource identified by relation `rel` from parent `res`. + * + * @param rel Relation name + * @param res Parent resource + * @param opts Set `shouldThrow`, if you want an error instead of an `undefined` return value + * @return Embedded resource or an `undefined` value + * @stable + */ export function embedded(rel: string, res: any, opts?: EmbeddedOpts): Resource { if (hasEmbedded(res)) { const em = res._embedded[rel]; @@ -105,6 +200,15 @@ export function embedded(rel: string, res: any, opts?: EmbeddedOpts): Reso } } +/** + * Return an array of embedded resources identified by relation `rel` from parent `res`. + * + * @param rel Relation name + * @param res Parent resource + * @param opts Set `shouldThrow`, if you want an error instead of an `undefined` return value + * @return Embedded resource or an `undefined` value + * @stable + */ export function embeddeds(rel: string, res: any, opts?: EmbeddedOpts): Resource[] { if (hasEmbedded(res)) { const em = res._embedded[rel]; From 624b71197512d059c3864c3e5387935ec62149ae Mon Sep 17 00:00:00 2001 From: David Date: Sun, 17 Feb 2019 19:39:44 +0100 Subject: [PATCH 3/5] style(syndesi): rename to `ApiClient` --- libs/syndesi/src/index.ts | 2 +- .../src/lib/api-client.service.spec.ts | 19 +++++++++++++++++++ ...lient.service.ts => api-client.service.ts} | 2 +- libs/syndesi/src/lib/call.ts | 4 ++-- libs/syndesi/src/lib/client.service.spec.ts | 15 --------------- 5 files changed, 23 insertions(+), 19 deletions(-) create mode 100644 libs/syndesi/src/lib/api-client.service.spec.ts rename libs/syndesi/src/lib/{client.service.ts => api-client.service.ts} (96%) delete mode 100644 libs/syndesi/src/lib/client.service.spec.ts diff --git a/libs/syndesi/src/index.ts b/libs/syndesi/src/index.ts index 8cbe8377..60c85647 100644 --- a/libs/syndesi/src/index.ts +++ b/libs/syndesi/src/index.ts @@ -1,5 +1,5 @@ +export * from './lib/api-client.service'; export * from './lib/call'; -export * from './lib/client.service'; export * from './lib/resources'; export * from './lib/uri'; export * from './lib/syndesi.module'; diff --git a/libs/syndesi/src/lib/api-client.service.spec.ts b/libs/syndesi/src/lib/api-client.service.spec.ts new file mode 100644 index 00000000..141668ea --- /dev/null +++ b/libs/syndesi/src/lib/api-client.service.spec.ts @@ -0,0 +1,19 @@ +import { async, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { ApiClient } from './api-client.service'; + +describe('ApiClient', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ HttpClientTestingModule ], + providers: [ ApiClient ] + }).compileComponents(); + })); + + it('should create', () => { + const service = TestBed.get(ApiClient); + expect(service).toBeTruthy(); + const backend = TestBed.get(HttpTestingController); + expect(backend).toBeTruthy(); + }); +}); diff --git a/libs/syndesi/src/lib/client.service.ts b/libs/syndesi/src/lib/api-client.service.ts similarity index 96% rename from libs/syndesi/src/lib/client.service.ts rename to libs/syndesi/src/lib/api-client.service.ts index 34977854..d564d1d1 100644 --- a/libs/syndesi/src/lib/client.service.ts +++ b/libs/syndesi/src/lib/api-client.service.ts @@ -6,7 +6,7 @@ import { Call } from './call'; import { Resource } from './resources'; @Injectable({ providedIn: 'root' }) -export class ClientService { +export class ApiClient { constructor( private http: HttpClient diff --git a/libs/syndesi/src/lib/call.ts b/libs/syndesi/src/lib/call.ts index 7bc605e0..f7864ea6 100644 --- a/libs/syndesi/src/lib/call.ts +++ b/libs/syndesi/src/lib/call.ts @@ -4,7 +4,7 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { expand, UriParams } from './uri'; -const nextCall = (call: Call) => { +const toNextCall = (call: Call) => { return function (res: Resource | HttpResponse>) { const json = res instanceof HttpResponse ? res.body : res; @@ -126,7 +126,7 @@ export class Call { observe: 'response', responseType: 'json' } - ).pipe(map(nextCall(this))); + ).pipe(map(toNextCall(this))); } private expandLink(rel: string, params?: UriParams): string { diff --git a/libs/syndesi/src/lib/client.service.spec.ts b/libs/syndesi/src/lib/client.service.spec.ts deleted file mode 100644 index d941e46f..00000000 --- a/libs/syndesi/src/lib/client.service.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { async, TestBed } from '@angular/core/testing'; -import { ClientService } from './client.service'; - -describe('SyndesiModule', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ - providers: [ClientService] - }).compileComponents(); - })); - - it('should create', () => { - const service = TestBed.get(ClientService); - expect(service).toBeTruthy(); - }); -}); From e575d11795cdbc4e9fa39028b03c590d17951f9d Mon Sep 17 00:00:00 2001 From: David Date: Sun, 17 Feb 2019 20:25:16 +0100 Subject: [PATCH 4/5] style: split into single source files --- libs/syndesi/src/index.ts | 5 +- .../src/lib/api-client.service.spec.ts | 32 +++ libs/syndesi/src/lib/api-client.service.ts | 7 +- libs/syndesi/src/lib/call.ts | 27 ++- .../src/lib/embedded.functions.spec.ts | 57 +++++ libs/syndesi/src/lib/embedded.functions.ts | 63 +++++ libs/syndesi/src/lib/link.functions.spec.ts | 76 ++++++ libs/syndesi/src/lib/link.functions.ts | 45 ++++ libs/syndesi/src/lib/resource.functions.ts | 10 + .../src/lib/resource.interfaces.spec.ts | 37 +++ libs/syndesi/src/lib/resource.interfaces.ts | 123 ++++++++++ libs/syndesi/src/lib/resources.spec.ts | 166 ------------- libs/syndesi/src/lib/resources.ts | 229 ------------------ libs/syndesi/src/lib/syndesi.module.ts | 1 + 14 files changed, 480 insertions(+), 398 deletions(-) create mode 100644 libs/syndesi/src/lib/embedded.functions.spec.ts create mode 100644 libs/syndesi/src/lib/embedded.functions.ts create mode 100644 libs/syndesi/src/lib/link.functions.spec.ts create mode 100644 libs/syndesi/src/lib/link.functions.ts create mode 100644 libs/syndesi/src/lib/resource.functions.ts create mode 100644 libs/syndesi/src/lib/resource.interfaces.spec.ts create mode 100644 libs/syndesi/src/lib/resource.interfaces.ts delete mode 100644 libs/syndesi/src/lib/resources.spec.ts delete mode 100644 libs/syndesi/src/lib/resources.ts diff --git a/libs/syndesi/src/index.ts b/libs/syndesi/src/index.ts index 60c85647..bf064d49 100644 --- a/libs/syndesi/src/index.ts +++ b/libs/syndesi/src/index.ts @@ -1,5 +1,8 @@ export * from './lib/api-client.service'; export * from './lib/call'; -export * from './lib/resources'; +export * from './lib/embedded.functions'; +export * from './lib/link.functions'; +export * from './lib/resource.interfaces'; +export * from './lib/resource.functions'; export * from './lib/uri'; export * from './lib/syndesi.module'; diff --git a/libs/syndesi/src/lib/api-client.service.spec.ts b/libs/syndesi/src/lib/api-client.service.spec.ts index 141668ea..2639e404 100644 --- a/libs/syndesi/src/lib/api-client.service.spec.ts +++ b/libs/syndesi/src/lib/api-client.service.spec.ts @@ -1,6 +1,7 @@ import { async, TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { ApiClient } from './api-client.service'; +import { Resource, ResourceCollection } from './resource.interfaces'; describe('ApiClient', () => { beforeEach(async(() => { @@ -16,4 +17,35 @@ describe('ApiClient', () => { const backend = TestBed.get(HttpTestingController); expect(backend).toBeTruthy(); }); + + describe(`call()`, () => { + interface Foo { + what: string; + } + + const foo: Resource = { + _links: { + self: { href: '/foo' } + }, + what: 'foo!' + }; + + interface FooCollectionResource extends ResourceCollection {} + + it(`should return an Observable`, () => { + const api: ApiClient = TestBed.get(ApiClient); + const obs = api.call(foo); + expect(obs).toBeTruthy(); + }); + + xit(`should...`, () => { + const api: ApiClient = TestBed.get(ApiClient); + + api.call(foo).get('next').send().subscribe(next => { + const bar = next.resource; + + expect(bar).toBeTruthy(); + }); + }); + }); }); diff --git a/libs/syndesi/src/lib/api-client.service.ts b/libs/syndesi/src/lib/api-client.service.ts index d564d1d1..4d31d8ad 100644 --- a/libs/syndesi/src/lib/api-client.service.ts +++ b/libs/syndesi/src/lib/api-client.service.ts @@ -3,8 +3,13 @@ import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { Call } from './call'; -import { Resource } from './resources'; +import { Resource } from './resource.interfaces'; +/** + * Inject `ApiClient` to start brosing a nicely crafted connected-media API. + * + * @experimental + */ @Injectable({ providedIn: 'root' }) export class ApiClient { diff --git a/libs/syndesi/src/lib/call.ts b/libs/syndesi/src/lib/call.ts index f7864ea6..c351b62f 100644 --- a/libs/syndesi/src/lib/call.ts +++ b/libs/syndesi/src/lib/call.ts @@ -1,7 +1,7 @@ import { HttpClient, HttpResponse } from '@angular/common/http'; -import { Resource } from './resources'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { Resource } from './resource.interfaces'; import { expand, UriParams } from './uri'; const toNextCall = (call: Call) => { @@ -14,6 +14,31 @@ const toNextCall = (call: Call) => { }; }; +/** + * A `Call` is a single HTTP interaction + * + * ### How To Use + * + * Inject `ApiClient` and obtain a call instance from a `Resource` obtained prior to this call: + * + * ```ts + * interface Entity { + * whatsUp: string; + * } + * + * @Injectable() + * export class EntityService { + * constructor(private api: ApiClient) + * + * fetchNextPage(res: Respurce) { + * return this.api.call(res).get('next').send<>(); + * } + * } + * ``` + * + * @param T Type declaration for a `Resource` obtained prior. + * @experimental + */ export class Call { private _http: HttpClient; private _uri: string; diff --git a/libs/syndesi/src/lib/embedded.functions.spec.ts b/libs/syndesi/src/lib/embedded.functions.spec.ts new file mode 100644 index 00000000..308e43db --- /dev/null +++ b/libs/syndesi/src/lib/embedded.functions.spec.ts @@ -0,0 +1,57 @@ +import { Resource } from './resource.interfaces'; +import { embedded, embeddeds } from './embedded.functions'; + +describe(`embedded()`, () => { + + const test: Resource = { + _links: { + self: { href: '/test' } + }, + _embedded: { + foo: { + _links: { self: { href: '/foo'} }, + value: 'first' + }, + bar: { + _links: { self: { href: '/bar'} }, + value: 'second' + }, + } + }; + + it(`should return Resource for rel`, () => { + const r = embedded('foo', test); + expect(r).toBeTruthy(); + expect(r.value).toEqual('first'); + + const r2 = embedded('bar', test); + expect(r2).toBeTruthy(); + expect(r2.value).toEqual('second'); + }); +}); + +describe(`embeddeds()`, () => { + + const test: Resource = { + _links: { + self: { href: '/test' } + }, + _embedded: { + foo: [ + { + _links: { self: { href: '/foo'} }, + value: 'first' + }, + { + _links: { self: { href: '/bar'} }, + value: 'second' + } + ] + } + }; + + it(`should return Resource[] for rel`, () => { + const r = embeddeds('foo', test); + expect(r.length).toEqual(2); + }); +}); diff --git a/libs/syndesi/src/lib/embedded.functions.ts b/libs/syndesi/src/lib/embedded.functions.ts new file mode 100644 index 00000000..7f9eb614 --- /dev/null +++ b/libs/syndesi/src/lib/embedded.functions.ts @@ -0,0 +1,63 @@ +import { Resource, ResourceWithEmbedded } from './resource.interfaces'; +import { isResource, isResources } from './resource.functions'; + +export function hasEmbedded(value: any): value is ResourceWithEmbedded { + return value && typeof value._embedded === 'object'; +} + +export interface EmbeddedOpts { + guard?: (value: any) => value is T; + shouldThrow?: boolean; +}; + +/** + * Return the embedded resource identified by relation `rel` from parent `res`. + * + * @param rel Relation name + * @param res Parent resource + * @param opts Set `shouldThrow`, if you want an error instead of an `undefined` return value + * @return Embedded resource or an `undefined` value + * @stable + */ +export function embedded(rel: string, res: any, opts?: EmbeddedOpts): Resource { + if (hasEmbedded(res)) { + const em = res._embedded[rel]; + const guard = opts && opts.guard ? opts.guard : (v: T): v is T => true; + + if (isResource(em) && guard(em)) { + return em; + } else if (opts && opts.shouldThrow) { + throw new Error(`rel=${rel} is not an embedded Resource on ${res}`); + } + } else if (opts && opts.shouldThrow) { + throw new Error(`${res} has no _embedded resources`); + } +} + +/** + * Return an array of embedded resources identified by relation `rel` from parent `res`. + * + * @param rel Relation name + * @param res Parent resource + * @param opts Set `shouldThrow`, if you want an error instead of an `undefined` return value + * @return Embedded resource or an `undefined` value + * @stable + */ +export function embeddeds(rel: string, res: any, opts?: EmbeddedOpts): Resource[] { + if (hasEmbedded(res)) { + const em = res._embedded[rel]; + const guard = opts && opts.guard ? opts.guard : (v: T): v is T => true; + + const isArrayOfT = (value: any): value is T[] => { + return value instanceof Array && value.every(val => guard(val)); + }; + + if (isResources(em) && isArrayOfT(em)) { + return em; + } else if (opts && opts.shouldThrow) { + throw new Error(`rel=${rel} is not an embedded Resource[] on ${res}`); + } + } else if (opts && opts.shouldThrow) { + throw new Error(`${res} has no _embedded resources`); + } +} diff --git a/libs/syndesi/src/lib/link.functions.spec.ts b/libs/syndesi/src/lib/link.functions.spec.ts new file mode 100644 index 00000000..fbd3a428 --- /dev/null +++ b/libs/syndesi/src/lib/link.functions.spec.ts @@ -0,0 +1,76 @@ +import { Resource } from './resource.interfaces'; +import { isLink, isLinks, links, link } from './link.functions'; + +describe(`isLink()`, () => { + + it(`should return true when property 'href' is string`, () => { + const foo = { href: '/bar' }; + expect(isLink(foo)).toBeTruthy(); + }); + + it(`should return false for undefined values`, () => { + expect(isLink(undefined)).toBeFalsy(); + expect(isLink(null)).toBeFalsy(); + }); + + it(`should return false for quirks`, () => { + expect(isLink([])).toBeFalsy(); + expect(isLink({})).toBeFalsy(); + }); + + it(`should be a type guard for Link`, () => { + const a = { + href: '/foo' + }; + + expect(isLink(a)).toBeTruthy(); + if (isLink(a)) { + expect(a.href).toEqual('/foo'); + } else { + fail('isLink() should return true for Link interface'); + } + }); +}); + +describe(`isLinks()`, () => { + + const foo = { href: '/foo' }; + const bar = { href: '/bar' }; + + it(`should return true for array of Link`, () => { + expect(isLinks([foo, bar])).toBeTruthy(); + }); +}); + +describe(`link()`, () => { + + const test: Resource = { + _links: { + self: { href: '/test' }, + foo: { href: '/bar' } + } + }; + + it(`should return Link for rel`, () => { + const l = link('self', test); + expect(l.href).toEqual('/test'); + }); +}); + +describe(`links()`, () => { + + const test: Resource = { + _links: { + self: { href: '/test' }, + foo: [ + { href: '/bar' }, + { href: '/bar1' } + ] + } + }; + + it(`should return Link for rel`, () => { + const l = links('foo', test); + expect(l.length).toEqual(2); + }); +}); diff --git a/libs/syndesi/src/lib/link.functions.ts b/libs/syndesi/src/lib/link.functions.ts new file mode 100644 index 00000000..33bbb60e --- /dev/null +++ b/libs/syndesi/src/lib/link.functions.ts @@ -0,0 +1,45 @@ +import { Resource, Link } from './resource.interfaces'; + +export function hasLinks(value: any): value is Resource { + return value && value._links; +} + +export function isLink(value: any): value is Link { + return value && typeof value.href === 'string'; +} + +export function isLinks(value: any): value is Link[] { + return value instanceof Array && value.every(val => isLink(val)); +} + +export interface LinkOpts { + shouldThrow?: boolean; +}; + +export function link(rel: string, res: any, opts?: LinkOpts): Link { + if (hasLinks(res)) { + const linkForRel = res._links[rel]; + + if (isLink(linkForRel)) { + return linkForRel; + } else if (opts && opts.shouldThrow) { + throw new Error(`rel=${rel} is not a Link on Resource ${res}`); + } + } else if (opts && opts.shouldThrow) { + throw new Error(`Object ${res} is not a Resource`); + } +} + +export function links(rel: string, res: any, opts?: LinkOpts): Link[] { + if (hasLinks(res)) { + const linkForRel = res._links[rel]; + + if (isLinks(linkForRel)) { + return linkForRel; + } else if (opts && opts.shouldThrow) { + throw new Error(`rel=${rel} is not an Link[] on Resource ${res}`); + } + } else if (opts && opts.shouldThrow) { + throw new Error(`Object ${res} is not a Resource`); + } +} diff --git a/libs/syndesi/src/lib/resource.functions.ts b/libs/syndesi/src/lib/resource.functions.ts new file mode 100644 index 00000000..5813a0ce --- /dev/null +++ b/libs/syndesi/src/lib/resource.functions.ts @@ -0,0 +1,10 @@ +import { Resource } from './resource.interfaces'; +import { hasLinks, isLink } from './link.functions'; + +export function isResource(value: any): value is Resource { + return hasLinks(value) && isLink(value._links.self); +} + +export function isResources(value: any): value is Resource[] { + return value instanceof Array && value.every(val => isResource(val)); +} diff --git a/libs/syndesi/src/lib/resource.interfaces.spec.ts b/libs/syndesi/src/lib/resource.interfaces.spec.ts new file mode 100644 index 00000000..5b6a68a3 --- /dev/null +++ b/libs/syndesi/src/lib/resource.interfaces.spec.ts @@ -0,0 +1,37 @@ +import { Resource } from './resource.interfaces'; + +describe(`Resource`, () => { + + it(`should extend existing interface types`, () => { + interface A { + b: number; + } + + const test: Resource = { + _links: { + self: { href: '/test' } + }, + b: 123 + }; + + expect(test.b).toEqual(123); + }); + + it(`should allow to declare custom interface types`, () => { + interface A { + id: number; + } + + interface AResource extends Resource {} + + const test: AResource = { + id: 123, + _links: { + self: { href: '/user/123' } + } + }; + + expect(test.id).toEqual(123); + expect(test._links.self.href).toEqual('/user/123'); + }); +}); diff --git a/libs/syndesi/src/lib/resource.interfaces.ts b/libs/syndesi/src/lib/resource.interfaces.ts new file mode 100644 index 00000000..37004996 --- /dev/null +++ b/libs/syndesi/src/lib/resource.interfaces.ts @@ -0,0 +1,123 @@ +/** + * Link representation. + * + * @experimental + */ +export interface Link { + href: string; + templated?: boolean; +} + +/** + * Base type for resource representations. + * + * A resource must be identifiable by a `self` URI. + * + * The generic type parameter may be used to extend existing type information, thus allows + * to add resource-capabilities to existing code in an unobstrusive way. + * + * ### How To Use + * + * The first approach is to declare a value with generic type information: + * + * ```ts + * export interface User { + * id: number; + * name: string; + * } + * + * const foo: Resource = { + * _links: { + * self: { href: '/user/123' } + * }, + * id: 123, + * name: 'Theo Test' + * } + * ``` + * + * The second approach is to extend existing type declarations: + * + * ```ts + * export interface User { + * id: number; + * name: string; + * } + * + * export interface UserResource extends Resource { + * } + * + * const foo: UserResource = { + * _links: { + * self: { href: '/user/123' } + * }, + * id: 123, + * name: 'Theo Test' + * } + * ``` + * + * @stable + */ +export type Resource = T & { + _links: { + self: Link; + [key: string]: Link | Link[]; + }; + _embedded?: { + [key: string]: Resource | Resource[]; + }; +} + +/** + * A resource with `_embedded` resources. + * + * @stable + */ +export type ResourceWithEmbedded = Resource & { + _embedded: { + [key: string]: Resource | Resource[]; + } +}; + +/** + * Resource metadata for collections of resources. + * + * @stable + */ +export interface CollectionMetadata { + /** The number of items contained in this collection representation. */ + count: number; + /** The total count of items in the full collection. Optional. */ + totalCount?: number; + /** The index of this page. Optional, but required when paging is enabled. */ + page?: number; + /** The total count of pages. Optional, but required when paging is enabled. */ + totalPages?: number; + /** The number of items per each page. Optional, but required when paging is enabled. */ + pageSize?: number; +} + +/** + * A collection of resources. + * + * The embedded `content` property must be an array of resources. + * Each resource must be indentifiable by a `self` URI. + * + * `ResourceCollection` itself is a resource and must be identifiable by `self` URI. + * It has metadata information from `CollectionMetadata`, such as the amount of items or paging + * information. + * + * @stable + */ +export type ResourceCollection = Resource & { + _embedded: { + content: Resource[]; + [key: string]: any; + } +} + +/** + * A resource collection with simple collection metadata. + * + * @stable + */ +export interface ResourcesCollection extends ResourceCollection {} diff --git a/libs/syndesi/src/lib/resources.spec.ts b/libs/syndesi/src/lib/resources.spec.ts deleted file mode 100644 index 9ef3e1ea..00000000 --- a/libs/syndesi/src/lib/resources.spec.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Resource, Link, isLink, isLinks, link, links, embedded, embeddeds } from './resources'; - -describe(`Resource`, () => { - - it(`should extend existing interface types`, () => { - interface A { - b: number; - } - - const test: Resource = { - _links: { - self: { href: '/test' } - }, - b: 123 - }; - - expect(test.b).toEqual(123); - }); - - it(`should allow to declare custom interface types`, () => { - interface A { - id: number; - } - - interface AResource extends Resource {} - - const test: AResource = { - id: 123, - _links: { - self: { href: '/user/123' } - } - }; - - expect(test.id).toEqual('123'); - expect(test._links.self.href).toEqual('/user/123'); - }); -}); - -describe(`isLink()`, () => { - - it(`should return true when property 'href' is string`, () => { - const foo = { href: '/bar' }; - expect(isLink(foo)).toBeTruthy(); - }); - - it(`should return false for undefined values`, () => { - expect(isLink(undefined)).toBeFalsy(); - expect(isLink(null)).toBeFalsy(); - }); - - it(`should return false for quirks`, () => { - expect(isLink([])).toBeFalsy(); - expect(isLink({})).toBeFalsy(); - }); - - it(`should be a type guard for Link`, () => { - const a = { - href: '/foo' - }; - - expect(isLink(a)).toBeTruthy(); - if (isLink(a)) { - expect(a.href).toEqual('/foo'); - } else { - fail('isLink() should return true for Link interface'); - } - }); -}); - -describe(`isLinks()`, () => { - - const foo = { href: '/foo' }; - const bar = { href: '/bar' }; - - it(`should return true for array of Link`, () => { - expect(isLinks([foo, bar])).toBeTruthy(); - }); -}); - -describe(`link()`, () => { - - const test: Resource = { - _links: { - self: { href: '/test' }, - foo: { href: '/bar' } - } - }; - - it(`should return Link for rel`, () => { - const l = link('self', test); - expect(l.href).toEqual('/test'); - }); -}); - -describe(`links()`, () => { - - const test: Resource = { - _links: { - self: { href: '/test' }, - foo: [ - { href: '/bar' }, - { href: '/bar1' } - ] - } - }; - - it(`should return Link for rel`, () => { - const l = links('foo', test); - expect(l.length).toEqual(2); - }); -}); - -describe(`embedded()`, () => { - - const test: Resource = { - _links: { - self: { href: '/test' } - }, - _embedded: { - foo: { - _links: { self: { href: '/foo'} }, - value: 'first' - }, - bar: { - _links: { self: { href: '/bar'} }, - value: 'second' - }, - } - }; - - it(`should return Resource for rel`, () => { - const r = embedded('foo', test); - expect(r).toBeTruthy(); - expect(r.value).toEqual('first'); - - const r2 = embedded('bar', test); - expect(r2).toBeTruthy(); - expect(r2.value).toEqual('second'); - }); -}); - -describe(`embeddeds()`, () => { - - const test: Resource = { - _links: { - self: { href: '/test' } - }, - _embedded: { - foo: [ - { - _links: { self: { href: '/foo'} }, - value: 'first' - }, - { - _links: { self: { href: '/bar'} }, - value: 'second' - } - ] - } - }; - - it(`should return Resource[] for rel`, () => { - const r = embeddeds('foo', test); - expect(r.length).toEqual(2); - }); -}); diff --git a/libs/syndesi/src/lib/resources.ts b/libs/syndesi/src/lib/resources.ts deleted file mode 100644 index dbad80b8..00000000 --- a/libs/syndesi/src/lib/resources.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * Link representation. - * - * @experimental - */ -export interface Link { - href: string; - templated?: boolean; -} - -/** - * Base type for resource representations. - * - * A resource must be identifiable by a `self` URI. - * - * The generic type parameter may be used to extend existing type information, thus allows - * to add resource-capabilities to existing code in an unobstrusive way. - * - * ### How To Use - * - * The first approach is to declare a value with generic type information: - * - * ```ts - * export interface User { - * id: number; - * name: string; - * } - * - * const foo: Resource = { - * _links: { - * self: { href: '/user/123' } - * }, - * id: 123, - * name: 'Theo Test' - * } - * ``` - * - * The second approach is to extend existing type declarations: - * - * ```ts - * export interface User { - * id: number; - * name: string; - * } - * - * export interface UserResource extends Resource { - * } - * - * const foo: UserResource = { - * _links: { - * self: { href: '/user/123' } - * }, - * id: 123, - * name: 'Theo Test' - * } - * ``` - * - * @stable - */ -export type Resource = T & { - _links: { - self: Link; - [key: string]: Link | Link[]; - }; - _embedded?: { - [key: string]: Resource | Resource[]; - }; -} - -/** - * A resource with `_embedded` resources. - * - * @stable - */ -export type ResourceWithEmbedded = Resource & { - _embedded: { - [key: string]: Resource | Resource[]; - } -}; - -/** - * Resource metadata for collections of resources. - * - * @stable - */ -export interface CollectionMetadata { - /** The number of items contained in this collection representation. */ - count: number; - /** The total count of items in the full collection. Optional. */ - totalCount?: number; - /** The index of this page. Optional, but required when paging is enabled. */ - page?: number; - /** The total count of pages. Optional, but required when paging is enabled. */ - totalPages?: number; - /** The number of items per each page. Optional, but required when paging is enabled. */ - pageSize?: number; -} - -/** - * A collection of resources. - * - * The embedded `content` property must be an array of resources. - * Each resource must be indentifiable by a `self` URI. - * - * `ResourceCollection` itself is a resource and must be identifiable by `self` URI. - * It has metadata information from `CollectionMetadata`, such as the amount of items or paging - * information. - * - * @stable - */ -export type ResourceCollection = Resource & { - _embedded: { - content: Resource[]; - [key: string]: any; - } -} - -export function hasLinks(value: any): value is Resource { - return value && value._links; -} - -export function isLink(value: any): value is Link { - return value && typeof value.href === 'string'; -} - -export function isLinks(value: any): value is Link[] { - return value instanceof Array && value.every(val => isLink(val)); -} - -export interface LinkOpts { - shouldThrow?: boolean; -}; - -export function link(rel: string, res: any, opts?: LinkOpts): Link { - if (hasLinks(res)) { - const linkForRel = res._links[rel]; - - if (isLink(linkForRel)) { - return linkForRel; - } else if (opts && opts.shouldThrow) { - throw new Error(`rel=${rel} is not a Link on Resource ${res}`); - } - } else if (opts && opts.shouldThrow) { - throw new Error(`Object ${res} is not a Resource`); - } -} - -export function links(rel: string, res: any, opts?: LinkOpts): Link[] { - if (hasLinks(res)) { - const linkForRel = res._links[rel]; - - if (isLinks(linkForRel)) { - return linkForRel; - } else if (opts && opts.shouldThrow) { - throw new Error(`rel=${rel} is not an Link[] on Resource ${res}`); - } - } else if (opts && opts.shouldThrow) { - throw new Error(`Object ${res} is not a Resource`); - } -} - -export function hasEmbedded(value: any): value is ResourceWithEmbedded { - return value && typeof value._embedded === 'object'; -} - -export function isResource(value: any): value is Resource { - return hasLinks(value) && isLink(value._links.self); -} - -export function isResources(value: any): value is Resource[] { - return value instanceof Array && value.every(val => isResource(val)); -} - -export interface EmbeddedOpts { - guard?: (value: any) => value is T; - shouldThrow?: boolean; -}; - -/** - * Return the embedded resource identified by relation `rel` from parent `res`. - * - * @param rel Relation name - * @param res Parent resource - * @param opts Set `shouldThrow`, if you want an error instead of an `undefined` return value - * @return Embedded resource or an `undefined` value - * @stable - */ -export function embedded(rel: string, res: any, opts?: EmbeddedOpts): Resource { - if (hasEmbedded(res)) { - const em = res._embedded[rel]; - const guard = opts && opts.guard ? opts.guard : (v: T): v is T => true; - - if (isResource(em) && guard(em)) { - return em; - } else if (opts && opts.shouldThrow) { - throw new Error(`rel=${rel} is not an embedded Resource on ${res}`); - } - } else if (opts && opts.shouldThrow) { - throw new Error(`${res} has no _embedded resources`); - } -} - -/** - * Return an array of embedded resources identified by relation `rel` from parent `res`. - * - * @param rel Relation name - * @param res Parent resource - * @param opts Set `shouldThrow`, if you want an error instead of an `undefined` return value - * @return Embedded resource or an `undefined` value - * @stable - */ -export function embeddeds(rel: string, res: any, opts?: EmbeddedOpts): Resource[] { - if (hasEmbedded(res)) { - const em = res._embedded[rel]; - const guard = opts && opts.guard ? opts.guard : (v: T): v is T => true; - - const isArrayOfT = (value: any): value is T[] => { - return value instanceof Array && value.every(val => guard(val)); - }; - - if (isResources(em) && isArrayOfT(em)) { - return em; - } else if (opts && opts.shouldThrow) { - throw new Error(`rel=${rel} is not an embedded Resource[] on ${res}`); - } - } else if (opts && opts.shouldThrow) { - throw new Error(`${res} has no _embedded resources`); - } -} diff --git a/libs/syndesi/src/lib/syndesi.module.ts b/libs/syndesi/src/lib/syndesi.module.ts index ae98c0b2..9bf2553e 100644 --- a/libs/syndesi/src/lib/syndesi.module.ts +++ b/libs/syndesi/src/lib/syndesi.module.ts @@ -1,6 +1,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +/** @experimental */ @NgModule({ imports: [CommonModule] }) From 8601caea336a2d12d8b9597abe434736d4d34702 Mon Sep 17 00:00:00 2001 From: dherges Date: Mon, 18 Feb 2019 08:17:48 +0100 Subject: [PATCH 5/5] fix(syndesi): try to keep type declaration for resource metadata --- libs/syndesi/src/lib/call.ts | 5 +++-- libs/syndesi/src/lib/embedded.functions.ts | 2 +- libs/syndesi/src/lib/link.functions.ts | 20 +++++++++++++++++++- libs/syndesi/src/lib/resource.interfaces.ts | 4 +++- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/libs/syndesi/src/lib/call.ts b/libs/syndesi/src/lib/call.ts index c351b62f..0760e571 100644 --- a/libs/syndesi/src/lib/call.ts +++ b/libs/syndesi/src/lib/call.ts @@ -1,7 +1,7 @@ import { HttpClient, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { Resource } from './resource.interfaces'; +import { Resource, ResourceMetadata } from './resource.interfaces'; import { expand, UriParams } from './uri'; const toNextCall = (call: Call) => { @@ -15,7 +15,7 @@ const toNextCall = (call: Call) => { }; /** - * A `Call` is a single HTTP interaction + * A `Call` is a single HTTP interaction for exchanging a resource between client and server. * * ### How To Use * @@ -45,6 +45,7 @@ export class Call { private _method: string; private _options = {}; + // TODO: public resource: T & ResourceMetadata constructor( http: HttpClient | Call, public resource: Resource, diff --git a/libs/syndesi/src/lib/embedded.functions.ts b/libs/syndesi/src/lib/embedded.functions.ts index 7f9eb614..9e6b7cf4 100644 --- a/libs/syndesi/src/lib/embedded.functions.ts +++ b/libs/syndesi/src/lib/embedded.functions.ts @@ -8,7 +8,7 @@ export function hasEmbedded(value: any): value is ResourceWithEmbedded { export interface EmbeddedOpts { guard?: (value: any) => value is T; shouldThrow?: boolean; -}; +} /** * Return the embedded resource identified by relation `rel` from parent `res`. diff --git a/libs/syndesi/src/lib/link.functions.ts b/libs/syndesi/src/lib/link.functions.ts index 33bbb60e..d0a88847 100644 --- a/libs/syndesi/src/lib/link.functions.ts +++ b/libs/syndesi/src/lib/link.functions.ts @@ -14,8 +14,17 @@ export function isLinks(value: any): value is Link[] { export interface LinkOpts { shouldThrow?: boolean; -}; +} +/** + * Return the link identified by relation `rel` from resource `res`. + * + * @param rel Relation name + * @param res Parent resource + * @param opts Set `shouldThrow`, if you want an error instead of an `undefined` return value + * @return An object of type `Link` or an `undefined` value + * @stable + */ export function link(rel: string, res: any, opts?: LinkOpts): Link { if (hasLinks(res)) { const linkForRel = res._links[rel]; @@ -30,6 +39,15 @@ export function link(rel: string, res: any, opts?: LinkOpts): Link { } } +/** + * Return an array of links identified by relation `rel` from resource `res`. + * + * @param rel Relation name + * @param res Parent resource + * @param opts Set `shouldThrow`, if you want an error instead of an `undefined` return value + * @return Array of `Link` or an `undefined` value + * @stable + */ export function links(rel: string, res: any, opts?: LinkOpts): Link[] { if (hasLinks(res)) { const linkForRel = res._links[rel]; diff --git a/libs/syndesi/src/lib/resource.interfaces.ts b/libs/syndesi/src/lib/resource.interfaces.ts index 37004996..e4d0df6d 100644 --- a/libs/syndesi/src/lib/resource.interfaces.ts +++ b/libs/syndesi/src/lib/resource.interfaces.ts @@ -57,7 +57,9 @@ export interface Link { * * @stable */ -export type Resource = T & { +export type Resource = T & ResourceMetadata; + +export interface ResourceMetadata { _links: { self: Link; [key: string]: Link | Link[];