-
Notifications
You must be signed in to change notification settings - Fork 41
/
Resource.ts
executable file
·265 lines (224 loc) · 7.93 KB
/
Resource.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
import { deleteNested, isPlainObject, objectIsEmpty } from "../util/misc";
import Relationship, { RelationshipJSON, RelationshipArgs } from "./Relationship";
import { UrlTemplates } from "./index";
export type ResourceJSON = {
id: string;
type: string;
attributes?: object;
relationships?: { [name: string]: RelationshipJSON };
meta?: object;
links?: { self?: string };
};
// Used after an id has been assigned by the server.
export type ResourceWithId = Resource & { id: string };
// Used after the typePath has been set.
export type ResourceWithTypePath = Resource & { typePath: string[] };
export default class Resource {
private _id: string | undefined;
private _type!: string;
private _relationships!: { [name: string]: Relationship };
private _attrs!: { [name: string]: any };
private _meta!: object;
/**
* A key that can hold arbitrary extra data that the adapter has asked to be
* associated with this resource. Used by the MongooseAdapter for updates.
*/
public adapterExtra: any;
/**
* The type path is an array of all the type names that apply to this
* resource, ordered with the smallest sub type first and parent types later.
* It is set after confirming the resource's types from the adapter or
* validating the user's `meta.types` input. By contrast, the typesList
* (see below) represents a provisional, unvalidated typePath as provided by
* the user, if any. Having this list, in this order, is important for the
* beforeSave/beforeRender transforms, which use it to lookup the transform
* functions from the right resource type descriptions.
*/
public typePath: string[] | undefined;
constructor(
type: string,
id?: string,
attrs = Object.create(null),
relationships = Object.create(null),
meta: { types?: string[] } = Object.create(null)
) {
[this.type, this.id, this.attrs, this.relationships, this.meta] =
[type, id, attrs, relationships, meta];
}
get id() {
return this._id;
}
set id(id) {
// allow empty id for the case of a new resource POST.
this._id = (typeof id !== "undefined") ? String(id) : undefined;
}
get type() {
return this._type;
}
set type(type) {
if(!type) {
throw errorWithCode("type is required", 1);
}
this._type = String(type);
}
/**
* The typesList is intended to represent a list of type names provided by
* the end-user. It should only be defined on resources that are created from
* end-user data, and it may not be a valid typePath. Resources instantiated
* by the server should never have a typesList, but should instead have a
* typePath. See Resource.typePath.
*/
get typesList() {
return (this.meta as any).types;
}
equals(otherResource: Resource) {
return this.id === otherResource.id && this.type === otherResource.type;
}
get attrs() {
return this._attrs;
}
set attrs(attrs) {
validateFieldGroup(attrs, this._relationships, true);
this._attrs = attrs;
}
get attributes() {
return this.attrs;
}
set attributes(attrs) {
this.attrs = attrs;
}
get relationships() {
return this._relationships;
}
set relationships(relationships: { [name: string]: Relationship }) {
validateFieldGroup(relationships, this._attrs);
this._relationships = relationships;
}
set meta(meta) {
if(typeof meta !== "object" || meta === null) {
throw errorWithCode("meta must be an object.", 2);
}
this._meta = meta;
}
get meta() {
return this._meta;
}
removeAttr(attrPath: string) {
if(this._attrs) {
deleteNested(attrPath, this._attrs);
}
}
removeRelationship(relationshipPath: string) {
if(this._relationships) {
deleteNested(relationshipPath, this._relationships);
}
}
setRelationship(relationshipPath: string, data: RelationshipArgs["data"]) {
validateFieldGroup({ [relationshipPath]: true }, this._attrs);
this._relationships[relationshipPath] = Relationship.of({
data,
owner: { type: this._type, id: this._id, path: relationshipPath }
});
}
toJSON(urlTemplates: UrlTemplates): ResourceJSON {
const hasMeta = !objectIsEmpty(this.meta);
const showTypePath = this.typePath && this.typePath.length > 1;
const meta = showTypePath
? { ...this.meta, types: this.typePath }
: this.meta;
const json = <ResourceJSON>{
id: this.id,
type: this.type,
attributes: this.attrs,
...(showTypePath || hasMeta ? { meta } : {})
};
// use type, id, meta and attrs for template data, even though building
// links from attr values is usually stupid (but there are cases for it).
const templateData = { ...json };
const selfTemplate = urlTemplates.self;
if(selfTemplate) {
json.links = {
self: selfTemplate(templateData)
}
}
if(!objectIsEmpty(this.relationships)) {
json.relationships = {};
Object.keys(this.relationships).forEach(path => {
const { related, relationship } = urlTemplates;
const finalTemplates = { related, self: relationship };
(json.relationships as any)[path] =
this.relationships[path].toJSON(finalTemplates);
});
}
return json;
}
}
/**
* Checks that a group of fields (i.e. the attributes or the relationships
* objects) are provided as objects and that they don't contain `type` and
* `id` members. Also checks that attributes and relationships don't contain
* the same keys as one another, and it checks that complex attributes don't
* contain "relationships" or "links" members.
*
* @param {Object} group The an object of fields (attributes or relationships)
* that the user is trying to add to the Resource.
* @param {Object} otherFields The other fields that will still exist on the
* Resource. The new fields are checked against these other fields for
* naming conflicts.
* @param {Boolean} isAttributes Whether the `group` points to the attributes
* of the resource. Triggers complex attribute validation.
* @return {undefined}
* @throws {Error} If the field group is invalid given the other fields.
*/
function validateFieldGroup(group: object, otherFields: object, isAttributes = false) {
if(!isPlainObject(group)) {
throw errorWithCode("Attributes and relationships must be provided as an object.", 3);
}
if("id" in group || "type" in group) {
throw errorWithCode("`type` and `id` cannot be used as field names.", 4);
}
Object.keys(group).forEach(field => {
if(isAttributes) {
validateComplexAttribute((group as any)[field]);
}
if(otherFields !== undefined && typeof (otherFields as any)[field] !== "undefined") {
throw errorWithCode(
"A resource can't have an attribute and a relationship with the same name.",
5,
{ field }
);
}
});
}
function validateComplexAttribute(attrOrAttrPart: any) {
if(isPlainObject(attrOrAttrPart)) {
const { relationships, links } = attrOrAttrPart;
if(typeof relationships !== "undefined" || typeof links !== "undefined") {
throw errorWithCode(
'Complex attributes may not have "relationships" or "links" keys.',
6
);
}
Object.keys(attrOrAttrPart).forEach(key => {
validateComplexAttribute(attrOrAttrPart[key]);
});
}
else if(Array.isArray(attrOrAttrPart)) {
attrOrAttrPart.forEach(validateComplexAttribute);
}
}
/**
* We throw errors with a code number so that we can identify the error type
* and convert the error to an APIError, but only if the error was caused by
* client-provided data. If, say, an Adapter created an invalid resource object
* from data it looked up, that's just an internal error and shouldn't be
* converted to an APIError at all or at least not to the same one.
*
* Note: the codes are only meant to be unique within this file!
*/
function errorWithCode(message: string, code: number, extra?: object) {
const x: any = new Error(message);
x.code = code;
Object.assign(x, extra);
return x;
}