Skip to content

Commit

Permalink
Merge pull request #165 from badgateway/collection-json
Browse files Browse the repository at this point in the history
Support for the Collection+JSON format
  • Loading branch information
evert authored Jan 5, 2020
2 parents 622ffb9 + 42fe190 commit 988ad21
Show file tree
Hide file tree
Showing 6 changed files with 331 additions and 3 deletions.
6 changes: 3 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ The Ketting library is an attempt at creating a 'generic' hypermedia client, it
supports an opinionated set of modern features REST services might have.

The library supports [HAL][hal], [JSON:API][jsonapi], [Siren][siren],
[Web Linking (HTTP Link Header)][1] and HTML5 links. It uses the Fetch API and
works both in the browsers and in node.js.
[Collection+JSON][coljson], [Web Linking (HTTP Link Header)][1] and HTML5
links. It uses the Fetch API and works both in the browsers and in node.js.

### Example

Expand Down Expand Up @@ -167,6 +167,6 @@ property is provided).
[jsonapi]: https://jsonapi.org/
[problem]: https://tools.ietf.org/html/rfc7807
[siren]: https://github.com/kevinswiber/siren "Structured Interface for Representing Entities"

[coljson]: http://amundsen.com/media-types/collection/format/
[prefer-push]: https://tools.ietf.org/html/draft-pot-prefer-push
[prefer-transclude]: https://github.com/inadarei/draft-prefer-transclude/blob/master/draft.md
141 changes: 141 additions & 0 deletions src/representor/collection-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import Link from '../link';
import Representation from './base';

type CjDocument = {
collection: CjCollection,
};

type CjCollection = {
version?: string,
href?: string,
links?: CjLink[],
items?: CjItem[],
queries?: CjQuery[],
template?: CjTemplate,
error?: CjError
};

type CjError = {
title?: string,
code?: string,
message?: string,
};

type CjTemplate = {
data?: CjProperty[]
};

type CjItem = {
href?: string,
data?: CjProperty[],
links?: CjLink[],
};

type CjProperty = {
name: string,
value?: string,
prompt?: string
};

type CjQuery = {
href: string,
rel: string,
name?: string,
prompt?: string,
data?: CjProperty[]
};

type CjLink = {
href: string,
rel: string,
name?: string,
render?: 'image' | 'link',
prompt?: string
};

