/
JsonResourceStorage.ts
149 lines (136 loc) · 5.79 KB
/
JsonResourceStorage.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
import { createHash } from 'crypto';
import { parse } from 'path';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { Representation } from '../../http/representation/Representation';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
import { ensureLeadingSlash, ensureTrailingSlash, isContainerIdentifier, joinUrl,
joinFilePath } from '../../util/PathUtil';
import { readableToString } from '../../util/StreamUtil';
import { LDP } from '../../util/Vocabularies';
import type { ResourceStore } from '../ResourceStore';
import type { KeyValueStorage } from './KeyValueStorage';
// Maximum allowed length for the keys, longer keys will be hashed.
const KEY_LENGTH_LIMIT = 255;
/**
* A {@link KeyValueStorage} for JSON-like objects using a {@link ResourceStore} as backend.
*
* Creates a base URL by joining the input base URL with the container string.
* The storage assumes it has ownership over all entries in the target container
* so no other classes should access resources there to prevent issues.
*
* Assumes the input keys can be safely used to generate identifiers,
* which will be appended to the stored base URL.
*
* All non-404 errors will be re-thrown.
*/
export class JsonResourceStorage<T> implements KeyValueStorage<string, T> {
private readonly source: ResourceStore;
private readonly container: string;
public constructor(source: ResourceStore, baseUrl: string, container: string) {
this.source = source;
this.container = ensureTrailingSlash(joinUrl(baseUrl, container));
}
public async get(key: string): Promise<T | undefined> {
try {
const identifier = this.keyToIdentifier(key);
const representation = await this.source.getRepresentation(identifier, { type: { 'application/json': 1 }});
return JSON.parse(await readableToString(representation.data));
} catch (error: unknown) {
if (!NotFoundHttpError.isInstance(error)) {
throw error;
}
}
}
public async has(key: string): Promise<boolean> {
const identifier = this.keyToIdentifier(key);
return await this.source.hasResource(identifier);
}
public async set(key: string, value: unknown): Promise<this> {
const identifier = this.keyToIdentifier(key);
const representation = new BasicRepresentation(JSON.stringify(value), identifier, 'application/json');
await this.source.setRepresentation(identifier, representation);
return this;
}
public async delete(key: string): Promise<boolean> {
try {
const identifier = this.keyToIdentifier(key);
await this.source.deleteResource(identifier);
return true;
} catch (error: unknown) {
if (!NotFoundHttpError.isInstance(error)) {
throw error;
}
return false;
}
}
public async* entries(): AsyncIterableIterator<[string, T]> {
yield* this.getResourceEntries({ path: this.container });
}
/**
* Recursively iterates through the container to find all documents.
*/
private async* getResourceEntries(identifier: ResourceIdentifier): AsyncIterableIterator<[string, T]> {
const representation = await this.safelyGetResource(identifier);
if (representation) {
if (isContainerIdentifier(identifier)) {
// Only need the metadata
representation.data.destroy();
const members = representation.metadata.getAll(LDP.terms.contains).map((term): string => term.value);
for (const path of members) {
yield* this.getResourceEntries({ path });
}
} else {
const json = JSON.parse(await readableToString(representation.data));
yield [ this.identifierToKey(identifier), json ];
}
}
}
/**
* Returns the representation for the given identifier.
* Returns undefined if a 404 error is thrown.
* Re-throws the error in all other cases.
*/
private async safelyGetResource(identifier: ResourceIdentifier): Promise<Representation | undefined> {
let representation: Representation | undefined;
try {
const preferences = isContainerIdentifier(identifier) ? {} : { type: { 'application/json': 1 }};
representation = await this.source.getRepresentation(identifier, preferences);
} catch (error: unknown) {
// Can happen if resource is deleted by this point.
// When using this for internal data this can specifically happen quite often with locks.
if (!NotFoundHttpError.isInstance(error)) {
throw error;
}
}
return representation;
}
/**
* Converts a key into an identifier for internal storage.
*/
private keyToIdentifier(key: string): ResourceIdentifier {
// Parse the key as a file path
const parsedPath = parse(key);
// Hash long filenames to prevent issues with the underlying storage.
// E.g. a UNIX a file name cannot exceed 255 bytes.
// This is a temporary fix for https://github.com/CommunitySolidServer/CommunitySolidServer/issues/1013,
// until we have a solution for data migration.
if (parsedPath.base.length > KEY_LENGTH_LIMIT) {
key = joinFilePath(parsedPath.dir, this.applyHash(parsedPath.base));
}
return { path: joinUrl(this.container, key) };
}
/**
* Converts an internal identifier to an external key.
*/
private identifierToKey(identifier: ResourceIdentifier): string {
// Due to the usage of `joinUrl` we don't know for sure if there was a preceding slash,
// so we always add one for consistency.
// In practice this would only be an issue if a class depends
// on the `entries` results matching a key that was sent before.
return ensureLeadingSlash(identifier.path.slice(this.container.length));
}
private applyHash(key: string): string {
return createHash('sha256').update(key).digest('hex');
}
}