├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── RELEASE.md ├── index.js ├── lib ├── authenticate.js ├── constants.js ├── defaults │ ├── index.js │ └── server-metadata.js ├── error.js ├── generate-renderer │ ├── color-ramp.js │ ├── create-symbol.js │ ├── index.js │ └── validate-classification-definition.js ├── helpers │ ├── calculate-extent.js │ ├── data-type-utils.js │ ├── errors.js │ ├── feature-layer-metadata.js │ ├── fields │ │ ├── constants.js │ │ ├── esri-type-utils.js │ │ ├── field-classes.js │ │ ├── fields.js │ │ ├── index.js │ │ ├── layer-fields.js │ │ ├── query-fields.js │ │ └── statistics-fields.js │ ├── get-collection-crs.js │ ├── get-geometry-type-from-geojson.js │ ├── get-spatial-reference.js │ ├── index.js │ ├── is-geojson-table.js │ ├── normalize-extent.js │ ├── normalize-input-data.js │ ├── normalize-spatial-reference.js │ ├── renderers.js │ └── table-layer-metadata.js ├── layer-metadata.js ├── layers-metadata.js ├── query │ ├── filter-and-transform.js │ ├── index.js │ ├── log-warnings.js │ ├── render-count-and-extent.js │ ├── render-features.js │ ├── render-precalculated-statistics.js │ └── render-statistics.js ├── queryRelatedRecords.js ├── relationships-info-route-handler.js ├── response-handler.js ├── rest-info-route-handler.js ├── route.js └── server-info-route-handler.js ├── package-lock.json ├── package.json ├── renovate.json ├── templates ├── errors │ ├── credentials-invalid.json │ └── unauthorized.json ├── features.json ├── renderers │ └── symbology │ │ ├── fill-symbol.json │ │ └── style.xml └── server.json └── test ├── integration ├── authenticate.spec.js ├── error.spec.js ├── fixtures │ ├── budget-table.json │ ├── data-with-complex-metadata.json │ ├── date-no-metadata.json │ ├── date-with-metadata.json │ ├── fully-specified-metadata.json │ ├── no-geometry.json │ ├── offset-applied.json │ ├── one-of-each.json │ ├── polygon-metadata-error.json │ ├── polygon.json │ ├── projection-applied.json │ ├── provider-statistics.json │ ├── relatedData.json │ ├── relatedDataCountProperty.json │ ├── snow-text-objectid.json │ ├── snow.json │ ├── stats-out-single.json │ ├── trees-crs-102645.json │ ├── trees-untagged-102645.json │ └── treesSubset.json ├── info.spec.js ├── layers.spec.js ├── query.spec.js ├── queryRelatedRecords.spec.js ├── route.spec.js ├── schemas │ └── index.js └── template.json.spec.js └── unit ├── defaults └── server-metadata.spec.js ├── generate-renderer ├── color-ramp.spec.js ├── create-symbol.spec.js ├── index.spec.js └── validate-classification-definition.spec.js ├── helpers ├── calculate-extent.spec.js ├── data-type-utils.spec.js ├── feature-layer-metadata.spec.js ├── fields │ ├── constants.spec.js │ ├── esri-type-utils.spec.js │ ├── field-classes.spec.js │ ├── fields.spec.js │ ├── layer-fields.spec.js │ ├── query-fields.spec.js │ └── statistics-fields.spec.js ├── get-collection-crs.spec.js ├── get-geometry-type-from-geojson.spec.js ├── get-spatial-reference.spec.js ├── is-geojson-table.spec.js ├── normalize-extent.spec.js ├── normalize-input-data.spec.js ├── normalize-spatial-reference.spec.js ├── renderers.spec.js └── table-layer-metadata.spec.js ├── layer-metadata.spec.js ├── layers-metadata.spec.js ├── query ├── filter-and-transform.spec.js ├── index.spec.js ├── render-count-and-extent.spec.js ├── render-features.spec.js ├── render-precalculated-statistics.spec.js └── render-statistics.spec.js ├── response-handler.spec.js ├── rest-info-route-handler.spec.js ├── route.spec.js └── server-info-route-handler.spec.js /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [14.x, 16.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm ci 29 | - run: npm run build --if-present 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | yarn.lock 4 | **/.DS_Store 5 | .nyc_output/ 6 | coverage/ 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Esri 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # FeatureServer Release Process 2 | 3 | ## 1. Contribute 4 | 5 | - Check issues 6 | - Write documentation 7 | - Write tests 8 | - Fix bugs 9 | - Refactor code 10 | - Add new features as needed 11 | 12 | ## 2. Review 13 | 14 | - Should have a human-readable change log update under an `Unreleased` heading 15 | - Be wary of breaking changes -- WWSVD? (Would Would [SemVer](http://semver.org/) Do?) 16 | - New functions should have tests and doc (using [jsdoc](http://usejsdoc.org)) 17 | - All tests should pass 18 | 19 | ## 3. Merge 20 | 21 | - Only merge to master if it's ready to go out with the next release 22 | - All patch and minor updates are fine to merge to master 23 | - Major updates (breaking changes) should be merged to a separate major branch 24 | - Use `[DNM]` (Do Not Merge) in the PR title to indicate more changes are pending 25 | 26 | ## 4. Prepare 27 | 28 | - Is this a patch, minor, or major release? 29 | - Are all tests passing? 30 | - Is the change log up to date? 31 | 32 | ## 5. Bump 33 | 34 | - One commit should handle the version bump 35 | - Commit message format: `:package: X.Y.Z` 36 | - Bump in `package.json` and `CHANGELOG.md` 37 | - Version number in `CHANGELOG.md` should have a compare link for diff from last version 38 | - Example: https://github.com/koopjs/FeatureServer/compare/v1.12.5...v1.12.6 39 | 40 | ## 6. Release 41 | 42 | - Create a release on github using [`gh-release`](https://github.com/ngoldman/gh-release) 43 | - Verify that everything looks okay (you can undo a release on github, but not on npm) 44 | - Run `npm run compile` 45 | - Run `npm publish` 46 | 47 | ## GOTO 1 48 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | route: require('./lib/route.js'), 3 | restInfo: require('./lib/rest-info-route-handler'), 4 | serverInfo: require('./lib/server-info-route-handler'), 5 | layerInfo: require('./lib/layer-metadata'), 6 | layersInfo: require('./lib/layers-metadata'), 7 | query: require('./lib/query'), 8 | queryRelatedRecords: require('./lib/queryRelatedRecords.js'), 9 | generateRenderer: require('./lib/generate-renderer'), 10 | error: require('./lib/error'), 11 | authenticate: require('./lib/authenticate') 12 | } 13 | -------------------------------------------------------------------------------- /lib/authenticate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Respond with an successful authentication response 3 | * @param {object} res express response object 4 | * @param {object} auth authentication result object 5 | * @param {string} auth.token encoded token 6 | * @param {integer} auth.expires token expiration time (epoch) 7 | * @param {boolean} ssl flag that indicates if token must always be passed back via HTTPS 8 | */ 9 | function authentication (res, auth, ssl = false) { 10 | auth.ssl = ssl 11 | res.status(200).json(auth) 12 | } 13 | 14 | module.exports = authentication 15 | -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | CURRENT_VERSION: 10.51, 3 | FULL_VERSION: '10.5.1' 4 | } 5 | -------------------------------------------------------------------------------- /lib/defaults/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | serverMetadata: require('./server-metadata') 3 | } 4 | -------------------------------------------------------------------------------- /lib/defaults/server-metadata.js: -------------------------------------------------------------------------------- 1 | const serviceResponseDefaults = { 2 | serviceDescription: 'This is a feature service exposed with Koop, an open source project that turns APIs into features. Service Description information may not be available for all services. For more information, check out https://github.com/koopjs/koop.', 3 | hasVersionedData: false, 4 | supportsDisconnectedEditing: false, 5 | supportsRelationshipsResource: false, 6 | supportedQueryFormats: 'JSON', 7 | maxRecordCount: 2000, 8 | hasStaticData: false, 9 | capabilities: 'Query', 10 | description: 'This is a feature service exposed with Koop, an open source project that turns APIs into features. Service Description information may not be available for all services. For more information, check out https://github.com/koopjs/koop.', 11 | copyrightText: 'Copyright information varies from provider to provider, for more information please contact the source of this data', 12 | spatialReference: { 13 | wkid: 4326, 14 | latestWkid: 4326 15 | }, 16 | initialExtent: { 17 | xmin: -180, 18 | ymin: -90, 19 | xmax: 180, 20 | ymax: 90, 21 | spatialReference: { 22 | wkid: 4326, 23 | latestWkid: 4326 24 | } 25 | }, 26 | fullExtent: { 27 | xmin: -180, 28 | ymin: -90, 29 | xmax: 180, 30 | ymax: 90, 31 | spatialReference: { 32 | wkid: 4326, 33 | latestWkid: 4326 34 | } 35 | }, 36 | allowGeometryUpdates: false, 37 | units: 'esriDecimalDegrees', 38 | syncEnabled: false, 39 | layers: [], 40 | tables: [] 41 | } 42 | 43 | module.exports = serviceResponseDefaults 44 | -------------------------------------------------------------------------------- /lib/error.js: -------------------------------------------------------------------------------- 1 | const authenticationErrorResponse = require('../templates/errors/credentials-invalid.json') 2 | const authorizationErrorResponse = require('../templates/errors/unauthorized.json') 3 | const responseHandler = require('./response-handler') 4 | 5 | /** 6 | * Respond with a authorization error response 7 | * @param {object} res express.js response object 8 | */ 9 | function authorization (req, res) { 10 | responseHandler(req, res, 200, authorizationErrorResponse) 11 | } 12 | 13 | /** 14 | * Respond with an authentication error response 15 | * @param {object} res express.js response object 16 | */ 17 | function authentication (req, res) { 18 | responseHandler(req, res, 200, authenticationErrorResponse) 19 | } 20 | 21 | module.exports = { authorization, authentication } 22 | -------------------------------------------------------------------------------- /lib/generate-renderer/color-ramp.js: -------------------------------------------------------------------------------- 1 | const chroma = require('chroma-js') 2 | const { CodedError } = require('../helpers/errors') 3 | 4 | module.exports = { createColorRamp } 5 | 6 | function createColorRamp (params = {}) { 7 | const { 8 | classification, 9 | colorRamps, 10 | type = 'algorithmic', 11 | fromColor = [0, 255, 0], 12 | toColor = [0, 0, 255], 13 | algorithm = 'HSVAlgorithm' 14 | } = params 15 | 16 | if (!classification || classification.length === 0) { 17 | throw new Error('Must supply classification') 18 | } 19 | 20 | if (type === 'algorithmic') { 21 | return createAlgorithmicRamp({ 22 | fromColor, 23 | toColor, 24 | algorithm, 25 | classificationCount: classification.length 26 | }) 27 | } 28 | 29 | if (type === 'multipart') { 30 | return createMultipartRamp({ colorRamps, classificationCount: classification.length }) 31 | } 32 | 33 | throw new CodedError(`Invalid color ramp type: ${type}`, 400) 34 | } 35 | 36 | function createMultipartRamp (options) { 37 | const { colorRamps, classificationCount } = options 38 | 39 | if (!colorRamps || !Array.isArray(colorRamps)) { 40 | throw new CodedError( 41 | 'Multipart color-ramps need a valid color-ramp configuration array' 42 | ) 43 | } 44 | 45 | return colorRamps.map((ramp) => { 46 | return createAlgorithmicRamp({ 47 | ...ramp, 48 | classificationCount 49 | }) 50 | }) 51 | } 52 | 53 | function createAlgorithmicRamp (options) { 54 | const { fromColor, toColor, algorithm, classificationCount } = options 55 | const colorRamp = chroma.scale([fromColor.slice(0, 3), toColor.slice(0, 3)]) 56 | 57 | if (algorithm === 'esriCIELabAlgorithm') { 58 | return colorRamp.mode('lab').colors(classificationCount, 'rgb') 59 | } 60 | 61 | if (algorithm === 'esriLabLChAlgorithm') { 62 | return colorRamp.mode('lch').colors(classificationCount, 'rgb') 63 | } 64 | 65 | return colorRamp.mode('hsl').colors(classificationCount, 'rgb') 66 | } 67 | -------------------------------------------------------------------------------- /lib/generate-renderer/create-symbol.js: -------------------------------------------------------------------------------- 1 | const { 2 | PointRenderer, 3 | LineRenderer, 4 | PolygonRenderer 5 | } = require('../helpers') 6 | const { CodedError } = require('../helpers/errors') 7 | 8 | module.exports = { createSymbol } 9 | 10 | function createSymbol (baseSymbol, color, geomType) { 11 | const symbol = baseSymbol || getDefaultSymbol(geomType) 12 | return { ...symbol, color } 13 | } 14 | 15 | function getDefaultSymbol (geomType) { 16 | const { symbol } = getSymbolRenderer(geomType) 17 | return symbol 18 | } 19 | 20 | function getSymbolRenderer (geomType) { 21 | if (geomType === 'esriGeometryPoint' || geomType === 'esriGeometryMultiPoint') { 22 | return new PointRenderer() 23 | } 24 | 25 | if (geomType === 'esriGeometryPolyline') { 26 | return new LineRenderer() 27 | } 28 | 29 | if (geomType === 'esriGeometryPolygon') { 30 | return new PolygonRenderer() 31 | } 32 | 33 | throw new CodedError('Dataset geometry type is not supported for renderers.', 400) 34 | } 35 | -------------------------------------------------------------------------------- /lib/generate-renderer/index.js: -------------------------------------------------------------------------------- 1 | const Winnow = require('winnow') 2 | const { getGeometryTypeFromGeojson } = require('../helpers') 3 | const validateClassificationDefinition = require('./validate-classification-definition') 4 | const { createColorRamp } = require('./color-ramp') 5 | const { createSymbol } = require('./create-symbol') 6 | 7 | module.exports = generateRenderer 8 | 9 | function generateRenderer (data = {}, options = {}) { 10 | const { statistics = {}, features } = data 11 | const geometryType = getGeometryTypeFromGeojson(data) 12 | const { classificationDef = {} } = options 13 | 14 | if (statistics.classBreaks) { 15 | return generateRendererFromPrecalculatedStatistics(statistics, { classificationDef, geometryType }) 16 | } 17 | 18 | if (features) { 19 | return generateRendererFromFeatures(data, { ...options, geometryType }) 20 | } 21 | 22 | return {} 23 | } 24 | 25 | function generateRendererFromPrecalculatedStatistics (statistics, options) { 26 | const { classificationDef, geometryType = 'esriGeometryPoint' } = options 27 | const { colorRamp: colorRampConfig = {}, baseSymbol } = classificationDef 28 | const classification = statistics.classBreaks.sort((a, b) => a[0] - b[0]) 29 | 30 | validateClassificationDefinition(classificationDef, geometryType, classification) 31 | 32 | const colorRamp = createColorRamp({ classification, ...colorRampConfig }) 33 | const symbolCollection = colorRamp.map((color) => { 34 | return createSymbol(baseSymbol, color, geometryType) 35 | }) 36 | return renderClassBreaks(classification, classificationDef, symbolCollection) 37 | } 38 | 39 | function generateRendererFromFeatures (data, params) { 40 | const { classificationDef, geometryType } = params 41 | 42 | // TODO: this seems weird; the winnow method is "query" but it's really a very specialized transform (aggregation) 43 | // consider changes to winnow - this should maybe be a different method 44 | const classification = Winnow.query(data, params) 45 | validateClassificationDefinition(classificationDef, geometryType, classification) 46 | 47 | const { colorRamp: colorRampConfig = {}, baseSymbol } = classificationDef 48 | 49 | const colorRamp = createColorRamp({ classification, ...colorRampConfig }) 50 | const symbolCollection = colorRamp.map((color) => { 51 | return createSymbol(baseSymbol, color, geometryType) 52 | }) 53 | 54 | if (classificationDef.type === 'classBreaksDef') { 55 | return renderClassBreaks(classification, classificationDef, symbolCollection) 56 | } 57 | 58 | // if not 'classBreaksDef', then its unique-values 59 | return renderUniqueValue(classification, classificationDef, symbolCollection) 60 | } 61 | 62 | function renderClassBreaks (breaks, classificationDef, symbolCollection) { 63 | return { 64 | type: 'classBreaks', 65 | field: classificationDef.classificationField || '', 66 | classificationMethod: classificationDef.classificationMethod || '', 67 | minValue: breaks[0][0], 68 | classBreakInfos: createClassBreakInfos(breaks, symbolCollection) 69 | } 70 | } 71 | 72 | function createClassBreakInfos (breaks, symbolCollection) { 73 | return breaks.map((classBreak, index) => { 74 | return { 75 | classMinValue: classBreak[0], 76 | classMaxValue: classBreak[1], 77 | label: `${classBreak[0]}-${classBreak[1]}`, 78 | description: '', 79 | symbol: symbolCollection[index] 80 | } 81 | }) 82 | } 83 | 84 | function renderUniqueValue (classification, classificationDef, symbolCollection) { 85 | const { uniqueValueFields, fieldDelimiter } = classificationDef 86 | return { 87 | type: 'uniqueValue', 88 | field1: uniqueValueFields[0], 89 | field2: '', 90 | field3: '', 91 | fieldDelimiter, 92 | defaultSymbol: {}, 93 | defaultLabel: '', 94 | uniqueValueInfos: createUniqueValueInfos( 95 | classification, 96 | fieldDelimiter, 97 | symbolCollection 98 | ) 99 | } 100 | } 101 | 102 | function createUniqueValueInfos ( 103 | uniqueValueEntries, 104 | fieldDelimiter, 105 | symbolCollection 106 | ) { 107 | return uniqueValueEntries.map((uniqueValue, index) => { 108 | const value = serializeUniqueValues(uniqueValue, fieldDelimiter) 109 | 110 | return { 111 | value, 112 | count: uniqueValue.count, 113 | label: value, 114 | description: '', 115 | symbol: symbolCollection[index] 116 | } 117 | }) 118 | } 119 | 120 | function serializeUniqueValues (uniqueValue, delimiter) { 121 | const { count, ...rest } = uniqueValue 122 | return Object.values(rest).join(delimiter) 123 | } 124 | -------------------------------------------------------------------------------- /lib/generate-renderer/validate-classification-definition.js: -------------------------------------------------------------------------------- 1 | const joi = require('joi') 2 | const { CodedError } = require('../helpers/errors') 3 | 4 | const classificationDefinitionSchema = joi 5 | .object({ 6 | type: joi 7 | .string() 8 | .valid('classBreaksDef', 'uniqueValueDef') 9 | .error(new Error('invalid classification type')), 10 | baseSymbol: joi 11 | .object({ 12 | type: joi 13 | .string() 14 | .valid('esriSMS', 'esriSLS', 'esriSFS') 15 | .required() 16 | .error( 17 | new Error( 18 | 'baseSymbol requires a valid type: esriSMS, esriSLS, esriSFS' 19 | ) 20 | ) 21 | }) 22 | .optional() 23 | .unknown(), 24 | uniqueValueFields: joi.array().items(joi.string()) 25 | }) 26 | .required() 27 | .unknown() 28 | .messages({ 29 | 'any.required': 'classification definition is required' 30 | }) 31 | 32 | function validateClassificationDefinition ( 33 | definition, 34 | geometryType, 35 | classification 36 | ) { 37 | validateDefinitionShape(definition) 38 | validateDefinitionSymbolAgainstGeometry(definition.baseSymbol, geometryType) 39 | validateUniqueValueFields(definition, classification) 40 | } 41 | 42 | function validateDefinitionShape (definition) { 43 | const { error } = classificationDefinitionSchema.validate(definition) 44 | 45 | if (error) { 46 | error.code = 400 47 | throw error 48 | } 49 | } 50 | 51 | function validateDefinitionSymbolAgainstGeometry ( 52 | baseSymbol = {}, 53 | geometryType 54 | ) { 55 | const { type: symbolType } = baseSymbol 56 | 57 | if (!symbolType) { 58 | return 59 | } 60 | 61 | if (symbolLookup(geometryType) !== symbolType) { 62 | const error = new Error( 63 | 'Classification defintion uses a base symbol type that is incompatiable with dataset geometry' 64 | ) 65 | error.code = 400 66 | throw error 67 | } 68 | } 69 | 70 | function symbolLookup (geometryType) { 71 | switch (geometryType) { 72 | case 'esriGeometryPoint': 73 | case 'esriGeometryMultipoint': 74 | return 'esriSMS' 75 | case 'esriGeometryPolyline': 76 | return 'esriSLS' 77 | case 'esriGeometryPolygon': 78 | return 'esriSFS' 79 | default: 80 | } 81 | } 82 | 83 | function validateUniqueValueFields (definition, classification) { 84 | const { uniqueValueFields, type } = definition 85 | if (type !== 'uniqueValueDef') { 86 | return 87 | } 88 | 89 | if (!uniqueValueFields) { 90 | throw new CodedError('uniqueValueDef requires a classification definition with "uniqueValueFields" array', 400) 91 | } 92 | const classificationFieldNames = Object.keys(classification[0]) 93 | 94 | if (areFieldsMissingFromClassification(uniqueValueFields, classificationFieldNames)) { 95 | throw new CodedError(`Unique value definition fields are incongruous with classification fields: ${uniqueValueFields.join(', ')} : ${classificationFieldNames.join(', ')}`, 400) 96 | } 97 | } 98 | 99 | function areFieldsMissingFromClassification (definitionFields, classificationFieldNames) { 100 | return definitionFields.some( 101 | (fieldName) => !classificationFieldNames.includes(fieldName) 102 | ) 103 | } 104 | 105 | module.exports = validateClassificationDefinition 106 | -------------------------------------------------------------------------------- /lib/helpers/calculate-extent.js: -------------------------------------------------------------------------------- 1 | const { calculateBounds } = require('@terraformer/spatial') 2 | const normalizeExtent = require('./normalize-extent') 3 | const debug = process.env.KOOP_LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'debug' 4 | 5 | function calculateExtent ({ isLayer, geojson, spatialReference }) { 6 | if (!isLayer) { 7 | return 8 | } 9 | 10 | try { 11 | const bounds = calculateBounds(geojson) 12 | return normalizeExtent(bounds, spatialReference) 13 | } catch (error) { 14 | if (debug) { 15 | console.log(`Could not calculate extent from data: ${error.message}`) 16 | } 17 | } 18 | } 19 | 20 | module.exports = calculateExtent 21 | -------------------------------------------------------------------------------- /lib/helpers/data-type-utils.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { 3 | isValidISODateString, 4 | isValidDate 5 | } = require('iso-datestring-validator') 6 | 7 | function getDataTypeFromValue (value) { 8 | if (_.isNumber(value)) { 9 | return Number.isInteger(value) ? 'Integer' : 'Double' 10 | } 11 | 12 | if (isDate(value)) { 13 | return 'Date' 14 | } 15 | 16 | return 'String' 17 | } 18 | 19 | function isDate (value) { 20 | return value instanceof Date || ((typeof value === 'string') && (isValidDate(value) || isValidISODateString(value))) 21 | } 22 | 23 | module.exports = { 24 | getDataTypeFromValue, 25 | isDate 26 | } 27 | -------------------------------------------------------------------------------- /lib/helpers/errors.js: -------------------------------------------------------------------------------- 1 | class CodedError extends Error { 2 | constructor (message, code) { 3 | super(message) 4 | this.name = 'CodedError' 5 | this.code = code || 500 6 | } 7 | } 8 | 9 | module.exports = { 10 | CodedError 11 | } 12 | -------------------------------------------------------------------------------- /lib/helpers/feature-layer-metadata.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const TableLayerMetadata = require('./table-layer-metadata') 3 | const { 4 | PointRenderer, 5 | LineRenderer, 6 | PolygonRenderer 7 | } = require('./renderers') 8 | const { calculateBounds } = require('@terraformer/spatial') 9 | const getSpatialReference = require('./get-spatial-reference') 10 | const getGeometryTypeFromGeojson = require('./get-geometry-type-from-geojson') 11 | const normalizeExtent = require('./normalize-extent') 12 | 13 | class FeatureLayerMetadata extends TableLayerMetadata { 14 | static create (geojson, options) { 15 | const { 16 | geojson: normalizedGeojson, 17 | options: normalizedOptions 18 | } = FeatureLayerMetadata.normalizeInput(geojson, options) 19 | const layerMetadata = new FeatureLayerMetadata() 20 | return layerMetadata.mixinOverrides(normalizedGeojson, normalizedOptions) 21 | } 22 | 23 | constructor () { 24 | super() 25 | Object.assign(this, { 26 | type: 'Feature Layer', 27 | minScale: 0, 28 | maxScale: 0, 29 | canScaleSymbols: false, 30 | drawingInfo: { 31 | renderer: {}, 32 | labelingInfo: null 33 | }, 34 | extent: { 35 | xmin: -180, 36 | ymin: -90, 37 | xmax: 180, 38 | ymax: 90, 39 | spatialReference: { 40 | wkid: 4326, 41 | latestWkid: 4326 42 | } 43 | }, 44 | supportsCoordinatesQuantization: false, 45 | hasLabels: false 46 | }) 47 | } 48 | 49 | mixinOverrides (geojson = {}, options = {}) { 50 | super.mixinOverrides(geojson, options) 51 | 52 | const { renderer, extent, inputCrs, sourceSR, capabilities = {} } = options 53 | 54 | this.geometryType = getGeometryTypeFromGeojson({ ...geojson, ...options }) 55 | 56 | this.supportsCoordinatesQuantization = !!capabilities.quantization 57 | 58 | this._setExtent(geojson, { inputCrs, sourceSR, extent }) 59 | 60 | this._setRenderer(renderer) 61 | 62 | this._setDirectOverrides(options) 63 | 64 | return this 65 | } 66 | 67 | _setExtent (geojson, options) { 68 | const extent = getLayerExtent(geojson, options) 69 | if (extent) { 70 | this.extent = extent 71 | } 72 | } 73 | 74 | _setRenderer (renderer) { 75 | if (renderer) { 76 | this.drawingInfo.renderer = renderer 77 | return 78 | } 79 | 80 | if (this.geometryType === 'esriGeometryPolygon') { 81 | this.drawingInfo.renderer = new PolygonRenderer() 82 | } else if (this.geometryType === 'esriGeometryPolyline') { 83 | this.drawingInfo.renderer = new LineRenderer() 84 | } else { 85 | this.drawingInfo.renderer = new PointRenderer() 86 | } 87 | } 88 | 89 | _setDirectOverrides (options) { 90 | super._setDirectOverrides(options) 91 | const { 92 | minScale, 93 | maxScale 94 | } = options 95 | 96 | _.merge(this, { 97 | minScale, 98 | maxScale 99 | }) 100 | } 101 | } 102 | 103 | function getLayerExtent (geojson, options) { 104 | const spatialReference = getSpatialReference(geojson, options) || { 105 | wkid: 4326, 106 | latestWkid: 4326 107 | } 108 | 109 | const { extent } = options 110 | 111 | if (extent) { 112 | return normalizeExtent(extent, spatialReference) 113 | } 114 | 115 | return calculateExtentFromFeatures(geojson, spatialReference) 116 | } 117 | 118 | function calculateExtentFromFeatures (geojson, spatialReference) { 119 | if (!geojson.features || geojson.features.length === 0) { 120 | return 121 | } 122 | 123 | try { 124 | const [xmin, ymin, xmax, ymax] = calculateBounds(geojson) 125 | 126 | return { 127 | xmin, 128 | xmax, 129 | ymin, 130 | ymax, 131 | spatialReference 132 | } 133 | } catch (error) { 134 | console.log(`Could not calculate extent from data: ${error.message}`) 135 | } 136 | } 137 | 138 | module.exports = FeatureLayerMetadata 139 | -------------------------------------------------------------------------------- /lib/helpers/fields/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ESRI_FIELD_TYPE_OID: 'esriFieldTypeOID', 3 | ESRI_FIELD_TYPE_STRING: 'esriFieldTypeString', 4 | ESRI_FIELD_TYPE_DATE: 'esriFieldTypeDate', 5 | ESRI_FIELD_TYPE_DOUBLE: 'esriFieldTypeDouble', 6 | SQL_TYPE_INTEGER: 'sqlTypeInteger', 7 | SQL_TYPE_OTHER: 'sqlTypeOther', 8 | SQL_TYPE_FLOAT: 'sqlTypeFloat', 9 | OBJECTID_DEFAULT_KEY: 'OBJECTID' 10 | } 11 | -------------------------------------------------------------------------------- /lib/helpers/fields/esri-type-utils.js: -------------------------------------------------------------------------------- 1 | const { getDataTypeFromValue } = require('../data-type-utils') 2 | const { 3 | ESRI_FIELD_TYPE_STRING, 4 | ESRI_FIELD_TYPE_DATE, 5 | ESRI_FIELD_TYPE_DOUBLE 6 | } = require('./constants') 7 | 8 | function getEsriTypeFromDefinition (typeDefinition = '') { 9 | switch (typeDefinition.toLowerCase()) { 10 | case 'double': 11 | return ESRI_FIELD_TYPE_DOUBLE 12 | case 'integer': 13 | return 'esriFieldTypeInteger' 14 | case 'date': 15 | return ESRI_FIELD_TYPE_DATE 16 | case 'blob': 17 | return 'esriFieldTypeBlob' 18 | case 'geometry': 19 | return 'esriFieldTypeGeometry' 20 | case 'globalid': 21 | return 'esriFieldTypeGlobalID' 22 | case 'guid': 23 | return 'esriFieldTypeGUID' 24 | case 'raster': 25 | return 'esriFieldTypeRaster' 26 | case 'single': 27 | return 'esriFieldTypeSingle' 28 | case 'smallinteger': 29 | return 'esriFieldTypeSmallInteger' 30 | case 'xml': 31 | return 'esriFieldTypeXML' 32 | case 'string': 33 | default: 34 | return ESRI_FIELD_TYPE_STRING 35 | } 36 | } 37 | 38 | function getEsriTypeFromValue (value) { 39 | const dataType = getDataTypeFromValue(value) 40 | 41 | return getEsriTypeFromDefinition(dataType) 42 | } 43 | 44 | module.exports = { 45 | getEsriTypeFromDefinition, 46 | getEsriTypeFromValue 47 | } 48 | -------------------------------------------------------------------------------- /lib/helpers/fields/field-classes.js: -------------------------------------------------------------------------------- 1 | const { 2 | getEsriTypeFromDefinition, 3 | getEsriTypeFromValue 4 | } = require('./esri-type-utils') 5 | const { 6 | ESRI_FIELD_TYPE_OID, 7 | ESRI_FIELD_TYPE_STRING, 8 | ESRI_FIELD_TYPE_DATE, 9 | ESRI_FIELD_TYPE_DOUBLE, 10 | SQL_TYPE_INTEGER, 11 | SQL_TYPE_OTHER, 12 | SQL_TYPE_FLOAT, 13 | OBJECTID_DEFAULT_KEY 14 | } = require('./constants') 15 | 16 | class Field { 17 | setEditable (value = false) { 18 | this.editable = value 19 | return this 20 | } 21 | 22 | setNullable (value = false) { 23 | this.nullable = value 24 | return this 25 | } 26 | 27 | setLength () { 28 | if (this.type === ESRI_FIELD_TYPE_STRING) { 29 | this.length = 128 30 | } else if (this.type === ESRI_FIELD_TYPE_DATE) { 31 | this.length = 36 32 | } 33 | } 34 | } 35 | 36 | class ObjectIdField extends Field { 37 | constructor (key = OBJECTID_DEFAULT_KEY) { 38 | super() 39 | this.name = key 40 | this.type = ESRI_FIELD_TYPE_OID 41 | this.alias = key 42 | this.sqlType = SQL_TYPE_INTEGER 43 | this.domain = null 44 | this.defaultValue = null 45 | } 46 | } 47 | 48 | class FieldFromKeyValue extends Field { 49 | constructor (key, value) { 50 | super() 51 | this.name = key 52 | this.type = getEsriTypeFromValue(value) 53 | this.alias = key 54 | this.sqlType = SQL_TYPE_OTHER 55 | this.domain = null 56 | this.defaultValue = null 57 | this.setLength() 58 | } 59 | } 60 | 61 | class StatisticField extends Field { 62 | constructor (key) { 63 | super() 64 | this.name = key 65 | this.type = ESRI_FIELD_TYPE_DOUBLE 66 | this.sqlType = SQL_TYPE_FLOAT 67 | this.alias = key 68 | this.domain = null 69 | this.defaultValue = null 70 | } 71 | } 72 | 73 | class StatisticDateField extends StatisticField { 74 | constructor (key) { 75 | super(key) 76 | this.type = ESRI_FIELD_TYPE_DATE 77 | this.sqlType = SQL_TYPE_OTHER 78 | } 79 | } 80 | 81 | class FieldFromFieldDefinition extends Field { 82 | constructor (fieldDefinition) { 83 | super() 84 | const { 85 | name, 86 | type, 87 | alias, 88 | domain, 89 | sqlType, 90 | length, 91 | defaultValue 92 | } = fieldDefinition 93 | 94 | this.name = name 95 | this.type = getEsriTypeFromDefinition(type) 96 | this.alias = alias || name 97 | this.sqlType = sqlType || SQL_TYPE_OTHER 98 | this.domain = domain || null 99 | this.defaultValue = defaultValue || null 100 | this.length = length 101 | 102 | if (!this.length || !Number.isInteger(this.length)) { 103 | this.setLength() 104 | } 105 | } 106 | } 107 | 108 | class ObjectIdFieldFromDefinition extends FieldFromFieldDefinition { 109 | constructor (definition = {}) { 110 | super(definition) 111 | this.type = ESRI_FIELD_TYPE_OID 112 | this.sqlType = SQL_TYPE_INTEGER 113 | delete this.length 114 | } 115 | } 116 | 117 | module.exports = { 118 | ObjectIdField, 119 | ObjectIdFieldFromDefinition, 120 | FieldFromKeyValue, 121 | FieldFromFieldDefinition, 122 | StatisticField, 123 | StatisticDateField 124 | } 125 | -------------------------------------------------------------------------------- /lib/helpers/fields/fields.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const chalk = require('chalk') 3 | const { 4 | ObjectIdField, 5 | FieldFromKeyValue, 6 | FieldFromFieldDefinition, 7 | ObjectIdFieldFromDefinition 8 | } = require('./field-classes') 9 | 10 | class Fields { 11 | static normalizeOptions (inputOptions) { 12 | const { 13 | features, 14 | metadata: { 15 | fields, 16 | idField 17 | } = {}, 18 | attributeSample, 19 | ...options 20 | } = inputOptions 21 | 22 | return { 23 | idField: options.idField || idField, 24 | fieldDefinitions: options.fieldDefinitions || options.fields || fields, 25 | attributeSample: attributeSample || getAttributeSample(features, attributeSample), 26 | ...options 27 | } 28 | } 29 | 30 | constructor (options = {}) { 31 | const { 32 | fieldDefinitions, 33 | idField, 34 | attributeSample = {} 35 | } = options 36 | 37 | if (shouldWarnAboutMissingIdFieldDefinition(idField, fieldDefinitions)) { 38 | console.warn( 39 | chalk.yellow(`WARNING: provider's "idField" is set to ${idField}, but this field is not found in field-definitions`) 40 | ) 41 | } 42 | 43 | const normalizedIdField = idField || 'OBJECTID' 44 | 45 | this.fields = fieldDefinitions 46 | ? setFieldsFromDefinitions(fieldDefinitions, normalizedIdField) 47 | : setFieldsFromProperties(attributeSample, normalizedIdField) 48 | } 49 | } 50 | 51 | function getAttributeSample (features) { 52 | return _.get(features, '[0].properties') || _.get(features, '[0].attributes', {}) 53 | } 54 | 55 | function shouldWarnAboutMissingIdFieldDefinition (idField, fieldDefinitions) { 56 | if (!idField || !fieldDefinitions) { 57 | return 58 | } 59 | 60 | const fieldNames = fieldDefinitions.map(field => field.name) 61 | 62 | return !fieldNames.includes(idField) 63 | } 64 | 65 | function setFieldsFromDefinitions (fieldDefinitions, idField) { 66 | const fields = fieldDefinitions 67 | .filter(fieldDefinition => fieldDefinition.name !== idField) 68 | .map(fieldDefinition => { 69 | return new FieldFromFieldDefinition(fieldDefinition) 70 | }) 71 | 72 | const idFieldDefinition = getIdFieldDefinition(fieldDefinitions, idField) 73 | fields.unshift(new ObjectIdFieldFromDefinition(idFieldDefinition)) 74 | return fields 75 | } 76 | 77 | function setFieldsFromProperties (propertiesSample, idField) { 78 | const fieldNames = Object.keys(propertiesSample) 79 | const simpleFieldNames = fieldNames.filter(name => name !== idField) 80 | 81 | const fields = simpleFieldNames.map((key) => { 82 | return new FieldFromKeyValue(key, propertiesSample[key]) 83 | }) 84 | 85 | fields.unshift(new ObjectIdField(idField)) 86 | 87 | return fields 88 | } 89 | 90 | function getIdFieldDefinition (fieldDefinitions, idField) { 91 | const idFieldDefinition = fieldDefinitions.find(definition => { 92 | return definition.name === idField 93 | }) 94 | 95 | if (idFieldDefinition) { 96 | return idFieldDefinition 97 | } 98 | 99 | return { name: 'OBJECTID' } 100 | } 101 | 102 | module.exports = Fields 103 | -------------------------------------------------------------------------------- /lib/helpers/fields/index.js: -------------------------------------------------------------------------------- 1 | const QueryFields = require('./query-fields') 2 | const StatisticsFields = require('./statistics-fields') 3 | const LayerFields = require('./layer-fields') 4 | 5 | module.exports = { 6 | QueryFields, 7 | StatisticsFields, 8 | LayerFields 9 | } 10 | -------------------------------------------------------------------------------- /lib/helpers/fields/layer-fields.js: -------------------------------------------------------------------------------- 1 | const Fields = require('./fields') 2 | 3 | class LayerFields extends Fields { 4 | static create (inputOptions) { 5 | const options = Fields.normalizeOptions(inputOptions) 6 | return new LayerFields(options) 7 | } 8 | 9 | constructor (options) { 10 | super(options) 11 | 12 | return this.fields.map(field => { 13 | const { editable = false, nullable = false } = findDefinition(field.name, options.fieldDefinitions) 14 | field.setEditable(editable).setNullable(nullable) 15 | return field 16 | }) 17 | } 18 | } 19 | 20 | function findDefinition (fieldName, fieldDefinitions = []) { 21 | return fieldDefinitions.find(definition => { 22 | return definition.name === fieldName 23 | }) || {} 24 | } 25 | module.exports = LayerFields 26 | -------------------------------------------------------------------------------- /lib/helpers/fields/query-fields.js: -------------------------------------------------------------------------------- 1 | const Fields = require('./fields') 2 | 3 | class QueryFields extends Fields { 4 | static create (inputOptions = {}) { 5 | const options = Fields.normalizeOptions(inputOptions) 6 | return new QueryFields(options) 7 | } 8 | 9 | constructor (options = {}) { 10 | super(options) 11 | 12 | const { 13 | outFields 14 | } = options 15 | 16 | if (outFields && outFields !== '*') { 17 | return filterByOutfields(outFields, this.fields) 18 | } 19 | 20 | return this.fields 21 | } 22 | } 23 | 24 | function filterByOutfields (outFields, fields) { 25 | const outFieldNames = outFields.split(/\s*,\s*/) 26 | return fields.filter(field => { 27 | return outFieldNames.includes(field.name) 28 | }) 29 | } 30 | 31 | module.exports = QueryFields 32 | -------------------------------------------------------------------------------- /lib/helpers/fields/statistics-fields.js: -------------------------------------------------------------------------------- 1 | const { isDate } = require('../data-type-utils') 2 | const { getEsriTypeFromDefinition } = require('./esri-type-utils') 3 | const { ESRI_FIELD_TYPE_DATE } = require('./constants') 4 | const { 5 | StatisticField, 6 | StatisticDateField, 7 | FieldFromFieldDefinition, 8 | FieldFromKeyValue 9 | } = require('./field-classes') 10 | 11 | class StatisticsFields { 12 | static normalizeOptions (inputOptions = {}) { 13 | const { 14 | statistics, 15 | metadata: { 16 | fields 17 | } = {}, 18 | groupByFieldsForStatistics = [], 19 | attributeSample, 20 | ...options 21 | } = inputOptions 22 | 23 | return { 24 | statisticsSample: Array.isArray(statistics) ? statistics[0] : statistics, 25 | fieldDefinitions: options.fieldDefinitions || options.fields || fields, 26 | groupByFieldsForStatistics: Array.isArray(groupByFieldsForStatistics) ? groupByFieldsForStatistics : groupByFieldsForStatistics 27 | .replace(/\s*,\s*/g, ',') 28 | .replace(/^\s/, '') 29 | .replace(/\s*$/, '') 30 | .split(','), 31 | ...options 32 | } 33 | } 34 | 35 | static create (inputOptions) { 36 | const options = StatisticsFields.normalizeOptions(inputOptions) 37 | return new StatisticsFields(options) 38 | } 39 | 40 | constructor (options = {}) { 41 | const { 42 | statisticsSample, 43 | groupByFieldsForStatistics = [], 44 | fieldDefinitions = [], 45 | outStatistics 46 | } = options 47 | const dateFieldRegexs = getDateFieldRegexs(fieldDefinitions, outStatistics) 48 | 49 | this.fields = Object 50 | .entries(statisticsSample) 51 | .map(([key, value]) => { 52 | if (groupByFieldsForStatistics.includes(key)) { 53 | const fieldDefinition = fieldDefinitions.find(({ name }) => name === key) 54 | 55 | if (fieldDefinition) { 56 | return new FieldFromFieldDefinition(fieldDefinition) 57 | } 58 | 59 | return new FieldFromKeyValue(key, value) 60 | } 61 | 62 | if (isDateField(dateFieldRegexs, key, value)) { 63 | return new StatisticDateField(key) 64 | } 65 | 66 | return new StatisticField(key) 67 | }) 68 | 69 | return this.fields 70 | } 71 | } 72 | 73 | function isDateField (regexs, fieldName, value) { 74 | return regexs.some(regex => { 75 | return regex.test(fieldName) 76 | }) || isDate(value) 77 | } 78 | 79 | function getDateFieldRegexs (fieldDefinitions = [], outStatistics = []) { 80 | const dateFields = fieldDefinitions.filter(({ type }) => { 81 | return getEsriTypeFromDefinition(type) === ESRI_FIELD_TYPE_DATE 82 | }).map(({ name }) => name) 83 | 84 | return outStatistics 85 | .filter(({ onStatisticField }) => dateFields.includes(onStatisticField)) 86 | .map((statistic) => { 87 | const { 88 | onStatisticField, 89 | outStatisticFieldName 90 | } = statistic 91 | 92 | const name = outStatisticFieldName || onStatisticField 93 | const spaceEscapedName = name.replace(/\s/g, '_') 94 | return new RegExp(`${spaceEscapedName}$`) 95 | }) 96 | } 97 | 98 | module.exports = StatisticsFields 99 | -------------------------------------------------------------------------------- /lib/helpers/get-collection-crs.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const OGC_WGS84 = 'ogc:1.3:crs84' 3 | 4 | function getCollectionCrs (collection) { 5 | const collectionCrs = _.get(collection, 'crs.properties.name') 6 | if (!collectionCrs) return 7 | 8 | const crs = collectionCrs.toLowerCase().replace(/urn:ogc:def:crs:/, '') 9 | if (crs === OGC_WGS84) return 10 | 11 | const crsRegex = /(?[a-z]+)(::|:)(?.+)/ 12 | const result = crsRegex.exec(crs) 13 | if (!result) return 14 | const { groups: { srid } } = result 15 | return srid 16 | } 17 | 18 | module.exports = getCollectionCrs 19 | -------------------------------------------------------------------------------- /lib/helpers/get-geometry-type-from-geojson.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const debug = process.env.KOOP_LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'debug' 3 | 4 | const esriLookup = { 5 | Point: 'esriGeometryPoint', 6 | MultiPoint: 'esriGeometryMultipoint', 7 | LineString: 'esriGeometryPolyline', 8 | MultiLineString: 'esriGeometryPolyline', 9 | Polygon: 'esriGeometryPolygon', 10 | MultiPolygon: 'esriGeometryPolygon', 11 | esriGeometryPoint: 'esriGeometryPoint', 12 | esriGeometryMultipoint: 'esriGeometryMultipoint', 13 | esriGeometryPolyline: 'esriGeometryPolyline', 14 | esriGeometryPolygon: 'esriGeometryPolygon' 15 | } 16 | 17 | module.exports = function getGeometryTypeFromGeojson ({ geometryType, metadata = {}, features = [] } = {}) { 18 | const type = geometryType || metadata.geometryType || findInFeatures(features) 19 | 20 | if (!type && debug) { 21 | console.log(`Input JSON has unsupported geometryType: ${type}`) 22 | } 23 | return esriLookup[type] 24 | } 25 | 26 | function findInFeatures (features) { 27 | const featureWithGeometryType = features.find(feature => { 28 | return _.get(feature, 'geometry.type') 29 | }) 30 | 31 | if (!featureWithGeometryType) return 32 | 33 | return featureWithGeometryType.geometry.type 34 | } 35 | -------------------------------------------------------------------------------- /lib/helpers/get-spatial-reference.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const getCollectionCrs = require('./get-collection-crs') 3 | const normalizeSpatialReference = require('./normalize-spatial-reference') 4 | 5 | function getSpatialReference (geojson, { inputCrs, sourceSR } = {}) { 6 | if (!inputCrs && !sourceSR && _.isEmpty(geojson)) return 7 | const spatialReference = inputCrs || sourceSR || getCollectionCrs(geojson) || { wkid: 4326, latestWkid: 4326 } 8 | 9 | if (!spatialReference) return 10 | 11 | const { latestWkid, wkid, wkt } = normalizeSpatialReference(spatialReference) 12 | 13 | if (wkid) { 14 | return { wkid, latestWkid } 15 | } 16 | 17 | return { wkt } 18 | } 19 | 20 | module.exports = getSpatialReference 21 | -------------------------------------------------------------------------------- /lib/helpers/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | normalizeExtent: require('./normalize-extent'), 3 | normalizeInputData: require('./normalize-input-data'), 4 | normalizeSpatialReference: require('./normalize-spatial-reference'), 5 | getCollectionCrs: require('./get-collection-crs'), 6 | getGeometryTypeFromGeojson: require('./get-geometry-type-from-geojson'), 7 | isTable: require('./is-geojson-table'), 8 | calculateExtent: require('./calculate-extent'), 9 | getSpatialReference: require('./get-spatial-reference'), 10 | TableLayerMetadata: require('./table-layer-metadata'), 11 | FeatureLayerMetadata: require('./feature-layer-metadata'), 12 | ...(require('./data-type-utils')), 13 | ...(require('./renderers')) 14 | } 15 | -------------------------------------------------------------------------------- /lib/helpers/is-geojson-table.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const getGeometryTypeFromGeojson = require('./get-geometry-type-from-geojson') 3 | 4 | function hasValidFullExent (data) { 5 | // Check for a valid fullExtent. If unset, assume this is a Table 6 | const fullExtent = data.fullExtent || (data.metadata && data.metadata.fullExtent) 7 | if (_.isUndefined(fullExtent) || _.isUndefined(fullExtent.xmin) || _.isUndefined(fullExtent.ymin) || fullExtent.xmin === Infinity) return true 8 | 9 | return false 10 | } 11 | 12 | module.exports = function isTable (data = {}) { 13 | // geometry indicates this in not a table 14 | const geometryType = getGeometryTypeFromGeojson(data) 15 | if (geometryType) return false 16 | 17 | return hasValidFullExent(data) 18 | } 19 | -------------------------------------------------------------------------------- /lib/helpers/normalize-extent.js: -------------------------------------------------------------------------------- 1 | const joi = require('joi') 2 | 3 | const esriExtentSchema = joi.object({ 4 | xmin: joi.number().required(), 5 | xmax: joi.number().required(), 6 | ymin: joi.number().required(), 7 | ymax: joi.number().required(), 8 | type: joi.string().optional(), 9 | spatialReference: joi.object().keys({ 10 | wkid: joi.number().integer().optional(), 11 | latestWkid: joi.number().integer().optional() 12 | }).optional() 13 | }).unknown() 14 | 15 | const simpleArraySchema = joi.array().items(joi.number().required()).min(4) 16 | const cornerArraySchema = joi.array().items(joi.array().items(joi.number()).length(2)).length(2) 17 | 18 | module.exports = function (input, spatialReference) { 19 | if (!input) return undefined 20 | 21 | const { value: arrayExtent } = validate(input, simpleArraySchema) 22 | 23 | if (arrayExtent) { 24 | return simpleArrayToEsriExtent(arrayExtent, spatialReference) 25 | } 26 | 27 | const { value: cornerArrayExtent } = validate(input, cornerArraySchema) 28 | 29 | if (cornerArrayExtent) { 30 | return cornerArrayToEsriExtent(cornerArrayExtent, spatialReference) 31 | } 32 | 33 | const { value: esriExtent } = validate(input, esriExtentSchema) 34 | 35 | if (esriExtent) { 36 | return { spatialReference, ...esriExtent } 37 | } 38 | 39 | throw new Error(`Received invalid extent: ${JSON.stringify(input)}`) 40 | } 41 | 42 | function validate (input, schema) { 43 | const { error, value } = schema.validate(input) 44 | if (error) return { error } 45 | return { value } 46 | } 47 | 48 | function simpleArrayToEsriExtent (arrayExent, spatialReference) { 49 | return { 50 | xmin: arrayExent[0], 51 | ymin: arrayExent[1], 52 | xmax: arrayExent[2], 53 | ymax: arrayExent[3], 54 | spatialReference 55 | } 56 | } 57 | 58 | function cornerArrayToEsriExtent (cornerArrayExtent, spatialReference) { 59 | return { 60 | xmin: cornerArrayExtent[0][0], 61 | ymin: cornerArrayExtent[0][1], 62 | xmax: cornerArrayExtent[1][0], 63 | ymax: cornerArrayExtent[1][1], 64 | spatialReference 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/helpers/normalize-input-data.js: -------------------------------------------------------------------------------- 1 | const getGeometryTypeFromGeojson = require('./get-geometry-type-from-geojson') 2 | 3 | module.exports = function normalizeInput (input = {}) { 4 | const { 5 | type, 6 | tables = [], 7 | layers = [], 8 | relationships = [] 9 | } = input 10 | 11 | if (type === 'FeatureCollection') { 12 | const geometryType = getGeometryTypeFromGeojson(input) 13 | if (geometryType) { 14 | return { layers: [input], tables, relationships } 15 | } 16 | return { tables: [input], layers, relationships } 17 | } 18 | 19 | return { layers, tables, relationships } 20 | } 21 | -------------------------------------------------------------------------------- /lib/helpers/normalize-spatial-reference.js: -------------------------------------------------------------------------------- 1 | const esriProjCodes = require('@esri/proj-codes') 2 | const Joi = require('joi') 3 | const wktParser = require('wkt-parser') 4 | const wktLookup = new Map() 5 | const schema = Joi.alternatives( 6 | Joi.string(), 7 | Joi.number().integer(), 8 | Joi.object({ 9 | wkid: Joi.number().integer().optional(), 10 | latestWkid: Joi.number().integer().optional(), 11 | wkt: Joi.string().optional() 12 | }).unknown().or('wkid', 'latestWkid', 'wkt').required() 13 | ) 14 | 15 | function normalizeSpatialReference (input) { 16 | if (!input) return { wkid: 4326, latestWkid: 4326 } 17 | 18 | const { error } = schema.validate(input) 19 | 20 | if (error) { 21 | if (process.env.NODE_ENV !== 'production') { 22 | console.log(`WARNING: ${input} is not a valid spatial reference; defaulting to none, error: ${error}`) 23 | } 24 | // Todo: throw error 25 | return { wkid: 4326, latestWkid: 4326 } 26 | } 27 | 28 | const { type, value } = parseSpatialReferenceInput(input) 29 | 30 | if (type === 'wkid') { 31 | return wktLookup.get(value) || esriWktLookup(value) || { wkid: 4326, latestWkid: 4326 } 32 | } 33 | 34 | return convertStringToSpatialReference(value) || { wkid: 4326, latestWkid: 4326 } 35 | } 36 | 37 | function parseSpatialReferenceInput (spatialReference) { 38 | // Search input for a wkid 39 | if (isNumericSpatialReferenceId(spatialReference)) { 40 | return { 41 | type: 'wkid', 42 | value: Number(spatialReference) 43 | } 44 | } 45 | 46 | if (isPrefixedSpatialReferenceId(spatialReference)) { 47 | return { 48 | type: 'wkid', 49 | value: extractPrefixedSpatialReferenceId(spatialReference) 50 | } 51 | } 52 | 53 | if (spatialReference.wkid || spatialReference.latestWkid) { 54 | return { 55 | type: 'wkid', 56 | value: spatialReference.wkid || spatialReference.latestWkid 57 | } 58 | } 59 | 60 | return { 61 | type: 'wkt', 62 | value: spatialReference.wkt || spatialReference 63 | } 64 | } 65 | 66 | function isNumericSpatialReferenceId (spatialReference) { 67 | return Number.isInteger(spatialReference) || Number.isInteger(Number(spatialReference)) 68 | } 69 | 70 | function isPrefixedSpatialReferenceId (spatialReference) { 71 | return /[A-Z]+:/.test(spatialReference) 72 | } 73 | 74 | function extractPrefixedSpatialReferenceId (prefixedId) { 75 | const spatialRefId = prefixedId.match(/[A-Z]*:(.*)/)[1] 76 | return Number(spatialRefId) 77 | } 78 | 79 | function esriWktLookup (lookupValue) { 80 | const result = esriProjCodes.lookup(lookupValue) 81 | 82 | if (!result) { 83 | // Todo - throw error 84 | if (process.env.NODE_ENV !== 'production') { 85 | console.log(`WARNING: An unknown spatial reference was detected: ${lookupValue}; defaulting to none`) 86 | } 87 | return 88 | } 89 | 90 | const { wkid, latestWkid } = result 91 | 92 | // Add the WKT to the local lookup so we don't need to scan the Esri lookups next time 93 | wktLookup.set(wkid, { wkid, latestWkid }) 94 | return { latestWkid, wkid } 95 | } 96 | 97 | function convertStringToSpatialReference (wkt) { 98 | if (/WGS_1984_Web_Mercator_Auxiliary_Sphere/.test(wkt)) return { wkid: 102100, latestWkid: 3857 } 99 | 100 | try { 101 | const wkid = getWktWkid(wkt) 102 | return wktLookup.get(wkid) || esriWktLookup(wkid) || { wkt } 103 | } catch (err) { 104 | if (process.env.NODE_ENV !== 'production') { 105 | console.log(`WARNING: An un-parseable WKT spatial reference was detected: ${wkt}`) 106 | } 107 | // Todo: throw error 108 | } 109 | } 110 | 111 | function getWktWkid (wkt) { 112 | const { AUTHORITY: authority } = wktParser(wkt) 113 | if (!authority) return 114 | const [, wkid] = Object.entries(authority)[0] 115 | return wkid 116 | } 117 | module.exports = normalizeSpatialReference 118 | -------------------------------------------------------------------------------- /lib/helpers/renderers.js: -------------------------------------------------------------------------------- 1 | class PointRenderer { 2 | constructor () { 3 | Object.assign(this, { 4 | type: 'simple', 5 | symbol: { 6 | color: [ 7 | 45, 8 | 172, 9 | 128, 10 | 161 11 | ], 12 | outline: { 13 | color: [ 14 | 190, 15 | 190, 16 | 190, 17 | 105 18 | ], 19 | width: 0.5, 20 | type: 'esriSLS', 21 | style: 'esriSLSSolid' 22 | }, 23 | size: 7.5, 24 | type: 'esriSMS', 25 | style: 'esriSMSCircle' 26 | } 27 | }) 28 | } 29 | } 30 | 31 | class LineRenderer { 32 | constructor () { 33 | Object.assign(this, { 34 | type: 'simple', 35 | symbol: { 36 | color: [ 37 | 247, 38 | 150, 39 | 70, 40 | 204 41 | ], 42 | width: 6.999999999999999, 43 | type: 'esriSLS', 44 | style: 'esriSLSSolid' 45 | } 46 | }) 47 | } 48 | } 49 | 50 | class PolygonRenderer { 51 | constructor () { 52 | Object.assign(this, { 53 | type: 'simple', 54 | symbol: { 55 | color: [ 56 | 75, 57 | 172, 58 | 198, 59 | 161 60 | ], 61 | outline: { 62 | color: [ 63 | 150, 64 | 150, 65 | 150, 66 | 155 67 | ], 68 | width: 0.5, 69 | type: 'esriSLS', 70 | style: 'esriSLSSolid' 71 | }, 72 | type: 'esriSFS', 73 | style: 'esriSFSSolid' 74 | } 75 | }) 76 | } 77 | } 78 | 79 | module.exports = { 80 | PointRenderer, 81 | PolygonRenderer, 82 | LineRenderer 83 | } 84 | -------------------------------------------------------------------------------- /lib/helpers/table-layer-metadata.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { 3 | CURRENT_VERSION, 4 | FULL_VERSION 5 | } = require('../constants') 6 | const { 7 | LayerFields 8 | } = require('../helpers/fields') 9 | 10 | class TableLayerMetadata { 11 | static create (geojson = {}, options = {}) { 12 | const { 13 | geojson: normalizedGeojson, 14 | options: normalizedOptions 15 | } = TableLayerMetadata.normalizeInput(geojson, options) 16 | const tableMetadata = new TableLayerMetadata() 17 | return tableMetadata.mixinOverrides(normalizedGeojson, normalizedOptions) 18 | } 19 | 20 | static normalizeInput (geojson, req) { 21 | const { 22 | metadata = {}, 23 | capabilities, 24 | ...normalizedGeojson 25 | } = geojson 26 | 27 | const { 28 | params: { 29 | layer: reqLayer 30 | } = {}, 31 | query = {} 32 | } = req 33 | 34 | const layerId = reqLayer != null ? reqLayer : req.layerId 35 | 36 | const { 37 | currentVersion, 38 | fullVersion, 39 | description 40 | } = _.get(req, 'app.locals.config.featureServer', {}) 41 | 42 | const normalizedOptions = _.pickBy({ 43 | currentVersion, 44 | fullVersion, 45 | description, 46 | layerId, 47 | ...query, 48 | ...metadata, 49 | capabilities: normalizeCapabilities(capabilities, metadata.capabilities) 50 | }, (value) => value) 51 | 52 | if (!normalizedGeojson.features) { 53 | normalizedGeojson.features = [] 54 | } 55 | 56 | return { 57 | geojson: normalizedGeojson, 58 | options: normalizedOptions 59 | } 60 | } 61 | 62 | constructor () { 63 | Object.assign(this, { 64 | id: 0, 65 | name: 'Not Set', 66 | type: 'Table', 67 | description: 'This is a feature service powered by https://github.com/featureserver/featureserver', 68 | copyrightText: ' ', 69 | parentLayer: null, 70 | subLayers: null, 71 | defaultVisibility: true, 72 | hasAttachments: false, 73 | htmlPopupType: 'esriServerHTMLPopupTypeNone', 74 | displayField: 'OBJECTID', 75 | typeIdField: null, 76 | fields: [], 77 | relationships: [], 78 | capabilities: 'Query', 79 | maxRecordCount: 2000, 80 | supportsStatistics: true, 81 | supportsAdvancedQueries: true, 82 | supportedQueryFormats: 'JSON', 83 | ownershipBasedAccessControlForFeatures: { 84 | allowOthersToQuery: true 85 | }, 86 | useStandardizedQueries: true, 87 | advancedQueryCapabilities: { 88 | useStandardizedQueries: true, 89 | supportsStatistics: true, 90 | supportsOrderBy: true, 91 | supportsDistinct: true, 92 | supportsPagination: true, 93 | supportsTrueCurve: false, 94 | supportsReturningQueryExtent: true, 95 | supportsQueryWithDistance: true 96 | }, 97 | canModifyLayer: false, 98 | dateFieldsTimeReference: null, 99 | isDataVersioned: false, 100 | supportsRollbackOnFailureParameter: true, 101 | hasM: false, 102 | hasZ: false, 103 | allowGeometryUpdates: true, 104 | objectIdField: 'OBJECTID', 105 | globalIdField: '', 106 | types: [], 107 | templates: [], 108 | hasStaticData: false, 109 | timeInfo: {}, 110 | uniqueIdField: { 111 | name: 'OBJECTID', 112 | isSystemMaintained: true 113 | }, 114 | currentVersion: CURRENT_VERSION, 115 | fullVersion: FULL_VERSION 116 | }) 117 | } 118 | 119 | mixinOverrides (geojson = {}, options = {}) { 120 | const { 121 | id, 122 | idField, 123 | displayField, 124 | capabilities, 125 | layerId, 126 | hasStaticData, 127 | supportsPagination, 128 | hasAttachments 129 | } = options 130 | 131 | this._setFields(geojson, options) 132 | 133 | this._setId(layerId, id) 134 | 135 | this._setDisplayField(displayField, idField) 136 | 137 | this._setHasStaticData(hasStaticData) 138 | 139 | this._setCapabilities(capabilities) 140 | 141 | this._setUniqueIdField(idField) 142 | 143 | this._setPagination(supportsPagination) 144 | 145 | this._setDirectOverrides(options) 146 | 147 | this._setHasAttachments(hasAttachments) 148 | 149 | return this 150 | } 151 | 152 | _setFields (data, options) { 153 | const fields = LayerFields.create({ ...data, ...options }) 154 | if (fields) { 155 | this.fields = fields 156 | } 157 | } 158 | 159 | _setId (layerId, metadataId) { 160 | const requestPathLayerId = parseInt(layerId) 161 | const id = !isNaN(requestPathLayerId) ? requestPathLayerId : metadataId 162 | 163 | if (id) { 164 | this.id = id 165 | } 166 | } 167 | 168 | _setDisplayField (displayField, idField) { 169 | const overrideDisplayField = displayField || idField 170 | 171 | if (overrideDisplayField) { 172 | this.displayField = overrideDisplayField 173 | } 174 | } 175 | 176 | _setHasStaticData (hasStaticData) { 177 | if (typeof hasStaticData === 'boolean') { 178 | this.hasStaticData = hasStaticData 179 | } 180 | } 181 | 182 | _setCapabilities (capabilities) { 183 | if (!capabilities) { 184 | return 185 | } 186 | 187 | if (capabilities.list) { 188 | this.capabilities = capabilities.list 189 | return 190 | } 191 | 192 | if (_.has(capabilities, 'extract') && !this.capabilities.includes('Extract')) { 193 | this.capabilities = `${this.capabilities},Extract` 194 | } 195 | } 196 | 197 | _setUniqueIdField (idField) { 198 | if (idField) { 199 | this.uniqueIdField.name = idField 200 | } 201 | } 202 | 203 | _setPagination (supportsPagination) { 204 | if (typeof supportsPagination === 'boolean') { 205 | this.advancedQueryCapabilities.supportsPagination = supportsPagination 206 | } 207 | } 208 | 209 | _setHasAttachments (hasAttachments) { 210 | if (hasAttachments != null && typeof hasAttachments === 'boolean') { 211 | this.hasAttachments = hasAttachments 212 | } 213 | } 214 | 215 | _setDirectOverrides (options) { 216 | const { 217 | name, 218 | relationships, 219 | description, 220 | copyrightText, 221 | templates, 222 | idField, 223 | timeInfo, 224 | maxRecordCount, 225 | defaultVisibility, 226 | currentVersion, 227 | fullVersion, 228 | hasZ 229 | } = options 230 | 231 | _.merge(this, { 232 | name, 233 | relationships, 234 | description, 235 | copyrightText, 236 | templates, 237 | objectIdField: idField, 238 | timeInfo, 239 | maxRecordCount, 240 | defaultVisibility, 241 | currentVersion, 242 | fullVersion, 243 | hasZ 244 | }) 245 | } 246 | } 247 | 248 | function normalizeCapabilities (capabilities, metadataCapabilites) { 249 | if (_.isString(metadataCapabilites)) { 250 | return { 251 | ...capabilities, 252 | list: metadataCapabilites 253 | } 254 | } 255 | 256 | return { 257 | ...(metadataCapabilites || {}), 258 | ...capabilities 259 | } 260 | } 261 | 262 | module.exports = TableLayerMetadata 263 | -------------------------------------------------------------------------------- /lib/layer-metadata.js: -------------------------------------------------------------------------------- 1 | const { 2 | isTable, 3 | TableLayerMetadata, 4 | FeatureLayerMetadata 5 | } = require('./helpers') 6 | 7 | function layerMetadata (data = {}, options = {}) { 8 | if (isTable({ ...data, ...options })) { 9 | return TableLayerMetadata.create(data, options) 10 | } 11 | 12 | return FeatureLayerMetadata.create(data, options) 13 | } 14 | 15 | module.exports = layerMetadata 16 | -------------------------------------------------------------------------------- /lib/layers-metadata.js: -------------------------------------------------------------------------------- 1 | const { 2 | normalizeInputData, 3 | TableLayerMetadata, 4 | FeatureLayerMetadata 5 | } = require('./helpers') 6 | 7 | module.exports = function layersMetadata (data, options = {}) { 8 | const { layers: layersInput, tables: tablesInput } = normalizeInputData(data) 9 | 10 | const layers = layersInput.map((layer, i) => { 11 | return FeatureLayerMetadata.create(layer, { layerId: i, ...options }) 12 | }) 13 | 14 | const tables = tablesInput.map((table, i) => { 15 | return TableLayerMetadata.create(table, { layerId: layers.length + i, ...options }) 16 | }) 17 | 18 | return { layers, tables } 19 | } 20 | -------------------------------------------------------------------------------- /lib/query/filter-and-transform.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { query } = require('winnow') 3 | const helpers = require('../helpers') 4 | 5 | function filterAndTransform (json, requestParams) { 6 | const { features, type, ...restJson } = json 7 | const params = FilterAndTransformParams.create(requestParams) 8 | .removeParamsAlreadyApplied(json.filtersApplied) 9 | .addToEsri() 10 | .addInputCrs(json) 11 | .normalizeObjectIds() 12 | 13 | const result = query(json, params) 14 | 15 | const { objectIds, outStatistics } = params 16 | 17 | if (outStatistics) { 18 | return { 19 | statistics: result, 20 | ...restJson 21 | } 22 | } 23 | 24 | if (!objectIds) { 25 | return result 26 | } 27 | 28 | return { 29 | ...result, 30 | features: filterByObjectIds(result, objectIds) 31 | } 32 | } 33 | 34 | class FilterAndTransformParams { 35 | static create (requestParams) { 36 | return new FilterAndTransformParams(requestParams) 37 | } 38 | 39 | static standardize (requestParams) { 40 | const { returnDistinctValues, ...rest } = requestParams 41 | 42 | if (returnDistinctValues === true) { 43 | rest.distinct = true 44 | } 45 | 46 | return rest 47 | } 48 | 49 | constructor (requestParams) { 50 | const params = FilterAndTransformParams.standardize(requestParams) 51 | Object.assign(this, params) 52 | } 53 | 54 | removeParamsAlreadyApplied (alreadyApplied) { 55 | for (const key in alreadyApplied) { 56 | if (key === 'projection') { 57 | delete this.outSR 58 | } 59 | 60 | if (key === 'offset') { 61 | delete this.resultOffset 62 | } 63 | 64 | if (key === 'limit') { 65 | delete this.resultRecordCount 66 | } 67 | 68 | delete this[key] 69 | } 70 | 71 | return this 72 | } 73 | 74 | addToEsri () { 75 | this.toEsri = this.f !== 'geojson' && !this.returnExtentOnly 76 | return this 77 | } 78 | 79 | addInputCrs (data = {}) { 80 | const { metadata = {} } = data 81 | this.inputCrs = this.inputCrs || this.sourceSR || metadata.crs || helpers.getCollectionCrs(data) || 4326 82 | delete this.sourceSR 83 | return this 84 | } 85 | 86 | normalizeObjectIds () { 87 | if (!this.objectIds) { 88 | return this 89 | } 90 | 91 | let ids 92 | if (Array.isArray(this.objectIds)) { 93 | ids = this.objectIds 94 | } else if (typeof this.objectIds === 'string') { 95 | ids = this.objectIds.split(',') 96 | } else if (typeof this.objectIds === 'number') { 97 | ids = [this.objectIds] 98 | } else { 99 | const error = new Error('Invalid "objectIds" parameter.') 100 | error.code = 400 101 | throw error 102 | } 103 | 104 | this.objectIds = ids.map(i => { 105 | if (isNaN(i)) { 106 | return i 107 | } 108 | 109 | return parseInt(i) 110 | }) 111 | 112 | return this 113 | } 114 | } 115 | 116 | function filterByObjectIds (data, objectIds) { 117 | const idField = _.get(data, 'metadata.idField') || 'OBJECTID' 118 | 119 | return data.features.filter(({ attributes = {}, properties = {} }) => { 120 | return objectIds.includes(attributes[idField]) || objectIds.includes(properties[idField]) 121 | }) 122 | } 123 | 124 | module.exports = { filterAndTransform } 125 | -------------------------------------------------------------------------------- /lib/query/index.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { filterAndTransform } = require('./filter-and-transform') 3 | const { logWarnings } = require('./log-warnings') 4 | const { renderFeaturesResponse } = require('./render-features') 5 | const { renderStatisticsResponse } = require('./render-statistics') 6 | const { renderPrecalculatedStatisticsResponse } = require('./render-precalculated-statistics') 7 | const { renderCountAndExtentResponse } = require('./render-count-and-extent') 8 | const { getGeometryTypeFromGeojson } = require('../helpers') 9 | 10 | function query (json, requestParams = {}) { 11 | const { 12 | features, 13 | filtersApplied: { 14 | all: skipFiltering 15 | } = {} 16 | } = json 17 | 18 | const { f: requestedFormat } = requestParams 19 | 20 | if (shouldRenderPrecalculatedData(json, requestParams)) { 21 | return renderPrecalculatedData(json, requestParams) 22 | } 23 | 24 | const data = (skipFiltering || !features) ? json : filterAndTransform(json, requestParams) 25 | 26 | if (shouldLogWarnings()) { 27 | logWarnings(data, requestParams.f) 28 | } 29 | 30 | if (requestedFormat === 'geojson') { 31 | return { 32 | type: 'FeatureCollection', 33 | features: data.features 34 | } 35 | } 36 | 37 | return renderGeoservicesResponse(data, { 38 | ...requestParams, 39 | attributeSample: _.get(json, 'features[0].properties'), 40 | geometryType: getGeometryTypeFromGeojson(json) 41 | }) 42 | } 43 | 44 | function shouldRenderPrecalculatedData ({ statistics, count, extent }, { returnCountOnly, returnExtentOnly }) { 45 | if (statistics) { 46 | return true 47 | } 48 | 49 | if (returnCountOnly === true && count !== undefined && returnExtentOnly === true && extent) { 50 | return true 51 | } 52 | 53 | if (returnCountOnly === true && count !== undefined && !returnExtentOnly) { 54 | return true 55 | } 56 | 57 | if (returnExtentOnly === true && extent && !returnCountOnly) { 58 | return true 59 | } 60 | 61 | return false 62 | } 63 | 64 | function renderPrecalculatedData (data, { 65 | returnCountOnly, 66 | returnExtentOnly, 67 | outStatistics, 68 | groupByFieldsForStatistics 69 | }) { 70 | const { statistics, count, extent } = data 71 | 72 | if (statistics) { 73 | return renderPrecalculatedStatisticsResponse(data, { outStatistics, groupByFieldsForStatistics }) 74 | } 75 | 76 | const retVal = {} 77 | 78 | if (returnCountOnly) { 79 | retVal.count = count 80 | } 81 | 82 | if (returnExtentOnly) { 83 | retVal.extent = extent 84 | } 85 | 86 | return retVal 87 | } 88 | 89 | function shouldLogWarnings () { 90 | return process.env.NODE_ENV !== 'production' && process.env.KOOP_WARNINGS !== 'suppress' 91 | } 92 | 93 | function renderGeoservicesResponse (data, params = {}) { 94 | const { 95 | returnCountOnly, 96 | returnExtentOnly, 97 | returnIdsOnly, 98 | outSR 99 | } = params 100 | 101 | if (returnCountOnly || returnExtentOnly) { 102 | return renderCountAndExtentResponse(data, { 103 | returnCountOnly, 104 | returnExtentOnly, 105 | outSR 106 | }) 107 | } 108 | 109 | if (returnIdsOnly) { 110 | return renderIdsOnlyResponse(data) 111 | } 112 | 113 | if (data.statistics) { 114 | return renderStatisticsResponse(data, params) 115 | } 116 | 117 | return renderFeaturesResponse(data, params) 118 | } 119 | 120 | function renderIdsOnlyResponse ({ features = [], metadata = {} }) { 121 | const objectIdFieldName = metadata.idField || 'OBJECTID' 122 | 123 | const objectIds = features.map(({ attributes }) => { 124 | return attributes[objectIdFieldName] 125 | }) 126 | 127 | return { 128 | objectIdFieldName, 129 | objectIds 130 | } 131 | } 132 | 133 | module.exports = query 134 | -------------------------------------------------------------------------------- /lib/query/log-warnings.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { getDataTypeFromValue } = require('../helpers') 3 | const chalk = require('chalk') 4 | 5 | function logWarnings (geojson, format) { 6 | const { metadata = {}, features } = geojson 7 | const esriFormat = format !== geojson 8 | 9 | if (esriFormat && !metadata.idField) { 10 | console.warn(chalk.yellow('WARNING: requested provider has no "idField" assignment. You will get the most reliable behavior from ArcGIS clients if the provider assigns the "idField" to a property that is an unchanging 32-bit integer. Koop will create an OBJECTID field in the absence of an "idField" assignment.')) 11 | } 12 | 13 | if (esriFormat && hasMixedCaseObjectIdKey(metadata.idField)) { 14 | console.warn(chalk.yellow('WARNING: requested provider\'s "idField" is a mixed-case version of "OBJECTID". This can cause errors in ArcGIS clients.')) 15 | } 16 | 17 | // Compare provider metadata fields to feature properties 18 | // TODO: refactor 19 | if (metadata.fields && _.has(features, '[0].properties')) { 20 | warnOnMetadataFieldDiscrepancies(geojson.metadata.fields, geojson.features[0].properties) 21 | } 22 | } 23 | 24 | function hasMixedCaseObjectIdKey (idField = '') { 25 | return idField.toLowerCase() === 'objectid' && idField !== 'OBJECTID' 26 | } 27 | 28 | /** 29 | * Compare fields generated from metadata to properties of a data feature. 30 | * Warn if differences discovered 31 | * @param {*} metadataFields 32 | * @param {*} properties 33 | */ 34 | function warnOnMetadataFieldDiscrepancies (metadataFields, featureProperties) { 35 | // build a comparison collection from the data samples properties 36 | const featureFields = Object.keys(featureProperties).map(name => { 37 | return { 38 | name, 39 | type: getDataTypeFromValue(featureProperties[name]) 40 | } 41 | }) 42 | 43 | // compare metadata to feature properties; identifies fields defined in metadata that are not found in feature properties 44 | // or that have a metadata type definition inconsistent with feature property's value 45 | metadataFields.forEach(field => { 46 | // look for a defined field in the features properties 47 | const featureField = _.find(featureFields, ['name', field.name]) || _.find(featureFields, ['name', field.alias]) 48 | if (!featureField || (field.type !== featureField.type && !(field.type === 'Date' && featureField.type === 'Integer') && !(field.type === 'Double' && featureField.type === 'Integer'))) { 49 | console.warn(chalk.yellow(`WARNING: requested provider's metadata field "${field.name} (${field.type})" not found in feature properties)`)) 50 | } 51 | }) 52 | 53 | // compare feature properties to metadata fields; identifies fields found on feature that are not defined in metadata field array 54 | featureFields.forEach(field => { 55 | const noNameMatch = _.find(metadataFields, ['name', field.name]) 56 | const noAliasMatch = _.find(metadataFields, ['alias', field.name]) 57 | 58 | // Exclude warnings on feature fields named OBJECTID because OBJECTID may have been added by winnow in which case it should not be in the metadata fields array 59 | if (!(noNameMatch || noAliasMatch) && field.name !== 'OBJECTID') { 60 | console.warn(chalk.yellow(`WARNING: requested provider's features have property "${field.name} (${field.type})" that was not defined in metadata fields array)`)) 61 | } 62 | }) 63 | } 64 | 65 | module.exports = { logWarnings } 66 | -------------------------------------------------------------------------------- /lib/query/render-count-and-extent.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const esriExtent = require('esri-extent') 3 | 4 | function renderCountAndExtentResponse (data, params) { 5 | const { 6 | returnCountOnly, 7 | returnExtentOnly, 8 | outSR 9 | } = params 10 | 11 | if (returnCountOnly && returnExtentOnly) { 12 | return { 13 | count: _.get(data, 'features.length', 0), 14 | extent: getExtent(data, outSR) 15 | } 16 | } 17 | 18 | if (returnCountOnly) { 19 | return { 20 | count: _.get(data, 'features.length', 0) 21 | } 22 | } 23 | 24 | return { 25 | extent: getExtent(data, outSR) 26 | } 27 | } 28 | 29 | /** 30 | * Get an extent object for passed GeoJSON 31 | * @param {object} geojson 32 | * @param {*} outSR Esri spatial reference object, or WKID integer 33 | */ 34 | function getExtent (geojson, outSR) { 35 | // Calculate extent from features 36 | const extent = esriExtent(geojson) 37 | 38 | if (!outSR) { 39 | return extent 40 | } 41 | 42 | // esri-extent assumes WGS84, but data passed in may have CRS. 43 | // Math should be the same different CRS but we need to alter the spatial reference 44 | 45 | if (_.isObject(outSR)) { 46 | extent.spatialReference = outSR 47 | } 48 | 49 | if (Number.isInteger(Number(outSR))) { 50 | extent.spatialReference = { wkid: Number(outSR) } 51 | } else if (_.isString(outSR)) { 52 | extent.spatialReference = { wkt: outSR } 53 | } 54 | 55 | return extent 56 | } 57 | 58 | module.exports = { renderCountAndExtentResponse } 59 | -------------------------------------------------------------------------------- /lib/query/render-features.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { 3 | QueryFields 4 | } = require('../helpers/fields') 5 | const { 6 | getCollectionCrs, 7 | normalizeSpatialReference 8 | } = require('../helpers') 9 | const featureResponseTemplate = require('../../templates/features.json') 10 | 11 | /** 12 | * Modifies a template features json file with metadata, capabilities, and data from the model 13 | * @param {object} data - data from provider model 14 | * @param {object} params 15 | * @return {object} formatted features data 16 | */ 17 | function renderFeaturesResponse (data = {}, params = {}) { 18 | const template = _.cloneDeep(featureResponseTemplate) 19 | 20 | const { 21 | uniqueIdField: uniqueIdFieldDefault, 22 | objectIdFieldName: objectIdFieldNameDefault 23 | } = template 24 | 25 | const { 26 | metadata: { 27 | limitExceeded, 28 | transform, 29 | idField 30 | } = {} 31 | } = data 32 | 33 | const computedProperties = { 34 | geometryType: params.geometryType, 35 | spatialReference: getOutputSpatialReference(data, params), 36 | fields: QueryFields.create({ ...data, ...params }), 37 | features: data.features || [], 38 | exceededTransferLimit: !!limitExceeded, 39 | objectIdFieldName: idField || objectIdFieldNameDefault, 40 | uniqueIdField: { 41 | ...uniqueIdFieldDefault, 42 | name: idField || uniqueIdFieldDefault.name 43 | } 44 | } 45 | 46 | if (transform) { 47 | computedProperties.transform = transform 48 | } 49 | 50 | return { ...template, ...computedProperties } 51 | } 52 | 53 | function getOutputSpatialReference (collection, { 54 | outSR, 55 | outputCrs, 56 | inputCrs, 57 | sourceSR 58 | }) { 59 | const spatialReference = outputCrs || outSR || inputCrs || sourceSR || getCollectionCrs(collection) || 4326 60 | 61 | const { wkid, wkt, latestWkid } = normalizeSpatialReference(spatialReference) 62 | 63 | if (wkid && latestWkid) { 64 | return { wkid, latestWkid } 65 | } 66 | 67 | if (wkid) { 68 | return { wkid } 69 | } 70 | 71 | if (latestWkid) { 72 | return { latestWkid } 73 | } 74 | 75 | return { wkt } 76 | } 77 | 78 | module.exports = { renderFeaturesResponse } 79 | -------------------------------------------------------------------------------- /lib/query/render-precalculated-statistics.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { isValidISODateString, isValidDate } = require('iso-datestring-validator') 3 | const { 4 | StatisticsFields 5 | } = require('../helpers/fields') 6 | 7 | function renderPrecalculatedStatisticsResponse (input, options) { 8 | const { 9 | statistics 10 | } = input 11 | 12 | const normalizedStatistics = Array.isArray(statistics) ? statistics : [statistics] 13 | const fields = StatisticsFields.create({ 14 | ...input, 15 | ...options 16 | }) 17 | 18 | return { 19 | fields, 20 | features: createStatisticFeatures(normalizedStatistics) 21 | } 22 | } 23 | 24 | function createStatisticFeatures (statistics) { 25 | return statistics.map(statistic => { 26 | return { 27 | attributes: convertDatePropertiesToTimestamps(statistic) 28 | } 29 | }) 30 | } 31 | 32 | function convertDatePropertiesToTimestamps (obj) { 33 | return _.mapValues(obj, value => { 34 | if (isDate(value)) { 35 | return new Date(value).getTime() 36 | } 37 | return value 38 | }) 39 | } 40 | 41 | function isDate (value) { 42 | return value instanceof Date || ((typeof value === 'string') && (isValidDate(value) || isValidISODateString(value))) 43 | } 44 | 45 | module.exports = { renderPrecalculatedStatisticsResponse } 46 | -------------------------------------------------------------------------------- /lib/query/render-statistics.js: -------------------------------------------------------------------------------- 1 | const { 2 | StatisticsFields 3 | } = require('../helpers/fields') 4 | 5 | function renderStatisticsResponse (input = {}, options = {}) { 6 | const { statistics } = input 7 | const normalizedStatistics = Array.isArray(statistics) ? statistics : [statistics] 8 | const features = normalizedStatistics.map(attributes => { 9 | return { attributes } 10 | }) 11 | 12 | const fields = StatisticsFields.create({ 13 | statistics, 14 | ...options 15 | }) 16 | 17 | return { 18 | displayFieldName: '', 19 | fields, 20 | features 21 | } 22 | } 23 | 24 | module.exports = { renderStatisticsResponse } 25 | -------------------------------------------------------------------------------- /lib/queryRelatedRecords.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { getCollectionCrs, getGeometryTypeFromGeojson } = require('./helpers') 3 | const { 4 | QueryFields 5 | } = require('./helpers/fields') 6 | 7 | module.exports = queryRelatedRecords 8 | 9 | function queryRelatedRecords (data, params = {}) { 10 | const response = { 11 | relatedRecordGroups: [] 12 | } 13 | 14 | if (!params.returnCountOnly) response.fields = QueryFields.create({ ...data, ...params }) 15 | 16 | const geomType = getGeometryTypeFromGeojson(data) 17 | if (geomType) { 18 | response.geomType = geomType 19 | response.spatialReference = getCollectionCrs(data) 20 | response.hasZ = false 21 | response.hasM = false 22 | } 23 | 24 | if (data.features) { 25 | response.relatedRecordGroups = data.features.map(featureCollection => { 26 | return convertFeaturesToRelatedRecordGroups(featureCollection, params.returnCountOnly) 27 | }) 28 | } 29 | 30 | return response 31 | } 32 | 33 | function convertFeaturesToRelatedRecordGroups ({ features, properties }, returnCountOnly = false) { 34 | const recordGroup = { 35 | objectId: properties.objectid 36 | } 37 | 38 | if (returnCountOnly) { 39 | // allow for preprocessing of count within provider 40 | if (properties.count || properties.count === 0) { 41 | recordGroup.count = properties.count 42 | } else { 43 | recordGroup.count = _.get(features, 'length', 0) 44 | } 45 | 46 | return recordGroup 47 | } 48 | 49 | if (features) { 50 | recordGroup.relatedRecords = features.map(({ geometry, properties }) => { 51 | return { 52 | attributes: properties, 53 | geometry: geometry 54 | } 55 | }) 56 | } 57 | 58 | return recordGroup 59 | } 60 | -------------------------------------------------------------------------------- /lib/relationships-info-route-handler.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { 3 | calculateExtent, 4 | getGeometryTypeFromGeojson, 5 | getSpatialReference, 6 | normalizeExtent, 7 | normalizeInputData 8 | } = require('./helpers') 9 | const { 10 | LayerFields 11 | } = require('./helpers/fields') 12 | const { layerMetadata: layerMetadataDefaults, renderers: rendererDefaults } = require('./defaults') 13 | 14 | module.exports = function relationshipsMetadata (providerResponse, queryParams = {}) { 15 | const { layers: layersInput, tables: tablesInput } = normalizeInputData(providerResponse) 16 | 17 | const layers = layersInput.map((layer, i) => { 18 | return formatResponse(layer, { queryParams, layerId: i, isLayer: true }) 19 | }) 20 | 21 | const tables = tablesInput.map((table, i) => { 22 | return formatResponse(table, { queryParams, layerId: layers.length + i }) 23 | }) 24 | 25 | return { layers, tables } 26 | } 27 | 28 | function formatResponse (geojson = {}, options = {}) { 29 | const { layerId, isLayer, queryParams } = options 30 | 31 | const { 32 | metadata = {}, 33 | capabilities: { 34 | quantization, 35 | extract 36 | } = {} 37 | } = geojson 38 | 39 | const { 40 | id, 41 | name, 42 | description, 43 | idField, 44 | displayField, 45 | timeInfo, 46 | maxRecordCount, 47 | renderer, 48 | defaultVisibility, 49 | minScale, 50 | maxScale, 51 | hasStaticData 52 | } = metadata 53 | const geometryType = isLayer ? getGeometryTypeFromGeojson(geojson) : undefined 54 | const spatialReference = getSpatialReference(geojson, queryParams) 55 | const extent = metadata.extent ? normalizeExtent(metadata.extent, spatialReference) : calculateExtent({ isLayer, geojson, spatialReference }) 56 | 57 | const json = { 58 | id: id || layerId, 59 | fields: LayerFields.create({ ...geojson, ...queryParams }) || [], 60 | type: isLayer ? 'Feature Layer' : 'Table', 61 | geometryType, 62 | drawingInfo: { 63 | renderer: renderer || rendererDefaults[geometryType], 64 | labelingInfo: null 65 | }, 66 | spatialReference, 67 | extent, 68 | defaultVisibility, 69 | minScale, 70 | maxScale, 71 | quantization, 72 | extract, 73 | hasStaticData, 74 | name, 75 | description, 76 | objectIdField: idField, 77 | displayField: displayField || idField, 78 | uniqueIdField: idField, 79 | timeInfo, 80 | maxRecordCount, 81 | supportsCoordinatesQuantization: !!quantization 82 | } 83 | 84 | return _.defaults(json, { 85 | capabilities: extract ? `${layerMetadataDefaults.capabilities},Extract` : layerMetadataDefaults.capabilities 86 | }, layerMetadataDefaults) 87 | } 88 | -------------------------------------------------------------------------------- /lib/response-handler.js: -------------------------------------------------------------------------------- 1 | module.exports = function responseHandler (req, res, statusCode, payload) { 2 | if (req.query.callback) { 3 | let sanitizedCallback = req.query.callback.replace(/[^\w\d\.\(\)\[\]]/g, '') // eslint-disable-line 4 | res.set('Content-Type', 'application/javascript') 5 | res.status(statusCode) 6 | res.send(`${sanitizedCallback}(${JSON.stringify(payload)})`) 7 | } else if (req.query && req.query.f === 'pjson') res.set('Content-type', 'application/json; charset=utf-8').status(statusCode).send(JSON.stringify(payload, null, 2)) 8 | else res.status(statusCode).json(payload) 9 | } 10 | -------------------------------------------------------------------------------- /lib/rest-info-route-handler.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { 3 | CURRENT_VERSION, 4 | FULL_VERSION 5 | } = require('./constants') 6 | 7 | function restInfo (data = {}, req) { 8 | const currentVersion = _.get(req, 'app.locals.config.featureServer.currentVersion', CURRENT_VERSION) 9 | const fullVersion = _.get(req, 'app.locals.config.featureServer.fullVersion', FULL_VERSION) 10 | 11 | return { 12 | currentVersion, 13 | fullVersion, 14 | ...data 15 | } 16 | } 17 | 18 | module.exports = restInfo 19 | -------------------------------------------------------------------------------- /lib/route.js: -------------------------------------------------------------------------------- 1 | const joi = require('joi') 2 | const geojsonhint = require('geojson-validation') 3 | const chalk = require('chalk') 4 | const layerInfo = require('./layer-metadata') 5 | const query = require('./query') 6 | const queryRelatedRecords = require('./queryRelatedRecords.js') 7 | const generateRenderer = require('./generate-renderer') 8 | const restInfo = require('./rest-info-route-handler') 9 | const serverInfo = require('./server-info-route-handler') 10 | const layersInfo = require('./layers-metadata') 11 | const relationshipsInfo = require('./relationships-info-route-handler') 12 | const responseHandler = require('./response-handler') 13 | 14 | const queryParamSchema = joi.object({ 15 | limit: joi.number().optional(), 16 | resultRecordCount: joi.number().optional() 17 | }).unknown() 18 | 19 | const geojsonMetadataSchema = joi.object({ 20 | maxRecordCount: joi.number().prefs({ convert: false }).optional().default(2000) 21 | }).unknown() 22 | 23 | module.exports = function route (req, res, geojson = {}, options = {}) { 24 | const { 25 | params: { 26 | method 27 | }, 28 | url, 29 | originalUrl, 30 | path 31 | } = req 32 | 33 | const [route] = (url || originalUrl).split('?') 34 | 35 | if (shouldValidateGeojson()) { 36 | validateGeojson(geojson, path) 37 | } 38 | 39 | try { 40 | const metadata = validateGeojsonMetadata(geojson.metadata) 41 | const queryParams = validateAndCoerceQueryParams(req.query, metadata) 42 | 43 | geojson = { ...geojson, metadata } 44 | req = { ...req, query: queryParams } 45 | let result 46 | 47 | if (method) { 48 | result = handleMethodRequest({ method, geojson, req }) 49 | } else if (isRestInfoRequest(route)) { 50 | result = restInfo(geojson, req) 51 | } else if (isServerMetadataRequest(route)) { 52 | result = serverInfo(geojson, req) 53 | } else if (isLayersMetadataRequest(route)) { 54 | result = layersInfo(geojson, queryParams) 55 | } else if (isRelationshipsMetadataRequest(route)) { 56 | result = relationshipsInfo(geojson, queryParams) 57 | } else if (isLayerMetadataRequest(route)) { 58 | result = layerInfo(geojson, req) 59 | } else { 60 | const error = new Error('Not Found') 61 | error.code = 404 62 | throw error 63 | } 64 | 65 | return responseHandler(req, res, 200, result) 66 | } catch (error) { 67 | if (process.env.KOOP_LOG_LEVEL === 'debug') console.trace(error) 68 | return responseHandler(req, res, error.code || 500, { error: error.message }) 69 | } 70 | } 71 | 72 | function handleMethodRequest ({ method, geojson, req }) { 73 | if (method === 'query') { 74 | return query(geojson, req.query) 75 | } else if (method === 'queryRelatedRecords') { 76 | return queryRelatedRecords(geojson, req.query) 77 | } else if (method === 'generateRenderer') { 78 | return generateRenderer(geojson, req.query) 79 | } else if (method === 'info') { 80 | return layerInfo(geojson, req) 81 | } 82 | const error = new Error('Method not supported') 83 | error.code = 400 84 | throw error 85 | } 86 | 87 | function shouldValidateGeojson () { 88 | const { 89 | KOOP_LOG_LEVEL, 90 | KOOP_DISABLE_GEOJSON_VALIDATION 91 | } = process.env 92 | return KOOP_LOG_LEVEL === 'debug' && KOOP_DISABLE_GEOJSON_VALIDATION !== 'true' 93 | } 94 | 95 | function validateGeojson (geojson, path) { 96 | const geojsonErrors = geojsonhint.valid(geojson, true) 97 | if (geojsonErrors.length > 0) { 98 | console.log(chalk.yellow(`WARNING: source data for ${path} contains invalid GeoJSON; ${geojsonErrors[0]}`)) 99 | } 100 | } 101 | 102 | function validateGeojsonMetadata (metadata = {}) { 103 | const { error, value } = geojsonMetadataSchema.validate(metadata) 104 | if (error) { 105 | error.code = 500 106 | throw error 107 | } 108 | return value 109 | } 110 | 111 | function validateAndCoerceQueryParams (queryParams, { maxRecordCount }) { 112 | const { error, value: query } = queryParamSchema.validate(queryParams) 113 | 114 | if (error) { 115 | error.code = 400 116 | throw error 117 | } 118 | 119 | const { limit, resultRecordCount } = query 120 | query.limit = limit || resultRecordCount || maxRecordCount 121 | return Object.keys(query).reduce((acc, key) => { 122 | const value = query[key] 123 | if (value === 'false') acc[key] = false 124 | else if (value === 'true') acc[key] = true 125 | else { 126 | acc[key] = tryParse(value) 127 | } 128 | return acc 129 | }, {}) 130 | } 131 | 132 | function tryParse (value) { 133 | try { 134 | return JSON.parse(value) 135 | } catch (e) { 136 | return value 137 | } 138 | } 139 | 140 | function isRestInfoRequest (url) { 141 | return /\/rest\/info$/i.test(url) 142 | } 143 | 144 | function isServerMetadataRequest (url) { 145 | return /\/FeatureServer$/i.test(url) || /\/FeatureServer\/info$/i.test(url) || /\/FeatureServer\/($|\?)/.test(url) 146 | } 147 | 148 | function isLayersMetadataRequest (url) { 149 | return /\/FeatureServer\/layers$/i.test(url) 150 | } 151 | 152 | function isRelationshipsMetadataRequest (url) { 153 | return /\/FeatureServer\/relationships$/i.test(url) 154 | } 155 | 156 | function isLayerMetadataRequest (url) { 157 | return /\/FeatureServer\/\d+$/i.test(url) || /\/FeatureServer\/\d+\/info$/i.test(url) || /\/FeatureServer\/\d+\/$/.test(url) 158 | } 159 | -------------------------------------------------------------------------------- /lib/server-info-route-handler.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { calculateBounds } = require('@terraformer/spatial') 3 | const { 4 | getCollectionCrs, 5 | getGeometryTypeFromGeojson, 6 | normalizeExtent, 7 | normalizeSpatialReference, 8 | normalizeInputData 9 | } = require('./helpers') 10 | const { serverMetadata: serverMetadataDefaults } = require('./defaults') 11 | const { 12 | CURRENT_VERSION, 13 | FULL_VERSION 14 | } = require('./constants') 15 | const debug = process.env.KOOP_LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'debug' 16 | 17 | function serverMetadata (json, req = {}) { 18 | const { 19 | query = {} 20 | } = req 21 | 22 | const { extent, metadata = {}, ...rest } = json 23 | const { 24 | maxRecordCount, 25 | hasStaticData, 26 | copyrightText, 27 | description: providerLayerDescription, 28 | serviceDescription: providerServiceDescription 29 | } = { ...metadata, ...rest } 30 | const spatialReference = getSpatialReference(json, query) 31 | const { layers, tables, relationships } = normalizeInputData(json) 32 | // TODO reproject default extents when non WGS84 CRS is found or passed 33 | const fullExtent = getServiceExtent({ extent, metadata, layers, spatialReference }) 34 | 35 | const { 36 | currentVersion = CURRENT_VERSION, 37 | fullVersion = FULL_VERSION, 38 | serviceDescription 39 | } = _.get(req, 'app.locals.config.featureServer', {}) 40 | 41 | return _.defaults({ 42 | currentVersion, 43 | fullVersion, 44 | spatialReference, 45 | fullExtent, 46 | initialExtent: fullExtent, 47 | layers: layers.map(layerInfo), 48 | tables: tables.map((json, idx) => { 49 | return tableInfo(json, layers.length + idx) 50 | }), 51 | relationships: relationships.map(relationshipInfo), 52 | supportsRelationshipsResource: relationships && relationships.length > 0, 53 | serviceDescription: serviceDescription || providerServiceDescription || providerLayerDescription, 54 | copyrightText: copyrightText, 55 | maxRecordCount: maxRecordCount || _.get(layers, '[0].metadata.maxRecordCount'), 56 | hasStaticData: typeof hasStaticData === 'boolean' ? hasStaticData : false 57 | }, serverMetadataDefaults) 58 | } 59 | 60 | function getServiceExtent ({ extent, metadata = {}, layers, spatialReference = { wkid: 4326, latestWkid: 4326 } }) { 61 | if (extent || metadata.extent) return normalizeExtent(extent || metadata.extent, spatialReference) 62 | return calculateServiceExtentFromLayers(layers, spatialReference) 63 | } 64 | 65 | function calculateServiceExtentFromLayers (layers, spatialReference) { 66 | try { 67 | if (layers.length === 0) { 68 | return 69 | } 70 | 71 | const layerBounds = layers.filter(layer => { 72 | return _.has(layer, 'features[0]') 73 | }).map(calculateBounds) 74 | 75 | if (layerBounds.length === 0) return 76 | 77 | const { xmins, xmaxs, ymins, ymaxs } = layerBounds.reduce((accumulator, bounds) => { 78 | const [xmin, ymin, xmax, ymax] = bounds 79 | accumulator.xmins.push(xmin) 80 | accumulator.xmaxs.push(xmax) 81 | accumulator.ymins.push(ymin) 82 | accumulator.ymaxs.push(ymax) 83 | return accumulator 84 | }, { xmins: [], xmaxs: [], ymins: [], ymaxs: [] }) 85 | 86 | return { 87 | xmin: Math.min(...xmins), 88 | xmax: Math.max(...xmaxs), 89 | ymin: Math.min(...ymins), 90 | ymax: Math.max(...ymaxs), 91 | spatialReference 92 | } 93 | } catch (error) { 94 | if (debug) { 95 | console.log(`Could not calculate extent from data: ${error.message}`) 96 | } 97 | } 98 | } 99 | 100 | function layerInfo (json = {}, defaultId) { 101 | return formatInfo(json, defaultId, 'layer') 102 | } 103 | 104 | function tableInfo (json = {}, defaultId) { 105 | return formatInfo(json, defaultId, 'table') 106 | } 107 | 108 | function formatInfo (json = {}, defaultId, type) { 109 | const { 110 | metadata: { 111 | id, 112 | name, 113 | minScale = 0, 114 | maxScale = 0, 115 | defaultVisibility 116 | } = {} 117 | } = json 118 | 119 | const defaultName = type === 'layer' ? `Layer_${id || defaultId}` : `Table_${id || defaultId}` 120 | return { 121 | id: id || defaultId, 122 | name: name || defaultName, 123 | parentLayerId: -1, 124 | defaultVisibility: defaultVisibility !== false, 125 | subLayerIds: null, 126 | minScale, 127 | maxScale, 128 | geometryType: type === 'layer' ? getGeometryTypeFromGeojson(json) : undefined 129 | } 130 | } 131 | 132 | function relationshipInfo (json = {}, relationshipIndex) { 133 | const { 134 | id, 135 | name 136 | } = json 137 | 138 | const defaultName = `Relationship_${id || relationshipIndex}` 139 | return { 140 | id: id || relationshipIndex, 141 | name: name || defaultName 142 | } 143 | } 144 | 145 | function getSpatialReference (collection, { 146 | inputCrs, 147 | sourceSR 148 | }) { 149 | if (!inputCrs && !sourceSR && _.isEmpty(collection)) return 150 | const spatialReference = inputCrs || sourceSR || getCollectionCrs(collection) 151 | 152 | if (!spatialReference) return 153 | 154 | const { latestWkid, wkid, wkt } = normalizeSpatialReference(spatialReference) 155 | 156 | if (wkid) { 157 | return { wkid, latestWkid } 158 | } 159 | 160 | return { wkt } 161 | } 162 | 163 | module.exports = serverMetadata 164 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "featureserver", 3 | "version": "5.0.0", 4 | "description": "*An open source implementation of the GeoServices specification*", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "standard && mocha 'test/**/*.spec.js' --recursive -t 5000", 11 | "test:cov": "nyc --reporter=html mocha 'test/**/*.spec.js' ", 12 | "test:cov-unit": "nyc --reporter=html mocha 'test/unit/**/*.spec.js' ", 13 | "fix": "standard --fix" 14 | }, 15 | "contributors": [ 16 | { 17 | "name": "Daniel Fenton" 18 | }, 19 | { 20 | "name": "Rich Gwozdz", 21 | "email": "rgwozdz@esri.com" 22 | } 23 | ], 24 | "license": "Apache-2.0", 25 | "dependencies": { 26 | "@esri/proj-codes": "^3.0.0", 27 | "@terraformer/spatial": "^2.0.7", 28 | "chalk": "^4.0.0", 29 | "chroma-js": "^2.0.0", 30 | "esri-extent": "^1.1.1", 31 | "geojson-validation": "^1.0.2", 32 | "iso-datestring-validator": "^2.2.0", 33 | "joi": "^17.3.0", 34 | "lodash": "^4.17.21", 35 | "winnow": "^2.6.0", 36 | "wkt-parser": "^1.3.2" 37 | }, 38 | "devDependencies": { 39 | "express": "^4.14.0", 40 | "mocha": "^10.0.0", 41 | "nyc": "^15.1.0", 42 | "proxyquire": "^2.1.3", 43 | "should": "^13.0.0", 44 | "should-sinon": "0.0.6", 45 | "sinon": "^15.0.0", 46 | "standard": "^14.3.0", 47 | "supertest": "^6.0.0" 48 | }, 49 | "standard": { 50 | "globals": [ 51 | "describe", 52 | "it", 53 | "before", 54 | "after", 55 | "beforeEach", 56 | "afterEach" 57 | ] 58 | }, 59 | "repository": { 60 | "type": "git", 61 | "url": "git+https://github.com/koopjs/FeatureServer.git" 62 | }, 63 | "keywords": [ 64 | "featureserver", 65 | "geoservices", 66 | "geojson", 67 | "sql" 68 | ], 69 | "bugs": { 70 | "url": "https://github.com/koopjs/FeatureServer/issues" 71 | }, 72 | "homepage": "https://github.com/koopjs/FeatureServer#readme" 73 | } 74 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /templates/errors/credentials-invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": 400, 4 | "message": "Unable to generate token.", 5 | "details": ["Invalid username or password."] 6 | } 7 | } -------------------------------------------------------------------------------- /templates/errors/unauthorized.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": 499, 4 | "message": "Token Required", 5 | "details": [] 6 | } 7 | } -------------------------------------------------------------------------------- /templates/features.json: -------------------------------------------------------------------------------- 1 | { 2 | "objectIdFieldName" : "OBJECTID", 3 | "uniqueIdField" : 4 | { 5 | "name" : "OBJECTID", 6 | "isSystemMaintained" : true 7 | }, 8 | "globalIdFieldName" : "", 9 | "hasZ": false, 10 | "hasM": false, 11 | "spatialReference" : {"wkid" : 4326, "latestWkid" : 4326}, 12 | "fields" : [], 13 | "features" : [], 14 | "exceededTransferLimit": false 15 | } 16 | -------------------------------------------------------------------------------- /templates/renderers/symbology/fill-symbol.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "esriSFS", 3 | "style": "esriSFSSolid", 4 | "color": [0, 0, 0, 255], 5 | "size": 5, 6 | "angle": 0, 7 | "xoffset": 0, 8 | "yoffset": 0, 9 | "outline": { 10 | "type": "esriSLS", 11 | "style": "esriSLSSolid", 12 | "color": [0,0, 0, 255], 13 | "width": 1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /templates/renderers/symbology/style.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 12 | 17 | 23 | 29 | 30 | -------------------------------------------------------------------------------- /templates/server.json: -------------------------------------------------------------------------------- 1 | { 2 | "serviceDescription": "This is a feature service exposed with Koop, an open source project that turns APIs into features. Service Description information may not be available for all services. For more information, check out https://github.com/koopjs/koop ", 3 | "hasVersionedData": false, 4 | "supportsDisconnectedEditing": false, 5 | "supportedQueryFormats": "JSON", 6 | "maxRecordCount": 2000, 7 | "hasStaticData": false, 8 | "capabilities": "Query", 9 | "description": "This is a feature service exposed with Koop, an open source project that turns APIs into features. Service Description information may not be available for all services. For more information, check out https://github.com/koopjs/koop", 10 | "copyrightText": "Copyright information varies from provider to provider, for more information please contact the source of this data", 11 | "spatialReference": { 12 | "wkid": 4326, 13 | "latestWkid": 4326 14 | }, 15 | "initialExtent": { 16 | "xmin": -180, 17 | "ymin": -90, 18 | "xmax": 180, 19 | "ymax": 90, 20 | "spatialReference": { 21 | "wkid": 4326, 22 | "latestWkid": 4326 23 | } 24 | }, 25 | "fullExtent": { 26 | "xmin": -180, 27 | "ymin": -90, 28 | "xmax": 180, 29 | "ymax": 90, 30 | "spatialReference": { 31 | "wkid": 4326, 32 | "latestWkid": 4326 33 | } 34 | }, 35 | "allowGeometryUpdates": false, 36 | "units": "esriDecimalDegrees", 37 | "syncEnabled": false, 38 | "layers": [], 39 | "tables": [] 40 | } 41 | -------------------------------------------------------------------------------- /test/integration/authenticate.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const { authenticate } = require('../..') 3 | 4 | describe('Authentication handler', () => { 5 | it('should return a status code 200 and token payload', () => { 6 | let statusCode 7 | let responsePayload 8 | const res = { 9 | status: function (code) { 10 | statusCode = code 11 | return res 12 | }, 13 | json: function (payload) { 14 | responsePayload = payload 15 | } 16 | } 17 | const mockAuthSuccess = { 18 | token: 'a-mocked-token-response', 19 | expires: 100000000 20 | } 21 | authenticate(res, mockAuthSuccess) 22 | statusCode.should.equal(200) 23 | responsePayload.should.be.instanceOf(Object) 24 | responsePayload.should.have.property('token', 'a-mocked-token-response') 25 | responsePayload.should.have.property('expires', 100000000) 26 | responsePayload.should.have.property('ssl', false) 27 | }) 28 | 29 | it('should return a status code 200 and token payload with ssl property = true', () => { 30 | let statusCode 31 | let responsePayload 32 | const res = { 33 | status: function (code) { 34 | statusCode = code 35 | return res 36 | }, 37 | json: function (payload) { 38 | responsePayload = payload 39 | } 40 | } 41 | const mockAuthSuccess = { 42 | token: 'a-mocked-token-response', 43 | expires: 100000000 44 | } 45 | authenticate(res, mockAuthSuccess, true) 46 | statusCode.should.equal(200) 47 | responsePayload.should.be.instanceOf(Object) 48 | responsePayload.should.have.property('token', 'a-mocked-token-response') 49 | responsePayload.should.have.property('expires', 100000000) 50 | responsePayload.should.have.property('ssl', true) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /test/integration/error.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const { error } = require('../..') 3 | 4 | describe('Error operations', () => { 5 | describe('authentication error', () => { 6 | it('without callback param, should return a status code 200 and error payload', () => { 7 | let statusCode 8 | let responsePayload 9 | const req = { query: {} } 10 | const res = { 11 | status: function (code) { 12 | statusCode = code 13 | return res 14 | }, 15 | json: function (payload) { 16 | responsePayload = payload 17 | } 18 | } 19 | error.authentication(req, res) 20 | statusCode.should.equal(200) 21 | responsePayload.should.be.instanceOf(Object) 22 | responsePayload.should.have.property('error').instanceOf(Object) 23 | responsePayload.error.should.have.property('code', 400) 24 | responsePayload.error.should.have.property('message', 'Unable to generate token.') 25 | responsePayload.error.details.should.be.instanceOf(Array) 26 | responsePayload.error.details[0].should.equal('Invalid username or password.') 27 | }) 28 | 29 | it('should return a status code 200 and error payload', () => { 30 | const req = { query: { callback: 'test' } } 31 | const res = { 32 | set: function (header, value) { 33 | header.should.equal('Content-Type') 34 | value.should.equal('application/javascript') 35 | return res 36 | }, 37 | send: function (response) { 38 | response.should.equal('test({"error":{"code":400,"message":"Unable to generate token.","details":["Invalid username or password."]}})') 39 | return res 40 | }, 41 | status: function (code) { 42 | code.should.equal(200) 43 | return res 44 | } 45 | } 46 | error.authentication(req, res) 47 | }) 48 | }) 49 | 50 | describe('authorization error', () => { 51 | it('should return a status code 200 and error payload', () => { 52 | let statusCode 53 | let responsePayload 54 | const req = { query: {} } 55 | const res = { 56 | status: function (code) { 57 | statusCode = code 58 | return res 59 | }, 60 | json: function (payload) { 61 | responsePayload = payload 62 | } 63 | } 64 | error.authorization(req, res) 65 | statusCode.should.equal(200) 66 | responsePayload.should.be.instanceOf(Object) 67 | responsePayload.should.have.property('error').instanceOf(Object) 68 | responsePayload.error.should.have.property('code', 499) 69 | responsePayload.error.should.have.property('message', 'Token Required') 70 | responsePayload.error.details.should.be.instanceOf(Array) 71 | }) 72 | 73 | it('with callback param, should return a status code 200 and error payload', () => { 74 | const req = { query: { callback: 'test' } } 75 | const res = { 76 | set: function (header, value) { 77 | header.should.equal('Content-Type') 78 | value.should.equal('application/javascript') 79 | return res 80 | }, 81 | send: function (response) { 82 | response.should.equal('test({"error":{"code":499,"message":"Token Required","details":[]}})') 83 | return res 84 | }, 85 | status: function (code) { 86 | code.should.equal(200) 87 | return res 88 | } 89 | } 90 | error.authorization(req, res) 91 | }) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /test/integration/fixtures/data-with-complex-metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "metadata": { 4 | "name": "Random polygons", 5 | "description": "MyTestDesc", 6 | "geometryType": "Polygon", 7 | "extent": { 8 | "xmin": -125, 9 | "ymin": 20, 10 | "xmax": -70, 11 | "ymax": 49, 12 | "spatialReference": { 13 | "wkid": 4326, 14 | "latestWkid": 4326 15 | } 16 | }, 17 | "idField": "interval", 18 | "capabilities": "Query,Delete", 19 | "displayField": "label", 20 | "maxRecordCount": 1, 21 | "hasStaticData": false, 22 | "renderer": { 23 | "type": "simple", 24 | "symbol": { 25 | "type": "esriSFS", 26 | "style": "esriSFSSolid", 27 | "color": [ 28 | 115, 29 | 76, 30 | 0, 31 | 255 32 | ], 33 | "outline": { 34 | "type": "esriSLS", 35 | "style": "esriSLSSolid", 36 | "color": [ 37 | 110, 38 | 110, 39 | 110, 40 | 255 41 | ], 42 | "width": 1 43 | } 44 | } 45 | } 46 | }, 47 | "capabilities": { 48 | "quantization": true, 49 | "extract": true 50 | }, 51 | "features": [ 52 | { 53 | "type": "Feature", 54 | "properties": { 55 | "interval": 0, 56 | "label": "Day 1" 57 | }, 58 | "geometry": { 59 | "type": "Polygon", 60 | "coordinates": [ 61 | [ 62 | [ 63 | -121.31103515625, 64 | 47.901613541421 65 | ], 66 | [ 67 | -121.22314453125, 68 | 47.81131001626 69 | ], 70 | [ 71 | -120.9814453125, 72 | 47.838970656476 73 | ], 74 | [ 75 | -120.98968505859, 76 | 47.888722666599 77 | ], 78 | [ 79 | -121.06109619141, 80 | 47.953144950156 81 | ], 82 | [ 83 | -121.25610351562, 84 | 47.956823800497 85 | ], 86 | [ 87 | -121.31103515625, 88 | 47.901613541421 89 | ] 90 | ] 91 | ] 92 | } 93 | }, 94 | { 95 | "type": "Feature", 96 | "properties": { 97 | "interval": 1, 98 | "label": "Day 2" 99 | }, 100 | "geometry": { 101 | "type": "Polygon", 102 | "coordinates": [ 103 | [ 104 | [ 105 | -121.46209716797, 106 | 48.048709942887 107 | ], 108 | [ 109 | -121.56372070312, 110 | 47.837127072369 111 | ], 112 | [ 113 | -121.2451171875, 114 | 47.624677852413 115 | ], 116 | [ 117 | -120.92376708984, 118 | 47.715305661596 119 | ], 120 | [ 121 | -120.88806152344, 122 | 47.947626183529 123 | ], 124 | [ 125 | -121.18194580078, 126 | 48.021161285658 127 | ], 128 | [ 129 | -121.46209716797, 130 | 48.048709942887 131 | ] 132 | ] 133 | ] 134 | } 135 | }, 136 | { 137 | "type": "Feature", 138 | "properties": { 139 | "interval": 2, 140 | "label": "Day 3" 141 | }, 142 | "geometry": { 143 | "type": "Polygon", 144 | "coordinates": [ 145 | [ 146 | [ 147 | -120.88119506836, 148 | 47.882276025692 149 | ], 150 | [ 151 | -120.86883544922, 152 | 47.976133467682 153 | ], 154 | [ 155 | -121.22589111328, 156 | 48.144097934939 157 | ], 158 | [ 159 | -121.46209716797, 160 | 48.130350972492 161 | ], 162 | [ 163 | -121.55136108398, 164 | 47.945786463687 165 | ], 166 | [ 167 | -121.58706665039, 168 | 47.843579330145 169 | ], 170 | [ 171 | -121.34536743164, 172 | 47.601533317387 173 | ], 174 | [ 175 | -121.06246948242, 176 | 47.578378538602 177 | ], 178 | [ 179 | -120.88668823242, 180 | 47.719001413202 181 | ], 182 | [ 183 | -120.88119506836, 184 | 47.882276025692 185 | ] 186 | ] 187 | ] 188 | } 189 | } 190 | ] 191 | } 192 | -------------------------------------------------------------------------------- /test/integration/fixtures/date-no-metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "features": [ 4 | { 5 | "type": "Feature", 6 | "geometry": null, 7 | "properties": { 8 | "dateField": "2017-06-16T01:58:36.179Z" 9 | } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /test/integration/fixtures/date-with-metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "features": [ 4 | { 5 | "type": "Feature", 6 | "geometry": null, 7 | "properties": { 8 | "dateField": "2017-06-16T01:58:36.179Z" 9 | } 10 | } 11 | ], 12 | "metadata": { 13 | "fields": [ 14 | { 15 | "name": "dateField", 16 | "type": "Date" 17 | } 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/integration/fixtures/fully-specified-metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "metadata": { 4 | "name": "restaurants", 5 | "fields": [ 6 | { 7 | "name": "OBJECTID", 8 | "type": "Integer" 9 | }, 10 | { 11 | "name": "Facility_N", 12 | "type": "String" 13 | }, 14 | { 15 | "name": "Facility_I", 16 | "type": "String" 17 | }, 18 | { 19 | "name": "Address", 20 | "type": "String" 21 | }, 22 | { 23 | "name": "City", 24 | "type": "String" 25 | }, 26 | { 27 | "name": "Zip", 28 | "type": "String" 29 | }, 30 | { 31 | "name": "Latitude", 32 | "type": "Double" 33 | }, 34 | { 35 | "name": "Longitude", 36 | "type": "Double" 37 | }, 38 | { 39 | "name": "Inspection", 40 | "type": "Date" 41 | }, 42 | { 43 | "name": "Related_ID", 44 | "type": "String" 45 | }, 46 | { 47 | "name": "Score", 48 | "type": "Double" 49 | } 50 | ], 51 | "idField": "OBJECTID", 52 | "displayField": "Facility_N" 53 | }, 54 | "filtersApplied": { 55 | "geometry": true, 56 | "where": true 57 | }, 58 | "features": [ 59 | { 60 | "type": "Feature", 61 | "properties": { 62 | "OBJECTID": 1, 63 | "Facility_N": "ROUND TABLE PIZZA", 64 | "Facility_I": "FA0016195", 65 | "Address": "14898 DALE EVANS", 66 | "City": "APPLE VALLEY", 67 | "Zip": "92307", 68 | "Latitude": 34.525655, 69 | "Longitude": -117.215373, 70 | "Inspection": "2017-02-28-04:00", 71 | "Related_ID": "PR0021541", 72 | "Score": 94 73 | } 74 | } 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /test/integration/fixtures/no-geometry.json: -------------------------------------------------------------------------------- 1 | {"type":"FeatureCollection","features":[{"type":"Feature","properties":{"Testing":"Testing","test":"test","OBJECTID":0},"geometry":null}]} -------------------------------------------------------------------------------- /test/integration/fixtures/offset-applied.json: -------------------------------------------------------------------------------- 1 | { 2 | "filtersApplied": { 3 | "offset": true 4 | }, 5 | "type": "FeatureCollection", 6 | "features": [ 7 | { 8 | "type": "Feature", 9 | "geometry": null, 10 | "properties": { 11 | } 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /test/integration/fixtures/one-of-each.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "features": [ 4 | { 5 | "type": "Feature", 6 | "properties": { 7 | "double": 1.1, 8 | "integer": 1, 9 | "string": "foobar", 10 | "date": "2012-03-15T00:00:00.000Z" 11 | }, 12 | "geometry": { 13 | "type": "Point", 14 | "coordinates": [ 15 | -88.00637099999999, 16 | 43.01818599999999 17 | ] 18 | } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /test/integration/fixtures/polygon-metadata-error.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "features": [], 4 | "count": 0, 5 | "metadata": { 6 | "name": "ArcGIS Search", 7 | "description": "Search content in ArcGIS Online", 8 | "displayField": "title", 9 | "fields": [ 10 | { 11 | "name": "id", 12 | "type": "esriFieldTypeString", 13 | "alias": "id", 14 | "length": null, 15 | "editable": false, 16 | "nullable": true, 17 | "domain": null 18 | } 19 | ] 20 | }, 21 | "filtersApplied": { 22 | "where": true 23 | } 24 | } -------------------------------------------------------------------------------- /test/integration/fixtures/polygon.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "metadata": { 4 | "name": "map" 5 | }, 6 | "features": [ 7 | { 8 | "type": "Feature", 9 | "properties": {}, 10 | "geometry": { 11 | "type": "Polygon", 12 | "coordinates": [ 13 | [ 14 | [ 15 | -119.00390625, 16 | 50.28933925329178 17 | ], 18 | [ 19 | -116.19140625, 20 | 29.38217507514529 21 | ], 22 | [ 23 | -91.7578125, 24 | 32.84267363195431 25 | ], 26 | [ 27 | -82.79296874999999, 28 | 39.639537564366684 29 | ], 30 | [ 31 | -102.48046875, 32 | 53.014783245859206 33 | ], 34 | [ 35 | -119.00390625, 36 | 50.28933925329178 37 | ] 38 | ] 39 | ] 40 | } 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /test/integration/fixtures/provider-statistics.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "metadata": { 4 | "name": "GDeltGKG" 5 | }, 6 | "filtersApplied": { 7 | "geometry": true, 8 | "where": true 9 | }, 10 | "statistics": [ 11 | { 12 | "min_2": 0, 13 | "max_2": 57611, 14 | "count_2": 75343 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /test/integration/fixtures/relatedDataCountProperty.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "features": [{ 4 | "type": "FeatureCollection", 5 | "features": [ 6 | ], 7 | "properties": { 8 | "objectid": 261193, 9 | "count": 11 10 | } 11 | } 12 | ], 13 | "metadata": { 14 | "idField": "OBJECTID", 15 | "fields": [{ 16 | "name": "OBJECTID", 17 | "alias": "objectid", 18 | "type": "OID" 19 | }, { 20 | "name": "upc", 21 | "type": "String" 22 | }, { 23 | "name": "filepath", 24 | "type": "String" 25 | }, { 26 | "name": "descript", 27 | "type": "String" 28 | }, { 29 | "name": "brewery_id", 30 | "type": "Integer" 31 | }, { 32 | "name": "ibu", 33 | "type": "String" 34 | }, { 35 | "name": "abv", 36 | "type": "String" 37 | }, { 38 | "name": "cat_id", 39 | "type": "SmallInteger" 40 | }, { 41 | "name": "NewProperty", 42 | "alias": "NewProperty", 43 | "type": "Integer" 44 | }, { 45 | "name": "globalid", 46 | "alias": "globalid", 47 | "type": "GlobalID" 48 | }, { 49 | "name": "style_id", 50 | "type": "Integer" 51 | }, { 52 | "name": "srm", 53 | "type": "String" 54 | }, { 55 | "name": "id", 56 | "type": "Integer" 57 | }, { 58 | "name": "add_user", 59 | "type": "SmallInteger" 60 | }, { 61 | "name": "name", 62 | "type": "String" 63 | }, { 64 | "name": "last_mod", 65 | "type": "String" 66 | } 67 | ] 68 | }, 69 | "filtersApplied": { 70 | "where": true, 71 | "geometry": true 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test/integration/fixtures/snow-text-objectid.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "metadata": { 4 | "name": "Snow", 5 | "description": "MyTestDesc" 6 | }, 7 | "features": [ 8 | { 9 | "type": "Feature", 10 | "id": "adtdmr052uaw", 11 | "geometry": { 12 | "type": "Point", 13 | "coordinates": [ 14 | -104.9476, 15 | 39.9448 16 | ] 17 | }, 18 | "properties": { 19 | "OBJECTID": "adtdmr052uaw", 20 | "station name": "Northglenn 4.6 NNE", 21 | "latitude": 39.9448, 22 | "daily precip": 0.31, 23 | "total precip": 0.31, 24 | "daily snow total": 4.9, 25 | "longitude": -104.9476, 26 | "station": "\ufffdCO-AD-50", 27 | "multi-day precip": null, 28 | "num of reports": 1 29 | } 30 | }, 31 | { 32 | "type": "Feature", 33 | "id": "adtdmrcvhg8l", 34 | "geometry": { 35 | "type": "Point", 36 | "coordinates": [ 37 | -104.8424, 38 | 39.9137 39 | ] 40 | }, 41 | "properties": { 42 | "OBJECTID": "adtdmrcvhg8l", 43 | "station name": "Thornton 7.5 ENE", 44 | "latitude": 39.9137, 45 | "daily precip": 0, 46 | "total precip": 0, 47 | "daily snow total": 3, 48 | "longitude": -104.8424, 49 | "station": "\ufffdCO-AD-75", 50 | "multi-day precip": null, 51 | "num of reports": 1 52 | } 53 | }, 54 | { 55 | "type": "Feature", 56 | "id": "adtdmr4es2lv", 57 | "geometry": { 58 | "type": "Point", 59 | "coordinates": [ 60 | -104.991153, 61 | 39.898467 62 | ] 63 | }, 64 | "properties": { 65 | "OBJECTID": "adtdmr4es2lv", 66 | "station name": "Northglenn 0.9 SW", 67 | "latitude": 39.898467, 68 | "daily precip": 0.61, 69 | "total precip": 0.61, 70 | "daily snow total": 5.5, 71 | "longitude": -104.991153, 72 | "station": "\ufffdCO-AD-98", 73 | "multi-day precip": null, 74 | "num of reports": 1 75 | } 76 | } 77 | ] 78 | } 79 | -------------------------------------------------------------------------------- /test/integration/fixtures/stats-out-single.json: -------------------------------------------------------------------------------- 1 | { 2 | "displayFieldName": "", 3 | "fieldAliases": { 4 | "TOTAL_STUD_SUM": "TOTAL_STUD_SUM", 5 | "ZIP_CODE_COUNT": "ZIP_CODE_COUNT" 6 | }, 7 | "fields": [ 8 | { 9 | "name": "TOTAL_STUD_SUM", 10 | "type": "esriFieldTypeDouble", 11 | "alias": "TOTAL_STUD_SUM" 12 | }, 13 | { 14 | "name": "ZIP_CODE_COUNT", 15 | "type": "esriFieldTypeDouble", 16 | "alias": "ZIP_CODE_COUNT" 17 | } 18 | ], 19 | "features": [ 20 | { 21 | "attributes": { 22 | "TOTAL_STUD_SUM": 5421, 23 | "ZIP_CODE_COUNT": 18 24 | } 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /test/integration/fixtures/trees-crs-102645.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "name": "street-trees-102645", 4 | "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:EPSG::102645" } }, 5 | "features": [ 6 | { "type": "Feature", "properties": { "OBJECTID": 31724, "Common_Nam": "JACARANDA", "Genus": "JACARANDA", "Species": "MIMOSIFOLIA", "House_Numb": 1999, "Street_Dir": null, "Street_Nam": "MENTONE", "Street_Typ": "AVE", "Street_Suf": null, "Trunk_Diam": 23, "Longitude": -118.15747343, "Latitude": 34.180086872000103 }, "geometry": { "type": "Point", "coordinates": [ 6514038.953486103564501, 1887956.492762538837269 ] } }, 7 | { "type": "Feature", "properties": { "OBJECTID": 31722, "Common_Nam": "JACARANDA", "Genus": "JACARANDA", "Species": "MIMOSIFOLIA", "House_Numb": 1991, "Street_Dir": null, "Street_Nam": "MENTONE", "Street_Typ": "AVE", "Street_Suf": null, "Trunk_Diam": 19, "Longitude": -118.15747179100001, "Latitude": 34.179981473000097 }, "geometry": { "type": "Point", "coordinates": [ 6514039.389178796671331, 1887918.135223435005173 ] } }, 8 | { "type": "Feature", "properties": { "OBJECTID": 31723, "Common_Nam": "JACARANDA", "Genus": "JACARANDA", "Species": "MIMOSIFOLIA", "House_Numb": 1996, "Street_Dir": null, "Street_Nam": "MENTONE", "Street_Typ": "AVE", "Street_Suf": null, "Trunk_Diam": 17, "Longitude": -118.157324903, "Latitude": 34.180040354 }, "geometry": { "type": "Point", "coordinates": [ 6514083.84874156396836, 1887939.493448474211618 ] } }, 9 | { "type": "Feature", "properties": { "OBJECTID": 31720, "Common_Nam": "JACARANDA", "Genus": "JACARANDA", "Species": "MIMOSIFOLIA", "House_Numb": 1980, "Street_Dir": null, "Street_Nam": "MENTONE", "Street_Typ": "AVE", "Street_Suf": null, "Trunk_Diam": 15, "Longitude": -118.15732032, "Latitude": 34.179902619000103 }, "geometry": { "type": "Point", "coordinates": [ 6514085.156479243189096, 1887889.367230779025704 ] } }, 10 | { "type": "Feature", "properties": { "OBJECTID": 31721, "Common_Nam": "JACARANDA", "Genus": "JACARANDA", "Species": "MIMOSIFOLIA", "House_Numb": 1981, "Street_Dir": null, "Street_Nam": "MENTONE", "Street_Typ": "AVE", "Street_Suf": null, "Trunk_Diam": 18, "Longitude": -118.157470069, "Latitude": 34.179832955000101 }, "geometry": { "type": "Point", "coordinates": [ 6514039.825198765844107, 1887864.086112950462848 ] } }, 11 | { "type": "Feature", "properties": { "OBJECTID": 31719, "Common_Nam": "JACARANDA", "Genus": "JACARANDA", "Species": "MIMOSIFOLIA", "House_Numb": 1980, "Street_Dir": null, "Street_Nam": "MENTONE", "Street_Typ": "AVE", "Street_Suf": null, "Trunk_Diam": 14, "Longitude": -118.157321544, "Latitude": 34.179787634 }, "geometry": { "type": "Point", "coordinates": [ 6514084.720782318152487, 1887847.522821671096608 ] } } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /test/integration/fixtures/trees-untagged-102645.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "name": "street-trees-102645", 4 | "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:EPSG::102645" } }, 5 | "features": [ 6 | { "type": "Feature", "properties": { "OBJECTID": 31724, "Common_Nam": "JACARANDA", "Genus": "JACARANDA", "Species": "MIMOSIFOLIA", "House_Numb": 1999, "Street_Dir": null, "Street_Nam": "MENTONE", "Street_Typ": "AVE", "Street_Suf": null, "Trunk_Diam": 23, "Longitude": -118.15747343, "Latitude": 34.180086872000103 }, "geometry": { "type": "Point", "coordinates": [ 6514038.953486103564501, 1887956.492762538837269 ] } }, 7 | { "type": "Feature", "properties": { "OBJECTID": 31722, "Common_Nam": "JACARANDA", "Genus": "JACARANDA", "Species": "MIMOSIFOLIA", "House_Numb": 1991, "Street_Dir": null, "Street_Nam": "MENTONE", "Street_Typ": "AVE", "Street_Suf": null, "Trunk_Diam": 19, "Longitude": -118.15747179100001, "Latitude": 34.179981473000097 }, "geometry": { "type": "Point", "coordinates": [ 6514039.389178796671331, 1887918.135223435005173 ] } }, 8 | { "type": "Feature", "properties": { "OBJECTID": 31723, "Common_Nam": "JACARANDA", "Genus": "JACARANDA", "Species": "MIMOSIFOLIA", "House_Numb": 1996, "Street_Dir": null, "Street_Nam": "MENTONE", "Street_Typ": "AVE", "Street_Suf": null, "Trunk_Diam": 17, "Longitude": -118.157324903, "Latitude": 34.180040354 }, "geometry": { "type": "Point", "coordinates": [ 6514083.84874156396836, 1887939.493448474211618 ] } }, 9 | { "type": "Feature", "properties": { "OBJECTID": 31720, "Common_Nam": "JACARANDA", "Genus": "JACARANDA", "Species": "MIMOSIFOLIA", "House_Numb": 1980, "Street_Dir": null, "Street_Nam": "MENTONE", "Street_Typ": "AVE", "Street_Suf": null, "Trunk_Diam": 15, "Longitude": -118.15732032, "Latitude": 34.179902619000103 }, "geometry": { "type": "Point", "coordinates": [ 6514085.156479243189096, 1887889.367230779025704 ] } }, 10 | { "type": "Feature", "properties": { "OBJECTID": 31721, "Common_Nam": "JACARANDA", "Genus": "JACARANDA", "Species": "MIMOSIFOLIA", "House_Numb": 1981, "Street_Dir": null, "Street_Nam": "MENTONE", "Street_Typ": "AVE", "Street_Suf": null, "Trunk_Diam": 18, "Longitude": -118.157470069, "Latitude": 34.179832955000101 }, "geometry": { "type": "Point", "coordinates": [ 6514039.825198765844107, 1887864.086112950462848 ] } }, 11 | { "type": "Feature", "properties": { "OBJECTID": 31719, "Common_Nam": "JACARANDA", "Genus": "JACARANDA", "Species": "MIMOSIFOLIA", "House_Numb": 1980, "Street_Dir": null, "Street_Nam": "MENTONE", "Street_Typ": "AVE", "Street_Suf": null, "Trunk_Diam": 14, "Longitude": -118.157321544, "Latitude": 34.179787634 }, "geometry": { "type": "Point", "coordinates": [ 6514084.720782318152487, 1887847.522821671096608 ] } } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /test/integration/layers.spec.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | const FeatureServer = require('../..') 3 | const data = require('./fixtures/snow.json') 4 | const should = require('should') 5 | should.config.checkProtoEql = false 6 | const _ = require('lodash') 7 | const Joi = require('joi') 8 | const { layerTemplateSchema } = require('./schemas') 9 | 10 | describe('Layers operations', () => { 11 | describe('layers info', () => { 12 | it('should conform to the prescribed schema', () => { 13 | const layers = FeatureServer.layersInfo(data) 14 | const layerSchemaOverride = layerTemplateSchema.append({ 15 | extent: Joi.object().keys({ 16 | xmin: Joi.number().valid(-108.9395), 17 | ymin: Joi.number().valid(37.084968), 18 | xmax: Joi.number().valid(-102), 19 | ymax: Joi.number().valid(40.8877), 20 | spatialReference: Joi.object().keys({ 21 | wkid: Joi.number().valid(4326), 22 | latestWkid: Joi.number().valid(4326) 23 | }) 24 | }), 25 | geometryType: Joi.string().allow('point'), 26 | drawingInfo: Joi.object().keys({ 27 | renderer: Joi.object().keys({ 28 | type: Joi.string().allow('simple') 29 | }).unknown() // TODO expand these? 30 | }).unknown() // TODO expand these? 31 | }) 32 | const layersTemplateSchema = Joi.object({ 33 | layers: Joi.array().items(layerSchemaOverride), 34 | tables: Joi.array().empty() 35 | }) 36 | layersTemplateSchema.validate(layers, { presence: 'required' }).should.not.have.property('error') 37 | }) 38 | 39 | it('should work with geojson passed in', () => { 40 | const layers = FeatureServer.layersInfo(data) 41 | layers.layers.length.should.equal(1) 42 | layers.tables.length.should.equal(0) 43 | }) 44 | 45 | it('should support a passed in metadata', () => { 46 | const input = _.cloneDeep(data) 47 | input.metadata = { 48 | foo: 'bar', 49 | displayField: 'myField', 50 | copyrightText: 'Custom copyright text', 51 | capabilities: 'list,of,stuff' 52 | } 53 | const layers = FeatureServer.layersInfo(input) 54 | layers.layers.length.should.equal(1) 55 | layers.layers[0].should.not.have.property('foo') 56 | layers.layers[0].displayField.should.equal('myField') 57 | layers.layers[0].copyrightText.should.equal('Custom copyright text') 58 | layers.layers[0].capabilities.should.equal('list,of,stuff') 59 | }) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /test/integration/queryRelatedRecords.spec.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | const should = require('should') // eslint-disable-line 3 | const FeatureServer = require('../..') 4 | const relatedData = require('./fixtures/relatedData.json') 5 | const relatedDataCount = require('./fixtures/relatedDataCountProperty.json') 6 | 7 | describe('QueryRelatedRecords operations', () => { 8 | it('should return the expected response schema for an optionless query', () => { 9 | const response = FeatureServer.queryRelatedRecords(relatedData, {}) 10 | response.should.have.property('fields') 11 | response.should.have.property('relatedRecordGroups') 12 | response.fields.should.have.length(16) 13 | response.relatedRecordGroups.should.have.length(1) 14 | response.relatedRecordGroups[0].should.have.property('objectId', 261193) 15 | response.relatedRecordGroups[0].should.have.property('relatedRecords') 16 | response.relatedRecordGroups[0].relatedRecords.should.have.length(11) 17 | }) 18 | 19 | it('should return count of features when returnCountOnly true in options', () => { 20 | const response = FeatureServer.queryRelatedRecords(relatedData, { returnCountOnly: true }) 21 | response.should.not.have.property('fields') 22 | response.should.have.property('relatedRecordGroups') 23 | response.relatedRecordGroups.should.have.length(1) 24 | response.relatedRecordGroups[0].should.have.property('objectId', 261193) 25 | response.relatedRecordGroups[0].should.not.have.property('relatedRecords') 26 | response.relatedRecordGroups[0].should.have.property('count', 11) 27 | }) 28 | 29 | it('should return count when specified in properties and returnCountOnly true in options', () => { 30 | const response = FeatureServer.queryRelatedRecords(relatedDataCount, { returnCountOnly: true }) 31 | response.should.not.have.property('fields') 32 | response.should.have.property('relatedRecordGroups') 33 | response.relatedRecordGroups.should.have.length(1) 34 | response.relatedRecordGroups[0].should.have.property('objectId', 261193) 35 | response.relatedRecordGroups[0].should.not.have.property('relatedRecords') 36 | response.relatedRecordGroups[0].should.have.property('count', 11) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /test/integration/schemas/index.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi') 2 | 3 | const featuresTemplateSchema = Joi.object().keys({ 4 | objectIdFieldName: 'OBJECTID', 5 | globalIdFieldName: Joi.string().valid(''), 6 | uniqueIdField: { 7 | name: 'OBJECTID', 8 | isSystemMaintained: true 9 | }, 10 | hasZ: Joi.boolean().valid(false), 11 | hasM: Joi.boolean().valid(false), 12 | spatialReference: Joi.object().keys({ 13 | latestWkid: Joi.number().valid(4326), 14 | wkid: Joi.number().valid(4326) 15 | }), 16 | fields: Joi.array(), 17 | features: Joi.array(), 18 | exceededTransferLimit: Joi.boolean().valid(false) 19 | }) 20 | 21 | const fieldsTemplateSchema = Joi.object().keys({ 22 | name: Joi.string(), 23 | type: Joi.string(), 24 | alias: Joi.string(), 25 | sqlType: Joi.string().valid('sqlTypeOther'), 26 | domain: Joi.valid(null), 27 | defaultValue: Joi.valid(null) 28 | }) 29 | 30 | const layerTemplateSchema = Joi.object().keys({ 31 | currentVersion: Joi.number().valid(10.51), 32 | fullVersion: Joi.string().valid('10.5.1'), 33 | id: Joi.number().integer().valid(0), 34 | name: Joi.string().allow(''), 35 | type: Joi.string().allow('Feature Layer'), 36 | description: Joi.string().allow(''), 37 | copyrightText: Joi.string().allow(''), 38 | parentLayer: Joi.valid(null), 39 | subLayers: Joi.valid(null), 40 | minScale: Joi.number().integer().valid(0), 41 | maxScale: Joi.number().integer().valid(0), 42 | defaultVisibility: Joi.boolean().valid(true), 43 | extent: Joi.object().keys({ 44 | xmin: Joi.number().valid(-180), 45 | ymin: Joi.number().valid(-90), 46 | xmax: Joi.number().valid(180), 47 | ymax: Joi.number().valid(90), 48 | spatialReference: Joi.object().keys({ 49 | wkid: Joi.number().valid(4326), 50 | latestWkid: Joi.number().valid(4326) 51 | }) 52 | }), 53 | hasAttachments: Joi.boolean().valid(false), 54 | htmlPopupType: Joi.string().allow('esriServerHTMLPopupTypeNone'), 55 | displayField: Joi.string().allow('OBJECTID'), 56 | typeIdField: Joi.valid(null), 57 | relationships: Joi.array().min(0), 58 | canModifyLayer: Joi.boolean().valid(false), 59 | canScaleSymbols: Joi.boolean().valid(false), 60 | hasLabels: Joi.boolean().valid(false), 61 | capabilities: Joi.string().allow('Query'), 62 | maxRecordCount: Joi.number().integer().valid(2000), 63 | supportsStatistics: Joi.boolean().valid(true), 64 | supportsAdvancedQueries: Joi.boolean().valid(true), 65 | supportedQueryFormats: Joi.string().allow('JSON'), 66 | ownershipBasedAccessControlForFeatures: Joi.object().keys({ 67 | allowOthersToQuery: Joi.boolean().valid(true) 68 | }), 69 | supportsCoordinatesQuantization: Joi.boolean().valid(false), 70 | useStandardizedQueries: Joi.boolean().valid(true), 71 | advancedQueryCapabilities: Joi.object().keys({ 72 | useStandardizedQueries: Joi.boolean().valid(true), 73 | supportsStatistics: Joi.boolean().valid(true), 74 | supportsOrderBy: Joi.boolean().valid(true), 75 | supportsDistinct: Joi.boolean().valid(true), 76 | supportsPagination: Joi.boolean().valid(true), 77 | supportsTrueCurve: Joi.boolean().valid(false), 78 | supportsReturningQueryExtent: Joi.boolean().valid(true), 79 | supportsQueryWithDistance: Joi.boolean().valid(true) 80 | }), 81 | dateFieldsTimeReference: null, 82 | isDataVersioned: Joi.boolean().valid(false), 83 | supportsRollbackOnFailureParameter: Joi.boolean().valid(true), 84 | hasM: Joi.boolean().valid(false), 85 | hasZ: Joi.boolean().valid(false), 86 | allowGeometryUpdates: Joi.boolean().valid(true), 87 | objectIdField: Joi.string().valid('OBJECTID'), 88 | globalIdField: Joi.string().valid(''), 89 | types: Joi.array().min(0), 90 | templates: Joi.array().min(0), 91 | hasStaticData: Joi.boolean().valid(false), 92 | timeInfo: Joi.object().keys({}), 93 | uniqueIdField: Joi.object().keys({ 94 | name: Joi.string().valid('OBJECTID'), 95 | isSystemMaintained: Joi.boolean().valid(true) 96 | }), 97 | fields: Joi.array().items(Joi.object().keys({ 98 | name: Joi.string(), 99 | type: Joi.string().allow('esriFieldTypeOID', 'esriFieldTypeInteger', 'esriFieldTypeDouble', 'esriFieldTypeString', 'esriFieldTypeDate'), 100 | alias: Joi.string(), 101 | length: Joi.optional().when('type', { 102 | is: Joi.string().allow('esriFieldTypeString', 'esriFieldTypeDate'), 103 | then: Joi.number().integer().min(0) 104 | }), 105 | defaultValue: Joi.any().valid(null), 106 | domain: Joi.any().valid(null), 107 | editable: Joi.boolean().valid(false, true), 108 | nullable: Joi.boolean().valid(false), 109 | sqlType: Joi.string().valid('sqlTypeOther', 'sqlTypeDouble', 'sqlTypeInteger') 110 | })).min(0), 111 | drawingInfo: Joi.object().keys({ 112 | renderer: Joi.object().keys({}), 113 | labelingInfo: Joi.valid(null) 114 | }) 115 | }) 116 | 117 | const oidTemplateSchema = Joi.object().keys({ 118 | name: Joi.string().valid('OBJECTID'), 119 | type: Joi.string().valid('esriFieldTypeOID'), 120 | alias: Joi.string().valid('OBJECTID'), 121 | sqlType: Joi.string().valid('sqlTypeInteger'), 122 | domain: Joi.valid(null), 123 | defaultValue: Joi.valid(null) 124 | }) 125 | 126 | const serverTemplateSchema = Joi.object().keys({ 127 | currentVersion: Joi.number().valid(10.51), 128 | fullVersion: Joi.string().valid('10.5.1'), 129 | serviceDescription: Joi.string().allow(''), 130 | hasVersionedData: Joi.boolean().valid(false), 131 | supportsDisconnectedEditing: Joi.boolean().valid(false), 132 | supportedQueryFormats: Joi.string().valid('JSON'), 133 | maxRecordCount: Joi.number().integer().valid(2000), 134 | hasStaticData: Joi.boolean().valid(false), 135 | capabilities: Joi.string().valid('Query'), 136 | description: Joi.string().allow(''), 137 | copyrightText: Joi.string().allow(''), 138 | spatialReference: Joi.object().keys({ 139 | wkid: Joi.number().valid(4326), 140 | latestWkid: Joi.number().valid(4326) 141 | }), 142 | initialExtent: Joi.object().keys({ 143 | xmin: Joi.number().valid(-180), 144 | ymin: Joi.number().valid(-90), 145 | xmax: Joi.number().valid(180), 146 | ymax: Joi.number().valid(90), 147 | spatialReference: Joi.object().keys({ 148 | wkid: Joi.number().valid(4326), 149 | latestWkid: Joi.number().valid(4326) 150 | }) 151 | }), 152 | fullExtent: Joi.object().keys({ 153 | xmin: Joi.number().valid(-180), 154 | ymin: Joi.number().valid(-90), 155 | xmax: Joi.number().valid(180), 156 | ymax: Joi.number().valid(90), 157 | spatialReference: Joi.object().keys({ 158 | wkid: Joi.number().valid(4326), 159 | latestWkid: Joi.number().valid(4326) 160 | }) 161 | }), 162 | relationships: Joi.array(), 163 | allowGeometryUpdates: Joi.boolean().valid(false), 164 | units: 'esriDecimalDegrees', 165 | syncEnabled: Joi.boolean().valid(false), 166 | layers: Joi.array().min(0), 167 | tables: Joi.array().min(0), 168 | supportsRelationshipsResource: Joi.boolean() 169 | }) 170 | 171 | module.exports = { featuresTemplateSchema, fieldsTemplateSchema, layerTemplateSchema, oidTemplateSchema, serverTemplateSchema } 172 | -------------------------------------------------------------------------------- /test/integration/template.json.spec.js: -------------------------------------------------------------------------------- 1 | const featuresJson = require('../../templates/features.json') 2 | const { featuresTemplateSchema } = require('./schemas') 3 | 4 | describe('Template content', () => { 5 | describe('features.json', () => { 6 | it('should conform to the prescribed schema', () => { 7 | // Use Joi to build expected schema and test against JSON. 8 | featuresTemplateSchema.validate(featuresJson, { presence: 'required' }).should.not.have.property('error') 9 | }) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /test/unit/defaults/server-metadata.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const defaultServerMetadata = require('../../../lib/defaults/server-metadata') 3 | 4 | describe('server metadata defaults', () => { 5 | it('defaults should have expected values', () => { 6 | defaultServerMetadata.should.deepEqual({ 7 | serviceDescription: 'This is a feature service exposed with Koop, an open source project that turns APIs into features. Service Description information may not be available for all services. For more information, check out https://github.com/koopjs/koop.', 8 | hasVersionedData: false, 9 | supportsDisconnectedEditing: false, 10 | supportsRelationshipsResource: false, 11 | supportedQueryFormats: 'JSON', 12 | maxRecordCount: 2000, 13 | hasStaticData: false, 14 | capabilities: 'Query', 15 | description: 'This is a feature service exposed with Koop, an open source project that turns APIs into features. Service Description information may not be available for all services. For more information, check out https://github.com/koopjs/koop.', 16 | copyrightText: 'Copyright information varies from provider to provider, for more information please contact the source of this data', 17 | spatialReference: { 18 | wkid: 4326, 19 | latestWkid: 4326 20 | }, 21 | initialExtent: { 22 | xmin: -180, 23 | ymin: -90, 24 | xmax: 180, 25 | ymax: 90, 26 | spatialReference: { 27 | wkid: 4326, 28 | latestWkid: 4326 29 | } 30 | }, 31 | fullExtent: { 32 | xmin: -180, 33 | ymin: -90, 34 | xmax: 180, 35 | ymax: 90, 36 | spatialReference: { 37 | wkid: 4326, 38 | latestWkid: 4326 39 | } 40 | }, 41 | allowGeometryUpdates: false, 42 | units: 'esriDecimalDegrees', 43 | syncEnabled: false, 44 | layers: [], 45 | tables: [] 46 | }) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /test/unit/generate-renderer/color-ramp.spec.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | const should = require('should'); // eslint-disable-line 3 | const { 4 | createColorRamp 5 | } = require('../../../lib/generate-renderer/color-ramp') 6 | 7 | const classification = [ 8 | [80, 147], 9 | [147, 174], 10 | [174, 195], 11 | [195, 218], 12 | [240, 270], 13 | [307, 360], 14 | [360, 558], 15 | [558, 799], 16 | [799, 2000] 17 | ] 18 | 19 | describe('when creating a color ramp that', () => { 20 | describe('is algorithmic', () => { 21 | it('should throw an error on no breaks option', () => { 22 | createColorRamp.bind().should.throw() 23 | }) 24 | 25 | it('should throw an error on invalid type option', () => { 26 | createColorRamp.bind(null, { classification, type: 'foo' }).should.throw() 27 | }) 28 | 29 | it('should throw an error on multipart type with color-ramps', () => { 30 | createColorRamp.bind(null, { classification, type: 'multipart' }).should.throw() 31 | }) 32 | 33 | it('should return correct hsv color ramp', () => { 34 | const response = createColorRamp({ 35 | classification, 36 | type: 'algorithmic', 37 | fromColor: [0, 255, 0], 38 | toColor: [0, 0, 255], 39 | algorithm: 'esriHSVAlgorithm' 40 | }) 41 | response.should.deepEqual([ 42 | [0, 255, 0], 43 | [0, 255, 64], 44 | [0, 255, 128], 45 | [0, 255, 191], 46 | [0, 255, 255], 47 | [0, 191, 255], 48 | [0, 127, 255], 49 | [0, 64, 255], 50 | [0, 0, 255] 51 | ]) 52 | }) 53 | 54 | it('should return correct lab color ramp', () => { 55 | const response = createColorRamp({ 56 | classification, 57 | type: 'algorithmic', 58 | fromColor: [0, 255, 0], 59 | toColor: [0, 0, 255], 60 | algorithm: 'esriCIELabAlgorithm' 61 | }) 62 | response.should.deepEqual([ 63 | [0, 255, 0], 64 | [87, 228, 79], 65 | [111, 201, 114], 66 | [123, 174, 141], 67 | [125, 147, 166], 68 | [121, 120, 189], 69 | [108, 91, 211], 70 | [83, 58, 233], 71 | [0, 0, 255] 72 | ]) 73 | }) 74 | 75 | it('should return correct lch color ramp', () => { 76 | const response = createColorRamp({ 77 | classification, 78 | type: 'algorithmic', 79 | fromColor: [0, 255, 0], 80 | toColor: [0, 0, 255], 81 | algorithm: 'esriLabLChAlgorithm' 82 | }) 83 | response.should.deepEqual([ 84 | [0, 255, 0], 85 | [0, 242, 105], 86 | [0, 225, 173], 87 | [0, 206, 237], 88 | [0, 186, 255], 89 | [0, 163, 255], 90 | [0, 135, 255], 91 | [0, 96, 255], 92 | [0, 0, 255] 93 | ]) 94 | }) 95 | 96 | it('should return multiple color ramps', () => { 97 | const multipartRamp = { 98 | type: 'multipart', 99 | colorRamps: [ 100 | { 101 | type: 'algorithmic', 102 | fromColor: [0, 255, 0], 103 | toColor: [0, 0, 255], 104 | algorithm: 'esriHSVAlgorithm' 105 | }, 106 | { 107 | type: 'algorithmic', 108 | fromColor: [0, 255, 0], 109 | toColor: [0, 0, 255], 110 | algorithm: 'esriCIELabAlgorithm' 111 | }, 112 | { 113 | type: 'algorithmic', 114 | fromColor: [0, 255, 0], 115 | toColor: [0, 0, 255], 116 | algorithm: 'esriLabLChAlgorithm' 117 | } 118 | ] 119 | } 120 | const response = createColorRamp({ classification, ...multipartRamp }) 121 | response.should.deepEqual([ 122 | [ 123 | [0, 255, 0], 124 | [0, 255, 64], 125 | [0, 255, 128], 126 | [0, 255, 191], 127 | [0, 255, 255], 128 | [0, 191, 255], 129 | [0, 127, 255], 130 | [0, 64, 255], 131 | [0, 0, 255] 132 | ], 133 | [ 134 | [0, 255, 0], 135 | [87, 228, 79], 136 | [111, 201, 114], 137 | [123, 174, 141], 138 | [125, 147, 166], 139 | [121, 120, 189], 140 | [108, 91, 211], 141 | [83, 58, 233], 142 | [0, 0, 255] 143 | ], 144 | [ 145 | [0, 255, 0], 146 | [0, 242, 105], 147 | [0, 225, 173], 148 | [0, 206, 237], 149 | [0, 186, 255], 150 | [0, 163, 255], 151 | [0, 135, 255], 152 | [0, 96, 255], 153 | [0, 0, 255] 154 | ] 155 | ]) 156 | }) 157 | }) 158 | }) 159 | -------------------------------------------------------------------------------- /test/unit/generate-renderer/create-symbol.spec.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | const should = require('should'); // eslint-disable-line 3 | const { 4 | createSymbol 5 | } = require('../../../lib/generate-renderer/create-symbol') 6 | 7 | describe('when creating a symbol', () => { 8 | it('uses passed in base symbol and color', () => { 9 | const result = createSymbol({ foo: 'bar' }, 'red') 10 | result.should.deepEqual({ foo: 'bar', color: 'red' }) 11 | }) 12 | 13 | it('errors without base symbol or geometry type', () => { 14 | try { 15 | createSymbol(undefined, 'red') 16 | should.fail('should have thrown error') 17 | } catch (error) { 18 | error.message.should.equal('Dataset geometry type is not supported for renderers.') 19 | error.code.should.equal(400) 20 | } 21 | }) 22 | 23 | it('gets symbol from point renderer', () => { 24 | const result = createSymbol(undefined, 'red', 'esriGeometryPoint') 25 | result.should.deepEqual({ 26 | color: 'red', 27 | outline: { 28 | color: [ 29 | 190, 30 | 190, 31 | 190, 32 | 105 33 | ], 34 | width: 0.5, 35 | type: 'esriSLS', 36 | style: 'esriSLSSolid' 37 | }, 38 | size: 7.5, 39 | type: 'esriSMS', 40 | style: 'esriSMSCircle' 41 | }) 42 | }) 43 | 44 | it('gets symbol from point renderer', () => { 45 | const result = createSymbol(undefined, 'red', 'esriGeometryMultiPoint') 46 | result.should.deepEqual({ 47 | color: 'red', 48 | outline: { 49 | color: [ 50 | 190, 51 | 190, 52 | 190, 53 | 105 54 | ], 55 | width: 0.5, 56 | type: 'esriSLS', 57 | style: 'esriSLSSolid' 58 | }, 59 | size: 7.5, 60 | type: 'esriSMS', 61 | style: 'esriSMSCircle' 62 | }) 63 | }) 64 | 65 | it('gets symbol from line renderer', () => { 66 | const result = createSymbol(undefined, 'red', 'esriGeometryPolyline') 67 | result.should.deepEqual({ 68 | color: 'red', 69 | width: 6.999999999999999, 70 | type: 'esriSLS', 71 | style: 'esriSLSSolid' 72 | }) 73 | }) 74 | 75 | it('gets symbol from polygon renderer', () => { 76 | const result = createSymbol(undefined, 'red', 'esriGeometryPolygon') 77 | result.should.deepEqual({ 78 | color: 'red', 79 | outline: { 80 | color: [ 81 | 150, 82 | 150, 83 | 150, 84 | 155 85 | ], 86 | width: 0.5, 87 | type: 'esriSLS', 88 | style: 'esriSLSSolid' 89 | }, 90 | type: 'esriSFS', 91 | style: 'esriSFSSolid' 92 | }) 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /test/unit/generate-renderer/validate-classification-definition.spec.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | const should = require('should'); // eslint-disable-line 3 | const validateClassificationDefinition = require('../../../lib/generate-renderer/validate-classification-definition') 4 | 5 | describe('when creating a symbol', () => { 6 | it('throws error with undefined classification definition', () => { 7 | try { 8 | validateClassificationDefinition() 9 | should.fail('should have thrown error') 10 | } catch (error) { 11 | error.message.should.equal('classification definition is required') 12 | error.code.should.equal(400) 13 | } 14 | }) 15 | 16 | it('throws error with unknown classification definition type', () => { 17 | try { 18 | validateClassificationDefinition({ type: 'lol' }) 19 | should.fail('should have thrown error') 20 | } catch (error) { 21 | error.message.should.equal('invalid classification type') 22 | error.code.should.equal(400) 23 | } 24 | }) 25 | 26 | it('throws error with unknown base symbol type', () => { 27 | try { 28 | validateClassificationDefinition({ type: 'classBreaksDef', baseSymbol: { type: 'foo' } }) 29 | should.fail('should have thrown error') 30 | } catch (error) { 31 | error.message.should.equal('baseSymbol requires a valid type: esriSMS, esriSLS, esriSFS') 32 | error.code.should.equal(400) 33 | } 34 | }) 35 | 36 | it('throws error with undefined base symbol type', () => { 37 | try { 38 | validateClassificationDefinition({ type: 'classBreaksDef', baseSymbol: {} }) 39 | should.fail('should have thrown error') 40 | } catch (error) { 41 | error.message.should.equal('baseSymbol requires a valid type: esriSMS, esriSLS, esriSFS') 42 | error.code.should.equal(400) 43 | } 44 | }) 45 | 46 | it('throws error with base symbol type / geometry: line/sms', () => { 47 | try { 48 | validateClassificationDefinition({ type: 'classBreaksDef', baseSymbol: { type: 'esriSMS' } }, 'esriGeometryPolyline') 49 | should.fail('should have thrown error') 50 | } catch (error) { 51 | error.message.should.equal('Classification defintion uses a base symbol type that is incompatiable with dataset geometry') 52 | error.code.should.equal(400) 53 | } 54 | }) 55 | 56 | it('throws error with base symbol type / geometry: polygon/sms', () => { 57 | try { 58 | validateClassificationDefinition({ type: 'classBreaksDef', baseSymbol: { type: 'esriSMS' } }, 'esriGeometryPolygon') 59 | should.fail('should have thrown error') 60 | } catch (error) { 61 | error.message.should.equal('Classification defintion uses a base symbol type that is incompatiable with dataset geometry') 62 | error.code.should.equal(400) 63 | } 64 | }) 65 | 66 | it('throws error with base symbol type / geometry: point/sls', () => { 67 | try { 68 | validateClassificationDefinition({ type: 'classBreaksDef', baseSymbol: { type: 'esriSLS' } }, 'esriGeometryPoint') 69 | should.fail('should have thrown error') 70 | } catch (error) { 71 | error.message.should.equal('Classification defintion uses a base symbol type that is incompatiable with dataset geometry') 72 | error.code.should.equal(400) 73 | } 74 | }) 75 | 76 | it('throws error with unsupport geometry', () => { 77 | try { 78 | validateClassificationDefinition({ type: 'classBreaksDef', baseSymbol: { type: 'esriSLS' } }, 'foo') 79 | should.fail('should have thrown error') 80 | } catch (error) { 81 | error.message.should.equal('Classification defintion uses a base symbol type that is incompatiable with dataset geometry') 82 | error.code.should.equal(400) 83 | } 84 | }) 85 | 86 | it('validates with undefined base symbol', () => { 87 | try { 88 | validateClassificationDefinition({ type: 'classBreaksDef' }) 89 | } catch (error) { 90 | should.fail(`should not have thrown error: ${error}`) 91 | } 92 | }) 93 | 94 | it('validates with base symbol / geometry match', () => { 95 | try { 96 | validateClassificationDefinition({ type: 'classBreaksDef', baseSymbol: { type: 'esriSMS' } }, 'esriGeometryPoint') 97 | } catch (error) { 98 | should.fail(`should not have thrown error: ${error}`) 99 | } 100 | }) 101 | 102 | it('throws error when unique-value-fields definition is missing uniqueValueFields array', () => { 103 | try { 104 | validateClassificationDefinition({ type: 'uniqueValueDef' }, 'esriGeometryPoint', [{ fooz: 'bar' }]) 105 | should.fail('should have thrown error') 106 | } catch (error) { 107 | error.message.should.equal('uniqueValueDef requires a classification definition with "uniqueValueFields" array') 108 | error.code.should.equal(400) 109 | } 110 | }) 111 | 112 | it('throws error when unique-value-fields definition diverges from classification', () => { 113 | try { 114 | validateClassificationDefinition({ type: 'uniqueValueDef', uniqueValueFields: ['foo'] }, 'esriGeometryPoint', [{ fooz: 'bar' }]) 115 | should.fail('should have thrown error') 116 | } catch (error) { 117 | error.message.should.equal('Unique value definition fields are incongruous with classification fields: foo : fooz') 118 | error.code.should.equal(400) 119 | } 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /test/unit/helpers/calculate-extent.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const calculateExtent = require('../../../lib/helpers/calculate-extent') 3 | 4 | describe('calculate-extent', () => { 5 | it('calculateExtent: no data passed', () => { 6 | const extent = calculateExtent({}) 7 | should(extent).equal(undefined) 8 | }) 9 | 10 | it('calculateExtent: Point', () => { 11 | const extent = calculateExtent({ 12 | isLayer: true, 13 | geojson: { 14 | type: 'Feature', 15 | geometry: { 16 | type: 'Point', 17 | coordinates: [102.0, 0.5] 18 | }, 19 | properties: { 20 | prop0: 'value0' 21 | } 22 | }, 23 | spatialReference: 4326 24 | }) 25 | should(extent).deepEqual({ xmin: 102, ymin: 0.5, xmax: 102, ymax: 0.5, spatialReference: 4326 }) 26 | }) 27 | 28 | it('calculateExtent: LineString', () => { 29 | const extent = calculateExtent({ 30 | isLayer: true, 31 | geojson: { 32 | type: 'Feature', 33 | geometry: { 34 | type: 'LineString', 35 | coordinates: [ 36 | [102.0, 0.5], 37 | [102.3, 0.8], 38 | [103.1, 1.2], 39 | [103.0, 1.0], 40 | [102.6, 0.9] 41 | ] 42 | }, 43 | properties: { 44 | prop0: 'value0' 45 | } 46 | }, 47 | spatialReference: 4326 48 | }) 49 | should(extent).deepEqual({ xmin: 102, ymin: 0.5, xmax: 103.1, ymax: 1.2, spatialReference: 4326 }) 50 | }) 51 | 52 | it('calculateExtent: Polygon', () => { 53 | const extent = calculateExtent({ 54 | isLayer: true, 55 | geojson: { 56 | type: 'Feature', 57 | geometry: { 58 | type: 'Polygon', 59 | coordinates: [ 60 | [ 61 | [100.0, 0.0], 62 | [101.0, 0.0], 63 | [101.0, 1.0], 64 | [100.0, 1.0], 65 | [100.0, 0.0] 66 | ] 67 | ] 68 | }, 69 | properties: { 70 | prop0: 'value0' 71 | } 72 | }, 73 | spatialReference: 102100 74 | }) 75 | should(extent).deepEqual({ xmin: 100, ymin: 0.0, xmax: 101.0, ymax: 1.0, spatialReference: 102100 }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /test/unit/helpers/data-type-utils.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const { 3 | getDataTypeFromValue, 4 | isDate 5 | } = require('../../../lib/helpers/data-type-utils') 6 | 7 | describe('getDataTypeFromValue', () => { 8 | it('should return integer', () => { 9 | getDataTypeFromValue(10).should.equal('Integer') 10 | }) 11 | 12 | it('should return double', () => { 13 | getDataTypeFromValue(10.10).should.equal('Double') 14 | }) 15 | 16 | it('should return string', () => { 17 | getDataTypeFromValue('10.10').should.equal('String') 18 | }) 19 | 20 | it('should return string as default', () => { 21 | getDataTypeFromValue().should.equal('String') 22 | }) 23 | 24 | it('should return date for date object', () => { 25 | getDataTypeFromValue(new Date()).should.equal('Date') 26 | }) 27 | 28 | it('should return date for data ISO string', () => { 29 | getDataTypeFromValue(new Date().toISOString()).should.equal('Date') 30 | }) 31 | }) 32 | 33 | describe('isDate', () => { 34 | it('should return true for date object', () => { 35 | isDate(new Date()).should.equal(true) 36 | }) 37 | 38 | it('should return true for ISO string', () => { 39 | getDataTypeFromValue(new Date().toISOString()).should.equal('Date') 40 | }) 41 | 42 | it('should return false for number', () => { 43 | isDate(1000).should.equal(false) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /test/unit/helpers/fields/constants.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const { 3 | ESRI_FIELD_TYPE_OID, 4 | ESRI_FIELD_TYPE_STRING, 5 | ESRI_FIELD_TYPE_DATE, 6 | ESRI_FIELD_TYPE_DOUBLE, 7 | SQL_TYPE_INTEGER, 8 | SQL_TYPE_OTHER, 9 | SQL_TYPE_FLOAT, 10 | OBJECTID_DEFAULT_KEY 11 | } = require('../../../../lib/helpers/fields/constants') 12 | 13 | describe('field constants', () => { 14 | it('ESRI_FIELD_TYPE_OID', () => { 15 | ESRI_FIELD_TYPE_OID.should.equal('esriFieldTypeOID') 16 | }) 17 | 18 | it('ESRI_FIELD_TYPE_STRING', () => { 19 | ESRI_FIELD_TYPE_STRING.should.equal('esriFieldTypeString') 20 | }) 21 | 22 | it('ESRI_FIELD_TYPE_DATE', () => { 23 | ESRI_FIELD_TYPE_DATE.should.equal('esriFieldTypeDate') 24 | }) 25 | 26 | it('ESRI_FIELD_TYPE_DOUBLE', () => { 27 | ESRI_FIELD_TYPE_DOUBLE.should.equal('esriFieldTypeDouble') 28 | }) 29 | 30 | it('SQL_TYPE_INTEGER', () => { 31 | SQL_TYPE_INTEGER.should.equal('sqlTypeInteger') 32 | }) 33 | 34 | it('SQL_TYPE_OTHER', () => { 35 | SQL_TYPE_OTHER.should.equal('sqlTypeOther') 36 | }) 37 | 38 | it('SQL_TYPE_FLOAT', () => { 39 | SQL_TYPE_FLOAT.should.equal('sqlTypeFloat') 40 | }) 41 | 42 | it('OBJECTID_DEFAULT_KEY', () => { 43 | OBJECTID_DEFAULT_KEY.should.equal('OBJECTID') 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /test/unit/helpers/fields/esri-type-utils.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const { 3 | getEsriTypeFromDefinition, 4 | getEsriTypeFromValue 5 | } = require('../../../../lib/helpers/fields/esri-type-utils') 6 | 7 | describe('getEsriTypeFromDefinition', () => { 8 | it('no definition should default to string', () => { 9 | const result = getEsriTypeFromDefinition() 10 | result.should.equal('esriFieldTypeString') 11 | }) 12 | 13 | it('string', () => { 14 | getEsriTypeFromDefinition('String').should.equal('esriFieldTypeString') 15 | getEsriTypeFromDefinition('string').should.equal('esriFieldTypeString') 16 | }) 17 | 18 | it('double', () => { 19 | getEsriTypeFromDefinition('Double').should.equal('esriFieldTypeDouble') 20 | getEsriTypeFromDefinition('double').should.equal('esriFieldTypeDouble') 21 | }) 22 | 23 | it('integer', () => { 24 | getEsriTypeFromDefinition('Integer').should.equal('esriFieldTypeInteger') 25 | getEsriTypeFromDefinition('integer').should.equal('esriFieldTypeInteger') 26 | }) 27 | 28 | it('date', () => { 29 | getEsriTypeFromDefinition('Date').should.equal('esriFieldTypeDate') 30 | getEsriTypeFromDefinition('date').should.equal('esriFieldTypeDate') 31 | }) 32 | 33 | it('blob', () => { 34 | getEsriTypeFromDefinition('Blob').should.equal('esriFieldTypeBlob') 35 | getEsriTypeFromDefinition('blob').should.equal('esriFieldTypeBlob') 36 | }) 37 | 38 | it('geometry', () => { 39 | getEsriTypeFromDefinition('Geometry').should.equal('esriFieldTypeGeometry') 40 | getEsriTypeFromDefinition('geometry').should.equal('esriFieldTypeGeometry') 41 | }) 42 | 43 | it('globalid', () => { 44 | getEsriTypeFromDefinition('GlobalID').should.equal('esriFieldTypeGlobalID') 45 | getEsriTypeFromDefinition('globalid').should.equal('esriFieldTypeGlobalID') 46 | }) 47 | 48 | it('guid', () => { 49 | getEsriTypeFromDefinition('GUID').should.equal('esriFieldTypeGUID') 50 | getEsriTypeFromDefinition('guid').should.equal('esriFieldTypeGUID') 51 | }) 52 | 53 | it('raster', () => { 54 | getEsriTypeFromDefinition('Raster').should.equal('esriFieldTypeRaster') 55 | getEsriTypeFromDefinition('raster').should.equal('esriFieldTypeRaster') 56 | }) 57 | 58 | it('single', () => { 59 | getEsriTypeFromDefinition('Single').should.equal('esriFieldTypeSingle') 60 | getEsriTypeFromDefinition('single').should.equal('esriFieldTypeSingle') 61 | }) 62 | 63 | it('small-integer', () => { 64 | getEsriTypeFromDefinition('SmallInteger').should.equal('esriFieldTypeSmallInteger') 65 | getEsriTypeFromDefinition('smallinteger').should.equal('esriFieldTypeSmallInteger') 66 | }) 67 | 68 | it('xml', () => { 69 | getEsriTypeFromDefinition('XML').should.equal('esriFieldTypeXML') 70 | getEsriTypeFromDefinition('xml').should.equal('esriFieldTypeXML') 71 | }) 72 | }) 73 | 74 | describe('getEsriTypeFromValue', () => { 75 | it('no value should default to string', () => { 76 | const result = getEsriTypeFromValue() 77 | result.should.equal('esriFieldTypeString') 78 | }) 79 | 80 | it('string', () => { 81 | getEsriTypeFromValue('some-string').should.equal('esriFieldTypeString') 82 | }) 83 | 84 | it('double', () => { 85 | getEsriTypeFromValue(3.145678).should.equal('esriFieldTypeDouble') 86 | }) 87 | 88 | it('integer', () => { 89 | getEsriTypeFromValue(2).should.equal('esriFieldTypeInteger') 90 | }) 91 | 92 | it('date', () => { 93 | getEsriTypeFromValue(new Date()).should.equal('esriFieldTypeDate') 94 | getEsriTypeFromValue((new Date()).toISOString()).should.equal('esriFieldTypeDate') 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /test/unit/helpers/fields/field-classes.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | should.config.checkProtoEql = false 3 | const { 4 | FieldFromKeyValue, 5 | ObjectIdField, 6 | FieldFromFieldDefinition, 7 | ObjectIdFieldFromDefinition, 8 | StatisticField, 9 | StatisticDateField 10 | } = require('../../../../lib/helpers/fields/field-classes') 11 | 12 | describe('FieldFromKeyValue', () => { 13 | it('should produce expected instance', () => { 14 | const result = new FieldFromKeyValue('foo', 'bar') 15 | result.should.deepEqual({ 16 | name: 'foo', 17 | alias: 'foo', 18 | type: 'esriFieldTypeString', 19 | sqlType: 'sqlTypeOther', 20 | length: 128, 21 | domain: null, 22 | defaultValue: null 23 | }) 24 | 25 | result.setEditable().setNullable() 26 | result.should.deepEqual({ 27 | name: 'foo', 28 | alias: 'foo', 29 | type: 'esriFieldTypeString', 30 | sqlType: 'sqlTypeOther', 31 | length: 128, 32 | domain: null, 33 | defaultValue: null, 34 | editable: false, 35 | nullable: false 36 | }) 37 | 38 | result.setEditable(true).setNullable(true) 39 | result.should.deepEqual({ 40 | name: 'foo', 41 | alias: 'foo', 42 | type: 'esriFieldTypeString', 43 | sqlType: 'sqlTypeOther', 44 | length: 128, 45 | domain: null, 46 | defaultValue: null, 47 | editable: true, 48 | nullable: true 49 | }) 50 | }) 51 | }) 52 | 53 | describe('ObjectIdField', () => { 54 | it('should produce expected instance', () => { 55 | const result = new ObjectIdField('idFoo') 56 | result.should.deepEqual({ 57 | name: 'idFoo', 58 | alias: 'idFoo', 59 | type: 'esriFieldTypeOID', 60 | sqlType: 'sqlTypeInteger', 61 | domain: null, 62 | defaultValue: null 63 | }) 64 | 65 | result.setEditable().setNullable() 66 | result.should.deepEqual({ 67 | name: 'idFoo', 68 | alias: 'idFoo', 69 | type: 'esriFieldTypeOID', 70 | sqlType: 'sqlTypeInteger', 71 | domain: null, 72 | defaultValue: null, 73 | editable: false, 74 | nullable: false 75 | }) 76 | 77 | result.setEditable(true).setNullable(true) 78 | result.should.deepEqual({ 79 | name: 'idFoo', 80 | alias: 'idFoo', 81 | type: 'esriFieldTypeOID', 82 | sqlType: 'sqlTypeInteger', 83 | domain: null, 84 | defaultValue: null, 85 | editable: true, 86 | nullable: true 87 | }) 88 | }) 89 | }) 90 | 91 | describe('FieldFromFieldDefinition', () => { 92 | it('should produce expected instance from name, type definitions', () => { 93 | const result = new FieldFromFieldDefinition({ 94 | name: 'foo', 95 | type: 'String' 96 | }) 97 | result.should.deepEqual({ 98 | name: 'foo', 99 | alias: 'foo', 100 | type: 'esriFieldTypeString', 101 | sqlType: 'sqlTypeOther', 102 | length: 128, 103 | domain: null, 104 | defaultValue: null 105 | }) 106 | 107 | result.setEditable().setNullable() 108 | result.should.deepEqual({ 109 | name: 'foo', 110 | alias: 'foo', 111 | type: 'esriFieldTypeString', 112 | sqlType: 'sqlTypeOther', 113 | length: 128, 114 | domain: null, 115 | defaultValue: null, 116 | editable: false, 117 | nullable: false 118 | }) 119 | 120 | result.setEditable(true).setNullable(true) 121 | result.should.deepEqual({ 122 | name: 'foo', 123 | alias: 'foo', 124 | type: 'esriFieldTypeString', 125 | sqlType: 'sqlTypeOther', 126 | length: 128, 127 | domain: null, 128 | defaultValue: null, 129 | editable: true, 130 | nullable: true 131 | }) 132 | }) 133 | 134 | it('should produce expected instance from name, type, plus all optional definitions', () => { 135 | const result = new FieldFromFieldDefinition({ 136 | name: 'foo', 137 | type: 'String', 138 | alias: 'foolish', 139 | domain: 'domain-value', 140 | defaultValue: 'default-value', 141 | length: 256 142 | }) 143 | result.should.deepEqual({ 144 | name: 'foo', 145 | alias: 'foolish', 146 | type: 'esriFieldTypeString', 147 | sqlType: 'sqlTypeOther', 148 | domain: 'domain-value', 149 | defaultValue: 'default-value', 150 | length: 256 151 | }) 152 | }) 153 | }) 154 | 155 | describe('ObjectIdFieldFromFieldDefinition', () => { 156 | it('should produce expected instance', () => { 157 | const result = new ObjectIdFieldFromDefinition({ 158 | name: 'foo', 159 | type: 'String', 160 | editable: true, 161 | nullable: true 162 | }) 163 | result.should.deepEqual({ 164 | name: 'foo', 165 | alias: 'foo', 166 | type: 'esriFieldTypeOID', 167 | sqlType: 'sqlTypeInteger', 168 | domain: null, 169 | defaultValue: null 170 | }) 171 | 172 | result.setEditable().setNullable() 173 | result.should.deepEqual({ 174 | name: 'foo', 175 | alias: 'foo', 176 | type: 'esriFieldTypeOID', 177 | sqlType: 'sqlTypeInteger', 178 | domain: null, 179 | defaultValue: null, 180 | editable: false, 181 | nullable: false 182 | }) 183 | 184 | result.setEditable(true).setNullable(true) 185 | result.should.deepEqual({ 186 | name: 'foo', 187 | alias: 'foo', 188 | type: 'esriFieldTypeOID', 189 | sqlType: 'sqlTypeInteger', 190 | domain: null, 191 | defaultValue: null, 192 | editable: true, 193 | nullable: true 194 | }) 195 | }) 196 | }) 197 | 198 | describe('StatisticsField', () => { 199 | it('should produce expected instance', () => { 200 | const result = new StatisticField('foo') 201 | result.should.deepEqual({ 202 | name: 'foo', 203 | alias: 'foo', 204 | type: 'esriFieldTypeDouble', 205 | sqlType: 'sqlTypeFloat', 206 | domain: null, 207 | defaultValue: null 208 | }) 209 | }) 210 | }) 211 | 212 | describe('StatisticsDateField', () => { 213 | it('should produce expected instance', () => { 214 | const result = new StatisticDateField('foo') 215 | result.should.deepEqual({ 216 | name: 'foo', 217 | alias: 'foo', 218 | type: 'esriFieldTypeDate', 219 | sqlType: 'sqlTypeOther', 220 | domain: null, 221 | defaultValue: null 222 | }) 223 | }) 224 | }) 225 | -------------------------------------------------------------------------------- /test/unit/helpers/fields/layer-fields.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | should.config.checkProtoEql = false 3 | const LayerFields = require('../../../../lib/helpers/fields/layer-fields') 4 | 5 | describe('LayerFields', () => { 6 | it('create fields from definitions, adds OBJECTID', () => { 7 | const result = LayerFields.create({ 8 | fields: [ 9 | { name: 'foo', type: 'String' }, 10 | { name: 'bar', type: 'String', editable: true, nullable: true } 11 | ] 12 | }) 13 | result.should.deepEqual([{ 14 | name: 'OBJECTID', 15 | alias: 'OBJECTID', 16 | type: 'esriFieldTypeOID', 17 | sqlType: 'sqlTypeInteger', 18 | domain: null, 19 | defaultValue: null, 20 | editable: false, 21 | nullable: false 22 | }, { 23 | name: 'foo', 24 | alias: 'foo', 25 | type: 'esriFieldTypeString', 26 | sqlType: 'sqlTypeOther', 27 | length: 128, 28 | domain: null, 29 | defaultValue: null, 30 | editable: false, 31 | nullable: false 32 | }, { 33 | name: 'bar', 34 | alias: 'bar', 35 | type: 'esriFieldTypeString', 36 | sqlType: 'sqlTypeOther', 37 | length: 128, 38 | domain: null, 39 | defaultValue: null, 40 | editable: true, 41 | nullable: true 42 | }]) 43 | }) 44 | 45 | it('create fields from definitions, assign idField as OBJECTID', () => { 46 | const result = LayerFields.create({ 47 | fields: [ 48 | { name: 'foo', type: 'Integer' } 49 | ], 50 | idField: 'foo' 51 | }) 52 | result.should.deepEqual([{ 53 | name: 'foo', 54 | alias: 'foo', 55 | type: 'esriFieldTypeOID', 56 | sqlType: 'sqlTypeInteger', 57 | domain: null, 58 | defaultValue: null, 59 | editable: false, 60 | nullable: false 61 | }]) 62 | }) 63 | 64 | it('create fields from attributes sample, adds OBJECTID', () => { 65 | const result = LayerFields.create({ 66 | attributeSample: { 67 | foo: 'bar' 68 | } 69 | }) 70 | result.should.deepEqual([{ 71 | name: 'OBJECTID', 72 | alias: 'OBJECTID', 73 | type: 'esriFieldTypeOID', 74 | sqlType: 'sqlTypeInteger', 75 | domain: null, 76 | defaultValue: null, 77 | editable: false, 78 | nullable: false 79 | }, { 80 | name: 'foo', 81 | alias: 'foo', 82 | type: 'esriFieldTypeString', 83 | sqlType: 'sqlTypeOther', 84 | length: 128, 85 | domain: null, 86 | defaultValue: null, 87 | editable: false, 88 | nullable: false 89 | }]) 90 | }) 91 | 92 | it('create fields from attributes sample, finds and uses OBJECTID', () => { 93 | const result = LayerFields.create({ 94 | attributeSample: { 95 | foo: 'bar', 96 | OBJECTID: 1 97 | } 98 | }) 99 | result.should.deepEqual([{ 100 | name: 'OBJECTID', 101 | alias: 'OBJECTID', 102 | type: 'esriFieldTypeOID', 103 | sqlType: 'sqlTypeInteger', 104 | domain: null, 105 | defaultValue: null, 106 | editable: false, 107 | nullable: false 108 | }, { 109 | name: 'foo', 110 | alias: 'foo', 111 | type: 'esriFieldTypeString', 112 | sqlType: 'sqlTypeOther', 113 | length: 128, 114 | domain: null, 115 | defaultValue: null, 116 | editable: false, 117 | nullable: false 118 | }]) 119 | }) 120 | 121 | it('create fields from attributes sample, adds OBJECTID', () => { 122 | const result = LayerFields.create({ 123 | attributeSample: { 124 | foo: 'bar' 125 | } 126 | }) 127 | result.should.deepEqual([{ 128 | name: 'OBJECTID', 129 | alias: 'OBJECTID', 130 | type: 'esriFieldTypeOID', 131 | sqlType: 'sqlTypeInteger', 132 | domain: null, 133 | defaultValue: null, 134 | editable: false, 135 | nullable: false 136 | }, { 137 | name: 'foo', 138 | alias: 'foo', 139 | type: 'esriFieldTypeString', 140 | sqlType: 'sqlTypeOther', 141 | length: 128, 142 | domain: null, 143 | defaultValue: null, 144 | editable: false, 145 | nullable: false 146 | }]) 147 | }) 148 | 149 | it('create fields from geojson data, adds OBJECTID', () => { 150 | const result = LayerFields.create({ 151 | features: [{ 152 | properties: { 153 | foo: 'bar' 154 | } 155 | }] 156 | }) 157 | 158 | result.should.deepEqual([{ 159 | name: 'OBJECTID', 160 | alias: 'OBJECTID', 161 | type: 'esriFieldTypeOID', 162 | sqlType: 'sqlTypeInteger', 163 | domain: null, 164 | defaultValue: null, 165 | editable: false, 166 | nullable: false 167 | }, { 168 | name: 'foo', 169 | alias: 'foo', 170 | type: 'esriFieldTypeString', 171 | sqlType: 'sqlTypeOther', 172 | length: 128, 173 | domain: null, 174 | defaultValue: null, 175 | editable: false, 176 | nullable: false 177 | }]) 178 | }) 179 | 180 | it('create fields from geojson data, finds and uses OBJECTID', () => { 181 | const result = LayerFields.create({ 182 | features: [{ 183 | properties: { 184 | OBJECTID: 1, 185 | foo: 'bar' 186 | } 187 | }] 188 | }) 189 | 190 | result.should.deepEqual([{ 191 | name: 'OBJECTID', 192 | alias: 'OBJECTID', 193 | type: 'esriFieldTypeOID', 194 | sqlType: 'sqlTypeInteger', 195 | domain: null, 196 | defaultValue: null, 197 | editable: false, 198 | nullable: false 199 | }, { 200 | name: 'foo', 201 | alias: 'foo', 202 | type: 'esriFieldTypeString', 203 | sqlType: 'sqlTypeOther', 204 | length: 128, 205 | domain: null, 206 | defaultValue: null, 207 | editable: false, 208 | nullable: false 209 | }]) 210 | }) 211 | 212 | it('create fields from esri json data, adds OBJECTID', () => { 213 | const result = LayerFields.create({ 214 | features: [{ 215 | attributes: { 216 | foo: 'bar' 217 | } 218 | }] 219 | }) 220 | 221 | result.should.deepEqual([{ 222 | name: 'OBJECTID', 223 | alias: 'OBJECTID', 224 | type: 'esriFieldTypeOID', 225 | sqlType: 'sqlTypeInteger', 226 | domain: null, 227 | defaultValue: null, 228 | editable: false, 229 | nullable: false 230 | }, { 231 | name: 'foo', 232 | alias: 'foo', 233 | type: 'esriFieldTypeString', 234 | sqlType: 'sqlTypeOther', 235 | length: 128, 236 | domain: null, 237 | defaultValue: null, 238 | editable: false, 239 | nullable: false 240 | }]) 241 | }) 242 | 243 | it('create fields from esri json data, finds and uses OBJECTID', () => { 244 | const result = LayerFields.create({ 245 | features: [{ 246 | attributes: { 247 | OBJECTID: 1, 248 | foo: 'bar' 249 | } 250 | }] 251 | }) 252 | 253 | result.should.deepEqual([{ 254 | name: 'OBJECTID', 255 | alias: 'OBJECTID', 256 | type: 'esriFieldTypeOID', 257 | sqlType: 'sqlTypeInteger', 258 | domain: null, 259 | defaultValue: null, 260 | editable: false, 261 | nullable: false 262 | }, { 263 | name: 'foo', 264 | alias: 'foo', 265 | type: 'esriFieldTypeString', 266 | sqlType: 'sqlTypeOther', 267 | length: 128, 268 | domain: null, 269 | defaultValue: null, 270 | editable: false, 271 | nullable: false 272 | }]) 273 | }) 274 | }) 275 | -------------------------------------------------------------------------------- /test/unit/helpers/fields/statistics-fields.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | should.config.checkProtoEql = false 3 | const StatisticsFields = require('../../../../lib/helpers/fields/statistics-fields') 4 | 5 | describe('StatisticsFields', () => { 6 | describe('static normalizeOptions method', () => { 7 | it('should use first element of statistics array as sample', () => { 8 | const { statisticsSample } = StatisticsFields.normalizeOptions({ 9 | statistics: [{ foo: '1.234' }] 10 | }) 11 | 12 | statisticsSample.should.deepEqual({ foo: '1.234' }) 13 | }) 14 | 15 | it('should use statistics object as sample', () => { 16 | const { statisticsSample } = StatisticsFields.normalizeOptions({ 17 | statistics: { foo: '1.234' } 18 | }) 19 | statisticsSample.should.deepEqual({ foo: '1.234' }) 20 | }) 21 | 22 | it('should defer to fieldsDefinitions when supplied', () => { 23 | const { fieldDefinitions } = StatisticsFields.normalizeOptions({ 24 | fieldDefinitions: 'foo', 25 | fields: 'bar', 26 | metadata: { 27 | fields: 'snafu' 28 | } 29 | }) 30 | 31 | fieldDefinitions.should.equal('foo') 32 | }) 33 | 34 | it('should defer to root-level "fields" when supplied', () => { 35 | const { fieldDefinitions } = StatisticsFields.normalizeOptions({ 36 | fields: 'bar', 37 | metadata: { 38 | fields: 'snafu' 39 | } 40 | }) 41 | 42 | fieldDefinitions.should.equal('bar') 43 | }) 44 | 45 | it('should use "metadata.fields" when supplied', () => { 46 | const { fieldDefinitions } = StatisticsFields.normalizeOptions({ 47 | metadata: { 48 | fields: 'snafu' 49 | } 50 | }) 51 | 52 | fieldDefinitions.should.equal('snafu') 53 | }) 54 | 55 | it('should convert groupByFieldsForStatistics string to array and remove whitespace', () => { 56 | const { groupByFieldsForStatistics } = StatisticsFields.normalizeOptions({ 57 | groupByFieldsForStatistics: 'hello, world , today ' 58 | }) 59 | 60 | groupByFieldsForStatistics.should.deepEqual(['hello', 'world', 'today']) 61 | }) 62 | 63 | it('should use groupByFieldsForStatistics array', () => { 64 | const { groupByFieldsForStatistics } = StatisticsFields.normalizeOptions({ 65 | groupByFieldsForStatistics: ['hello'] 66 | }) 67 | 68 | groupByFieldsForStatistics.should.deepEqual(['hello']) 69 | }) 70 | 71 | it('should default groupByFieldsForStatistics to empty array', () => { 72 | const { groupByFieldsForStatistics } = StatisticsFields.normalizeOptions({}) 73 | 74 | groupByFieldsForStatistics.should.deepEqual([]) 75 | }) 76 | }) 77 | 78 | describe('static create method', () => { 79 | it('should create fields from statistics and without definitions', () => { 80 | const result = StatisticsFields.create({ 81 | statisticsSample: { foo: 1.234 } 82 | }) 83 | result.should.deepEqual([{ 84 | name: 'foo', 85 | type: 'esriFieldTypeDouble', 86 | sqlType: 'sqlTypeFloat', 87 | alias: 'foo', 88 | domain: null, 89 | defaultValue: null 90 | }]) 91 | }) 92 | 93 | it('should create date field when value is ISO-string date', () => { 94 | const result = StatisticsFields.create({ 95 | statisticsSample: { foo: new Date().toISOString() } 96 | }) 97 | result.should.deepEqual([{ 98 | name: 'foo', 99 | type: 'esriFieldTypeDate', 100 | sqlType: 'sqlTypeOther', 101 | alias: 'foo', 102 | domain: null, 103 | defaultValue: null 104 | }]) 105 | }) 106 | 107 | it('should create date field when field is defined as date', () => { 108 | const result = StatisticsFields.create({ 109 | statisticsSample: { foo: 100000 }, 110 | fieldDefinitions: [{ name: 'foo', type: 'Date' }], 111 | outStatistics: [{ onStatisticField: 'foo' }] 112 | }) 113 | result.should.deepEqual([{ 114 | name: 'foo', 115 | type: 'esriFieldTypeDate', 116 | sqlType: 'sqlTypeOther', 117 | alias: 'foo', 118 | domain: null, 119 | defaultValue: null 120 | }]) 121 | }) 122 | 123 | it('should create date field when field is defined as date, but custom label requested', () => { 124 | const result = StatisticsFields.create({ 125 | statisticsSample: { bar: 100000 }, 126 | fieldDefinitions: [{ name: 'foo', type: 'Date' }], 127 | outStatistics: [{ onStatisticField: 'foo', outStatisticFieldName: 'bar' }] 128 | }) 129 | result.should.deepEqual([{ 130 | name: 'bar', 131 | type: 'esriFieldTypeDate', 132 | sqlType: 'sqlTypeOther', 133 | alias: 'bar', 134 | domain: null, 135 | defaultValue: null 136 | }]) 137 | }) 138 | 139 | it('should create date field and groupBy fields', () => { 140 | const result = StatisticsFields.create({ 141 | statisticsSample: { foo: 100000, bar: 'hello', walter: 1 }, 142 | fieldDefinitions: [{ name: 'foo', type: 'Date' }], 143 | outStatistics: [{ onStatisticField: 'foo' }], 144 | groupByFieldsForStatistics: 'bar,walter' 145 | }) 146 | result.should.deepEqual([{ 147 | name: 'foo', 148 | type: 'esriFieldTypeDate', 149 | sqlType: 'sqlTypeOther', 150 | alias: 'foo', 151 | domain: null, 152 | defaultValue: null 153 | }, { 154 | name: 'bar', 155 | type: 'esriFieldTypeString', 156 | sqlType: 'sqlTypeOther', 157 | length: 128, 158 | alias: 'bar', 159 | domain: null, 160 | defaultValue: null 161 | }, { 162 | name: 'walter', 163 | type: 'esriFieldTypeInteger', 164 | sqlType: 'sqlTypeOther', 165 | alias: 'walter', 166 | domain: null, 167 | defaultValue: null 168 | }]) 169 | }) 170 | }) 171 | }) 172 | -------------------------------------------------------------------------------- /test/unit/helpers/get-collection-crs.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const getCollectionCrs = require('../../../lib/helpers/get-collection-crs') 3 | 4 | describe('get-collection-crs', () => { 5 | it('getCollectionCrs: no collection', () => { 6 | const crs = getCollectionCrs() 7 | should(crs).equal(undefined) 8 | }) 9 | 10 | it('getCollectionCrs: no crs', () => { 11 | const crs = getCollectionCrs({}) 12 | should(crs).equal(undefined) 13 | }) 14 | 15 | it('getCollectionCrs: no crs', () => { 16 | const crs = getCollectionCrs({}) 17 | should(crs).equal(undefined) 18 | }) 19 | 20 | it('getCollectionCrs: no crs object', () => { 21 | const crs = getCollectionCrs({ crs: {} }) 22 | should(crs).equal(undefined) 23 | }) 24 | 25 | it('getCollectionCrs: bad crs definition', () => { 26 | const crs = getCollectionCrs({ crs: { properties: { name: 'foodbar' } } }) 27 | should(crs).equal(undefined) 28 | }) 29 | 30 | it('getCollectionCrs: WGS84 definition', () => { 31 | const crs = getCollectionCrs({ crs: { properties: { name: 'urn:ogc:def:crs:ogc:1.3:crs84' } } }) 32 | should(crs).equal(undefined) 33 | }) 34 | 35 | it('getCollectionCrs: non-WGS84 definition', () => { 36 | const crs = getCollectionCrs({ crs: { properties: { name: 'urn:ogc:def:crs:EPSG::2285' } } }) 37 | should(crs).equal('2285') 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /test/unit/helpers/get-geometry-type-from-geojson.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const { getGeometryTypeFromGeojson } = require('../../../lib/helpers') 3 | 4 | describe('get-geometry-type-from-geojson', function () { 5 | it('undefined input returns undefined', () => { 6 | const result = getGeometryTypeFromGeojson() 7 | should(result).equal() 8 | }) 9 | 10 | it('string input returns undefined', () => { 11 | const result = getGeometryTypeFromGeojson('foo') 12 | should(result).equal() 13 | }) 14 | 15 | it('empty object returns undefined', () => { 16 | const result = getGeometryTypeFromGeojson({}) 17 | should(result).equal() 18 | }) 19 | 20 | it('defers to root-level geometryType', () => { 21 | const result = getGeometryTypeFromGeojson({ geometryType: 'Point', metadata: { geometryType: 'foo' } }) 22 | result.should.equal('esriGeometryPoint') 23 | }) 24 | 25 | it('uses metadata.geometryType when no root-level geometryType', () => { 26 | const result = getGeometryTypeFromGeojson({ metadata: { geometryType: 'Point' } }) 27 | result.should.equal('esriGeometryPoint') 28 | }) 29 | 30 | it('uses feature geometry-type if no other source', () => { 31 | const result = getGeometryTypeFromGeojson({ features: [{ geometry: { type: 'Point' } }] }) 32 | result.should.equal('esriGeometryPoint') 33 | }) 34 | 35 | it('Searches for first feature geometry-type if no other source', () => { 36 | const result = getGeometryTypeFromGeojson({ features: [{ geometry: null }, { geometry: { type: 'Point' } }] }) 37 | result.should.equal('esriGeometryPoint') 38 | }) 39 | 40 | it('returns undefined feature geometry-type not defined', () => { 41 | const result = getGeometryTypeFromGeojson({ features: [{ geometry: null }] }) 42 | should(result).equal() 43 | }) 44 | 45 | it('supports defined set of input types', () => { 46 | const types = { 47 | Point: 'esriGeometryPoint', 48 | MultiPoint: 'esriGeometryMultipoint', 49 | LineString: 'esriGeometryPolyline', 50 | MultiLineString: 'esriGeometryPolyline', 51 | Polygon: 'esriGeometryPolygon', 52 | MultiPolygon: 'esriGeometryPolygon' 53 | } 54 | Object.entries(types).forEach(([key, value]) => { 55 | const result = getGeometryTypeFromGeojson({ geometryType: key }) 56 | result.should.equal(value) 57 | }) 58 | }) 59 | 60 | it('returns undefined for unsupported geometry types', () => { 61 | const result = getGeometryTypeFromGeojson({ geometryType: 'Other' }) 62 | should(result).equal() 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /test/unit/helpers/get-spatial-reference.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const getSpatialReference = require('../../../lib/helpers/get-spatial-reference') 3 | 4 | describe('get-spatial-reference', () => { 5 | it('getSpatialReference: no data passed', () => { 6 | const wkt = getSpatialReference() 7 | should(wkt).equal(undefined) 8 | }) 9 | 10 | it('getSpatialReference: only inputCrs', () => { 11 | const wkt = getSpatialReference(undefined, { inputCrs: { wkid: 4326, latestWkid: 4326 } }) 12 | should(wkt).deepEqual({ wkid: 4326, latestWkid: 4326 }) 13 | }) 14 | 15 | it('getSpatialReference: numeric inputCrs', () => { 16 | const wkt = getSpatialReference(undefined, { inputCrs: 4326 }) 17 | should(wkt).deepEqual({ wkid: 4326, latestWkid: 4326 }) 18 | }) 19 | 20 | it('getSpatialReference: mercator inputCrs', () => { 21 | const wkt = getSpatialReference(undefined, { inputCrs: 102100 }) 22 | should(wkt).deepEqual({ wkid: 102100, latestWkid: 3857 }) 23 | }) 24 | 25 | it('getSpatialReference: only sourceSR', () => { 26 | const wkt = getSpatialReference(undefined, { sourceSR: 3857 }) 27 | should(wkt).deepEqual({ wkid: 3857, latestWkid: 3857 }) 28 | }) 29 | 30 | it('getSpatialReference: only geojson', () => { 31 | const wkt = getSpatialReference({ crs: { properties: { name: 'epsg:3857' } } }) 32 | should(wkt).deepEqual({ wkid: 3857, latestWkid: 3857 }) 33 | }) 34 | 35 | it('getSpatialReference: all inputs available', () => { 36 | const wkt = getSpatialReference({ crs: { properties: { name: 'epsg:3857' } } }, { inputCrs: 4326, sourceSR: 102100 }) 37 | should(wkt).deepEqual({ wkid: 4326, latestWkid: 4326 }) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /test/unit/helpers/is-geojson-table.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const isTable = require('../../../lib/helpers/is-geojson-table') 3 | 4 | describe('is-geojson-table', function () { 5 | it('undefined input should return true', () => { 6 | const result = isTable() 7 | should(result).be.exactly(true) 8 | }) 9 | 10 | it('non-object input should return true', () => { 11 | const result = isTable('test') 12 | should(result).be.exactly(true) 13 | }) 14 | 15 | it('GeoJSON collection input with geometry should return false', () => { 16 | const collection = { type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [-100, 40] } }, { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [-101, 41] } }, { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [-99, 39] } }] } 17 | const result = isTable(collection) 18 | should(result).be.exactly(false) 19 | }) 20 | 21 | it('GeoJSON collection input with NO geometry should return true', () => { 22 | const collection = { type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features: [{ type: 'Feature', properties: {} }] } 23 | const result = isTable(collection) 24 | should(result).be.exactly(true) 25 | }) 26 | 27 | it('GeoJSON collection input with valid geometryType should return false', () => { 28 | const collection = { type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features: [{ type: 'Feature', properties: {} }] } 29 | const geomTypes = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon'] 30 | geomTypes.forEach((geomType) => { 31 | collection.geometryType = geomType 32 | const result = isTable(collection) 33 | should(result).be.exactly(false) 34 | }) 35 | }) 36 | 37 | it('GeoJSON collection input with INvalid geometryType should return true', () => { 38 | const collection = { type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features: [{ type: 'Feature', properties: {} }] } 39 | const geomTypes = ['Other'] 40 | geomTypes.forEach((geomType) => { 41 | collection.geomType = geomType 42 | const result = isTable(collection) 43 | should(result).be.exactly(true) 44 | }) 45 | }) 46 | 47 | it('GeoJSON collection input without features should return true', () => { 48 | const collection = { type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features: [] } 49 | const result = isTable(collection) 50 | should(result).be.exactly(true) 51 | }) 52 | 53 | it('GeoJSON collection input with metadata geometryType but no features should return false', () => { 54 | const collection = { metadata: { geometryType: 'Point' }, type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features: [] } 55 | const result = isTable(collection) 56 | should(result).be.exactly(false) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /test/unit/helpers/normalize-extent.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const { normalizeExtent } = require('../../../lib/helpers') 3 | 4 | describe('normalize-extent', function () { 5 | it('undefined input should return undefined', () => { 6 | const result = normalizeExtent() 7 | should(result).equal(undefined) 8 | }) 9 | 10 | it('string input should throw error', () => { 11 | try { 12 | normalizeExtent('string') 13 | should.fail() 14 | } catch (error) { 15 | error.message.should.equal('Received invalid extent: "string"') 16 | } 17 | }) 18 | 19 | it('empty array input should throw error', () => { 20 | try { 21 | normalizeExtent([]) 22 | should.fail() 23 | } catch (error) { 24 | error.message.should.equal('Received invalid extent: []') 25 | } 26 | }) 27 | 28 | it('simple extent array with less than required coordinates should throw error', () => { 29 | try { 30 | normalizeExtent([-180, -90, 180], { wkid: 4326 }) 31 | should.fail() 32 | } catch (error) { 33 | error.message.should.equal('Received invalid extent: [-180,-90,180]') 34 | } 35 | }) 36 | 37 | it('simple extent array with NaNs should throw error', () => { 38 | try { 39 | normalizeExtent([-180, -90, 180, 'foo'], { wkid: 4326 }) 40 | should.fail() 41 | } catch (error) { 42 | error.message.should.equal('Received invalid extent: [-180,-90,180,"foo"]') 43 | } 44 | }) 45 | 46 | it('simple extent array should return Esri Extent', () => { 47 | const result = normalizeExtent([-180, -90, 180, 90], { wkid: 4326 }) 48 | result.should.deepEqual({ 49 | xmin: -180, 50 | ymin: -90, 51 | xmax: 180, 52 | ymax: 90, 53 | spatialReference: { wkid: 4326 } 54 | }) 55 | }) 56 | 57 | it('uses only first four elements of simple extent array', () => { 58 | const result = normalizeExtent([-180, -90, 180, 90, 3857], { wkid: 4326 }) 59 | result.should.deepEqual({ 60 | xmin: -180, 61 | ymin: -90, 62 | xmax: 180, 63 | ymax: 90, 64 | spatialReference: { wkid: 4326 } 65 | }) 66 | }) 67 | 68 | it('empty corner array should throw error', () => { 69 | try { 70 | normalizeExtent([[], []], { wkid: 4326 }) 71 | should.fail() 72 | } catch (error) { 73 | error.message.should.equal('Received invalid extent: [[],[]]') 74 | } 75 | }) 76 | 77 | it('corner array with missing coordinates should throw error', () => { 78 | try { 79 | normalizeExtent([[3], [4]], { wkid: 4326 }) 80 | should.fail() 81 | } catch (error) { 82 | error.message.should.equal('Received invalid extent: [[3],[4]]') 83 | } 84 | }) 85 | 86 | it('corner array with NaNs should throw error', () => { 87 | try { 88 | normalizeExtent([[3, 'food'], [4, 5]], { wkid: 4326 }) 89 | should.fail() 90 | } catch (error) { 91 | error.message.should.equal('Received invalid extent: [[3,"food"],[4,5]]') 92 | } 93 | }) 94 | 95 | it('corner array with too many coordinates should throw error', () => { 96 | try { 97 | normalizeExtent([[3, 5, 7], [4, 5]], { wkid: 4326 }) 98 | should.fail() 99 | } catch (error) { 100 | error.message.should.equal('Received invalid extent: [[3,5,7],[4,5]]') 101 | } 102 | }) 103 | 104 | it('corner extent array should return Esri Extent', () => { 105 | const result = normalizeExtent([[-180, -90], [180, 90]], { wkid: 4326 }) 106 | result.should.deepEqual({ 107 | xmin: -180, 108 | ymin: -90, 109 | xmax: 180, 110 | ymax: 90, 111 | spatialReference: { wkid: 4326 } 112 | }) 113 | }) 114 | 115 | it('Complete Esri extent passed in should get returned', () => { 116 | const result = normalizeExtent({ 117 | xmin: 40, 118 | ymin: 10, 119 | xmax: 55, 120 | ymax: 25, 121 | spatialReference: { 122 | wkid: 4326 123 | } 124 | }, { 125 | wkid: 3857 126 | }) 127 | result.should.deepEqual({ 128 | xmin: 40, 129 | ymin: 10, 130 | xmax: 55, 131 | ymax: 25, 132 | spatialReference: { wkid: 4326 } 133 | }) 134 | }) 135 | 136 | it('Esri extent without spatial ref, should get spatial ref added', () => { 137 | const result = normalizeExtent({ 138 | xmin: 40, 139 | ymin: 10, 140 | xmax: 55, 141 | ymax: 25 142 | }, { 143 | wkid: 4326 144 | }) 145 | result.should.deepEqual({ 146 | xmin: 40, 147 | ymin: 10, 148 | xmax: 55, 149 | ymax: 25, 150 | spatialReference: { wkid: 4326 } 151 | }) 152 | }) 153 | }) 154 | -------------------------------------------------------------------------------- /test/unit/helpers/normalize-input-data.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const { normalizeInputData } = require('../../../lib/helpers') 3 | 4 | describe('normalize-input-data', function () { 5 | it('undefined input should return empty tables and layers', () => { 6 | const result = normalizeInputData() 7 | result.should.deepEqual({ tables: [], layers: [], relationships: [] }) 8 | }) 9 | 10 | it('non-object input should return empty tables and layers', () => { 11 | const result = normalizeInputData('test') 12 | result.should.deepEqual({ tables: [], layers: [], relationships: [] }) 13 | }) 14 | 15 | it('GeoJSON collection input with geometry should return single layer', () => { 16 | const collection = { type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [-100, 40] } }, { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [-101, 41] } }, { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [-99, 39] } }] } 17 | const result = normalizeInputData(collection) 18 | result.should.deepEqual({ tables: [], layers: [collection], relationships: [] }) 19 | }) 20 | 21 | it('GeoJSON collection input without features should return single table', () => { 22 | const collection = { type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features: [] } 23 | const result = normalizeInputData(collection) 24 | result.should.deepEqual({ tables: [collection], layers: [], relationships: [] }) 25 | }) 26 | 27 | it('GeoJSON collection input with metadata geometryType but no features should return single layer', () => { 28 | const collection = { metadata: { geometryType: 'Point' }, type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features: [] } 29 | const result = normalizeInputData(collection) 30 | result.should.deepEqual({ tables: [], layers: [collection], relationships: [] }) 31 | }) 32 | 33 | it('GeoJSON metadata layers and tables should be returned unaltered', () => { 34 | const collection1 = { type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features: [] } 35 | const collection2 = { type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [-100, 40] } }, { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [-101, 41] } }, { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [-99, 39] } }] } 36 | const result = normalizeInputData({ layers: [collection1], tables: [collection2], relationships: [] }) 37 | result.should.deepEqual({ tables: [collection2], layers: [collection1], relationships: [] }) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /test/unit/helpers/normalize-spatial-reference.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const { normalizeSpatialReference } = require('../../../lib/helpers') 3 | 4 | describe('normalize-spatial-reference', function () { 5 | it('undefined', () => { 6 | const spatialRef = normalizeSpatialReference() 7 | spatialRef.should.deepEqual({ 8 | latestWkid: 4326, 9 | wkid: 4326 10 | }) 11 | }) 12 | 13 | it('invalid object', () => { 14 | const spatialRef = normalizeSpatialReference({ test: 'foo' }) 15 | spatialRef.should.deepEqual({ 16 | latestWkid: 4326, 17 | wkid: 4326 18 | }) 19 | }) 20 | 21 | it('invalid wkid', () => { 22 | const spatialRef = normalizeSpatialReference(99999) 23 | spatialRef.should.deepEqual({ 24 | latestWkid: 4326, 25 | wkid: 4326 26 | }) 27 | }) 28 | 29 | it('invalid wkt', () => { 30 | const spatialRef = normalizeSpatialReference('foodbar') 31 | spatialRef.should.deepEqual({ 32 | latestWkid: 4326, 33 | wkid: 4326 34 | }) 35 | }) 36 | 37 | it('object with wkt that is Web Mercator string', () => { 38 | const inputWkt = 'PROJCS["WGS_1984_Web_Mercator_Auxiliary_Sphere",GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Mercator_Auxiliary_Sphere"],PARAMETER["False_Easting",0.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",-6.828007551173374],PARAMETER["Standard_Parallel_1",0.0],PARAMETER["Auxiliary_Sphere_Type",0.0],UNIT["Meter",1.0]]' 39 | const spatialRef = normalizeSpatialReference({ wkt: inputWkt }) 40 | spatialRef.should.deepEqual({ 41 | latestWkid: 3857, 42 | wkid: 102100 43 | }) 44 | }) 45 | 46 | it('object with wkt that is not Web Mercator string', () => { 47 | const inputWkt = `PROJCS["NAD_1983_StatePlane_California_V_FIPS_0405_Feet", 48 | GEOGCS["GCS_North_American_1983", 49 | DATUM["North_American_Datum_1983", 50 | SPHEROID["GRS_1980",6378137,298.257222101]], 51 | PRIMEM["Greenwich",0], 52 | UNIT["Degree",0.017453292519943295]], 53 | PROJECTION["Lambert_Conformal_Conic_2SP"], 54 | PARAMETER["False_Easting",6561666.666666666], 55 | PARAMETER["False_Northing",1640416.666666667], 56 | PARAMETER["Central_Meridian",-118], 57 | PARAMETER["Standard_Parallel_1",34.03333333333333], 58 | PARAMETER["Standard_Parallel_2",35.46666666666667], 59 | PARAMETER["Latitude_Of_Origin",33.5], 60 | UNIT["Foot_US",0.30480060960121924], 61 | AUTHORITY["EPSG","102645"]]` 62 | const spatialRef = normalizeSpatialReference({ wkt: inputWkt }) 63 | spatialRef.should.deepEqual({ 64 | latestWkid: 2229, 65 | wkid: 102645 66 | }) 67 | }) 68 | 69 | it('Web Mercator wkt string', () => { 70 | const inputWkt = 'PROJCS["WGS_1984_Web_Mercator_Auxiliary_Sphere",GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Mercator_Auxiliary_Sphere"],PARAMETER["False_Easting",0.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",-6.828007551173374],PARAMETER["Standard_Parallel_1",0.0],PARAMETER["Auxiliary_Sphere_Type",0.0],UNIT["Meter",1.0]]' 71 | const spatialRef = normalizeSpatialReference(inputWkt) 72 | spatialRef.should.deepEqual({ 73 | latestWkid: 3857, 74 | wkid: 102100 75 | }) 76 | }) 77 | 78 | it('prefixed wkid', () => { 79 | const spatialRef = normalizeSpatialReference('EPSG:3857') 80 | spatialRef.should.deepEqual({ 81 | latestWkid: 3857, 82 | wkid: 3857 83 | }) 84 | }) 85 | 86 | it('wkid number', () => { 87 | const spatialRef = normalizeSpatialReference(3857) 88 | spatialRef.should.deepEqual({ 89 | latestWkid: 3857, 90 | wkid: 3857 91 | }) 92 | }) 93 | 94 | it('wkid 102100', () => { 95 | const spatialRef = normalizeSpatialReference(102100) 96 | spatialRef.should.deepEqual({ 97 | latestWkid: 3857, 98 | wkid: 102100 99 | }) 100 | }) 101 | it('wkid 102100 extra properties', () => { 102 | const spatialRef = normalizeSpatialReference({ 103 | wkid: 102100, 104 | latestWkid: 3857, 105 | xyTolerance: 0.001, 106 | zTolerance: 0.001, 107 | mTolerance: 0.001, 108 | falseX: -20037700, 109 | falseY: -30241100, 110 | xyUnits: 10000, 111 | falseZ: -100000, 112 | zUnits: 10000, 113 | falseM: -100000, 114 | mUnits: 10000 115 | }) 116 | spatialRef.should.deepEqual({ 117 | latestWkid: 3857, 118 | wkid: 102100 119 | }) 120 | }) 121 | it('wkid 7853 extra properties', () => { 122 | const spatialRef = normalizeSpatialReference({ 123 | wkid: 7853, 124 | latestWkid: 7853, 125 | xyTolerance: 0.001, 126 | zTolerance: 0.001, 127 | mTolerance: 0.001, 128 | falseX: -5120900, 129 | falseY: 1900, 130 | xyUnits: 10000, 131 | falseZ: -100000, 132 | zUnits: 10000, 133 | falseM: -100000, 134 | mUnits: 10000 135 | }) 136 | spatialRef.should.deepEqual({ 137 | latestWkid: 7853, 138 | wkid: 7853 139 | }) 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /test/unit/helpers/renderers.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') 2 | should.config.checkProtoEql = false 3 | 4 | const { 5 | PointRenderer, 6 | PolygonRenderer, 7 | LineRenderer 8 | } = require('../../../lib/helpers/renderers') 9 | 10 | describe('Renderers', () => { 11 | it('should produce default PointRenderer instance', () => { 12 | const renderer = new PointRenderer() 13 | renderer.should.deepEqual({ 14 | type: 'simple', 15 | symbol: { 16 | color: [ 17 | 45, 18 | 172, 19 | 128, 20 | 161 21 | ], 22 | outline: { 23 | color: [ 24 | 190, 25 | 190, 26 | 190, 27 | 105 28 | ], 29 | width: 0.5, 30 | type: 'esriSLS', 31 | style: 'esriSLSSolid' 32 | }, 33 | size: 7.5, 34 | type: 'esriSMS', 35 | style: 'esriSMSCircle' 36 | } 37 | }) 38 | }) 39 | 40 | it('should produce default LineRenderer instance', () => { 41 | const renderer = new LineRenderer() 42 | renderer.should.deepEqual({ 43 | type: 'simple', 44 | symbol: { 45 | color: [ 46 | 247, 47 | 150, 48 | 70, 49 | 204 50 | ], 51 | width: 6.999999999999999, 52 | type: 'esriSLS', 53 | style: 'esriSLSSolid' 54 | } 55 | }) 56 | }) 57 | 58 | it('should produce default PolygonRenderer instance', () => { 59 | const renderer = new PolygonRenderer() 60 | renderer.should.deepEqual({ 61 | type: 'simple', 62 | symbol: { 63 | color: [ 64 | 75, 65 | 172, 66 | 198, 67 | 161 68 | ], 69 | outline: { 70 | color: [ 71 | 150, 72 | 150, 73 | 150, 74 | 155 75 | ], 76 | width: 0.5, 77 | type: 'esriSLS', 78 | style: 'esriSLSSolid' 79 | }, 80 | type: 'esriSFS', 81 | style: 'esriSFSSolid' 82 | } 83 | }) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /test/unit/layer-metadata.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const sinon = require('sinon') 3 | const proxyquire = require('proxyquire') 4 | const TableCreateSpy = sinon.spy(function () { 5 | return 'table-layer-metadata' 6 | }) 7 | const FeatureLayerCreateSpy = sinon.spy(function () { 8 | return 'feature-layer-metadata' 9 | }) 10 | 11 | describe('layerMetadata', function () { 12 | it('isTable === true, returns TableLayerMetadata instance', () => { 13 | const isTableSpy = sinon.spy(function () { return true }) 14 | const layerMetadata = proxyquire('../../lib/layer-metadata', { 15 | './helpers': { 16 | isTable: isTableSpy, 17 | TableLayerMetadata: { create: TableCreateSpy }, 18 | FeatureLayerMetadata: { create: FeatureLayerCreateSpy } 19 | } 20 | }) 21 | const result = layerMetadata({ foo: 'bar' }, { sna: 'fu' }) 22 | should(result).deepEqual('table-layer-metadata') 23 | isTableSpy.callCount.should.equal(1) 24 | isTableSpy.firstCall.args.should.deepEqual([{ foo: 'bar', sna: 'fu' }]) 25 | TableCreateSpy.callCount.should.equal(1) 26 | TableCreateSpy.firstCall.args.should.deepEqual([{ foo: 'bar' }, { sna: 'fu' }]) 27 | TableCreateSpy.resetHistory() 28 | }) 29 | 30 | it('isTable === false, returns FeatureLayerMetadata instance', () => { 31 | const isTableSpy = sinon.spy(function () { return false }) 32 | const layerMetadata = proxyquire('../../lib/layer-metadata', { 33 | './helpers': { 34 | isTable: isTableSpy, 35 | TableLayerMetadata: { create: TableCreateSpy }, 36 | FeatureLayerMetadata: { create: FeatureLayerCreateSpy } 37 | } 38 | }) 39 | const result = layerMetadata({ foo: 'bar' }, { sna: 'fu' }) 40 | should(result).deepEqual('feature-layer-metadata') 41 | isTableSpy.callCount.should.equal(1) 42 | isTableSpy.firstCall.args.should.deepEqual([{ foo: 'bar', sna: 'fu' }]) 43 | FeatureLayerCreateSpy.callCount.should.equal(1) 44 | FeatureLayerCreateSpy.firstCall.args.should.deepEqual([{ foo: 'bar' }, { sna: 'fu' }]) 45 | FeatureLayerCreateSpy.resetHistory() 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /test/unit/layers-metadata.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | should.config.checkProtoEql = false 3 | const sinon = require('sinon') 4 | const proxyquire = require('proxyquire') 5 | 6 | describe('layers metadata', () => { 7 | it('empty input returns empty table and layer arrays', () => { 8 | const normalizeInputData = sinon.spy(function () { 9 | return { 10 | layers: [], 11 | tables: [] 12 | } 13 | }) 14 | const TableLayerMetadata = sinon.spy() 15 | const FeatureLayerMetadata = sinon.spy() 16 | 17 | const layersInfoHandler = proxyquire('../../lib/layers-metadata', { 18 | './helpers': { 19 | normalizeInputData, 20 | TableLayerMetadata, 21 | FeatureLayerMetadata 22 | } 23 | }) 24 | 25 | const layersInfo = layersInfoHandler({}) 26 | 27 | layersInfo.should.deepEqual({ 28 | layers: [], 29 | tables: [] 30 | }) 31 | 32 | normalizeInputData.callCount.should.equal(1) 33 | normalizeInputData.firstCall.args.should.deepEqual([{}]) 34 | TableLayerMetadata.callCount.should.equal(0) 35 | FeatureLayerMetadata.callCount.should.equal(0) 36 | }) 37 | 38 | it('table/feature layer input should generate appropriate class instances with default ids', () => { 39 | const normalizeInputData = sinon.spy(function () { 40 | return { 41 | layers: ['layer1', 'layer2'], 42 | tables: ['table1'] 43 | } 44 | }) 45 | 46 | const tableCreateSpy = sinon.spy() 47 | const TableLayerMetadata = class TableClass { 48 | static create (input, options) { 49 | tableCreateSpy(input, options) 50 | return `${input}-metadata` 51 | } 52 | } 53 | 54 | const featureLayerCreateSpy = sinon.spy() 55 | const FeatureLayerMetadata = class FeatureClass { 56 | static create (input, options) { 57 | featureLayerCreateSpy(input, options) 58 | return `${input}-metadata` 59 | } 60 | } 61 | 62 | const layersInfoHandler = proxyquire('../../lib/layers-metadata', { 63 | './helpers': { 64 | normalizeInputData, 65 | TableLayerMetadata, 66 | FeatureLayerMetadata 67 | } 68 | }) 69 | 70 | const layersInfo = layersInfoHandler({ hello: 'world' }, { some: 'options' }) 71 | 72 | layersInfo.should.deepEqual({ 73 | layers: ['layer1-metadata', 'layer2-metadata'], 74 | tables: ['table1-metadata'] 75 | }) 76 | 77 | normalizeInputData.callCount.should.equal(1) 78 | normalizeInputData.firstCall.args.should.deepEqual([{ hello: 'world' }]) 79 | featureLayerCreateSpy.callCount.should.equal(2) 80 | featureLayerCreateSpy.firstCall.args.should.deepEqual(['layer1', { layerId: 0, some: 'options' }]) 81 | featureLayerCreateSpy.secondCall.args.should.deepEqual(['layer2', { layerId: 1, some: 'options' }]) 82 | tableCreateSpy.callCount.should.equal(1) 83 | tableCreateSpy.firstCall.args.should.deepEqual(['table1', { layerId: 2, some: 'options' }]) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /test/unit/query/render-count-and-extent.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const sinon = require('sinon') 3 | const proxyquire = require('proxyquire') 4 | 5 | const esriExtentSpy = sinon.spy(function () { 6 | return { 7 | foo: 'bar', 8 | spatialReference: { 9 | wkid: 4326, 10 | latestWkid: 4326 11 | } 12 | } 13 | }) 14 | 15 | const stub = { 16 | 'esri-extent': esriExtentSpy 17 | } 18 | 19 | const { renderCountAndExtentResponse } = proxyquire('../../../lib/query/render-count-and-extent', stub) 20 | 21 | describe('renderCountAndExtent', () => { 22 | afterEach(function () { 23 | esriExtentSpy.resetHistory() 24 | }) 25 | 26 | it('should render count and extent', () => { 27 | const result = renderCountAndExtentResponse({ features: ['test'] }, { returnCountOnly: true, returnExtentOnly: true }) 28 | result.should.deepEqual({ 29 | extent: { 30 | foo: 'bar', 31 | spatialReference: { 32 | wkid: 4326, 33 | latestWkid: 4326 34 | } 35 | }, 36 | count: 1 37 | }) 38 | esriExtentSpy.callCount.should.equal(1) 39 | esriExtentSpy.firstCall.args.should.deepEqual([{ features: ['test'] }]) 40 | }) 41 | 42 | it('should render count', () => { 43 | const result = renderCountAndExtentResponse({ features: ['test'] }, { returnCountOnly: true }) 44 | result.should.deepEqual({ 45 | count: 1 46 | }) 47 | esriExtentSpy.callCount.should.equal(0) 48 | }) 49 | 50 | it('should render extent', () => { 51 | const result = renderCountAndExtentResponse({ features: ['test'] }, { returnExtentOnly: true }) 52 | result.should.deepEqual({ 53 | extent: { 54 | foo: 'bar', 55 | spatialReference: { 56 | wkid: 4326, 57 | latestWkid: 4326 58 | } 59 | } 60 | }) 61 | esriExtentSpy.callCount.should.equal(1) 62 | esriExtentSpy.firstCall.args.should.deepEqual([{ features: ['test'] }]) 63 | }) 64 | 65 | it('should render extent and replace spatialReference with outSR wkid', () => { 66 | const result = renderCountAndExtentResponse({ features: ['test'] }, { returnExtentOnly: true, outSR: { wkid: 1234 } }) 67 | result.should.deepEqual({ 68 | extent: { 69 | foo: 'bar', 70 | spatialReference: { 71 | wkid: 1234 72 | } 73 | } 74 | }) 75 | esriExtentSpy.callCount.should.equal(1) 76 | esriExtentSpy.firstCall.args.should.deepEqual([{ features: ['test'] }]) 77 | }) 78 | 79 | it('should render extent and replace spatialReference with outSR latestWkid', () => { 80 | const result = renderCountAndExtentResponse({ features: ['test'] }, { returnExtentOnly: true, outSR: { latestWkid: 1234 } }) 81 | result.should.deepEqual({ 82 | extent: { 83 | foo: 'bar', 84 | spatialReference: { 85 | latestWkid: 1234 86 | } 87 | } 88 | }) 89 | esriExtentSpy.callCount.should.equal(1) 90 | esriExtentSpy.firstCall.args.should.deepEqual([{ features: ['test'] }]) 91 | }) 92 | 93 | it('should render extent and replace spatialReference with outSR wkt', () => { 94 | const result = renderCountAndExtentResponse({ features: ['test'] }, { returnExtentOnly: true, outSR: { wkt: '1234' } }) 95 | result.should.deepEqual({ 96 | extent: { 97 | foo: 'bar', 98 | spatialReference: { 99 | wkt: '1234' 100 | } 101 | } 102 | }) 103 | esriExtentSpy.callCount.should.equal(1) 104 | esriExtentSpy.firstCall.args.should.deepEqual([{ features: ['test'] }]) 105 | }) 106 | 107 | it('should render extent and replace spatialReference with outSR id', () => { 108 | const result = renderCountAndExtentResponse({ features: ['test'] }, { returnExtentOnly: true, outSR: 1234 }) 109 | result.should.deepEqual({ 110 | extent: { 111 | foo: 'bar', 112 | spatialReference: { 113 | wkid: 1234 114 | } 115 | } 116 | }) 117 | esriExtentSpy.callCount.should.equal(1) 118 | esriExtentSpy.firstCall.args.should.deepEqual([{ features: ['test'] }]) 119 | }) 120 | 121 | it('should render extent and replace spatialReference with outSR string id', () => { 122 | const result = renderCountAndExtentResponse({ features: ['test'] }, { returnExtentOnly: true, outSR: '1234' }) 123 | result.should.deepEqual({ 124 | extent: { 125 | foo: 'bar', 126 | spatialReference: { 127 | wkid: 1234 128 | } 129 | } 130 | }) 131 | esriExtentSpy.callCount.should.equal(1) 132 | esriExtentSpy.firstCall.args.should.deepEqual([{ features: ['test'] }]) 133 | }) 134 | 135 | it('should render extent and replace spatialReference with outSR string', () => { 136 | const result = renderCountAndExtentResponse({ features: ['test'] }, { returnExtentOnly: true, outSR: 'big-WKT-string' }) 137 | result.should.deepEqual({ 138 | extent: { 139 | foo: 'bar', 140 | spatialReference: { 141 | wkt: 'big-WKT-string' 142 | } 143 | } 144 | }) 145 | esriExtentSpy.callCount.should.equal(1) 146 | esriExtentSpy.firstCall.args.should.deepEqual([{ features: ['test'] }]) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /test/unit/query/render-precalculated-statistics.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const sinon = require('sinon') 3 | const proxyquire = require('proxyquire') 4 | 5 | const createStatisticsFieldsSpy = sinon.spy(function () { 6 | return ['fields'] 7 | }) 8 | 9 | const fields = { 10 | StatisticsFields: { 11 | create: createStatisticsFieldsSpy 12 | } 13 | } 14 | 15 | const stub = { 16 | '../helpers/fields': fields 17 | } 18 | 19 | const { renderPrecalculatedStatisticsResponse } = proxyquire('../../../lib/query/render-precalculated-statistics', stub) 20 | 21 | describe('renderPrecalculatedStatisticsResponse', () => { 22 | afterEach(function () { 23 | createStatisticsFieldsSpy.resetHistory() 24 | }) 25 | 26 | it('should convert precalculated statistics array without metadata to Geoservices JSON', () => { 27 | const statistics = [ 28 | { 29 | FACUSE: 'Middle School', 30 | TOTAL_STUD_SUM: 5421, 31 | ZIP_CODE_COUNT: 18, 32 | SOME_DATE_STRING: '2020-12-01', 33 | SOME_ISO_DATE_STRING: '2020-12-01T17:00:14.000Z' 34 | }, 35 | { 36 | FACUSE: 'Elementary School', 37 | TOTAL_STUD_SUM: 23802, 38 | ZIP_CODE_COUNT: 72, 39 | SOME_DATE_STRING: '2020-12-01', 40 | SOME_ISO_DATE_STRING: '2020-12-01T17:00:14.000Z' 41 | } 42 | ] 43 | const result = renderPrecalculatedStatisticsResponse({ statistics }) 44 | result.should.deepEqual({ 45 | fields: ['fields'], 46 | features: [ 47 | { 48 | attributes: { 49 | FACUSE: 'Middle School', 50 | TOTAL_STUD_SUM: 5421, 51 | ZIP_CODE_COUNT: 18, 52 | SOME_DATE_STRING: 1606780800000, 53 | SOME_ISO_DATE_STRING: 1606842014000 54 | } 55 | }, 56 | { 57 | attributes: { 58 | FACUSE: 'Elementary School', 59 | TOTAL_STUD_SUM: 23802, 60 | ZIP_CODE_COUNT: 72, 61 | SOME_DATE_STRING: 1606780800000, 62 | SOME_ISO_DATE_STRING: 1606842014000 63 | } 64 | } 65 | ] 66 | }) 67 | createStatisticsFieldsSpy.callCount.should.equal(1) 68 | }) 69 | 70 | it('should convert precalculated statistics object without metadata to Geoservices JSON', () => { 71 | const statistics = { 72 | FACUSE: 'Middle School', 73 | TOTAL_STUD_SUM: 5421, 74 | ZIP_CODE_COUNT: 18 75 | } 76 | const result = renderPrecalculatedStatisticsResponse({ statistics }) 77 | result.should.deepEqual({ 78 | fields: ['fields'], 79 | features: [ 80 | { 81 | attributes: { 82 | FACUSE: 'Middle School', 83 | TOTAL_STUD_SUM: 5421, 84 | ZIP_CODE_COUNT: 18 85 | } 86 | } 87 | ] 88 | }) 89 | createStatisticsFieldsSpy.callCount.should.equal(1) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /test/unit/query/render-statistics.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const sinon = require('sinon') 3 | const proxyquire = require('proxyquire') 4 | const createStatisticsFieldsSpy = sinon.spy(function () { 5 | return [{ 6 | foo: 'bar' 7 | }] 8 | }) 9 | 10 | const fields = { 11 | StatisticsFields: { 12 | create: createStatisticsFieldsSpy 13 | } 14 | } 15 | 16 | const stub = { 17 | '../helpers/fields': fields 18 | } 19 | 20 | const { renderStatisticsResponse } = proxyquire('../../../lib/query/render-statistics', stub) 21 | 22 | describe('renderStatisticsResponse', () => { 23 | afterEach(function () { 24 | createStatisticsFieldsSpy.resetHistory() 25 | }) 26 | 27 | it('should convert statistics array to Geoservices JSON', () => { 28 | const result = renderStatisticsResponse({ statistics: [{ min_precip: 0 }] }, { 29 | outStatistics: [{ 30 | statisticType: 'MIN', 31 | onStatisticField: 'total precip', 32 | outStatisticFieldName: 'min_precip' 33 | }] 34 | }) 35 | result.should.deepEqual({ 36 | displayFieldName: '', 37 | fields: [{ 38 | foo: 'bar' 39 | }], 40 | features: [ 41 | { 42 | attributes: { 43 | min_precip: 0 44 | } 45 | } 46 | ] 47 | }) 48 | createStatisticsFieldsSpy.callCount.should.equal(1) 49 | createStatisticsFieldsSpy.firstCall.args.should.deepEqual([ 50 | { 51 | statistics: [{ min_precip: 0 }], 52 | outStatistics: [{ 53 | statisticType: 'MIN', 54 | onStatisticField: 'total precip', 55 | outStatisticFieldName: 'min_precip' 56 | }] 57 | } 58 | ]) 59 | }) 60 | 61 | it('should convert statistics object to Geoservices JSON', () => { 62 | const result = renderStatisticsResponse({ statistics: { min_precip: 0 } }, { 63 | outStatistics: [{ 64 | statisticType: 'MIN', 65 | onStatisticField: 'total precip', 66 | outStatisticFieldName: 'min_precip' 67 | }] 68 | }) 69 | result.should.deepEqual({ 70 | displayFieldName: '', 71 | fields: [{ 72 | foo: 'bar' 73 | }], 74 | features: [ 75 | { 76 | attributes: { 77 | min_precip: 0 78 | } 79 | } 80 | ] 81 | }) 82 | createStatisticsFieldsSpy.callCount.should.equal(1) 83 | createStatisticsFieldsSpy.firstCall.args.should.deepEqual([ 84 | { 85 | statistics: { min_precip: 0 }, 86 | outStatistics: [{ 87 | statisticType: 'MIN', 88 | onStatisticField: 'total precip', 89 | outStatisticFieldName: 'min_precip' 90 | }] 91 | } 92 | ]) 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /test/unit/response-handler.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const sinon = require('sinon') 3 | const responseHandler = require('../../lib/response-handler') 4 | 5 | describe('request handler', () => { 6 | const res = { 7 | json: () => { return res }, 8 | send: () => { return res }, 9 | set: () => { return res }, 10 | status: () => { return res } 11 | } 12 | 13 | const req = { 14 | query: {} 15 | } 16 | 17 | beforeEach(() => { 18 | sinon.spy(res) 19 | }) 20 | 21 | afterEach(() => { 22 | sinon.restore() 23 | }) 24 | 25 | it('return 200 and json', () => { 26 | responseHandler(req, res, 200, { test: true }) 27 | res.send.notCalled.should.equal(true) 28 | res.status.firstCall.args[0].should.be.exactly(200) 29 | res.json.firstCall.args[0].should.deepEqual({ test: true }) 30 | }) 31 | 32 | it('return 500 and json', () => { 33 | responseHandler(req, res, 500, { test: true }) 34 | res.send.notCalled.should.equal(true) 35 | res.status.firstCall.args[0].should.be.exactly(500) 36 | res.json.firstCall.args[0].should.deepEqual({ test: true }) 37 | }) 38 | 39 | it('return 200 and callback', () => { 40 | const reqCallback = { 41 | query: { 42 | callback: 'test' 43 | } 44 | } 45 | responseHandler(reqCallback, res, 200, { test: true }) 46 | res.send.firstCall.args[0].should.equal('test({"test":true})') 47 | res.status.firstCall.args[0].should.be.exactly(200) 48 | res.json.notCalled.should.equal(true) 49 | }) 50 | 51 | it('return 200 and pjson', () => { 52 | const reqPretty = { 53 | query: { 54 | f: 'pjson' 55 | } 56 | } 57 | responseHandler(reqPretty, res, 200, { test: true }) 58 | res.send.firstCall.args[0].should.equal(`{ 59 | "test": true 60 | }`) 61 | res.status.firstCall.args[0].should.be.exactly(200) 62 | res.json.notCalled.should.equal(true) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /test/unit/rest-info-route-handler.spec.js: -------------------------------------------------------------------------------- 1 | const should = require('should') // eslint-disable-line 2 | const restInfo = require('../../lib/rest-info-route-handler') 3 | 4 | describe('rest/info handler', () => { 5 | it('should return default info', () => { 6 | const req = { 7 | app: { 8 | locals: {} 9 | } 10 | } 11 | const result = restInfo({}, req) 12 | result.should.deepEqual({ 13 | currentVersion: 10.51, 14 | fullVersion: '10.5.1' 15 | }) 16 | }) 17 | 18 | it('should return default plus supplied info', () => { 19 | const data = { 20 | hello: { 21 | world: true 22 | } 23 | } 24 | const req = { 25 | app: { 26 | locals: {} 27 | } 28 | } 29 | const result = restInfo(data, req) 30 | result.should.deepEqual({ 31 | currentVersion: 10.51, 32 | fullVersion: '10.5.1', 33 | hello: { 34 | world: true 35 | } 36 | }) 37 | }) 38 | 39 | it('should return versions from app.locals', () => { 40 | const req = { 41 | app: { 42 | locals: { 43 | config: { 44 | featureServer: { 45 | currentVersion: 10.81, 46 | fullVersion: '10.8.1' 47 | } 48 | } 49 | } 50 | } 51 | } 52 | const result = restInfo({}, req) 53 | result.should.deepEqual({ 54 | currentVersion: 10.81, 55 | fullVersion: '10.8.1' 56 | }) 57 | }) 58 | }) 59 | --------------------------------------------------------------------------------