/**
* The Representation class is basically a 'body' of a request
* or response.
*
* This class is for the Collection+JSON format, defined here:
*
* http://amundsen.com/media-types/collection/format/#object-collection
*/
export default class CollectionJson extends Representation<CjDocument> {

parse(body: string): CjDocument {

return JSON.parse(body);

}

parseLinks(body: CjDocument): Link[] {

const result: Link[] = [];
if (body.collection.links !== undefined) {

// Lets start with all links from the links property.
for (const link of body.collection.links) {
result.push(new Link({
context: this.uri,
href: link.href,
rel: link.rel,
title: link.name,
}));
}
}

if (body.collection.items !== undefined) {

// Things that are in the 'items' array should also be considered links
// with the 'item' link relationship.
for (const item of body.collection.items) {

if (!item.href) {
continue;
}

result.push(new Link({
context: this.uri,
href: item.href,
rel: 'item',
}));
}

}


if (body.collection.queries !== undefined) {

// Things that are in the 'queries' array can be considered links too.
for (const query of body.collection.queries) {

if (!query.data) {
// Non-templated
result.push(new Link({
context: this.uri,
href: query.href,
rel: query.rel,
title: query.name,
}));
} else {
// This query has a data property so we need 50% more magic
result.push(new Link({
context: this.uri,
href: query.href + query.data.map(
property => '{?' + property.name + '}'
).join(''),
templated: true,
rel: query.rel,
title: query.name,
}));
}
}

}

return result;

}

}
8 changes: 8 additions & 0 deletions src/representor/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as LinkHeader from 'http-link-header';
import { Link, LinkSet } from '../link';
import { ContentType } from '../types';
import Representor from './base';
import CollectionJsonRepresentor from './collection-json';
import HalRepresentor from './hal';
import HtmlRepresentor from './html';
import JsonApiRepresentor from './jsonapi';
Expand Down Expand Up @@ -29,6 +30,11 @@ export default class RepresentorHelper {
representor: 'siren',
q: '0.9',
},
{
mime: 'application/vnd.collection+json',
representor: 'collection-json',
q: '0.9',
},
{
mime: 'application/json',
representor: 'hal',
Expand Down Expand Up @@ -79,6 +85,8 @@ export default class RepresentorHelper {
return new JsonApiRepresentor(uri, contentType, body, headerLinks);
case 'siren' :
return new SirenRepresentor(uri, contentType, body, headerLinks);
case 'collection-json' :
return new CollectionJsonRepresentor(uri, contentType, body, headerLinks);
default :
throw new Error('Unknown representor: ' + type);
}
Expand Down
1 change: 1 addition & 0 deletions test/integration/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe('Issuing a GET request', async () => {
'application/hal+json;q=1.0',
'application/vnd.api+json;q=0.9',
'application/vnd.siren+json;q=0.9',
'application/vnd.collection+json;q=0.9',
'application/json;q=0.8',
'text/html;q=0.7',
];
Expand Down
169 changes: 169 additions & 0 deletions test/unit/representor/collection-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { expect } from 'chai';
import Link from '../../../src/link';
import CollectionJson from '../../../src/representor/collection-json';

describe('collection+json representor', () => {

it('should parse the "Minimal Representation" example', () => {

const exampleObj = { "collection" :
{
"version" : "1.0",

"href" : "http://example.org/friends/"
}
};

const cj = new CollectionJson(
'http://example.org/friends/',
'application/vnd.collection+json',
JSON.stringify(exampleObj),
new Map()
);
expect(cj.getLinks()).to.eql([]);

});

it('should parse the "Collection Representation" example', () => {

const exampleObj = { "collection" :
{
"version" : "1.0",
"href" : "http://example.org/friends/",

"links" : [
{"rel" : "feed", "href" : "http://example.org/friends/rss"}
],

"items" : [
{
"href" : "http://example.org/friends/jdoe",
"data" : [
{"name" : "full-name", "value" : "J. Doe", "prompt" : "Full Name"},
{"name" : "email", "value" : "[email protected]", "prompt" : "Email"}
],
"links" : [
{"rel" : "blog", "href" : "http://examples.org/blogs/jdoe", "prompt" : "Blog"},
{"rel" : "avatar", "href" : "http://examples.org/images/jdoe", "prompt" : "Avatar", "render" : "image"}
]
},

{
"href" : "http://example.org/friends/msmith",
"data" : [
{"name" : "full-name", "value" : "M. Smith", "prompt" : "Full Name"},
{"name" : "email", "value" : "[email protected]", "prompt" : "Email"}
],
"links" : [
{"rel" : "blog", "href" : "http://examples.org/blogs/msmith", "prompt" : "Blog"},
{"rel" : "avatar", "href" : "http://examples.org/images/msmith", "prompt" : "Avatar", "render" : "image"}
]
},

{
"href" : "http://example.org/friends/rwilliams",
"data" : [
{"name" : "full-name", "value" : "R. Williams", "prompt" : "Full Name"},
{"name" : "email", "value" : "[email protected]", "prompt" : "Email"}
],
"links" : [
{"rel" : "blog", "href" : "http://examples.org/blogs/rwilliams", "prompt" : "Blog"},
{"rel" : "avatar", "href" : "http://examples.org/images/rwilliams", "prompt" : "Avatar", "render" : "image"}
]
}
],

"queries" : [
{"rel" : "search", "href" : "http://example.org/friends/search", "prompt" : "Search",
"data" : [
{"name" : "search", "value" : ""}
]
},
{"rel" : "get-new", "href" : "http://example.org/friends/new", "prompt" : "New friends" },
],

"template" : {
"data" : [
{"name" : "full-name", "value" : "", "prompt" : "Full Name"},
{"name" : "email", "value" : "", "prompt" : "Email"},
{"name" : "blog", "value" : "", "prompt" : "Blog"},
{"name" : "avatar", "value" : "", "prompt" : "Avatar"}

]
}
}
};

const cj = new CollectionJson(
'http://example.org/friends/',
'application/vnd.collection+json',
JSON.stringify(exampleObj),
new Map()
);
expect(cj.getLinks()).to.eql([
new Link({
rel: 'feed',
href: 'http://example.org/friends/rss',
context: 'http://example.org/friends/',
}),
new Link({
rel: 'item',
href: 'http://example.org/friends/jdoe',
context: 'http://example.org/friends/',
}),
new Link({
rel: 'item',
href: 'http://example.org/friends/msmith',
context: 'http://example.org/friends/',
}),
new Link({
rel: 'item',
href: 'http://example.org/friends/rwilliams',
context: 'http://example.org/friends/',
}),
new Link({
rel: 'search',
href: 'http://example.org/friends/search{?search}',
templated: true,
context: 'http://example.org/friends/',
}),
new Link({
rel: 'get-new',
href: 'http://example.org/friends/new',
context: 'http://example.org/friends/',
}),
]);

});

it('should correctly handle edge-cases', () => {

const exampleObj = { "collection" :
{
"version" : "1.0",
"href" : "http://example.org/friends/",
"items" : [
{
"data" : [
{"name" : "full-name", "value" : "J. Doe", "prompt" : "Full Name"},
{"name" : "email", "value" : "[email protected]", "prompt" : "Email"}
],
"links" : [
{"rel" : "blog", "href" : "http://examples.org/blogs/jdoe", "prompt" : "Blog"},
{"rel" : "avatar", "href" : "http://examples.org/images/jdoe", "prompt" : "Avatar", "render" : "image"}
]
},
],
}
};

const cj = new CollectionJson(
'http://example.org/friends/',
'application/vnd.collection+json',
JSON.stringify(exampleObj),
new Map()
);
expect(cj.getLinks()).to.eql([]);

});
});
9 changes: 9 additions & 0 deletions test/unit/representor/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Ketting from '../../../src/ketting';
import Hal from '../../../src/representor/hal';
import Html from '../../../src/representor/html';
import Siren from '../../../src/representor/siren';
import CollectionJson from '../../../src/representor/collection-json';
import Helper from '../../../src/representor/helper';

describe('Representor Helper', () => {
Expand Down Expand Up @@ -34,6 +35,14 @@ describe('Representor Helper', () => {

});

it('should return a collection+json representor when requested', () => {

const helper = new Helper([]);
const representor = helper.create('/foo', 'application/vnd.collection+json', null, new Map());
expect(representor).to.be.instanceof(CollectionJson);

});

it('should throw an error when an unknown representor was requested ', () => {

const helper = new Helper([]);
Expand Down

0 comments on commit 988ad21

Please sign in to comment.