@opentermsarchive/engine 15.3.1 → 16.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import { js2xml } from 'xml-js';
3
3
 
4
4
  import { getCollection } from '../../archivist/collection/index.js';
5
5
  import { toISODateWithoutMilliseconds } from '../../archivist/utils/date.js';
6
+ import { buildAbsoluteBaseUrl } from '../utils/url.js';
6
7
 
7
8
  const RECORD_TYPES = {
8
9
  firstRecord: 'First record',
@@ -18,12 +19,6 @@ const SCHEMES = Object.freeze({
18
19
  recordType: `tag:${TAG_AUTHORITY}:scheme:record-type`,
19
20
  });
20
21
 
21
- function buildAbsoluteBaseUrl(req) {
22
- const host = req.get('X-Forwarded-Host') ?? req.get('host'); // Behind a trusted reverse proxy, the public host comes from X-Forwarded-Host. req.get('host') only sees the internal Host header, so we read the forwarded value explicitly and fall back to the direct host for non-proxied setups (dev, tests).
23
-
24
- return `${req.protocol}://${host}${req.baseUrl}`;
25
- }
26
-
27
22
  function classifyRecordType(version) {
28
23
  return version.isFirstRecord ? RECORD_TYPES.firstRecord : RECORD_TYPES.change;
29
24
  }
@@ -5,7 +5,9 @@ import helmet from 'helmet';
5
5
  import { getCollection } from '../../archivist/collection/index.js';
6
6
  import RepositoryFactory from '../../archivist/recorder/repositories/factory.js';
7
7
  import * as Services from '../../archivist/services/index.js';
8
+ import DatasetStorage from '../../dataset/storage.js';
8
9
 
10
+ import datasetRouter from './dataset.js';
9
11
  import docsRouter from './docs.js';
10
12
  import feedRouter from './feed.js';
11
13
  import metadataRouter from './metadata.js';
@@ -40,6 +42,7 @@ export default async function apiRouter(basePath) {
40
42
  const versionsRepository = await RepositoryFactory.create(versionsStorageConfig).initialize();
41
43
  const snapshotsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.snapshots.storage')).initialize();
42
44
  const feedConfig = config.get('@opentermsarchive/engine.collection-api.feed');
45
+ const datasetStorage = new DatasetStorage(config.get('@opentermsarchive/engine.dataset.storagePath'));
43
46
 
44
47
  if (!collection.metadata?.id) {
45
48
  throw new Error('Collection metadata "id" is required to expose feed endpoints, as it is used to build the tag URIs that uniquely identify the feed and its entries. Add an "id" field to the collection metadata file.');
@@ -53,6 +56,7 @@ export default async function apiRouter(basePath) {
53
56
  router.use(servicesRouter(services));
54
57
  router.use(versionsRouter(versionsRepository, snapshotsRepository));
55
58
  router.use(feedRouter(services, versionsRepository, versionsStorageConfig.type, feedConfig.limit, feedConfig.versionUrlTemplate));
59
+ router.use(datasetRouter(datasetStorage));
56
60
 
57
61
  return router;
58
62
  }
@@ -0,0 +1,3 @@
1
+ export function buildAbsoluteBaseUrl(req) {
2
+ return `${req.protocol}://${req.host}${req.baseUrl}`;
3
+ }
@@ -0,0 +1,4 @@
1
+ export const SPDX_ID = 'ODbL-1.0'; // Identifier of the license shipped in scripts/dataset/assets/LICENSE
2
+ export const DATAGOUV_ID = 'odc-odbl'; // data.gouv.fr's own identifier for the same license, distinct from the SPDX scheme
3
+ export const DISPLAY_NAME = 'Open Database (ODbL) License';
4
+ export const INFO_URL = 'https://opendatacommons.org/licenses/odbl/1.0/';
@@ -0,0 +1,66 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ export const TEMPORARY_SUFFIX = '.tmp';
5
+
6
+ const METADATA_FILENAME = 'metadata.json';
7
+ const ARCHIVE_EXTENSION = '.zip';
8
+
9
+ export default class DatasetStorage {
10
+ constructor(storagePath) {
11
+ this.path = path.resolve(process.cwd(), storagePath);
12
+ }
13
+
14
+ get metadataPath() {
15
+ return path.join(this.path, METADATA_FILENAME);
16
+ }
17
+
18
+ archivePath(filename) {
19
+ return path.join(this.path, filename);
20
+ }
21
+
22
+ async findLatest() {
23
+ let metadata;
24
+
25
+ try {
26
+ metadata = JSON.parse(await fs.readFile(this.metadataPath, 'utf8'));
27
+ await fs.access(this.archivePath(metadata.filename));
28
+ } catch (error) {
29
+ if (error.code === 'ENOENT') {
30
+ return null;
31
+ }
32
+
33
+ throw error;
34
+ }
35
+
36
+ return metadata;
37
+ }
38
+
39
+ async save(metadata) {
40
+ await fs.access(this.archivePath(metadata.filename)); // Never describe an archive that is not in place
41
+
42
+ const temporaryPath = `${this.metadataPath}${TEMPORARY_SUFFIX}`;
43
+
44
+ await fs.writeFile(temporaryPath, JSON.stringify(metadata, null, 2));
45
+ await fs.rename(temporaryPath, this.metadataPath); // Readers see either the previous or the new metadata, never a partial file
46
+ }
47
+
48
+ async removePreviousArchives() {
49
+ const metadata = await this.findLatest();
50
+
51
+ if (!metadata) {
52
+ return;
53
+ }
54
+
55
+ await this.#removeArchivesExcept(metadata.filename);
56
+ }
57
+
58
+ async #removeArchivesExcept(filename) {
59
+ const entries = await fs.readdir(this.path);
60
+ const isArchiveOrLeftover = entry => entry.endsWith(ARCHIVE_EXTENSION) || entry.endsWith(`${ARCHIVE_EXTENSION}${TEMPORARY_SUFFIX}`); // Cleanup is restricted to archives and their leftovers: the storage path is user-provided and may hold unrelated files
61
+
62
+ await Promise.all(entries
63
+ .filter(entry => entry !== filename && isArchiveOrLeftover(entry))
64
+ .map(entry => fs.rm(this.archivePath(entry), { force: true })));
65
+ }
66
+ }
@@ -0,0 +1,209 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ import { expect, use } from 'chai';
6
+ import chaiAsPromised from 'chai-as-promised';
7
+ import sinon from 'sinon';
8
+
9
+ import DatasetStorage, { TEMPORARY_SUFFIX } from './storage.js';
10
+
11
+ use(chaiAsPromised);
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ const TMP_PATH = path.resolve(__dirname, './tmp');
16
+ const ARCHIVE_FILENAME = 'dataset-2026-01-01.zip';
17
+ const PREVIOUS_ARCHIVE_FILENAME = 'dataset-2025-12-25.zip';
18
+ const INTERRUPTED_ARCHIVE_FILENAME = `dataset-2025-12-26.zip${TEMPORARY_SUFFIX}`;
19
+ const UNRELATED_FILENAME = 'notes.txt';
20
+ const METADATA = {
21
+ filename: ARCHIVE_FILENAME,
22
+ title: 'sandbox',
23
+ license: 'ODbL-1.0',
24
+ releaseDate: '2026-01-01T08:30:00Z',
25
+ firstVersionDate: '2021-01-01T11:27:00Z',
26
+ lastVersionDate: '2022-01-06T11:32:47Z',
27
+ servicesCount: 2,
28
+ termsCount: 3,
29
+ versionsCount: 4,
30
+ size: 15,
31
+ sha256: 'a'.repeat(64),
32
+ };
33
+
34
+ describe('DatasetStorage', () => {
35
+ const storage = new DatasetStorage(TMP_PATH);
36
+
37
+ async function removeStorage() {
38
+ await fs.rm(TMP_PATH, { recursive: true, force: true });
39
+ }
40
+
41
+ before(removeStorage);
42
+
43
+ describe('#findLatest', () => {
44
+ context('when the storage directory does not exist', () => {
45
+ it('returns null', async () => {
46
+ expect(await storage.findLatest()).to.be.null;
47
+ });
48
+ });
49
+
50
+ context('when no metadata file exists', () => {
51
+ before(async () => {
52
+ await fs.mkdir(TMP_PATH, { recursive: true });
53
+ });
54
+
55
+ after(removeStorage);
56
+
57
+ it('returns null', async () => {
58
+ expect(await storage.findLatest()).to.be.null;
59
+ });
60
+ });
61
+
62
+ context('when the metadata file references a missing archive', () => {
63
+ before(async () => {
64
+ await fs.mkdir(TMP_PATH, { recursive: true });
65
+ await fs.writeFile(storage.metadataPath, JSON.stringify(METADATA));
66
+ });
67
+
68
+ after(removeStorage);
69
+
70
+ it('returns null', async () => {
71
+ expect(await storage.findLatest()).to.be.null;
72
+ });
73
+ });
74
+
75
+ context('when the metadata file is corrupted', () => {
76
+ before(async () => {
77
+ await fs.mkdir(TMP_PATH, { recursive: true });
78
+ await fs.writeFile(storage.metadataPath, '{ not json');
79
+ });
80
+
81
+ after(removeStorage);
82
+
83
+ it('rejects', async () => {
84
+ await expect(storage.findLatest()).to.be.rejected;
85
+ });
86
+ });
87
+
88
+ context('when the metadata file and the archive exist', () => {
89
+ before(async () => {
90
+ await fs.mkdir(TMP_PATH, { recursive: true });
91
+ await fs.writeFile(storage.archivePath(ARCHIVE_FILENAME), 'archive content');
92
+ await fs.writeFile(storage.metadataPath, JSON.stringify(METADATA));
93
+ });
94
+
95
+ after(removeStorage);
96
+
97
+ it('returns the metadata', async () => {
98
+ expect(await storage.findLatest()).to.deep.equal(METADATA);
99
+ });
100
+ });
101
+ });
102
+
103
+ describe('#save', () => {
104
+ context('when the archive is missing', () => {
105
+ before(async () => {
106
+ await fs.mkdir(TMP_PATH, { recursive: true });
107
+ });
108
+
109
+ after(removeStorage);
110
+
111
+ it('rejects', async () => {
112
+ await expect(storage.save(METADATA)).to.be.rejected;
113
+ });
114
+
115
+ it('writes no metadata file', async () => {
116
+ await storage.save(METADATA).catch(() => {});
117
+
118
+ await expect(fs.access(storage.metadataPath)).to.be.rejected;
119
+ });
120
+ });
121
+
122
+ context('when the archive exists', () => {
123
+ before(async () => {
124
+ await fs.mkdir(TMP_PATH, { recursive: true });
125
+ await fs.writeFile(storage.archivePath(ARCHIVE_FILENAME), 'archive content');
126
+ await storage.save(METADATA);
127
+ });
128
+
129
+ after(removeStorage);
130
+
131
+ it('writes the metadata file', async () => {
132
+ expect(JSON.parse(await fs.readFile(storage.metadataPath, 'utf8'))).to.deep.equal(METADATA);
133
+ });
134
+
135
+ it('leaves no temporary metadata file', async () => {
136
+ await expect(fs.access(`${storage.metadataPath}${TEMPORARY_SUFFIX}`)).to.be.rejected;
137
+ });
138
+
139
+ it('keeps the archive', async () => {
140
+ await expect(fs.access(storage.archivePath(ARCHIVE_FILENAME))).to.be.fulfilled;
141
+ });
142
+ });
143
+ });
144
+
145
+ describe('#removePreviousArchives', () => {
146
+ context('when no metadata exists', () => {
147
+ before(async () => {
148
+ await fs.mkdir(TMP_PATH, { recursive: true });
149
+ });
150
+
151
+ after(removeStorage);
152
+
153
+ it('does nothing', async () => {
154
+ await expect(storage.removePreviousArchives()).to.be.fulfilled;
155
+ });
156
+ });
157
+
158
+ context('when metadata exists', () => {
159
+ before(async () => {
160
+ await fs.mkdir(TMP_PATH, { recursive: true });
161
+ await fs.writeFile(storage.archivePath(ARCHIVE_FILENAME), 'archive content');
162
+ await fs.writeFile(storage.archivePath(PREVIOUS_ARCHIVE_FILENAME), 'previous archive');
163
+ await fs.writeFile(storage.archivePath(INTERRUPTED_ARCHIVE_FILENAME), 'interrupted archive');
164
+ await fs.writeFile(storage.archivePath(UNRELATED_FILENAME), 'unrelated content');
165
+ await fs.writeFile(storage.metadataPath, JSON.stringify(METADATA));
166
+
167
+ await storage.removePreviousArchives();
168
+ });
169
+
170
+ after(removeStorage);
171
+
172
+ it('keeps the current archive', async () => {
173
+ await expect(fs.access(storage.archivePath(ARCHIVE_FILENAME))).to.be.fulfilled;
174
+ });
175
+
176
+ it('removes previous archives', async () => {
177
+ await expect(fs.access(storage.archivePath(PREVIOUS_ARCHIVE_FILENAME))).to.be.rejected;
178
+ });
179
+
180
+ it('removes interrupted archives', async () => {
181
+ await expect(fs.access(storage.archivePath(INTERRUPTED_ARCHIVE_FILENAME))).to.be.rejected;
182
+ });
183
+
184
+ it('keeps unrelated files', async () => {
185
+ await expect(fs.access(storage.archivePath(UNRELATED_FILENAME))).to.be.fulfilled;
186
+ });
187
+ });
188
+
189
+ context('when a previous archive fails to be removed', () => {
190
+ before(async () => {
191
+ await fs.mkdir(TMP_PATH, { recursive: true });
192
+ await fs.writeFile(storage.archivePath(ARCHIVE_FILENAME), 'archive content');
193
+ await fs.writeFile(storage.archivePath(PREVIOUS_ARCHIVE_FILENAME), 'previous archive');
194
+ await fs.writeFile(storage.metadataPath, JSON.stringify(METADATA));
195
+
196
+ sinon.stub(fs, 'rm').rejects(new Error('Permission denied'));
197
+ });
198
+
199
+ after(async () => {
200
+ sinon.restore(); // Restored first, so that the cleanup below runs the real `fs.rm`
201
+ await removeStorage();
202
+ });
203
+
204
+ it('rejects', async () => {
205
+ await expect(storage.removePreviousArchives()).to.be.rejected;
206
+ });
207
+ });
208
+ });
209
+ });