-
Notifications
You must be signed in to change notification settings - Fork 121
/
FileDataAccessor.ts
331 lines (296 loc) · 12.2 KB
/
FileDataAccessor.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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
import type { Stats } from 'fs';
import { createWriteStream, createReadStream, promises as fsPromises } from 'fs';
import { posix } from 'path';
import type { Readable } from 'stream';
import { DataFactory } from 'n3';
import type { NamedNode, Quad } from 'rdf-js';
import type { Representation } from '../../ldp/representation/Representation';
import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
import { ConflictHttpError } from '../../util/errors/ConflictHttpError';
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
import { isSystemError } from '../../util/errors/SystemError';
import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMediaTypeHttpError';
import type { MetadataController } from '../../util/MetadataController';
import { CONTENT_TYPE, DCTERMS, POSIX, RDF, XSD } from '../../util/UriConstants';
import { toNamedNode, toTypedLiteral } from '../../util/UriUtil';
import { pushQuad } from '../../util/Util';
import type { ExtensionBasedMapper } from '../ExtensionBasedMapper';
import type { ResourceLink } from '../FileIdentifierMapper';
import type { DataAccessor } from './DataAccessor';
const { join: joinPath } = posix;
/**
* DataAccessor that uses the file system to store data resources as files and containers as folders.
*/
export class FileDataAccessor implements DataAccessor {
private readonly resourceMapper: ExtensionBasedMapper;
private readonly metadataController: MetadataController;
public constructor(resourceMapper: ExtensionBasedMapper, metadataController: MetadataController) {
this.resourceMapper = resourceMapper;
this.metadataController = metadataController;
}
/**
* Only binary data can be directly stored as files so will error on non-binary data.
*/
public async canHandle(representation: Representation): Promise<void> {
if (!representation.binary) {
throw new UnsupportedMediaTypeHttpError('Only binary data is supported.');
}
}
/**
* Will return data stream directly to the file corresponding to the resource.
* Will throw NotFoundHttpError if the input is a container.
*/
public async getData(identifier: ResourceIdentifier): Promise<Readable> {
const link = await this.resourceMapper.mapUrlToFilePath(identifier);
const stats = await this.getStats(link.filePath);
if (stats.isFile()) {
return createReadStream(link.filePath);
}
throw new NotFoundHttpError();
}
/**
* Will return corresponding metadata by reading the metadata file (if it exists)
* and adding file system specific metadata elements.
*/
public async getMetadata(identifier: ResourceIdentifier): Promise<RepresentationMetadata> {
const link = await this.resourceMapper.mapUrlToFilePath(identifier);
const stats = await this.getStats(link.filePath);
if (!identifier.path.endsWith('/') && stats.isFile()) {
return this.getFileMetadata(link, stats);
}
if (identifier.path.endsWith('/') && stats.isDirectory()) {
return this.getDirectoryMetadata(link, stats);
}
throw new NotFoundHttpError();
}
/**
* Writes the given data as a file (and potential metadata as additional file).
* The metadata file will be written first and will be deleted if something goes wrong writing the actual data.
*/
public async writeDocument(identifier: ResourceIdentifier, data: Readable, metadata: RepresentationMetadata):
Promise<void> {
const link = await this.resourceMapper
.mapUrlToFilePath(identifier, metadata.contentType);
if (this.isMetadataPath(link.filePath)) {
throw new ConflictHttpError('Not allowed to create files with the metadata extension.');
}
const wroteMetadata = await this.writeMetadata(link, metadata);
try {
await this.writeDataFile(link.filePath, data);
} catch (error: unknown) {
// Delete the metadata if there was an error writing the file
if (wroteMetadata) {
await fsPromises.unlink(this.getMetadataPath(link.filePath));
}
throw error;
}
}
/**
* Creates corresponding folder if necessary and writes metadata to metadata file if necessary.
*/
public async writeContainer(identifier: ResourceIdentifier, metadata: RepresentationMetadata): Promise<void> {
const link = await this.resourceMapper.mapUrlToFilePath(identifier);
try {
await fsPromises.mkdir(link.filePath);
} catch (error: unknown) {
// Don't throw if directory already exists
if (!isSystemError(error) || error.code !== 'EEXIST') {
throw error;
}
}
await this.writeMetadata(link, metadata);
}
/**
* Removes the corresponding file/folder (and metadata file).
*/
public async deleteResource(identifier: ResourceIdentifier): Promise<void> {
const link = await this.resourceMapper.mapUrlToFilePath(identifier);
const stats = await this.getStats(link.filePath);
try {
await fsPromises.unlink(this.getMetadataPath(link.filePath));
} catch (error: unknown) {
// Ignore if it doesn't exist
if (!isSystemError(error) || error.code !== 'ENOENT') {
throw error;
}
}
if (!identifier.path.endsWith('/') && stats.isFile()) {
await fsPromises.unlink(link.filePath);
} else if (identifier.path.endsWith('/') && stats.isDirectory()) {
await fsPromises.rmdir(link.filePath);
} else {
throw new NotFoundHttpError();
}
}
/**
* Gets the Stats object corresponding to the given file path.
* @param path - File path to get info from.
*
* @throws NotFoundHttpError
* If the file/folder doesn't exist.
*/
private async getStats(path: string): Promise<Stats> {
try {
return await fsPromises.lstat(path);
} catch (error: unknown) {
if (isSystemError(error) && error.code === 'ENOENT') {
throw new NotFoundHttpError();
}
throw error;
}
}
/**
* Generates file path that corresponds to the metadata file of the given file path.
*/
private getMetadataPath(path: string): string {
return `${path}.meta`;
}
/**
* Checks if the given file path is a metadata path.
*/
private isMetadataPath(path: string): boolean {
return path.endsWith('.meta');
}
/**
* Reads and generates all metadata relevant for the given file,
* ingesting it into a RepresentationMetadata object.
*
* @param link - Path related metadata.
* @param stats - Stats object of the corresponding file.
*/
private async getFileMetadata(link: ResourceLink, stats: Stats):
Promise<RepresentationMetadata> {
return (await this.getBaseMetadata(link, stats, false))
.set(CONTENT_TYPE, link.contentType);
}
/**
* Reads and generates all metadata relevant for the given directory,
* ingesting it into a RepresentationMetadata object.
*
* @param link - Path related metadata.
* @param stats - Stats object of the corresponding directory.
*/
private async getDirectoryMetadata(link: ResourceLink, stats: Stats):
Promise<RepresentationMetadata> {
return (await this.getBaseMetadata(link, stats, true))
.addQuads(await this.getChildMetadataQuads(link));
}
/**
* Writes the metadata of the resource to a meta file.
* @param link - Path related metadata of the resource.
* @param metadata - Metadata to write.
*
* @returns True if data was written to a file.
*/
private async writeMetadata(link: ResourceLink, metadata: RepresentationMetadata): Promise<boolean> {
// These are stored by file system conventions
metadata.removeAll(RDF.type);
metadata.removeAll(CONTENT_TYPE);
const quads = metadata.quads();
if (quads.length > 0) {
const serializedMetadata = this.metadataController.serializeQuads(quads);
await this.writeDataFile(this.getMetadataPath(link.filePath), serializedMetadata);
return true;
}
return false;
}
/**
* Generates metadata relevant for any resources stored by this accessor.
* @param link - Path related metadata.
* @param stats - Stats objects of the corresponding directory.
* @param isContainer - If the path points to a container (directory) or not.
*/
private async getBaseMetadata(link: ResourceLink, stats: Stats, isContainer: boolean):
Promise<RepresentationMetadata> {
const metadata = new RepresentationMetadata(link.identifier.path)
.addQuads(await this.getRawMetadata(link.filePath));
metadata.addQuads(this.metadataController.generateResourceQuads(metadata.identifier as NamedNode, isContainer));
metadata.addQuads(this.generatePosixQuads(metadata.identifier as NamedNode, stats));
return metadata;
}
/**
* Reads the metadata from the corresponding metadata file.
* Returns an empty array if there is no metadata file.
*
* @param path - File path of the resource (not the metadata!).
*/
private async getRawMetadata(path: string): Promise<Quad[]> {
try {
// Check if the metadata file exists first
await fsPromises.lstat(this.getMetadataPath(path));
const readMetadataStream = createReadStream(this.getMetadataPath(path));
return await this.metadataController.parseQuads(readMetadataStream);
} catch (error: unknown) {
// Metadata file doesn't exist so lets keep `rawMetaData` an empty array.
if (!isSystemError(error) || error.code !== 'ENOENT') {
throw error;
}
return [];
}
}
/**
* Generate all containment related triples for a container.
* These include the actual containment triples and specific triples for every child resource.
*
* @param link - Path related metadata.
*/
private async getChildMetadataQuads(link: ResourceLink): Promise<Quad[]> {
const quads: Quad[] = [];
const childURIs: string[] = [];
const files = await fsPromises.readdir(link.filePath);
// For every child in the container we want to generate specific metadata
for (const childName of files) {
// Hide metadata files from containment triples
if (this.isMetadataPath(childName)) {
continue;
}
// Ignore non-file/directory entries in the folder
const childStats = await fsPromises.lstat(joinPath(link.filePath, childName));
if (!childStats.isFile() && !childStats.isDirectory()) {
continue;
}
// Generate the URI corresponding to the child resource
const childLink = await this.resourceMapper
.mapFilePathToUrl(joinPath(link.filePath, childName), childStats.isDirectory());
// Generate metadata of this specific child
const subject = DataFactory.namedNode(childLink.identifier.path);
quads.push(...this.metadataController.generateResourceQuads(subject, childStats.isDirectory()));
quads.push(...this.generatePosixQuads(subject, childStats));
childURIs.push(childLink.identifier.path);
}
// Generate containment metadata
const containsQuads = this.metadataController.generateContainerContainsResourceQuads(
DataFactory.namedNode(link.identifier.path), childURIs,
);
return quads.concat(containsQuads);
}
/**
* Helper function to add file system related metadata.
* @param subject - Subject for the new quads.
* @param stats - Stats of the file/directory corresponding to the resource.
*/
private generatePosixQuads(subject: NamedNode, stats: Stats): Quad[] {
const quads: Quad[] = [];
pushQuad(quads, subject, toNamedNode(POSIX.size), toTypedLiteral(stats.size, XSD.integer));
pushQuad(quads, subject, toNamedNode(DCTERMS.modified), toTypedLiteral(stats.mtime.toISOString(), XSD.dateTime));
pushQuad(quads, subject, toNamedNode(POSIX.mtime), toTypedLiteral(
Math.floor(stats.mtime.getTime() / 1000), XSD.integer,
));
return quads;
}
/**
* Helper function without extra validation checking to create a data file.
* @param path - The filepath of the file to be created.
* @param data - The data to be put in the file.
*/
private async writeDataFile(path: string, data: Readable): Promise<void> {
return new Promise((resolve, reject): any => {
const writeStream = createWriteStream(path);
data.pipe(writeStream);
data.on('error', reject);
writeStream.on('error', reject);
writeStream.on('finish', resolve);
});
}
}