scripts/generate-manifest.js (216 lines of code) (raw):
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { readFileSync } from "fs";
import semver from "semver";
import { orderBy, groupBy, get, mapValues } from "lodash-es";
import constants from "./constants.js";
import {
coerceToDateSemver,
coerceToSemVer,
checkDateVersion,
} from "./date-versions.js";
export { generateVectorManifest, generateCatalogueManifest };
/**
* Generate a catalogue manifest for a specific version of Elastic Maps Service
* @param {Object} [opts]
* @param {string} [opts.version='v0'] - Version of vector file manifest to link to
* @param {string} [opts.tileHostname=`${constants.TILE_STAGING_HOST}`] - Hostname for tile manifest
* @param {string} [opts.vectorHostname=`${constants.VECTOR_STAGING_HOST}`] - Hostname for files manifest
*/
function generateCatalogueManifest(opts) {
opts = {
version: 'v0',
tileHostname: constants.TILE_STAGING_HOST,
vectorHostname: constants.VECTOR_STAGING_HOST,
...opts,
};
// Get the Semantic Version (in case it is date based)
const version = coerceToSemVer(opts.version);
//Catalogue manifest was removed in 7.6+
if (semver.gte(version, '7.6.0')) {
return;
}
const tilesManifest = semver.lt(version, '7.2.0')
? { id: 'tiles_v2', version: 'v2' }
: { id: 'tiles', version: 'v7.2' };
const manifest = {
version: `${version.major}.${version.minor}`,
services: [{
id: tilesManifest.id,
name: 'Elastic Maps Tile Service',
manifest: `https://${opts.tileHostname}/${tilesManifest.version}/manifest`,
type: 'tms',
}, {
id: 'geo_layers',
name: 'Elastic Maps Vector Service',
manifest: `https://${opts.vectorHostname}/${opts.version}/manifest`,
type: 'file',
}],
};
return manifest;
}
/**
* Generate a vector manifest for a specific version
* @param {Object[]} sources - An array of layer objects
* @param {Object} [opts]
* @param {string} [opts.version='0'] - Only include layers that satisfy this semver version
* @param {boolean} [opts.production=false] - If true, include only production layers
* @param {string} [opts.hostname=`${constants.VECTOR_STAGING_HOST}`] - Hostname for files in manifest
* @param {Object} [opts.fieldInfo=null] - Field metadata
*/
function generateVectorManifest(sources, opts) {
opts = {
version: 'v0',
production: false,
hostname: constants.VECTOR_STAGING_HOST,
fieldInfo: null,
dataDir: 'data',
...opts,
};
const isDateVersion = checkDateVersion(opts.version);
// Get the Semantic Version (in case it is date based)
const manifestVersion = coerceToSemVer(opts.version);
const layers = [];
const uniqueProperties = [];
for (const source of orderBy(sources, ['weight', 'name'], ['desc', 'asc'])) {
if (!semver.validRange(source.versions)) {
throw new Error(`Invalid versions specified for ${source.name}`);
}
if ((!opts.production ||
(opts.production && source.production)) &&
semver.satisfies(manifestVersion, source.versions)) {
switch (semver.major(manifestVersion)) {
case 1:
uniqueProperties.push('name', 'id');
layers.push(manifestLayerV1(source, opts.hostname, { manifestVersion }));
break;
case 2:
uniqueProperties.push('name', 'id');
layers.push(manifestLayerV2(source, opts.hostname, { manifestVersion }));
break;
case 6:
case 7:
case 8:
case 9: // v6, v7, v8, v9 manifest schema are the same
uniqueProperties.push('layer_id');
layers.push(manifestLayerV6(source, opts.hostname, { manifestVersion, fieldInfo: opts.fieldInfo, dataDir: opts.dataDir }));
break;
default:
throw new Error(`Unable to get a manifest for version ${manifestVersion}`);
}
}
}
for (const prop of uniqueProperties) {
throwIfDuplicates(layers, prop);
}
const manifest = {
version: isDateVersion
// Get the Date Version
? coerceToDateSemver(opts.version)?.date
// Get the Semantic Version
: `${semver.major(manifestVersion)}.${semver.minor(manifestVersion)}`,
layers: layers,
};
return manifest;
}
function throwIfDuplicates(array, prop) {
const uniqueNames = groupBy(array, prop);
for (const key of Object.getOwnPropertyNames(uniqueNames)) {
if (uniqueNames[key].length > 1) {
throw new Error(`${key} has duplicate ${prop}`);
}
}
return false;
}
function manifestLayerV1(data, hostname, opts) {
const format = getDefaultFormat(data.emsFormats);
const pathname = `/blob/${data.id}`;
const layer = {
attribution: data.attribution.map(getAttributionString).join('|'),
weight: data.weight,
name: data.humanReadableName.en,
url: getFileUrl(hostname, pathname, opts.manifestVersion),
format: format.type,
fields: data.fieldMapping
.filter(fieldMap => ['id', 'property'].includes(fieldMap.type))
.map(fieldMap => ({
name: fieldMap.name,
description: fieldMap.desc,
})),
created_at: data.createdAt,
tags: [],
id: data.id,
};
return layer;
}
function manifestLayerV2(data, hostname, opts) {
const layer = manifestLayerV1(data, hostname, opts);
const format = getDefaultFormat(data.emsFormats);
if (format.type === 'topojson') {
layer.meta = {
feature_collection_path: get(format, 'meta.feature_collection_path', 'data'),
};
}
return layer;
}
function manifestLayerV6(data, hostname, { manifestVersion, fieldInfo, dataDir }) {
const formats = data.emsFormats.map(format => {
const pathname = `/files/${format.file}`;
return { ...{
type: format.type,
url: getFileUrl(hostname, pathname, manifestVersion),
legacy_default: format.default || false,
}, ...(format.meta && { meta: format.meta }) };
});
const idFields = data.fieldMapping.filter(field => field.type === 'id');
const { file } = getDefaultFormat(data.emsFormats);
const idInfos = getIdsFromFile(dataDir, file, idFields);
const fields = getFieldMapping(data.fieldMapping, manifestVersion, idInfos, fieldInfo);
const layer = {
layer_id: data.name,
created_at: data.createdAt,
attribution: data.attribution,
formats,
fields: fields,
legacy_ids: data.legacyIds,
layer_name: data.humanReadableName,
};
return layer;
}
function getDefaultFormat(emsFormats) {
return emsFormats.find(format => format.default);
}
function getIdsFromFile(dataDir, file, fields) {
const fieldMap = {};
const fieldsWithIds = fields.filter(field => !field.skipCopy);
if (fieldsWithIds.length == 0) return fieldMap;
// Only read the dataset if there are identfiers to return
const json = JSON.parse(readFileSync(`${dataDir}/${file}`, 'utf8'));
const features = json.features || json.objects.data.geometries;
for (const { name } of fieldsWithIds) {
fieldMap[name] = new Set(); // Probably unnecessary but ensures unique ids
}
for (const feature of features) {
for (const { name } of fieldsWithIds) {
fieldMap[name].add(feature.properties[name]);
}
}
return fieldMap;
}
/**
* Get localized labels for the field. Field names starting with `label_` are assumed to be
* followed by an ISO 639 language code and the field name is mapped to the `name.18n` property
* in `fieldInfo`. Otherwise, field names are mapped to the matching property in `fieldInfo`.
* @private
* @param {string} fieldName Name of the field in the source vector file
* @param {Object} fieldInfo Metadata about all available fields
* @param {Object.<string, string>} fieldInfo.i18n Each key is an ISO 639 language code and
* the value is the field translation
* @returns {Object.<string, string>}
* @example
* // returns { en: 'name (en)', fr: 'nom (en) }
* getFieldLabels('label_en', { name: { i18n: { en: 'name', fr: 'nom' } } });
* @example
* // returns { en: 'Dantai code', fr: 'code dantai' }
* getFieldLabels('dantai', { dantai: { i18n: { en: 'Dantai code', fr: 'code dantai' } } });
*/
function getFieldLabels(fieldName, fieldInfo) {
if (fieldName.startsWith('label_') && get(fieldInfo, 'name.i18n')) {
const lang = fieldName.replace('label_', '');
const labels = mapValues(fieldInfo.name.i18n, label => `${label} (${lang})`);
return labels;
} else if (get(fieldInfo, `${fieldName}.i18n`)) {
return fieldInfo[fieldName].i18n;
} else {
return;
}
}
/**
* Get label or Markdown href string for v1 and v2 manifests
* @private
* @param {Object} attr Localized attribution
* @param {string} attr.label Attribution label
* @param {string} [attr.url] URL link to attribution (optional)
* @returns {string}
*/
function getAttributionString(attr) {
if (!attr.label) {
throw new Error(`Attribution sources must have a 'label' property`);
}
return attr.url ? `[${attr.label.en}](${attr.url.en})` : `${attr.label.en}`;
}
/**
* Get a file url for a vector manifest.
* EMS versions <7.6 include the full URL and query string. At EMS v7.6,
* URLs are relative to the manifest hostname and the query string must be
* applied by the client.
* @private
* @param {string} hostname
* @param {string} pathname
* @param {string} manifestVersion
* @returns {string}
*/
function getFileUrl(hostname, pathname, manifestVersion) {
if (semver.lt(manifestVersion, '7.6.0')) {
return `https://${hostname}${pathname}?elastic_tile_service_tos=agree`;
} else {
return `${pathname}`;
}
}
function getFieldMapping(sourceFieldsMap, manifestVersion, idInfos, fieldInfo) {
const supportsFieldMeta = semver.gte(manifestVersion, '7.14.0');
return sourceFieldsMap
.filter(sourceFieldMap => ['id', 'property'].includes(sourceFieldMap.type))
.map(sourceFieldMap => {
const { type, name, desc, regex, alias, skipCopy } = sourceFieldMap;
const values = type === 'id' && !skipCopy ? [...idInfos[name]] : undefined;
return {
type,
id: name,
label: { ...{ en: desc }, ...getFieldLabels(name, fieldInfo) },
...(supportsFieldMeta && regex && ({ regex })),
...(supportsFieldMeta && alias && ({ alias })),
...(supportsFieldMeta && values && ({ values })),
};
});
}