fhirsmith 0.14.1 → 0.14.2
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.
- package/CHANGELOG.md +35 -0
- package/README.md +3 -2
- package/config-template.json +9 -0
- package/library/fhir-openapi-schema.js +441 -0
- package/library/html-server.js +9 -3
- package/library/npm-audit.js +265 -0
- package/library/openapi-doc.js +557 -0
- package/library/utilities.js +21 -0
- package/package.json +10 -4
- package/packages/openapi.js +13 -0
- package/packages/openapi.yaml +603 -0
- package/packages/packages-template.html +4 -2
- package/packages/packages.js +286 -81
- package/packages/readme.md +120 -0
- package/registry/api.js +117 -41
- package/registry/crawler.js +24 -0
- package/registry/fhirsmith-releases.js +170 -0
- package/registry/model.js +10 -0
- package/registry/openapi.js +13 -0
- package/registry/openapi.yaml +311 -0
- package/registry/readme.md +107 -208
- package/registry/registry-template.html +4 -1
- package/registry/registry.js +180 -19
- package/server.js +19 -5
- package/testing/openapi-schemas.config.js +58 -0
- package/testing/openapi-schemas.json +1920 -0
- package/testing/openapi.js +15 -0
- package/testing/openapi.yaml +273 -0
- package/testing/readme.md +43 -1
- package/testing/search.js +1 -1
- package/testing/testing-template.html +2 -0
- package/testing/testing.js +24 -0
- package/translations/Messages.properties +1 -0
- package/tx/README.md +58 -0
- package/tx/cm/cm-database.js +10 -1
- package/tx/cm/cm-package.js +8 -1
- package/tx/cs/cs-rxnorm.js +23 -6
- package/tx/html/tx-template.html +2 -0
- package/tx/library/canonical-resource.js +50 -1
- package/tx/library/codesystem.js +1 -0
- package/tx/library/conceptmap.js +1 -0
- package/tx/library/valueset.js +16 -0
- package/tx/library.js +17 -2
- package/tx/openapi-operations.js +363 -0
- package/tx/openapi-schemas.config.js +118 -0
- package/tx/openapi-schemas.json +7903 -0
- package/tx/openapi.js +289 -0
- package/tx/openapi.yaml +151 -0
- package/tx/operation-context.js +15 -0
- package/tx/params.js +3 -12
- package/tx/provider.js +10 -1
- package/tx/tx-html.js +6 -0
- package/tx/tx.js +55 -0
- package/tx/vs/vs-database.js +14 -5
- package/tx/vs/vs-package.js +10 -0
- package/tx/workers/expand.js +35 -16
- package/tx/workers/metadata.js +0 -13
- package/tx/workers/validate.js +65 -19
- package/tx/workers/worker.js +29 -8
- package/utilities/extract-schema-json.js +167 -0
- package/utilities/generate-openapi-schemas.js +49 -0
- package/xig/xig.js +2 -4
package/packages/packages.js
CHANGED
|
@@ -14,10 +14,70 @@ const htmlServer = require('../library/html-server');
|
|
|
14
14
|
const folders = require('../library/folder-setup');
|
|
15
15
|
const escape = require('escape-html');
|
|
16
16
|
const Logger = require('../library/logger');
|
|
17
|
-
const {validateParameter} = require("../library/utilities");
|
|
17
|
+
const {validateParameter, Utilities} = require("../library/utilities");
|
|
18
18
|
const {describeCron} = require("../library/cron-utilities");
|
|
19
19
|
const {tokenMatches, tokenConfigured} = require("../library/request-token");
|
|
20
20
|
const pckLog = Logger.getInstance().child({ module: 'packages' });
|
|
21
|
+
const packagesOpenApi = require('./openapi');
|
|
22
|
+
|
|
23
|
+
// Query parameter rules for validateQueryParams. These are also the contract published in
|
|
24
|
+
// openapi.yaml, and tests/packages/openapi.test.js checks that the two agree - so change
|
|
25
|
+
// both together.
|
|
26
|
+
const SEARCH_PARAMS = {
|
|
27
|
+
name: { maxLength: 100, pattern: /^[a-zA-Z0-9._#-]*$/ },
|
|
28
|
+
dependson: { maxLength: 100, pattern: /^[a-zA-Z0-9._#|@-]*$/ },
|
|
29
|
+
pkgcanonical: { maxLength: 200, pattern: /^[a-zA-Z0-9._:/-]*%?$/ },
|
|
30
|
+
canonical: { maxLength: 200, pattern: /^[a-zA-Z0-9._:/-]*$/ },
|
|
31
|
+
fhirversion: { maxLength: 10, pattern: /^(R2|R2B|R3|R4|R4B|R5|R6)?$/ },
|
|
32
|
+
dependency: { maxLength: 100, pattern: /^[a-zA-Z0-9._#|@-]*$/ },
|
|
33
|
+
sort: { maxLength: 20, pattern: /^-?(name|version|date|count|fhirversion|kind|canonical)$/ },
|
|
34
|
+
objWrapper: { maxLength: 10, pattern: /^(true|false)?$/ },
|
|
35
|
+
// Sent by the Java PackageClient (org.hl7.fhir.utilities.npm). Accepted, but has no
|
|
36
|
+
// effect: a search already returns pre-release versions.
|
|
37
|
+
prerelease: { maxLength: 5, pattern: /^(true|false)?$/ }
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// /-/v1/search also takes the npm registry search parameters. npm always sends all of
|
|
41
|
+
// these; quality, popularity and maintenance are ranking weights, accepted and ignored.
|
|
42
|
+
const V1_SEARCH_PARAMS = {
|
|
43
|
+
...SEARCH_PARAMS,
|
|
44
|
+
text: { maxLength: 200, pattern: /^[a-zA-Z0-9._:@/#| -]*$/ },
|
|
45
|
+
size: { maxLength: 3, pattern: /^\d{1,3}$/ },
|
|
46
|
+
from: { maxLength: 6, pattern: /^\d{1,6}$/ },
|
|
47
|
+
quality: { maxLength: 10, pattern: /^(\d+(\.\d+)?)?$/ },
|
|
48
|
+
popularity: { maxLength: 10, pattern: /^(\d+(\.\d+)?)?$/ },
|
|
49
|
+
maintenance: { maxLength: 10, pattern: /^(\d+(\.\d+)?)?$/ }
|
|
50
|
+
};
|
|
51
|
+
const V1_SEARCH_MAX_SIZE = 250;
|
|
52
|
+
|
|
53
|
+
// FHIR release codes accepted by fhirversion=, mapped to the version prefix they select.
|
|
54
|
+
const FHIR_RELEASE_VERSIONS = {
|
|
55
|
+
'R2': '1.0',
|
|
56
|
+
'R2B': '1.4',
|
|
57
|
+
'R3': '3.0',
|
|
58
|
+
'R4': '4.0',
|
|
59
|
+
'R4B': '4.3',
|
|
60
|
+
'R5': '5.0',
|
|
61
|
+
'R6': '6.0'
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// No default for dateValue: a default computed here would be frozen at the date the server
|
|
65
|
+
// started. The /updates handler supplies today's date when none is given.
|
|
66
|
+
const UPDATES_PARAMS = {
|
|
67
|
+
dateType: { maxLength: 10, pattern: /^(relative|absolute)?$/, default: 'relative' },
|
|
68
|
+
daysValue: { maxLength: 3, pattern: /^\d{1,3}$/, default: '10' },
|
|
69
|
+
dateValue: { maxLength: 10, pattern: /^\d{4}-\d{2}-\d{2}$/ }
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const BROKEN_PARAMS = {
|
|
73
|
+
filter: { maxLength: 100, pattern: /^[a-zA-Z0-9._-]*$/ }
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Path parameter rules for GET /:id/:version (also published in openapi.yaml).
|
|
77
|
+
const PACKAGE_ID_PATTERN = /^(@[a-z0-9._-]+\/)?[a-zA-Z0-9._-]+$/;
|
|
78
|
+
const PACKAGE_ID_MAX_LENGTH = 100;
|
|
79
|
+
const PACKAGE_VERSION_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
80
|
+
const PACKAGE_VERSION_MAX_LENGTH = 50;
|
|
21
81
|
|
|
22
82
|
class PackagesModule {
|
|
23
83
|
constructor(stats) {
|
|
@@ -52,6 +112,8 @@ class PackagesModule {
|
|
|
52
112
|
"frame-ancestors 'none'"
|
|
53
113
|
].join('; '));
|
|
54
114
|
res.removeHeader('X-Powered-By');
|
|
115
|
+
// RFC 8631: where to find the machine-readable description of this API
|
|
116
|
+
res.setHeader('Link', `<${req.baseUrl}/openapi.json>; rel="service-desc", <${req.baseUrl}/openapi>; rel="service-doc"`);
|
|
55
117
|
next();
|
|
56
118
|
});
|
|
57
119
|
}
|
|
@@ -145,6 +207,10 @@ class PackagesModule {
|
|
|
145
207
|
} else if (condition.operator === 'IN_SUBQUERY') {
|
|
146
208
|
query += ` AND ${condition.column} IN (${condition.subquery})`;
|
|
147
209
|
params.push(condition.value);
|
|
210
|
+
} else if (condition.operator === 'TEXT') {
|
|
211
|
+
// A free-text search term: in the package id or its description
|
|
212
|
+
query += ' AND (PackageVersions.Id LIKE ? OR CAST(PackageVersions.Description AS TEXT) LIKE ?)';
|
|
213
|
+
params.push(`%${condition.value}%`, `%${condition.value}%`);
|
|
148
214
|
}
|
|
149
215
|
});
|
|
150
216
|
return { query, params };
|
|
@@ -158,6 +224,7 @@ class PackagesModule {
|
|
|
158
224
|
canonicalUrl = '',
|
|
159
225
|
fhirVersion = '',
|
|
160
226
|
dependency = '',
|
|
227
|
+
text = '',
|
|
161
228
|
sort = ''
|
|
162
229
|
} = params;
|
|
163
230
|
|
|
@@ -182,13 +249,14 @@ class PackagesModule {
|
|
|
182
249
|
// Add the missing dependency search logic
|
|
183
250
|
if (dependson) {
|
|
184
251
|
validateParameter(dependson, "dependson", String);
|
|
185
|
-
versioned = dependson
|
|
186
|
-
//
|
|
252
|
+
versioned = /[#|@]/.test(dependson);
|
|
253
|
+
// Dependencies are stored as id@version (npm convention), so accept # and | as
|
|
254
|
+
// the separator too.
|
|
187
255
|
conditions.push({
|
|
188
256
|
column: 'PackageVersions.PackageVersionKey',
|
|
189
257
|
operator: 'IN_SUBQUERY',
|
|
190
258
|
subquery: 'SELECT PackageVersionKey FROM PackageDependencies WHERE Dependency LIKE ?',
|
|
191
|
-
value: `%${dependson}%`
|
|
259
|
+
value: `%${dependson.replace(/[#|]/g, '@')}%`
|
|
192
260
|
});
|
|
193
261
|
}
|
|
194
262
|
|
|
@@ -212,7 +280,7 @@ class PackagesModule {
|
|
|
212
280
|
|
|
213
281
|
// Add FHIR version search (requires PackageFHIRVersions table)
|
|
214
282
|
if (fhirVersion) {
|
|
215
|
-
const mappedVersion =
|
|
283
|
+
const mappedVersion = FHIR_RELEASE_VERSIONS[fhirVersion] || fhirVersion;
|
|
216
284
|
conditions.push({
|
|
217
285
|
column: 'PackageVersions.PackageVersionKey',
|
|
218
286
|
operator: 'IN_SUBQUERY',
|
|
@@ -224,13 +292,13 @@ class PackagesModule {
|
|
|
224
292
|
// Add dependency search
|
|
225
293
|
if (dependency) {
|
|
226
294
|
validateParameter(dependency, "dependency", String);
|
|
295
|
+
// Dependencies are stored as id@version (npm convention). Clients send id#version
|
|
296
|
+
// or id|version (the Java PackageClient sends |), or just id for any version.
|
|
227
297
|
let depQuery;
|
|
228
|
-
if (dependency
|
|
229
|
-
depQuery = `${dependency}%`;
|
|
230
|
-
} else if (dependency.includes('|')) {
|
|
231
|
-
depQuery = `${dependency.replace('|', '#')}%`;
|
|
298
|
+
if (/[#|@]/.test(dependency)) {
|
|
299
|
+
depQuery = `${dependency.replace(/[#|]/g, '@')}%`;
|
|
232
300
|
} else {
|
|
233
|
-
depQuery = `${dependency}
|
|
301
|
+
depQuery = `${dependency}@%`;
|
|
234
302
|
}
|
|
235
303
|
|
|
236
304
|
conditions.push({
|
|
@@ -241,6 +309,12 @@ class PackagesModule {
|
|
|
241
309
|
});
|
|
242
310
|
}
|
|
243
311
|
|
|
312
|
+
// Free text (npm search): every term must appear in the id or the description.
|
|
313
|
+
// npm qualifiers (keywords:, author:, scope: etc.) have no equivalent here and are ignored.
|
|
314
|
+
for (const term of text.split(/\s+/).filter(t => t && !/^[a-z-]+:/.test(t))) {
|
|
315
|
+
conditions.push({ operator: 'TEXT', value: term });
|
|
316
|
+
}
|
|
317
|
+
|
|
244
318
|
// Build appropriate base query
|
|
245
319
|
if (versioned) {
|
|
246
320
|
baseQuery = `SELECT Id, Version, PubDate, FhirVersions, Kind, Canonical, Description
|
|
@@ -407,12 +481,14 @@ class PackagesModule {
|
|
|
407
481
|
// Get counts from database
|
|
408
482
|
const tableCounts = await this.getDatabaseTableCounts();
|
|
409
483
|
|
|
484
|
+
// the page footer: the last crawl this run, else when the database was last written
|
|
485
|
+
const updated = this.lastRunTime || dbAge.lastModified;
|
|
410
486
|
return {
|
|
411
487
|
downloadDate: downloadDate,
|
|
488
|
+
crawlerStatus: updated ? `last updated ${Utilities.describeAgo(updated)}` : 'not yet updated',
|
|
412
489
|
totalResources: 0, // Packages don't track individual resources
|
|
413
490
|
totalPackages: tableCounts.packages || 0,
|
|
414
491
|
totalVersions: tableCounts.packageVersions || 0,
|
|
415
|
-
version: '4.0.1',
|
|
416
492
|
crawlerEnabled: this.config.crawler.enabled,
|
|
417
493
|
lastCrawlerRun: this.lastRunTime,
|
|
418
494
|
totalCrawlerRuns: this.totalRuns
|
|
@@ -426,7 +502,6 @@ class PackagesModule {
|
|
|
426
502
|
totalResources: 0,
|
|
427
503
|
totalPackages: 0,
|
|
428
504
|
totalVersions: 0,
|
|
429
|
-
version: '4.0.1',
|
|
430
505
|
crawlerEnabled: false,
|
|
431
506
|
lastCrawlerRun: null,
|
|
432
507
|
totalCrawlerRuns: 0
|
|
@@ -434,8 +509,15 @@ class PackagesModule {
|
|
|
434
509
|
}
|
|
435
510
|
}
|
|
436
511
|
|
|
512
|
+
// The database file, resolved the same way initializeDatabase opens it: a relative
|
|
513
|
+
// config.database is relative to the data directory, not the working directory.
|
|
514
|
+
getDatabasePath() {
|
|
515
|
+
return path.isAbsolute(this.config.database) ? this.config.database : folders.filePath('packages', this.config.database);
|
|
516
|
+
}
|
|
517
|
+
|
|
437
518
|
getDatabaseAgeInfo() {
|
|
438
|
-
|
|
519
|
+
const dbPath = this.getDatabasePath();
|
|
520
|
+
if (!fs.existsSync(dbPath)) {
|
|
439
521
|
return {
|
|
440
522
|
lastModified: null,
|
|
441
523
|
daysOld: null,
|
|
@@ -443,10 +525,11 @@ class PackagesModule {
|
|
|
443
525
|
};
|
|
444
526
|
}
|
|
445
527
|
|
|
446
|
-
const stats = fs.statSync(
|
|
528
|
+
const stats = fs.statSync(dbPath);
|
|
447
529
|
const lastModified = stats.mtime;
|
|
448
530
|
const now = new Date();
|
|
449
|
-
|
|
531
|
+
// Clamped: a file timestamp slightly ahead of this clock (e.g. on a network mount) is 'Today', not '-1 days ago'
|
|
532
|
+
const ageInDays = Math.max(0, Math.floor((now - lastModified) / (1000 * 60 * 60 * 24)));
|
|
450
533
|
|
|
451
534
|
return {
|
|
452
535
|
lastModified: lastModified,
|
|
@@ -596,7 +679,7 @@ class PackagesModule {
|
|
|
596
679
|
async initializeDatabase() {
|
|
597
680
|
return new Promise((resolve, reject) => {
|
|
598
681
|
// Use config path if absolute, otherwise resolve relative to data dir
|
|
599
|
-
const dbPath =
|
|
682
|
+
const dbPath = this.getDatabasePath();
|
|
600
683
|
|
|
601
684
|
// Ensure directory exists
|
|
602
685
|
const dbDir = path.dirname(dbPath);
|
|
@@ -909,23 +992,64 @@ class PackagesModule {
|
|
|
909
992
|
}
|
|
910
993
|
|
|
911
994
|
setupRoutes() {
|
|
912
|
-
|
|
913
|
-
const
|
|
914
|
-
name: { maxLength: 100, pattern: /^[a-zA-Z0-9._#-]*$/ },
|
|
915
|
-
dependson: { maxLength: 100, pattern: /^[a-zA-Z0-9._#-]*$/ },
|
|
916
|
-
pkgcanonical: { maxLength: 200, pattern: /^[a-zA-Z0-9._:/-]*%?$/ },
|
|
917
|
-
canonical: { maxLength: 200, pattern: /^[a-zA-Z0-9._:/-]*$/ },
|
|
918
|
-
fhirversion: { maxLength: 10, pattern: /^(R2|R2B|R3|R4|R4B|R5|R6)?$/ },
|
|
919
|
-
dependency: { maxLength: 100, pattern: /^[a-zA-Z0-9._#|-]*$/ },
|
|
920
|
-
sort: { maxLength: 20, pattern: /^-?(name|version|date|count|fhirversion|kind|canonical)$/ },
|
|
921
|
-
objWrapper: { maxLength: 10, pattern: /^(true|false)?$/ }
|
|
922
|
-
};
|
|
995
|
+
const searchParams = SEARCH_PARAMS;
|
|
996
|
+
const updatesParams = UPDATES_PARAMS;
|
|
923
997
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
998
|
+
// OpenAPI description of this API: /openapi.json, /openapi.yaml, and /openapi (an HTML
|
|
999
|
+
// reference for browsers, the JSON otherwise). Registered before /:id, which would
|
|
1000
|
+
// otherwise treat "openapi" as a package id.
|
|
1001
|
+
this.router.get('/openapi.json', (req, res) => {
|
|
1002
|
+
const start = Date.now();
|
|
1003
|
+
try {
|
|
1004
|
+
res.type('application/json').send(packagesOpenApi.getJson());
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
pckLog.error('Error in /packages/openapi.json:', error);
|
|
1007
|
+
res.status(500).json({error: 'Failed to load the OpenAPI description', message: error.message});
|
|
1008
|
+
} finally {
|
|
1009
|
+
this.stats.countRequest('openapi', Date.now() - start);
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
this.router.get('/openapi.yaml', (req, res) => {
|
|
1014
|
+
const start = Date.now();
|
|
1015
|
+
try {
|
|
1016
|
+
res.setHeader('Content-Type', 'application/yaml');
|
|
1017
|
+
res.send(packagesOpenApi.getYaml());
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
pckLog.error('Error in /packages/openapi.yaml:', error);
|
|
1020
|
+
res.status(500).json({error: 'Failed to load the OpenAPI description', message: error.message});
|
|
1021
|
+
} finally {
|
|
1022
|
+
this.stats.countRequest('openapi', Date.now() - start);
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
this.router.get('/openapi', async (req, res) => {
|
|
1027
|
+
const start = Date.now();
|
|
1028
|
+
const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
|
|
1029
|
+
try {
|
|
1030
|
+
if (!acceptsHtml) {
|
|
1031
|
+
res.type('application/json').send(packagesOpenApi.getJson());
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
if (!htmlServer.hasTemplate('packages')) {
|
|
1035
|
+
htmlServer.loadTemplate('packages', path.join(__dirname, 'packages-template.html'));
|
|
1036
|
+
}
|
|
1037
|
+
const content = packagesOpenApi.renderHtml();
|
|
1038
|
+
const stats = await this.gatherPackageStatistics();
|
|
1039
|
+
stats.processingTime = Date.now() - start;
|
|
1040
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1041
|
+
res.send(htmlServer.renderPage('packages', 'Package Server API', content, stats));
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
pckLog.error('Error in /packages/openapi:', error);
|
|
1044
|
+
if (acceptsHtml) {
|
|
1045
|
+
htmlServer.sendErrorResponse(res, 'packages', error);
|
|
1046
|
+
} else {
|
|
1047
|
+
res.status(500).json({error: 'Failed to load the OpenAPI description', message: error.message});
|
|
1048
|
+
}
|
|
1049
|
+
} finally {
|
|
1050
|
+
this.stats.countRequest('openapi', Date.now() - start);
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
929
1053
|
|
|
930
1054
|
// GET /packages/catalog - Search packages or get updates
|
|
931
1055
|
this.router.get('/catalog', this.validateQueryParams(searchParams), async (req, res) => {
|
|
@@ -944,12 +1068,11 @@ class PackagesModule {
|
|
|
944
1068
|
});
|
|
945
1069
|
|
|
946
1070
|
// GET /packages/-/v1/search - Search packages (v1 API)
|
|
947
|
-
this.router.get('/-/v1/search', this.validateQueryParams(
|
|
1071
|
+
this.router.get('/-/v1/search', this.validateQueryParams(V1_SEARCH_PARAMS), async (req, res) => {
|
|
948
1072
|
const start = Date.now();
|
|
949
1073
|
try {
|
|
950
1074
|
try {
|
|
951
|
-
req
|
|
952
|
-
await this.serveSearch(req, res);
|
|
1075
|
+
await this.serveV1Search(req, res);
|
|
953
1076
|
pckLog.info("/search?" + searchParams);
|
|
954
1077
|
} catch (error) {
|
|
955
1078
|
pckLog.error('Error in /packages/-/v1/search:', error);
|
|
@@ -1056,9 +1179,7 @@ class PackagesModule {
|
|
|
1056
1179
|
});
|
|
1057
1180
|
|
|
1058
1181
|
// GET /packages/broken
|
|
1059
|
-
this.router.get('/broken', this.validateQueryParams({
|
|
1060
|
-
filter: { maxLength: 100, pattern: /^[a-zA-Z0-9._-]*$/ }
|
|
1061
|
-
}), async (req, res) => {
|
|
1182
|
+
this.router.get('/broken', this.validateQueryParams(BROKEN_PARAMS), async (req, res) => {
|
|
1062
1183
|
const start = Date.now();
|
|
1063
1184
|
try {
|
|
1064
1185
|
try {
|
|
@@ -1082,13 +1203,11 @@ class PackagesModule {
|
|
|
1082
1203
|
// Validate path parameters
|
|
1083
1204
|
const {id, version} = req.params;
|
|
1084
1205
|
|
|
1085
|
-
if (!id || !version ||
|
|
1086
|
-
!/^(@[a-z0-9._-]+\/)?[a-zA-Z0-9._-]+$/.test(id) ||
|
|
1087
|
-
!/^[a-zA-Z0-9._-]+$/.test(version)) {
|
|
1206
|
+
if (!id || !version || !PACKAGE_ID_PATTERN.test(id) || !PACKAGE_VERSION_PATTERN.test(version)) {
|
|
1088
1207
|
return res.status(400).json({error: `Invalid package id or version format: ${id}`});
|
|
1089
1208
|
}
|
|
1090
1209
|
|
|
1091
|
-
if (id.length >
|
|
1210
|
+
if (id.length > PACKAGE_ID_MAX_LENGTH || version.length > PACKAGE_VERSION_MAX_LENGTH) {
|
|
1092
1211
|
return res.status(400).json({error: `Package id or version too long: ${id}`});
|
|
1093
1212
|
}
|
|
1094
1213
|
|
|
@@ -1146,7 +1265,7 @@ class PackagesModule {
|
|
|
1146
1265
|
});
|
|
1147
1266
|
|
|
1148
1267
|
// GET /packages/:id - Get package versions
|
|
1149
|
-
this.router.get('/:id', async (req, res) => {
|
|
1268
|
+
this.router.get('/:id', async (req, res, next) => {
|
|
1150
1269
|
const start = Date.now();
|
|
1151
1270
|
try {
|
|
1152
1271
|
|
|
@@ -1154,10 +1273,13 @@ class PackagesModule {
|
|
|
1154
1273
|
const {id} = req.params;
|
|
1155
1274
|
const {sort} = req.query;
|
|
1156
1275
|
|
|
1157
|
-
// Don't process routes that are handled elsewhere
|
|
1276
|
+
// Don't process routes that are handled elsewhere. These must call next():
|
|
1277
|
+
// returning without responding leaves the request hanging, which is what
|
|
1278
|
+
// /status, /stats and /search (registered after this route) used to do.
|
|
1158
1279
|
if (['catalog', 'log', 'broken', 'stats', 'status', 'search', 'updates'].includes(id) ||
|
|
1159
1280
|
id.endsWith('.html') || id === '-') {
|
|
1160
|
-
|
|
1281
|
+
next();
|
|
1282
|
+
return;
|
|
1161
1283
|
}
|
|
1162
1284
|
|
|
1163
1285
|
await this.serveVersions(id, sort, req.secure, req, res);
|
|
@@ -1277,10 +1399,6 @@ class PackagesModule {
|
|
|
1277
1399
|
totalRuns: this.totalRuns,
|
|
1278
1400
|
lastLog: this.lastCrawlerLog || null
|
|
1279
1401
|
},
|
|
1280
|
-
paths: {
|
|
1281
|
-
database: this.config.database,
|
|
1282
|
-
mirror: this.config.mirrorPath
|
|
1283
|
-
},
|
|
1284
1402
|
config: {
|
|
1285
1403
|
masterUrl: this.config.masterUrl
|
|
1286
1404
|
}
|
|
@@ -1794,7 +1912,7 @@ class PackagesModule {
|
|
|
1794
1912
|
|
|
1795
1913
|
const versionObj = {
|
|
1796
1914
|
name: id,
|
|
1797
|
-
_id: `${id}@${
|
|
1915
|
+
_id: `${id}@${pv.Version}`,
|
|
1798
1916
|
version: pv.Version,
|
|
1799
1917
|
date: new Date(pv.PubDate).toISOString(),
|
|
1800
1918
|
fhirVersion: this.interpretVersion(pv.FhirVersions),
|
|
@@ -1845,7 +1963,7 @@ class PackagesModule {
|
|
|
1845
1963
|
buildTarballUrl(id, version, secure, req) {
|
|
1846
1964
|
if (this.config.bucketPath) {
|
|
1847
1965
|
let bucketUrl = this.getBucketUrl(secure);
|
|
1848
|
-
return `${bucketUrl}${id}-${version}.tgz`;
|
|
1966
|
+
return `${bucketUrl}${this.fixPrefix(id)}-${version}.tgz`;
|
|
1849
1967
|
} else {
|
|
1850
1968
|
// Use direct server URL
|
|
1851
1969
|
const protocol = secure ? 'https' : 'http';
|
|
@@ -1987,6 +2105,52 @@ class PackagesModule {
|
|
|
1987
2105
|
return escape(text).replace(/\n/g, '<br>');
|
|
1988
2106
|
}
|
|
1989
2107
|
|
|
2108
|
+
// GET /-/v1/search: the npm registry search API. Takes the FHIR search parameters plus
|
|
2109
|
+
// npm's text/size/from, and returns npm's result shape. Without size, every result is
|
|
2110
|
+
// returned (npm's own clients always send size).
|
|
2111
|
+
async serveV1Search(req, res) {
|
|
2112
|
+
const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
|
|
2113
|
+
if (acceptsHtml) {
|
|
2114
|
+
await this.serveSearch(req, res);
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
const q = req.query;
|
|
2119
|
+
const secure = req.secure || req.headers['x-forwarded-proto'] === 'https';
|
|
2120
|
+
try {
|
|
2121
|
+
const results = await this.searchPackages({
|
|
2122
|
+
name: q.name,
|
|
2123
|
+
dependson: q.dependson,
|
|
2124
|
+
canonicalPkg: q.pkgcanonical,
|
|
2125
|
+
canonicalUrl: q.canonical,
|
|
2126
|
+
fhirVersion: q.fhirversion,
|
|
2127
|
+
dependency: q.dependency,
|
|
2128
|
+
text: q.text,
|
|
2129
|
+
sort: q.sort
|
|
2130
|
+
}, req, secure);
|
|
2131
|
+
|
|
2132
|
+
const from = parseInt(q.from, 10) || 0;
|
|
2133
|
+
const page = q.size
|
|
2134
|
+
? results.slice(from, from + Math.min(parseInt(q.size, 10), V1_SEARCH_MAX_SIZE))
|
|
2135
|
+
: results.slice(from);
|
|
2136
|
+
|
|
2137
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2138
|
+
res.json({
|
|
2139
|
+
objects: page.map(pkg => ({
|
|
2140
|
+
// The npm CLI requires maintainers (it calls maintainers.map) and reads keywords
|
|
2141
|
+
package: { ...pkg, keywords: [], maintainers: [] },
|
|
2142
|
+
score: { final: 1, detail: { quality: 1, popularity: 1, maintenance: 1 } },
|
|
2143
|
+
searchScore: 1
|
|
2144
|
+
})),
|
|
2145
|
+
total: results.length,
|
|
2146
|
+
time: new Date().toUTCString()
|
|
2147
|
+
});
|
|
2148
|
+
} catch (error) {
|
|
2149
|
+
pckLog.error('Error in v1 search:', error);
|
|
2150
|
+
res.status(500).json({error: 'Search failed', message: error.message});
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
|
|
1990
2154
|
async serveSearch(req, res) {
|
|
1991
2155
|
const {
|
|
1992
2156
|
name = '',
|
|
@@ -2029,7 +2193,7 @@ class PackagesModule {
|
|
|
2029
2193
|
// Return JSON response
|
|
2030
2194
|
let responseData;
|
|
2031
2195
|
|
|
2032
|
-
if (objWrapper) {
|
|
2196
|
+
if (objWrapper === true || objWrapper === 'true') {
|
|
2033
2197
|
// V1 API format with object wrapper
|
|
2034
2198
|
responseData = {
|
|
2035
2199
|
objects: results.map(pkg => ({package: pkg}))
|
|
@@ -2265,18 +2429,6 @@ class PackagesModule {
|
|
|
2265
2429
|
return str.replace(/'/g, "''");
|
|
2266
2430
|
}
|
|
2267
2431
|
|
|
2268
|
-
getVersion(fhirVersion) {
|
|
2269
|
-
// Map common FHIR version aliases to actual versions
|
|
2270
|
-
const versionMap = {
|
|
2271
|
-
'R2': '1.0.2',
|
|
2272
|
-
'R3': '3.0.2',
|
|
2273
|
-
'R4': '4.0.1',
|
|
2274
|
-
'R5': '5.0.0'
|
|
2275
|
-
};
|
|
2276
|
-
|
|
2277
|
-
return versionMap[fhirVersion] || fhirVersion;
|
|
2278
|
-
}
|
|
2279
|
-
|
|
2280
2432
|
interpretVersion(fhirVersions) {
|
|
2281
2433
|
if (!fhirVersions) return '';
|
|
2282
2434
|
|
|
@@ -2300,7 +2452,7 @@ class PackagesModule {
|
|
|
2300
2452
|
buildPackageUrl(id, version, secure = false, req = null) {
|
|
2301
2453
|
if (this.config.bucketPath) {
|
|
2302
2454
|
let bucketUrl = this.getBucketUrl(secure);
|
|
2303
|
-
return `${bucketUrl}${id}-${version}.tgz`;
|
|
2455
|
+
return `${bucketUrl}${this.fixPrefix(id)}-${version}.tgz`;
|
|
2304
2456
|
} else {
|
|
2305
2457
|
// Use direct server URL
|
|
2306
2458
|
const protocol = secure ? 'https' : 'http';
|
|
@@ -2337,6 +2489,15 @@ class PackagesModule {
|
|
|
2337
2489
|
case 'count':
|
|
2338
2490
|
comparison = (a.count || 0) - (b.count || 0);
|
|
2339
2491
|
break;
|
|
2492
|
+
case 'fhirversion':
|
|
2493
|
+
comparison = this.compareVersions(a.fhirVersion || '', b.fhirVersion || '');
|
|
2494
|
+
break;
|
|
2495
|
+
case 'kind':
|
|
2496
|
+
comparison = (a.kind || '').localeCompare(b.kind || '');
|
|
2497
|
+
break;
|
|
2498
|
+
case 'canonical':
|
|
2499
|
+
comparison = (a.canonical || '').localeCompare(b.canonical || '');
|
|
2500
|
+
break;
|
|
2340
2501
|
default:
|
|
2341
2502
|
return 0;
|
|
2342
2503
|
}
|
|
@@ -2345,20 +2506,38 @@ class PackagesModule {
|
|
|
2345
2506
|
});
|
|
2346
2507
|
}
|
|
2347
2508
|
|
|
2509
|
+
// Semver-style comparison: numeric major.minor.patch, then a version with a pre-release
|
|
2510
|
+
// label (1.0.0-ballot) sorts before the release (1.0.0). Anything unparseable falls back
|
|
2511
|
+
// to string order, so the result is always a number (Array.sort misbehaves on NaN).
|
|
2348
2512
|
compareVersions(a, b) {
|
|
2349
|
-
const
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2513
|
+
const parse = v => {
|
|
2514
|
+
const [core, ...pre] = String(v).trim().split('-');
|
|
2515
|
+
return { nums: core.split('.').map(n => parseInt(n, 10)), pre: pre.join('-') };
|
|
2516
|
+
};
|
|
2517
|
+
const va = parse(a);
|
|
2518
|
+
const vb = parse(b);
|
|
2519
|
+
|
|
2520
|
+
for (let i = 0; i < Math.max(va.nums.length, vb.nums.length); i++) {
|
|
2521
|
+
const aPart = va.nums[i] || 0;
|
|
2522
|
+
const bPart = vb.nums[i] || 0;
|
|
2523
|
+
if (Number.isNaN(aPart) || Number.isNaN(bPart)) {
|
|
2524
|
+
return String(a).localeCompare(String(b));
|
|
2525
|
+
}
|
|
2356
2526
|
if (aPart !== bPart) {
|
|
2357
2527
|
return aPart - bPart;
|
|
2358
2528
|
}
|
|
2359
2529
|
}
|
|
2360
2530
|
|
|
2361
|
-
|
|
2531
|
+
if (va.pre === vb.pre) {
|
|
2532
|
+
return 0;
|
|
2533
|
+
}
|
|
2534
|
+
if (!va.pre) {
|
|
2535
|
+
return 1;
|
|
2536
|
+
}
|
|
2537
|
+
if (!vb.pre) {
|
|
2538
|
+
return -1;
|
|
2539
|
+
}
|
|
2540
|
+
return va.pre.localeCompare(vb.pre, undefined, { numeric: true });
|
|
2362
2541
|
}
|
|
2363
2542
|
|
|
2364
2543
|
generateSearchHtml(req, results, params) {
|
|
@@ -2691,13 +2870,14 @@ class PackagesModule {
|
|
|
2691
2870
|
|
|
2692
2871
|
if (logData) {
|
|
2693
2872
|
if (logData.startTime) {
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
if (logData.endTime) {
|
|
2697
|
-
content += `<tr><td>End Time:</td><td>${new Date(logData.endTime).toLocaleString()}</td></tr>`;
|
|
2873
|
+
// relative, because the server's local clock means nothing to the reader
|
|
2874
|
+
content += `<tr><td>Started:</td><td title="${new Date(logData.startTime).toISOString()}">${this.formatTimeAgo(logData.startTime)}</td></tr>`;
|
|
2698
2875
|
}
|
|
2699
2876
|
if (logData.runTime) {
|
|
2700
|
-
|
|
2877
|
+
const ms = parseInt(logData.runTime, 10);
|
|
2878
|
+
if (!isNaN(ms)) {
|
|
2879
|
+
content += `<tr><td>Duration:</td><td>${(ms / 1000).toFixed(1)} seconds</td></tr>`;
|
|
2880
|
+
}
|
|
2701
2881
|
}
|
|
2702
2882
|
if (logData.totalBytes) {
|
|
2703
2883
|
content += `<tr><td>Total Bytes:</td><td>${logData.totalBytes.toLocaleString()}</td></tr>`;
|
|
@@ -2733,6 +2913,24 @@ class PackagesModule {
|
|
|
2733
2913
|
return content;
|
|
2734
2914
|
}
|
|
2735
2915
|
|
|
2916
|
+
formatTimeAgo(time) {
|
|
2917
|
+
const secs = Math.max(0, Math.round((Date.now() - new Date(time).getTime()) / 1000));
|
|
2918
|
+
const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'} ago`;
|
|
2919
|
+
if (secs < 60) {
|
|
2920
|
+
return plural(secs, 'second');
|
|
2921
|
+
}
|
|
2922
|
+
const mins = Math.floor(secs / 60);
|
|
2923
|
+
if (mins < 60) {
|
|
2924
|
+
return plural(mins, 'minute');
|
|
2925
|
+
}
|
|
2926
|
+
const hours = Math.floor(mins / 60);
|
|
2927
|
+
if (hours < 24) {
|
|
2928
|
+
const rem = mins % 60;
|
|
2929
|
+
return rem ? `${hours} hour${hours === 1 ? '' : 's'} ${rem} minute${rem === 1 ? '' : 's'} ago` : plural(hours, 'hour');
|
|
2930
|
+
}
|
|
2931
|
+
return plural(Math.floor(hours / 24), 'day');
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2736
2934
|
// Add this new method to format the crawler log as readable text
|
|
2737
2935
|
formatCrawlerLog(logData) {
|
|
2738
2936
|
let output = '';
|
|
@@ -2773,12 +2971,12 @@ class PackagesModule {
|
|
|
2773
2971
|
getStatus() {
|
|
2774
2972
|
return {
|
|
2775
2973
|
enabled: true,
|
|
2974
|
+
// No file system paths here: this is served publicly (/packages/status and the
|
|
2975
|
+
// server health check)
|
|
2776
2976
|
database: {
|
|
2777
|
-
connected: this.db ? true : false
|
|
2778
|
-
path: this.config.database
|
|
2977
|
+
connected: this.db ? true : false
|
|
2779
2978
|
},
|
|
2780
2979
|
mirror: {
|
|
2781
|
-
path: this.config.mirrorPath,
|
|
2782
2980
|
exists: fs.existsSync(this.config.mirrorPath)
|
|
2783
2981
|
},
|
|
2784
2982
|
crawler: {
|
|
@@ -2857,4 +3055,11 @@ class PackagesModule {
|
|
|
2857
3055
|
}
|
|
2858
3056
|
}
|
|
2859
3057
|
|
|
3058
|
+
PackagesModule.QUERY_PARAMS = { search: SEARCH_PARAMS, v1Search: V1_SEARCH_PARAMS, updates: UPDATES_PARAMS, broken: BROKEN_PARAMS };
|
|
3059
|
+
PackagesModule.FHIR_RELEASE_VERSIONS = FHIR_RELEASE_VERSIONS;
|
|
3060
|
+
PackagesModule.PATH_PARAMS = {
|
|
3061
|
+
id: { maxLength: PACKAGE_ID_MAX_LENGTH, pattern: PACKAGE_ID_PATTERN },
|
|
3062
|
+
version: { maxLength: PACKAGE_VERSION_MAX_LENGTH, pattern: PACKAGE_VERSION_PATTERN }
|
|
3063
|
+
};
|
|
3064
|
+
|
|
2860
3065
|
module.exports = PackagesModule;
|