Skip to content

Commit e5c96c3

Browse files
authored
Merge pull request #17 from mqchau/JsonApiLink
Support links as indicated in JSON:API specification
2 parents 800a2ed + 97a478c commit e5c96c3

File tree

8 files changed

+173
-3
lines changed

8 files changed

+173
-3
lines changed

src/decorators.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,27 @@ const AttrDecoratorFactory: {
165165
}
166166
}
167167

168+
const LinkDecoratorFactory = function(
169+
fieldDetail?: FieldDecoratorDescriptor
170+
): any {
171+
const trackLink = (Model: typeof SpraypaintBase, propKey: string) => {
172+
ensureModelInheritance(Model)
173+
Model.linkList.push(propKey)
174+
}
175+
176+
if (isModernDecoratorDescriptor(fieldDetail)) {
177+
return Object.assign(fieldDetail, {
178+
finisher: (Model: typeof SpraypaintBase) => {
179+
trackLink(Model, fieldDetail.key)
180+
}
181+
})
182+
} else {
183+
return (target: SpraypaintBase, propKey: string) => {
184+
trackLink(<any>target.constructor, propKey)
185+
}
186+
}
187+
}
188+
168189
const ensureModelInheritance = (ModelClass: typeof SpraypaintBase) => {
169190
if (ModelClass.currentClass !== ModelClass) {
170191
ModelClass.currentClass.inherited(ModelClass)
@@ -319,6 +340,7 @@ const BelongsToDecoratorFactory = AssociationDecoratorFactoryBuilder(BelongsTo)
319340
export {
320341
ModelDecorator as Model,
321342
AttrDecoratorFactory as Attr,
343+
LinkDecoratorFactory as Link,
322344
HasManyDecoratorFactory as HasMany,
323345
HasOneDecoratorFactory as HasOne,
324346
BelongsToDecoratorFactory as BelongsTo

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export { Attribute, attr } from "./attribute"
44

55
export { hasMany, hasOne, belongsTo } from "./associations"
66

7-
export { Model, Attr, HasMany, HasOne, BelongsTo } from "./decorators"
7+
export { Model, Attr, HasMany, HasOne, BelongsTo, Link } from "./decorators"
88

99
export { MiddlewareStack } from "./middleware-stack"
1010

src/model.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export class SpraypaintBase {
165165
static clientApplication: string | null = null
166166

167167
static attributeList: Record<string, Attribute> = {}
168+
static linkList: Array<string> = []
168169
static extendOptions: any
169170
static parentClass: typeof SpraypaintBase
170171
static currentClass: typeof SpraypaintBase = SpraypaintBase
@@ -245,6 +246,7 @@ export class SpraypaintBase {
245246
subclass.currentClass = subclass
246247
subclass.prototype.klass = subclass
247248
subclass.attributeList = cloneDeep(subclass.attributeList)
249+
subclass.linkList = cloneDeep(subclass.linkList)
248250
}
249251

250252
static setAsBase(): void {
@@ -369,6 +371,7 @@ export class SpraypaintBase {
369371
}
370372

371373
Subclass.attributeList = Object.assign({}, Subclass.attributeList, attrs)
374+
Subclass.linkList = Subclass.linkList.slice()
372375

373376
applyModelConfig(Subclass, options.static || {})
374377

@@ -407,13 +410,17 @@ export class SpraypaintBase {
407410
> = {}
408411
@nonenumerable private _attributes!: ModelRecord<this>
409412
@nonenumerable private _originalAttributes: ModelRecord<this>
413+
@nonenumerable private _links!: ModelRecord<this>
414+
@nonenumerable private _originalLinks!: ModelRecord<this>
410415
@nonenumerable private __meta__: any
411416
@nonenumerable private _errors: ValidationErrors<this> = {}
412417

413418
constructor(attrs?: Record<string, any>) {
414419
this._initializeAttributes()
420+
this._initializeLinks()
415421
this.assignAttributes(attrs)
416422
this._originalAttributes = cloneDeep(this._attributes)
423+
this._originalLinks = cloneDeep(this._links)
417424
this._originalRelationships = this.relationshipResourceIdentifiers(
418425
Object.keys(this.relationships)
419426
)
@@ -424,6 +431,10 @@ export class SpraypaintBase {
424431
this._copyPrototypeDescriptors()
425432
}
426433

434+
private _initializeLinks() {
435+
this._links = {}
436+
}
437+
427438
/*
428439
* VueJS, along with a few other frameworks rely on objects being "reactive". In practice, this
429440
* means that when passing an object into an context where you would need change detection, vue
@@ -672,6 +683,7 @@ export class SpraypaintBase {
672683
cloned.isMarkedForDestruction = this.isMarkedForDestruction
673684
cloned.isMarkedForDisassociation = this.isMarkedForDisassociation
674685
cloned.errors = Object.assign({}, this.errors)
686+
cloned.links = Object.assign({}, this.links)
675687
return cloned
676688
}
677689

@@ -967,6 +979,25 @@ export class SpraypaintBase {
967979
Object.keys(includeDirective)
968980
)
969981
}
982+
983+
get links(): Record<string, any> {
984+
return this._links
985+
}
986+
987+
set links(links: Record<string, any>) {
988+
this._links = {}
989+
this.assignLinks(links)
990+
}
991+
992+
assignLinks(links?: Record<string, any>): void {
993+
if (!links) return
994+
for (const key in links) {
995+
const attributeName = this.klass.deserializeKey(key)
996+
if (this.klass.linkList.indexOf(attributeName) > -1) {
997+
this._links[attributeName] = links[key]
998+
}
999+
}
1000+
}
9701001
}
9711002

9721003
;(<any>SpraypaintBase.prototype).klass = SpraypaintBase

src/util/deserialize.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ class Deserializer {
130130
// assign attrs
131131
instance.assignAttributes(datum.attributes)
132132

133+
// assign links
134+
instance.assignLinks(datum.links)
135+
133136
// assign meta
134137
instance.setMeta(datum.meta)
135138

test/es6-compatibility/decorators.test.mjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import {
44
Attr,
55
HasMany,
66
HasOne,
7-
BelongsTo
7+
BelongsTo,
8+
Link
89
} from "../../lib-esm"
910
import { expect } from "chai"
1011

@@ -27,6 +28,12 @@ describe("Decorators work with ES6/Babel", () => {
2728
@BelongsTo author
2829
}
2930

31+
@Model({ jsonapiType: "users_with_link" })
32+
class UserWithLink extends ApplicationRecord {
33+
@Attr name
34+
@Link self
35+
}
36+
3037
expect(ApplicationRecord.parentClass).to.eq(SpraypaintBase)
3138
expect(ApplicationRecord.typeRegistry.get("users")).to.eq(User)
3239
expect(Object.keys(User.attributeList)).to.deep.eq([
@@ -35,6 +42,7 @@ describe("Decorators work with ES6/Babel", () => {
3542
"supervisor"
3643
])
3744
expect(Object.keys(Post.attributeList)).to.deep.eq(["title", "author"])
45+
expect(UserWithLink.linkList).to.deep.eq(["self"])
3846
})
3947
})
4048
})

test/fixtures.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
hasOne
88
} from "../src/index"
99

10-
import { Attr, BelongsTo, HasMany, HasOne } from "../src/decorators"
10+
import { Attr, BelongsTo, HasMany, HasOne, Link } from "../src/decorators"
1111

1212
@Model({
1313
baseUrl: "http://example.com",
@@ -30,6 +30,15 @@ export class PersonWithExtraAttr extends Person {
3030
@Attr({ persist: false }) extraThing!: string
3131
}
3232

33+
@Model()
34+
export class PersonWithLinks extends Person {
35+
static endpoint = "/v1/people_with_links"
36+
static jsonapiType = "people_with_links"
37+
38+
@Link() self!: string
39+
@Link() webView!: string
40+
}
41+
3342
@Model({ keyCase: { server: "snake", client: "snake" } })
3443
export class PersonWithoutCamelizedKeys extends Person {
3544
@Attr first_name!: string

test/unit/decorators.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
HasOne,
66
HasMany,
77
BelongsTo,
8+
Link,
89
initModel
910
} from "../../src/decorators"
1011
import { Association } from "../../src/associations"
@@ -157,6 +158,40 @@ describe("Decorators", () => {
157158
)
158159
})
159160

161+
describe("@Link", () => {
162+
let BaseModel: typeof SpraypaintBase
163+
164+
beforeEach(() => {
165+
@Model()
166+
class MyBase extends SpraypaintBase {}
167+
BaseModel = MyBase
168+
})
169+
170+
it("adds to the link list", () => {
171+
@Model()
172+
class TestClass extends BaseModel {
173+
@Link() link1!: string
174+
}
175+
176+
expect(TestClass.linkList).to.include("link1")
177+
})
178+
179+
it("child class adds to the link list of parent", () => {
180+
@Model()
181+
class TestClass extends BaseModel {
182+
@Link() link1!: string
183+
}
184+
185+
class ChildTestClass extends TestClass {
186+
@Link() link2!: string
187+
}
188+
189+
expect(ChildTestClass.linkList).to.include("link1")
190+
expect(ChildTestClass.linkList).to.include("link2")
191+
expect(TestClass.linkList).to.not.include("link2")
192+
})
193+
})
194+
160195
const singleDecorators = [
161196
{ Assoc: HasMany, Name: "@HasMany" },
162197
{ Assoc: HasOne, Name: "@HasOne" },

test/unit/model.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { EventBus } from "../../src/event-bus"
1515
import {
1616
ApplicationRecord,
1717
Person,
18+
PersonWithLinks,
1819
Book,
1920
Author,
2021
Genre,
@@ -1530,4 +1531,65 @@ describe("Model", () => {
15301531
expect(headers["Content-Type"]).to.eq("application/vnd.api+json")
15311532
})
15321533
})
1534+
1535+
describe("links", () => {
1536+
it("duplicates the links when object is duplicated", () => {
1537+
let author = new PersonWithLinks({ id: "1", firstName: "Stephen" })
1538+
author.assignLinks({ self: "/api/person/1", web_view: "/person/1" })
1539+
1540+
let duped = author.dup()
1541+
duped.assignLinks({ self: "/api/humans/1" })
1542+
1543+
expect(author.links.self).to.eq("/api/person/1")
1544+
expect(author.links.webView).to.eq("/person/1")
1545+
expect(duped.links.self).to.eq("/api/humans/1")
1546+
expect(duped.links.webView).to.eq("/person/1")
1547+
})
1548+
1549+
it("discard links that are undeclared but included in payload", () => {
1550+
let author = new PersonWithLinks({ id: "1", firstName: "Stephen" })
1551+
author.assignLinks({
1552+
self: "/api/person/1",
1553+
web_view: "/person/1",
1554+
undeclared: "/something"
1555+
})
1556+
expect(author.links.self).to.eq("/api/person/1")
1557+
expect(author.links.webView).to.eq("/person/1")
1558+
expect(author.links.undeclared).to.be.undefined
1559+
})
1560+
1561+
describe("deserialize from payload correctly", () => {
1562+
const doc = {
1563+
data: {
1564+
id: "1",
1565+
type: "people_with_links",
1566+
attributes: { firstName: "Donald Budge" },
1567+
links: {
1568+
self: { href: "/api/person/1", meta: { count: 10 } },
1569+
web_view: "/person/1"
1570+
}
1571+
}
1572+
}
1573+
1574+
function assertPersonIsCorrect(person: PersonWithLinks) {
1575+
expect(person.links.self).to.deep.equal({
1576+
href: "/api/person/1",
1577+
meta: { count: 10 }
1578+
})
1579+
expect(person.links.webView).to.eq("/person/1")
1580+
expect(person.links.comments).to
1581+
}
1582+
1583+
it("from instance", () => {
1584+
const person = new PersonWithLinks({ id: "1", firstName: "Stephen" })
1585+
person.fromJsonapi(doc.data, doc)
1586+
assertPersonIsCorrect(person)
1587+
})
1588+
1589+
it("from class", () => {
1590+
const person = PersonWithLinks.fromJsonapi(doc.data, doc)
1591+
assertPersonIsCorrect(person)
1592+
})
1593+
})
1594+
})
15331595
})

0 commit comments

Comments
 (0)