-
Notifications
You must be signed in to change notification settings - Fork 3
/
ElementEndpoint.ts
161 lines (148 loc) · 8.16 KB
/
ElementEndpoint.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
import { ETagEndpointBase } from ".";
import { Endpoint } from "../Endpoint";
import { HttpMethod, HttpHeader, HttpStatusCode } from "../../http";
import { ConcurrencyError } from "../../errors";
/**
* Endpoint for an individual resource.
* @typeParam TEntity The type of entity the endpoint represents.
*/
export class ElementEndpoint<TEntity> extends ETagEndpointBase {
/**
* Creates a new element endpoint.
* @param referrer The endpoint used to navigate to this one.
* @param relativeUri The URI of this endpoint relative to the `referrer`'s. Add a `./` prefix here to imply a trailing slash in the `referrer`'s URI.
*/
constructor(referrer: Endpoint, relativeUri: URL | string) {
super(referrer, relativeUri);
}
/**
* A cached copy of the entity as received from the server.
*/
public get response(): TEntity | undefined {
return this.responseCache
? this.serializer.deserialize<TEntity>(this.responseCache.content)
: undefined;
}
/**
* Returns the `TEntity`.
* @param signal Used to cancel the request.
* @throws {@link AuthenticationError}: {@link HttpStatusCode.Unauthorized}
* @throws {@link AuthorizationError}: {@link HttpStatusCode.Forbidden}
* @throws {@link NotFoundError}: {@link HttpStatusCode.NotFound} or {@link HttpStatusCode.Gone}
* @throws {@link HttpError}: Other non-success status code
*/
async read(signal?: AbortSignal) {
return this.serializer.deserialize<TEntity>(await this.getContent(signal));
}
/**
* Determines whether the element currently exists.
* @param signal Used to cancel the request.
* @throws {@link AuthenticationError}: {@link HttpStatusCode.Unauthorized}
* @throws {@link AuthorizationError}: {@link HttpStatusCode.Forbidden}
* @throws {@link HttpError}: Other non-success status code
*/
async exists(signal?: AbortSignal) {
const response = await this.httpClient.send(this.uri, HttpMethod.Head, signal);
if (response.ok) return true;
if (response.status === HttpStatusCode.NotFound || response.status === HttpStatusCode.Gone) return false;
await this.errorHandler.handle(response);
return false;
}
/**
* Shows whether the server has indicated that {@link set} is currently allowed.
* Uses cached data from last response.
* @returns `true` if the method is allowed, `false` if the method is not allowed, `undefined` if no request has been sent yet or the server did not specify allowed methods.
*/
get setAllowed() { return this.isMethodAllowed(HttpMethod.Put); }
/**
* Sets/replaces the `TEntity`.
* @param entity The new `TEntity`.
* @param signal Used to cancel the request.
* @returns The `TEntity` as returned by the server, possibly with additional fields set. undefined if the server does not respond with a result entity.
* @throws {@link ConcurrencyError}: The entity has changed since it was last retrieved with {@link read}. Your changes were rejected to prevent a lost update.
* @throws {@link BadRequestError}: {@link HttpStatusCode.BadRequest}
* @throws {@link AuthenticationError}: {@link HttpStatusCode.Unauthorized}
* @throws {@link AuthorizationError}: {@link HttpStatusCode.Forbidden}
* @throws {@link NotFoundError}: {@link HttpStatusCode.NotFound} or {@link HttpStatusCode.Gone}
* @throws {@link HttpError}: Other non-success status code
*/
async set(entity: TEntity, signal?: AbortSignal): Promise<(TEntity | undefined)> {
const response = await this.putContent(entity, signal);
const text = await response.text();
if (text) {
return this.serializer.deserialize<TEntity>(text);
}
}
/**
* Shows whether the server has indicated that {@link merge} is currently allowed.
* Uses cached data from last response.
* @returns `true` if the method is allowed, `false` if the method is not allowed, `undefined` if no request has been sent yet or the server did not specify allowed methods.
*/
get mergeAllowed() { return this.isMethodAllowed(HttpMethod.Patch); }
/**
* Modifies an existing `TEntity` by merging changes on the server-side.
* @param entity The `TEntity` data to merge with the existing element.
* @param signal Used to cancel the request.
* @returns The `TEntity` as returned by the server, possibly with additional fields set. undefined if the server does not respond with a result entity.
* @throws {@link ConcurrencyError}: The entity has changed since it was last retrieved with {@link read}. Your changes were rejected to prevent a lost update.
* @throws {@link BadRequestError}: {@link HttpStatusCode.BadRequest}
* @throws {@link AuthenticationError}: {@link HttpStatusCode.Unauthorized}
* @throws {@link AuthorizationError}: {@link HttpStatusCode.Forbidden}
* @throws {@link NotFoundError}: {@link HttpStatusCode.NotFound} or {@link HttpStatusCode.Gone}
* @throws {@link HttpError}: Other non-success status code
*/
async merge(entity: TEntity, signal?: AbortSignal): Promise<(TEntity | undefined)> {
this.responseCache = undefined;
const response = await this.send(HttpMethod.Patch, signal, {
[HttpHeader.ContentType]: this.serializer.supportedMediaTypes[0]
}, this.serializer.serialize(entity));
const text = await response.text();
if (text) {
return this.serializer.deserialize<TEntity>(text);
}
}
/**
* Reads the current state of the entity, applies a change to it and stores the result. Applies optimistic concurrency using automatic retries.
* @param updateAction A callback that takes the current state of the entity and applies the desired modifications.
* @param maxRetries The maximum number of retries to perform for optimistic concurrency before giving up.
* @param signal Used to cancel the request.
* @returns The `TEntity` as returned by the server, possibly with additional fields set. undefined if the server does not respond with a result entity.
* @throws {@link ConcurrencyError}: The maximum number of retries to perform for optimistic concurrency before giving up.
* @throws {@link BadRequestError}: {@link HttpStatusCode.BadRequest}
* @throws {@link AuthenticationError}: {@link HttpStatusCode.Unauthorized}
* @throws {@link AuthorizationError}: {@link HttpStatusCode.Forbidden}
* @throws {@link NotFoundError}: {@link HttpStatusCode.NotFound} or {@link HttpStatusCode.Gone}
* @throws {@link HttpError}: Other non-success status code
*/
async update(updateAction: (entity: TEntity) => void, maxRetries: number = 3, signal?: AbortSignal): Promise<(TEntity | undefined)> {
let retryCounter = 0;
while (true) {
const entity = await this.read(signal);
updateAction(entity);
try {
return await this.set(entity, signal);
} catch (err) {
if (retryCounter++ >= maxRetries || !(err instanceof ConcurrencyError))
throw err;
}
}
}
/**
* Shows whether the server has indicated that {@link delete} is currently allowed.
* Uses cached data from last response.
* @returns `true` if the method is allowed, `false` if the method is not allowed, `undefined` if no request has been sent yet or the server did not specify allowed methods.
*/
get deleteAllowed() { return this.isMethodAllowed(HttpMethod.Delete); }
/**
* Deletes the element.
* @param signal Used to cancel the request.
* @throws {@link ConcurrencyError}: The entity has changed since it was last retrieved with {@link read}. Your changes were rejected to prevent a lost update.
* @throws {@link AuthenticationError}: {@link HttpStatusCode.Unauthorized}
* @throws {@link AuthorizationError}: {@link HttpStatusCode.Forbidden}
* @throws {@link NotFoundError}: {@link HttpStatusCode.NotFound} or {@link HttpStatusCode.Gone}
* @throws {@link HttpError}: Other non-success status code
*/
async delete(signal?: AbortSignal) {
await this.deleteContent(signal);
}
}