diff --git a/gbfs-validator/__test__/fixtures/conditionnal_no_vehicle_type_id.js b/gbfs-validator/__test__/fixtures/conditionnal_no_vehicle_type_id.js index afb97d8..f47dceb 100644 --- a/gbfs-validator/__test__/fixtures/conditionnal_no_vehicle_type_id.js +++ b/gbfs-validator/__test__/fixtures/conditionnal_no_vehicle_type_id.js @@ -1,5 +1,7 @@ const fastify = require('fastify') +const last_updated_fresh = Math.floor(Date.now() / 1000) - 30 + function build(opts = {}) { const app = fastify(opts) @@ -49,7 +51,7 @@ function build(opts = {}) { app.get('/free_bike_status.json', async function(request, reply) { return { - last_updated: 1566224400, + last_updated: last_updated_fresh, ttl: 0, version: '2.2', data: { diff --git a/gbfs-validator/__test__/fixtures/conditionnal_vehicle_type_id.js b/gbfs-validator/__test__/fixtures/conditionnal_vehicle_type_id.js index 0606fd8..bc905e5 100644 --- a/gbfs-validator/__test__/fixtures/conditionnal_vehicle_type_id.js +++ b/gbfs-validator/__test__/fixtures/conditionnal_vehicle_type_id.js @@ -1,5 +1,7 @@ const fastify = require('fastify') +const last_updated_fresh = Math.floor(Date.now() / 1000) - 30 + function build(opts = {}) { const app = fastify(opts) @@ -64,15 +66,12 @@ function build(opts = {}) { propulsion_type: 'human', name: 'Example Basic Bike', default_reserve_time: 30, - return_type: ['any_station', 'free_floating'], vehicle_assets: { icon_url: 'https://www.example.com/assets/icon_bicycle.svg', icon_url_dark: 'https://www.example.com/assets/icon_bicycle_dark.svg', icon_last_modified: '2021-06-15' - }, - default_pricing_plan_id: 'bike_plan_1', - pricing_plan_ids: ['bike_plan_1', 'bike_plan_2', 'bike_plan_3'] + } }, { vehicle_type_id: 'efg456', @@ -81,14 +80,11 @@ function build(opts = {}) { name: 'Example Electric Car', default_reserve_time: 30, max_range_meters: 100, - return_type: ['any_station', 'free_floating'], vehicle_assets: { icon_url: 'https://www.example.com/assets/icon_car.svg', icon_url_dark: 'https://www.example.com/assets/icon_car_dark.svg', icon_last_modified: '2021-06-15' - }, - default_pricing_plan_id: 'car_plan_1', - pricing_plan_ids: ['car_plan_1', 'car_plan_2', 'car_plan_3'] + } } ] } @@ -97,7 +93,7 @@ function build(opts = {}) { app.get('/free_bike_status.json', async function(request, reply) { return { - last_updated: 1566224400, + last_updated: last_updated_fresh, ttl: 0, version: '2.2', data: { diff --git a/gbfs-validator/__test__/fixtures/conditionnal_vehicle_types_available.js b/gbfs-validator/__test__/fixtures/conditionnal_vehicle_types_available.js index 4cf811c..e40a835 100644 --- a/gbfs-validator/__test__/fixtures/conditionnal_vehicle_types_available.js +++ b/gbfs-validator/__test__/fixtures/conditionnal_vehicle_types_available.js @@ -1,5 +1,7 @@ const fastify = require('fastify') +const last_updated_fresh = Math.floor(Date.now() / 1000) - 30 + function build(opts = {}) { const app = fastify(opts) @@ -60,7 +62,6 @@ function build(opts = {}) { propulsion_type: 'human', name: 'Example Basic Bike', default_reserve_time: 30, - return_type: ['any_station', 'free_floating'], vehicle_assets: { icon_url: 'https://www.example.com/assets/icon_bicycle.svg', icon_url_dark: @@ -77,7 +78,6 @@ function build(opts = {}) { name: 'Example Electric Car', default_reserve_time: 30, max_range_meters: 100, - return_type: ['any_station', 'free_floating'], vehicle_assets: { icon_url: 'https://www.example.com/assets/icon_car.svg', icon_url_dark: 'https://www.example.com/assets/icon_car_dark.svg', @@ -93,7 +93,7 @@ function build(opts = {}) { app.get('/station_status.json', async function(request, reply) { return { - last_updated: 1566224400, + last_updated: last_updated_fresh, ttl: 0, version: '2.2', data: { diff --git a/gbfs-validator/__test__/fixtures/missing_vehicle_types.js b/gbfs-validator/__test__/fixtures/missing_vehicle_types.js index b7414c6..646eb8e 100644 --- a/gbfs-validator/__test__/fixtures/missing_vehicle_types.js +++ b/gbfs-validator/__test__/fixtures/missing_vehicle_types.js @@ -1,5 +1,7 @@ const fastify = require('fastify') +const last_updated_fresh = Math.floor(Date.now() / 1000) - 30 + function build(opts = {}) { const app = fastify(opts) @@ -49,7 +51,7 @@ function build(opts = {}) { app.get('/free_bike_status.json', async function(request, reply) { return { - last_updated: 1566224400, + last_updated: last_updated_fresh, ttl: 0, version: '2.2', data: { diff --git a/gbfs-validator/__test__/fixtures/plan_id.js b/gbfs-validator/__test__/fixtures/plan_id.js index 3b7df92..e8e748e 100644 --- a/gbfs-validator/__test__/fixtures/plan_id.js +++ b/gbfs-validator/__test__/fixtures/plan_id.js @@ -1,9 +1,11 @@ const fastify = require('fastify') +const last_updated_fresh = Math.floor(Date.now() / 1000) - 30 + function build(opts = {}) { const app = fastify(opts) - app.get('/gbfs.json', async function(request, reply) { + app.get('/gbfs.json', async function (request, reply) { return { last_updated: 1566224400, ttl: 0, @@ -57,7 +59,7 @@ function build(opts = {}) { app.get('/free_bike_status.json', async function(request, reply) { return { - last_updated: 1566224400, + last_updated: last_updated_fresh, ttl: 0, version: '2.3', data: { @@ -109,7 +111,7 @@ function build(opts = {}) { propulsion_type: 'human', name: 'Example Basic Bike', default_reserve_time: 30, - return_type: ['any_station', 'free_floating'], + return_constraint: 'any_station', vehicle_assets: { icon_url: 'https://www.example.com/assets/icon_bicycle.svg', icon_url_dark: @@ -126,7 +128,7 @@ function build(opts = {}) { name: 'Example Electric Car', default_reserve_time: 30, max_range_meters: 100, - return_type: ['any_station', 'free_floating'], + return_constraint: 'free_floating', vehicle_assets: { icon_url: 'https://www.example.com/assets/icon_car.svg', icon_url_dark: 'https://www.example.com/assets/icon_car_dark.svg', diff --git a/gbfs-validator/__test__/fixtures/v3.0-RC/default.js b/gbfs-validator/__test__/fixtures/v3.0-RC/default.js index 31919d2..47fd4eb 100644 --- a/gbfs-validator/__test__/fixtures/v3.0-RC/default.js +++ b/gbfs-validator/__test__/fixtures/v3.0-RC/default.js @@ -116,15 +116,13 @@ class MockRequests { } ], default_reserve_time: 30, - return_type: ['any_station', 'free_floating'], + return_constraint: 'hybrid', vehicle_assets: { icon_url: 'https://www.example.com/assets/icon_bicycle.svg', icon_url_dark: 'https://www.example.com/assets/icon_bicycle_dark.svg', icon_last_modified: '2021-06-15' - }, - default_pricing_plan_id: 'bike_plan_1', - pricing_plan_ids: ['bike_plan_1', 'bike_plan_2', 'bike_plan_3'] + } }, { vehicle_type_id: 'cartype1', @@ -138,14 +136,12 @@ class MockRequests { ], default_reserve_time: 30, max_range_meters: 100, - return_type: ['any_station', 'free_floating'], + return_constraint: 'any_station', vehicle_assets: { icon_url: 'https://www.example.com/assets/icon_car.svg', icon_url_dark: 'https://www.example.com/assets/icon_car_dark.svg', icon_last_modified: '2021-06-15' - }, - default_pricing_plan_id: 'car_plan_1', - pricing_plan_ids: ['car_plan_1', 'car_plan_2', 'car_plan_3'] + } } ] } diff --git a/gbfs-validator/__test__/fixtures/v3.0-RC/exaustive.js b/gbfs-validator/__test__/fixtures/v3.0-RC/exaustive.js index ed1aaed..1842835 100644 --- a/gbfs-validator/__test__/fixtures/v3.0-RC/exaustive.js +++ b/gbfs-validator/__test__/fixtures/v3.0-RC/exaustive.js @@ -211,7 +211,7 @@ class MockRequests { } ], default_reserve_time: 30, - return_type: ['any_station', 'free_floating'], + return_constraint: 'free_floating', vehicle_assets: { icon_url: 'https://www.example.com/assets/icon_bicycle.svg', icon_url_dark: @@ -243,7 +243,6 @@ class MockRequests { vehicle_accessories: ['automatic', 'air_conditioning'], g_CO2_km: 0, default_reserve_time: 30, - return_type: ['any_station', 'free_floating'], vehicle_image: 'https://www.example.com/assets/car.jpg', make: [ { @@ -267,7 +266,6 @@ class MockRequests { wheel_count: 4, max_permitted_speed: 200, rated_power: 100, - default_reserve_time: 30, return_constraint: 'hybrid', vehicle_assets: { icon_url: 'https://www.example.com/assets/icon_car.svg', @@ -290,7 +288,20 @@ class MockRequests { data: { stations: [ { - station_id: 'pga', + station_id: 'station1', + name: [ + { + text: 'Station 1', + language: 'en' + } + ], + lat: 12.345578, + lon: 45.678801, + is_virtual_station: false, + capacity: 5 + }, + { + station_id: 'station2', name: [ { text: 'Parking garage A', @@ -329,7 +340,7 @@ class MockRequests { parking_type: 'underground_parking', parking_hoop: false, contact_phone: '+33109874321', - capacity: 10, + capacity: 20, vehicle_type_area_capacity: [ { vehicle_type_id: 'abc123', @@ -454,8 +465,7 @@ class MockRequests { rental_uris: { android: 'https://www.example.com/app?vehicle_id=973a5c94-c288-4a2b-afa6-de8aeb6ae2e5&platform=android&', - ios: - 'https://www.example.com/app?vehicle_id=973a5c94-c288-4a2b-afa6-de8aeb6ae2e5&platform=ios', + ios: 'https://www.example.com/app?vehicle_id=973a5c94-c288-4a2b-afa6-de8aeb6ae2e5&platform=ios', web: 'https://www.example.com/app?sid=1234567890' }, vehicle_type_id: 'biketype1', @@ -584,7 +594,7 @@ class MockRequests { language: 'en' } ], - currency: 'CAD', + currency: 'EUR', price: 3.0, reservation_price_flat_rate: 3.0, is_taxable: true, diff --git a/gbfs-validator/__test__/gbfs.v3.0-RC.test.js b/gbfs-validator/__test__/gbfs.v3.0-RC.test.js index 5aed3c8..80379f7 100644 --- a/gbfs-validator/__test__/gbfs.v3.0-RC.test.js +++ b/gbfs-validator/__test__/gbfs.v3.0-RC.test.js @@ -7,20 +7,38 @@ const serverOpts = { function get_errors(result) { let errors = [] + let nonSchemaErrors = [] + let warnings = [] - result.files?.map(f => { - if (f.errors) { + result.files?.map((f) => { + if (f.errors?.length) { errors.push({ file: f.file, errors: f.errors }) } - f.languages?.map(l => { - if (l.errors) { + if (f.nonSchemaErrors?.length) { + nonSchemaErrors.push({ file: f.file, errors: f.nonSchemaErrors }) + } + + if (f.warnings?.length) { + warnings.push({ file: f.file, warnings: f.warnings }) + } + + f.languages?.map((l) => { + if (l.errors?.length) { errors.push({ file: f.file, lang: l.lang, errors: l.errors }) } + + if (l.nonSchemaErrors?.length) { + nonSchemaErrors.push({ file: f.file, lang: l.lang, errors: l.nonSchemaErrors }) + } + + if (l.warnings?.length) { + warnings.push({ file: f.file, lang: l.lang, warnings: l.warnings }) + } }) }) - return errors + return { errors, nonSchemaErrors, warnings } } describe('default feed', () => { @@ -47,7 +65,7 @@ describe('default feed', () => { expect.assertions(1) - return gbfs.validation().then(result => { + return gbfs.validation().then((result) => { expect(result).toMatchObject({ summary: expect.objectContaining({ version: { detected: '3.0-RC', validated: '3.0-RC' }, @@ -94,7 +112,7 @@ describe('invalid feed', () => { expect.assertions(2) - return gbfs.validation().then(result => { + return gbfs.validation().then((result) => { expect(result).toMatchObject({ summary: expect.objectContaining({ version: { detected: '3.0-RC', validated: '3.0-RC' }, @@ -105,7 +123,7 @@ describe('invalid feed', () => { files: expect.any(Array) }) - let error = result.files.find(f => f.file === 'system_information.json') + let error = result.files.find((f) => f.file === 'system_information.json') ?.languages?.[0].errors?.[0].schemaPath expect(error).toBe('#/properties/data/required') }) @@ -137,8 +155,12 @@ describe('exaustive feed', () => { expect.assertions(2) - return gbfs.validation().then(result => { - expect(get_errors(result)).toEqual([]) + return gbfs.validation().then((result) => { + expect(get_errors(result)).toEqual({ + errors: [], + nonSchemaErrors: [], + warnings: [] + }) expect(result).toMatchObject({ summary: expect.objectContaining({ @@ -151,3 +173,89 @@ describe('exaustive feed', () => { }) }) }) + +describe('default_reserve_time REQUIRED if reservation_price_per_min or reservation_price_flat_rate are defined', () => { + let gbfsFeedServer + + beforeAll(async () => { + const { MockRequests } = require('./fixtures/v3.0-RC/exaustive') + + class InvalidMockRequests extends MockRequests { + system_pricing_plans(...args) { + const json = super.system_pricing_plans(...args) + + // This plan will require a default_reserve_time on vehicle_types using it. + json.data.plans[0].reservation_price_per_min = 1 + json.data.plans[0].reservation_price_flat_rate = 2 + + // This plan will not. + delete json.data.plans[1].reservation_price_per_min + delete json.data.plans[1].reservation_price_flat_rate + + return json + } + + vehicle_types(...args) { + const json = super.vehicle_types(...args) + + const system_pricing_plans = super.system_pricing_plans(...args) + let id_requiring = system_pricing_plans.data.plans[0].plan_id + let id_not_requiring = system_pricing_plans.data.plans[1].plan_id + + // This invalid vehicle_type will require a default_reserve_time. + delete json.data.vehicle_types[0].default_reserve_time + json.data.vehicle_types[0].default_pricing_plan_id = id_requiring + json.data.vehicle_types[0].pricing_plan_ids = [ + id_requiring, + id_not_requiring + ] + + // This valid vehicle_type will not. + delete json.data.vehicle_types[1].default_reserve_time + json.data.vehicle_types[1].default_pricing_plan_id = id_not_requiring + json.data.vehicle_types[1].pricing_plan_ids = [id_not_requiring] + + return json + } + } + + let mockRequests = new InvalidMockRequests() + + gbfsFeedServer = mockRequests.build() + + await gbfsFeedServer.listen(serverOpts) + }) + + afterAll(() => { + return gbfsFeedServer.close() + }) + + test('should not validate feed', async () => { + const url = `http://${gbfsFeedServer.server.address().address}:${ + gbfsFeedServer.server.address().port + }` + const gbfs = new GBFS(`${url}/gbfs.json`) + + expect.assertions(3) + + return gbfs.validation().then((result) => { + let { errors, nonSchemaErrors, warnings } = get_errors(result) + + expect(errors).toEqual([]) + expect(warnings).toEqual([]) + expect(nonSchemaErrors).toEqual([ + { + errors: [ + { + key: 'default_reserve_time', + message: 'Missing default_reserve_time.', + path: '/data/vehicle_types/0' + } + ], + file: 'vehicle_types.json', + lang: undefined + } + ]) + }) + }) +}) diff --git a/gbfs-validator/gbfs.js b/gbfs-validator/gbfs.js index f3a2086..9104a0b 100644 --- a/gbfs-validator/gbfs.js +++ b/gbfs-validator/gbfs.js @@ -5,45 +5,155 @@ const validatorVersion = process.env.COMMIT_REF : require('./package.json').version function hasErrors(data, required) { - let hasError = false - - data.forEach((el) => { + for (const el of data) { if (Array.isArray(el)) { if (hasErrors(el, required)) { - hasError = true + return true } } else { - if (required && !el.exists ? true : !!el.errors || el.hasErrors) { - hasError = true + if (required && !el.exists) { + return true + } + + if (el.hasErrors || el.errors?.length || el.nonSchemaErrors?.length) { + return true } } - }) + } - return hasError + return false +} + +function hasWarnings(data) { + for (const el of data) { + if (Array.isArray(el)) { + if (hasWarnings(el)) { + return true + } + } + + if (el.hasWarnings || el.nonSchemaWarnings?.length) { + return true + } + } + + return false } function countErrors(file) { let count = 0 - if (file.hasErrors) { - if (file.errors) { - count = file.errors.length - } else if (file.languages) { - if (file.required) { - count += file.languages.filter((l) => !l.exists).length + count += file.errors?.length || 0 + count += file.nonSchemaErrors?.length || 0 + + for (const lang of file.languages || []) { + count += lang.errors?.length || 0 + count += lang.nonSchemaErrors?.length || 0 + } + + return count +} + +function jsonSchemaSummary(errors) { + if (!errors) { + return [] + } + + let summary = {} + + for (const error of errors) { + let message = error.message + + if (error.params && error.params.additionalProperty) { + message += `: ${error.params.additionalProperty}` + } + + let instancePath = error.instancePath + .replace(/\/\d+\//g, '/#/') + .replace(/\/\d+$/g, '/#') + + if (!summary[message]) { + summary[message] = {} + } + + if (!summary[message][instancePath]) { + summary[message][instancePath] = 1 + } else { + summary[message][instancePath]++ + } + } + + return Object.entries(summary).map(([message, value]) => { + let values = Object.entries(value).map(([path, count]) => { + return { + path, + message, + count } + }) - count += file.languages.reduce((acc, l) => { - if (l.exists) { - acc += l.errors.length - } + return { + key: message, + message: message, + values + } + }) +} + +function nonSchemaSummary(nonSchemas) { + if (!nonSchemas) { + return [] + } + + let summary = {} + for (const nonSchema of nonSchemas) { + let key = nonSchema.key + let message = nonSchema.message + + if (nonSchema.additionalProperty) { + message = + message.replace(/\.$/, '') + `: '${nonSchema.additionalProperty}'` + } + + if (!summary[message]) { + summary[message] = {} + } - return acc - }, 0) + let path = nonSchema.path + .replace(/\/\d+\//g, '/#/') + .replace(/\/\d+$/g, '/#') + + if (!summary[message][path]) { + summary[message][path] = { count: 1, key } + } else { + summary[message][path].count++ } } - return count + return Object.entries(summary).map(([message, value]) => { + let values = Object.entries(value).map(([path, { count, key }]) => { + return { + key, + path, + message, + count + } + }) + + return { + key: values[0].key, + message, + values + } + }) +} + +function getSummary(errors, nonSchemaErrors, nonSchemaWarnings) { + return { + errors: jsonSchemaSummary(errors), + nonSchemaErrors: nonSchemaSummary(nonSchemaErrors), + nonSchemaWarnings: nonSchemaSummary(nonSchemaWarnings) + } } function getPartialSchema(version, partial, data = {}) { @@ -144,11 +254,15 @@ function fileExist(file) { return false } +function getVersionConfiguration(version) { + return require(`./versions/v${version}`) +} + function isGBFSFileRequire(version) { if (!version) { return false } else { - return require(`./versions/v${version}`).gbfsRequired + return getVersionConfiguration(version).gbfsRequired } } @@ -161,7 +275,7 @@ class GBFS { throw new Error('Missing URL') } - this.url = url + this.url = url.trim() this.options = { docked, freefloating, @@ -207,15 +321,22 @@ class GBFS { } this.autoDiscovery = body - const { errors, schema } = this.validateFile( - this.options.version || body.version || '1.0', - 'gbfs', - this.autoDiscovery - ) + + const { errors, schema, nonSchemaWarnings, nonSchemaErrors } = + this.validateFile( + this.options.version || body.version || '1.0', + 'gbfs', + this.autoDiscovery + ) + + const summary = getSummary(errors, nonSchemaErrors, nonSchemaWarnings) return { schema, errors, + nonSchemaWarnings, + nonSchemaErrors, + summary, url, version: body.version, recommended: true, @@ -253,15 +374,21 @@ class GBFS { this.autoDiscovery = body - const { errors, schema } = this.validateFile( - this.options.version || body.version || '1.0', - 'gbfs', - this.autoDiscovery - ) + const { errors, schema, nonSchemaWarnings, nonSchemaErrors } = + this.validateFile( + this.options.version || body.version || '1.0', + 'gbfs', + this.autoDiscovery + ) + + const summary = getSummary(errors, nonSchemaErrors, nonSchemaWarnings) return { schema, errors, + nonSchemaWarnings, + nonSchemaErrors, + summary, url: this.url, version: body.version || '1.0', recommended: true, @@ -292,35 +419,71 @@ class GBFS { }) } - validateFile(version, file, data, options) { - let schema + validateFile(version, file, data, options, { allFiles, lang } = {}) { + let s try { - schema = require(`./versions/schemas/v${version}/${file}`) + s = require(`./versions/schemas/v${version}/${file}`) } catch (e) { console.log(e) throw new Error('can not require') } - return validate(schema, data, options) + let { schema, errors } = validate(s, data, options) + + let nonSchema = { errors: [], warnings: [] } + try { + const files = getVersionConfiguration(version).files(this.options) + + let f = files.find((f) => f.file === file) + + const fns = f?.nonSchemaRules || [] + + for (const fn of fns) { + try { + fn({ + errors: nonSchema.errors, + warnings: nonSchema.warnings, + version, + file, + data, + schema, + lang, + allFiles + }) + } catch (error) { + console.error(error) + } + } + } catch (e) { + // No additional validation + } + + return { + schema, + errors, + nonSchemaErrors: nonSchema.errors, + nonSchemaWarnings: nonSchema.warnings, + summary: getSummary(errors, nonSchema.errors, nonSchema.warnings) + } } getFile(type, required) { if (this.autoDiscovery) { - let urls + let urls = [] let version = this.options.version || this.autoDiscovery.version // 3.0-RC , 3.0 and upcoming minor versions if (/^3\.\d/.test(version)) { - urls = - this.autoDiscovery.data.feeds?.filter((f) => f.name === type) || [] - } else { - urls = Object.entries(this.autoDiscovery.data).map((key) => { - return Object.assign( - { lang: key[0] }, - this.autoDiscovery.data[key[0]].feeds.find((f) => f.name === type) - ) + urls = this.autoDiscovery.data?.feeds?.filter((f) => f.name === type) + } else if (this.autoDiscovery.data) { + Object.entries(this.autoDiscovery.data).map(([key, value]) => { + let feed = value.feeds?.find((f) => f.name === type) + + if (feed) { + urls.push(Object.assign({ lang: key }, feed)) + } }) } @@ -381,13 +544,16 @@ class GBFS { } } - validationFile(body, version, type, required, options) { + validationFile(body, version, type, required, options, { allFiles } = {}) { if (Array.isArray(body)) { body = body .filter((b) => b.exists || b.required) .map((b) => ({ ...b, - ...this.validateFile(version, type, b.body, options) + ...this.validateFile(version, type, b.body, options, { + allFiles, + lang: b.lang + }) })) return { @@ -397,12 +563,16 @@ class GBFS { ? body.reduce((acc, l) => acc && l.exists, true) : false, file: `${type}.json`, - hasErrors: hasErrors(body, required) + hasErrors: hasErrors(body, required), + hasWarnings: hasWarnings(body) } } else { return { required, - ...this.validateFile(version, type, body, options), + ...this.validateFile(version, type, body, options, { + allFiles + }), + exists: !!body, file: `${type}.json`, url: `${this.url}/${type}.json` @@ -629,16 +799,53 @@ class GBFS { } result.push( - this.validationFile(f.body, gbfsVersion, f.type, required, { - addSchema - }) + this.validationFile( + f.body, + gbfsVersion, + f.type, + required, + { addSchema }, + { allFiles: files } + ) ) }) - const filesResult = result.map((file) => ({ - ...file, - errorsCount: countErrors(file) - })) + let errorsCount = 0 + let summaryErrorCount = 0 + let summaryWarningCount = 0 + + const filesResult = result.map((file) => { + let errorsCountFile = countErrors(file) + + let fileSummaryErrorCount = 0 + let fileSummaryWarningCount = 0 + + if (file.summary) { + fileSummaryErrorCount += + file.summary.errors.length + file.summary.nonSchemaErrors.length + fileSummaryWarningCount += file.summary.nonSchemaWarnings.length + } else if (file.languages) { + file.languages.forEach((language) => { + if (language.summary) { + fileSummaryErrorCount += + language.summary.errors.length + + language.summary.nonSchemaErrors.length + fileSummaryWarningCount += language.summary.nonSchemaWarnings.length + } + }) + } + + errorsCount += errorsCountFile + summaryErrorCount += fileSummaryErrorCount + summaryWarningCount += fileSummaryWarningCount + + return { + ...file, + errorsCount: errorsCountFile, + summaryErrorCount: fileSummaryErrorCount, + summaryWarningCount: fileSummaryWarningCount + } + }) return { summary: { @@ -648,10 +855,10 @@ class GBFS { validated: this.options.version || result[0].version }, hasErrors: hasErrors(result), - errorsCount: filesResult.reduce((acc, file) => { - acc += file.errorsCount - return acc - }, 0) + hasWarnings: hasWarnings(result), + errorsCount, + summaryErrorCount, + summaryWarningCount }, files: filesResult } diff --git a/gbfs-validator/nonSchemaValidation/README.md b/gbfs-validator/nonSchemaValidation/README.md new file mode 100644 index 0000000..0997f9b --- /dev/null +++ b/gbfs-validator/nonSchemaValidation/README.md @@ -0,0 +1,147 @@ +# last_updated_future + +If `last_updated` is in the future, the validator will return an error. + +# last_updated_outdated + +If `last_updated` is older than 5 minutes for near real-time feeds, +the validator will return an error. + +# additional_properties + +If any file contains additional properties, the validator will return a warning, to help detects typos or fields that could be updated to conform to the GBFS specification. + +# missing_translation + +If any `Localized String` is missing a lang declared in the `languages` field of `system_information.json`, the validator will return an error. + +# duplicate_translation + +If any `Localized String` has more than one translation for the same lang, the validator will return an error. + +# unknown_language + +If any `Localized String` has a lang that is not declared in the `languages` field of `system_information.json`, the validator will return an error. + +# missing_system_pricing_plans + +If `default_pricing_plan_id` or `pricing_plan_ids` is specified in `vehicle_types.json`, but no pricing plan is defined in `system_pricing_plans.json`, the validator will return an error. + +# missing_default_pricing_plan_id + +If the `default_pricing_plan_id` is not present in the `vehicle_types.json` file when pricing plans are present, the validator will return an error. + +# missing_station_information + +If a station is declared in `station_status.json` but not in `station_information.json`, the validator will return an error. + +# missing_station_status + +If a station is declared in `station_information.json` but not in `station_status.json`, the validator will return an error. + +# duplicate_station_id +If `station_information.json` or `station_status.json` contain duplicate `station_id`, the validator will return an error. + +# unknown_plan_id + +If a pricing plan id is used (like in `default_pricing_plan_id` or `pricing_plan_ids` of the `vehicle_types.json` file ), but is not present in the `system_pricing_plans.json` file, the validator will return an error. + +# default_reserve_time + +If the `default_reserve_time` is not present in the `vehicle_types.json` file for a vehicle type that use a pricing declaring `reservation_price_per_min` or `reservation_price_flat_rate`, the validator will return an error. + +# num_vehicles_available_incorrect + +If `num_vehicles_available` on a station is not equal to the sum of `vehicle_types_available.count` for each vehicle type, the validator will return an error. + +# num_docks_available_incorrect + +If `num_docks_available` on a station is not equal to the sum of `vehicle_docks_available.count` for each vehicle type, the validator will return an error. + +# num_docks_available_high + +If `num_docks_available` on a station is unusually high validator will return a warning. + +# num_vehicles_available_high + +If `num_vehicles_available` on a station is unusually high, the validator will return a warning. + +# station_capacity_too_low + +If `capacity` on a station is lower than the sum of `num_vehicles_available` and `num_docks_available`, the validator will return an error. + +# unclosed_polygon + +If a the last coordinate of the LineString of a polygon is not the same as the first coordinate, the validator will return an error. + +# unexpected_rider_capacity + +If the `rider_capacity` value is unexpected regarding the vehicle type, the validator will return a warning. + +# unexpected_cargo_volume_capacity + +If the `cargo_volume_capacity` value is unexpected regarding the vehicle type, the validator will return a warning. + +# unexpected_cargo_load_capacity + +If the `cargo_load_capacity` value is unexpected regarding the vehicle type, the validator will return a warning. + +# unexpected_propulsion_type + +If the `propulsion_type` value is unexpected regarding the vehicle type, the validator will return a warning. + +# unexpectedly_low_range_meters + +If the `max_range_meters` value is unexpectedly low regarding the vehicle type, the validator will return a warning. + +# unexpectedly_high_range_meters + +If the `max_range_meters` value is unexpectedly high regarding the vehicle type, the validator will return a warning. + +# unexpected_vehicle_accessory + +If the `vehicle_accessories` value is unexpected regarding the vehicle type, the validator will return a warning. + +# unexpectedly_low_wheel_count + +If the `wheel_count` value is unexpectedly low regarding the vehicle type, the validator will return a warning. + +# unexpectedly_high_wheel_count + +If the `wheel_count` value is unexpectedly high regarding the vehicle type, the validator will return a warning. + +# duplicate_vehicle_accessory + +If the `vehicle_accessories` value contains duplicates, the validator will return an error. + +# multiple_door_counts + +If the `vehicle_accessories` value contains multiple door counts, the validator will return an error. + +# duplicate_bike_id + +If the `free_bike_status.json` contains duplicate `bike_id`, the validator will return an error. + +# invalid_vehicle_type_id + +If a referenced `vehicle_type_id` is not present in the `vehicle_types.json` file, the validator will return an error. + +# duplicate_vehicle_id + +If the `vehicle_status.json` contains duplicate `vehicle_id`, the validator will return an error. + +# invalid_pricing_plan_id + +If a referenced `pricing_plan_id` is not present in the `system_pricing_plans.json` file, the validator will return an error. + +# low_cost + +If the computed cost seams low regarding the `form_factor`, the validator will return a warning. + +# high_cost + +If the computed cost seams high regarding the `form_factor`, the validator will return a warning. + +# duplicate_vehicle_type_id + +If the `vehicle_types.json` contains duplicate `vehicle_type_id`, the validator will return an error. diff --git a/gbfs-validator/nonSchemaValidation/additional_properties.js b/gbfs-validator/nonSchemaValidation/additional_properties.js new file mode 100644 index 0000000..75f02ca --- /dev/null +++ b/gbfs-validator/nonSchemaValidation/additional_properties.js @@ -0,0 +1,72 @@ +const Ajv = require('ajv') +const ajvErrors = require('ajv-errors') +const addFormats = require('ajv-formats') + +/** + * Update the schema to check for additionalProperties errors + */ +function addAdditionalPropertiesErrors(sub_schema) { + if (sub_schema.type === 'object' && sub_schema.properties) { + let additionalProperties + if (sub_schema.additionalProperties) { + // Additional properties are allowed + additionalProperties = true + } else if (sub_schema.additionalProperties === false) { + // Additional properties are already reported as error, we ignore them + additionalProperties = true + } else { + // Additional properties will be reported as error by ajv, and we will report them as warnings + additionalProperties = false + } + + const res = { + additionalProperties, + properties: {}, + patternProperties: { + '^_': {} // Properties starting with _ are ignored + } + } + + for (const [key, value] of Object.entries(sub_schema.properties)) { + res.properties[key] = addAdditionalPropertiesErrors(value) + } + + return res + } else if (sub_schema.type === 'array' && sub_schema.items) { + return { + type: 'array', + items: addAdditionalPropertiesErrors(sub_schema.items) + } + } else { + return {} // Remove all validations + } +} + +function checkAdditionalProperties({ warnings, data, schema }) { + let duplicateSchema = JSON.parse(JSON.stringify(schema)) + + duplicateSchema = addAdditionalPropertiesErrors(duplicateSchema) + + const ajv = new Ajv({ allErrors: true, strict: false }) + ajvErrors(ajv) + addFormats(ajv) + + const validate = ajv.compile(duplicateSchema) + + const valid = validate(data) + + if (!valid) { + warnings.push( + ...validate.errors + .filter((e) => e.keyword === 'additionalProperties') + .map((e) => ({ + path: e.schemaPath.replace(/\/additionalProperties$/, ''), + key: 'additional_properties', + message: `Additional property detected.`, + additionalProperty: e.params.additionalProperty + })) + ) + } +} + +module.exports = { checkAdditionalProperties } diff --git a/gbfs-validator/nonSchemaValidation/check_geofencing_zones.js b/gbfs-validator/nonSchemaValidation/check_geofencing_zones.js new file mode 100644 index 0000000..6fff67f --- /dev/null +++ b/gbfs-validator/nonSchemaValidation/check_geofencing_zones.js @@ -0,0 +1,25 @@ +function checkGeofencingZones({ errors, data }) { + data.data?.geofencing_zones?.features?.map((feature) => { + const multypolygon = feature.geometry + + for (const multi_coordinates of multypolygon.coordinates) { + for (const geo of multi_coordinates) { + const first = geo[0] + const last = geo[geo.length - 1] + + if (first[0] !== last[0] || first[1] !== last[1]) { + errors.push({ + path: '/data/geofencing_zones/features/geometry/coordinates', + key: 'unclosed_polygon', + message: `The polygon is not closed` + }) + break + } + } + } + }) +} + +module.exports = { + checkGeofencingZones +} diff --git a/gbfs-validator/nonSchemaValidation/check_pricing_plans.js b/gbfs-validator/nonSchemaValidation/check_pricing_plans.js new file mode 100644 index 0000000..898e6e4 --- /dev/null +++ b/gbfs-validator/nonSchemaValidation/check_pricing_plans.js @@ -0,0 +1,132 @@ +const { getFileBody } = require('./utils') + +const TYPICAL_PRICING_PER_TYPE = { + bicycle: { km: 5, minutes: 20, minCost: 1, maxCost: 15 }, + cargo_bicycle: { km: 5, minutes: 20, minCost: 1, maxCost: 15 }, + scooter_standing: { km: 5, minutes: 20, minCost: 1, maxCost: 15 }, + scooter_seated: { km: 5, minutes: 20, minCost: 1, maxCost: 15 }, + scooter: { km: 5, minutes: 20, minCost: 1, maxCost: 15 }, + car: { km: 30, minutes: 60, minCost: 5, maxCost: 50 }, + moped: { km: 30, minutes: 60, minCost: 2, maxCost: 30 } +} + +function computeInterval(value, { start, end, interval }) { + if (value <= start) { + // The trip distance or duration is inferior to the kilometer or minute + // at which this segment rate starts being charged (inclusive). + return 0 + } + + if (!interval) { + // An interval of 0 indicates the rate is only charged once. + return 1 + } + + // The value at which the rate will no longer apply (exclusive) for example, + // if end is 20 the rate no longer applies at 20.00. + // If this field is empty, the price issued for this segment is charged until the trip ends, + // in addition to the cost of any subsequent segments. + end = end ? Math.min(value, end) : value + + // Rate that is charged at each interval after the start. + // Can be a negative number, which indicates that the traveler will receive a discount. + return Math.ceil((end - start) / interval) +} + +function computeCost({ plan, km, min }) { + let cost = 0 + + if (typeof plan.price === 'string') { + cost = parseFloat(plan.price) || 0 + } else if (typeof plan.price === 'number') { + cost = plan.price + } + + // Array of segments when the price is a function of distance traveled, displayed in kilometers. + plan.per_km_pricing?.map((per_km) => { + cost += per_km.rate * computeInterval(km, per_km) + }) + + // Array of segments when the price is a function of time traveled, displayed in minutes. + plan.per_min_pricing?.map((per_min) => { + cost += per_min.rate * computeInterval(min, per_min) + }) + + return Math.round(cost * 100) / 100 +} + +function checkVehicleTypePricingPlanCosts({ + errors, + warnings, + data, + lang, + allFiles +}) { + const pricing_plans = getFileBody(allFiles, 'system_pricing_plans', lang) + + const plans = pricing_plans?.data?.plans + if (!plans) { + return + } + + data.data?.vehicle_types?.map((vehicle_type) => { + let plan_ids + if (vehicle_type.pricing_plan_ids) { + plan_ids = vehicle_type.pricing_plan_ids + } else if (vehicle_type.default_pricing_plan_id) { + plan_ids = [vehicle_type.default_pricing_plan_id] + } else { + return + } + + plan_ids.map((plan_id) => { + const plan = plans.find((plan) => plan.plan_id === plan_id) + + if (!plan) { + errors.push({ + path: '/data/vehicle_types/pricing_plan_ids', + key: 'invalid_pricing_plan_id', + message: `Invalid pricing_plan_id`, + plan_id + }) + return + } + + if (plan.currency !== 'EUR' && plan.currency !== 'USD') { + return + } + + const typical_pricing = TYPICAL_PRICING_PER_TYPE[vehicle_type.form_factor] + + if (!typical_pricing) { + return + } + + const cost = computeCost({ + plan, + km: typical_pricing.km, + min: typical_pricing.minutes + }) + + if (cost > typical_pricing.maxCost) { + warnings.push({ + path: '/data/vehicle_types/pricing_plan_ids', + key: 'high_cost', + message: `High cost: ${cost} ${plan.currency} for ${typical_pricing.km} km and ${typical_pricing.minutes} min on ${vehicle_type.form_factor} (${vehicle_type.vehicle_type_id})` + }) + } + + if (cost < typical_pricing.minCost) { + warnings.push({ + path: '/data/vehicle_types/pricing_plan_ids', + key: 'low_cost', + message: `Low cost: ${cost} ${plan.currency} for ${typical_pricing.km} km and ${typical_pricing.minutes} min on ${vehicle_type.form_factor} (${vehicle_type.vehicle_type_id})` + }) + } + }) + }) +} + +module.exports = { + checkVehicleTypePricingPlanCosts +} diff --git a/gbfs-validator/nonSchemaValidation/check_stations.js b/gbfs-validator/nonSchemaValidation/check_stations.js new file mode 100644 index 0000000..e1d2259 --- /dev/null +++ b/gbfs-validator/nonSchemaValidation/check_stations.js @@ -0,0 +1,177 @@ +const { getFileBody } = require('./utils') + +const HIGH_NUM_VEHICLES_AVAILABLE = 100 +const HIGH_NUM_DOCKS_AVAILABLE = 100 + +function checkStationInformationIDs({ errors, data, lang, allFiles }) { + const stationsStatus = getFileBody(allFiles, 'station_status', lang) + + if (!stationsStatus) { + return + } + + const ids = new Set() + + data.data.stations.map((station) => { + if (!station.station_id) { + return + } + + if (ids.has(station.station_id)) { + errors.push({ + path: '/data/stations/station_id', + key: 'duplicate_station_id', + message: `Duplicate station_id`, + station_id: station.station_id + }) + return + } + + ids.add(station.station_id) + + let status = stationsStatus.data?.stations?.find( + (s) => s.station_id === station.station_id + ) + if (!status) { + errors.push({ + path: '/data/stations/station_id', + key: 'missing_station_status', + message: `Missing station_status`, + station_id: station.station_id + }) + } + }) +} + +function checkStationStatusIDs({ errors, data, lang, allFiles }) { + const stationsInformation = getFileBody(allFiles, 'station_information', lang) + + if (!stationsInformation) { + return + } + + const ids = new Set() + + data.data.stations.map((station) => { + if (!station.station_id) { + return + } + + if (ids.has(station.station_id)) { + errors.push({ + path: '/data/stations/station_id', + key: 'duplicate_station_id', + message: `Duplicate station_id`, + station_id: station.station_id + }) + return + } + + ids.add(station.station_id) + + let information = stationsInformation.data?.stations?.find( + (s) => s.station_id === station.station_id + ) + if (!information) { + errors.push({ + path: '/data/stations/station_id', + key: 'missing_station_information', + message: `Missing station_information`, + station_id: station.station_id + }) + } + }) +} + +function checkStationStatusCounts({ + errors, + warnings, + data, + version, + lang, + allFiles +}) { + const stationsInformation = getFileBody(allFiles, 'station_information', lang) + + data.data.stations.map((station) => { + if (station.vehicle_types_available) { + let num_vehicles_available = + version === '3.0-RC' + ? station.num_vehicles_available + : station.num_bikes_available + + let count = 0 + station.vehicle_types_available.map((vehicle) => { + count += vehicle.count + }) + + if (count !== num_vehicles_available) { + errors.push({ + path: '/data/station/num_vehicles_available', + key: 'num_vehicles_available_incorrect', + message: `num_vehicles_available is not equal to the sum of vehicle_types_available.count`, + station_id: station.station_id + }) + } + } + + if (station.vehicle_docks_available) { + let num_docks_available = 0 + station.vehicle_docks_available.map((vehicle) => { + num_docks_available += vehicle.count + }) + + if (num_docks_available !== station.num_docks_available) { + errors.push({ + path: '/data/station/num_docks_available', + key: 'num_docks_available_incorrect', + message: `num_docks_available is not equal to the sum of vehicle_docks_available.count`, + station_id: station.station_id + }) + } + } + + if (station.num_docks_available > HIGH_NUM_DOCKS_AVAILABLE) { + warnings.push({ + path: '/data/station/num_docks_available', + key: 'num_docks_available_high', + message: `num_docks_available is greater than ${HIGH_NUM_DOCKS_AVAILABLE}`, + station_id: station.station_id + }) + } + + if (station.num_vehicles_available > HIGH_NUM_VEHICLES_AVAILABLE) { + warnings.push({ + path: '/data/station/num_vehicles_available', + key: 'num_vehicles_available_high', + message: `num_vehicles_available is greater than ${HIGH_NUM_VEHICLES_AVAILABLE}`, + station_id: station.station_id + }) + } + + let information = stationsInformation.data?.stations?.find( + (s) => s.station_id === station.station_id + ) + if (information && information.capacity) { + let capacity = parseInt(information.capacity) + + if ( + capacity < + station.num_vehicles_available + station.num_docks_available + ) { + errors.push({ + path: '/data/station/capacity', + key: 'capacity_too_low', + message: `capacity is less than the sum of num_vehicles_available and num_docks_available`, + station_id: station.station_id + }) + } + } + }) +} + +module.exports = { + checkStationInformationIDs, + checkStationStatusIDs, + checkStationStatusCounts +} diff --git a/gbfs-validator/nonSchemaValidation/check_vehicle_types.js b/gbfs-validator/nonSchemaValidation/check_vehicle_types.js new file mode 100644 index 0000000..eb08b1f --- /dev/null +++ b/gbfs-validator/nonSchemaValidation/check_vehicle_types.js @@ -0,0 +1,210 @@ +function checkVehicleTypeConsistency({ errors, warnings, data }) { + let vehicle_types_ids = new Set() + + data.data.vehicle_types.map((vehicle_type) => { + let max_rider_capacity = 2 + let max_cargo_volume_capacity = 100 + let max_cargo_load_capacity = 100 + let propulsion_types + let max_range_meters + let min_range_meters + let vehicle_accessories + let min_wheel_count = 2 + let max_wheel_count = 4 + + switch (vehicle_type.form_factor) { + case 'bicycle': + max_rider_capacity = 2 + max_cargo_volume_capacity = 50 + max_cargo_load_capacity = 50 + propulsion_types = ['human', 'electric_assist'] + if (vehicle_type.max_range_meters) { + min_range_meters = 5_000 + max_range_meters = 100_000 + } + vehicle_accessories = ['navigation'] + min_wheel_count = 2 + break + case 'cargo_bicycle': + max_rider_capacity = 2 + max_cargo_volume_capacity = 1000 + max_cargo_load_capacity = 500 + propulsion_types = ['human', 'electric_assist'] + if (vehicle_type.max_range_meters) { + min_range_meters = 5_000 + max_range_meters = 100_000 + } + vehicle_accessories = ['navigation'] + break + case 'car': + max_rider_capacity = 5 + max_cargo_volume_capacity = 1000 + max_cargo_load_capacity = 1000 + propulsion_types = [ + 'electric', + 'combustion', + 'combustion_diesel', + 'hybrid', + 'plug_in_hybrid', + 'hydrogen_fuel_cell' + ] + min_range_meters = 50_000 + max_range_meters = 1000_000 + min_wheel_count = 4 + break + case 'moped': + max_rider_capacity = 2 + max_cargo_volume_capacity = 50 + max_cargo_load_capacity = 50 + propulsion_types = ['electric', 'combustion'] + min_range_meters = 5_000 + max_range_meters = 100_000 + vehicle_accessories = ['navigation'] + max_wheel_count = 3 + break + case 'scooter': + case 'scooter_standing': + case 'scooter_seated': + max_rider_capacity = 1 + max_cargo_volume_capacity = 50 + max_cargo_load_capacity = 50 + propulsion_types = ['electric'] + min_range_meters = 5_000 + max_range_meters = 100_000 + vehicle_accessories = ['navigation'] + max_wheel_count = 3 + break + } + + if (vehicle_type.rider_capacity > max_rider_capacity) { + warnings.push({ + path: '/data/vehicle_types/rider_capacity', + key: 'unexpected_rider_capacity', + message: `Unexpected rider_capacity for ${vehicle_type.form_factor}: ${vehicle_type.rider_capacity}` + }) + } + + if (vehicle_type.cargo_volume_capacity > max_cargo_volume_capacity) { + warnings.push({ + path: '/data/vehicle_types/cargo_volume_capacity', + key: 'unexpected_cargo_volume_capacity', + message: `Unexpected cargo_volume_capacity for ${vehicle_type.form_factor}: ${vehicle_type.cargo_volume_capacity}` + }) + } + + if (vehicle_type.cargo_load_capacity > max_cargo_load_capacity) { + warnings.push({ + path: '/data/vehicle_types/cargo_load_capacity', + key: 'unexpected_cargo_load_capacity', + message: `Unexpected cargo_load_capacity for ${vehicle_type.form_factor}: ${vehicle_type.cargo_load_capacity}` + }) + } + + if ( + propulsion_types && + !propulsion_types.includes(vehicle_type.propulsion_type) + ) { + warnings.push({ + path: '/data/vehicle_types/propulsion_type', + key: 'unexpected_propulsion_type', + message: `Unexpected propulsion_type for ${vehicle_type.form_factor}: ${vehicle_type.propulsion_type}` + }) + } + + if (min_range_meters && vehicle_type.max_range_meters < min_range_meters) { + warnings.push({ + path: '/data/vehicle_types/max_range_meters', + key: 'unexpectedly_low_range_meters', + message: `Unexpected max_range_meters for ${vehicle_type.form_factor}: ${vehicle_type.max_range_meters}` + }) + } + + if (max_range_meters && vehicle_type.max_range_meters > max_range_meters) { + warnings.push({ + path: '/data/vehicle_types/max_range_meters', + key: 'unexpectedly_high_range_meters', + message: `Unexpected max_range_meters for ${vehicle_type.form_factor}: ${vehicle_type.max_range_meters}` + }) + } + + if (vehicle_accessories && vehicle_type.vehicle_accessories) { + vehicle_type.vehicle_accessories.map((vehicle_accessory) => { + if (!vehicle_accessories.includes(vehicle_accessory)) { + warnings.push({ + path: '/data/vehicle_types/vehicle_accessories', + key: 'unexpected_vehicle_accessory', + message: `Unexpected vehicle_accessory for ${vehicle_type.form_factor}: ${vehicle_accessory}` + }) + } + }) + + let has_door_count = false + let accessories = new Set() + + vehicle_type.vehicle_accessories.map((vehicle_accessory) => { + if (accessories.has(vehicle_accessory)) { + errors.push({ + path: '/data/vehicle_types/vehicle_accessories', + key: 'duplicate_vehicle_accessory', + message: `Duplicate vehicle_accessory for ${vehicle_type.form_factor}: ${vehicle_accessory}` + }) + } + + if (vehicle_accessory.match(/doors_\d+$/g)) { + if (has_door_count) { + errors.push({ + key: 'multiple_door_counts', + message: `Multiple door counts for ${vehicle_type.form_factor}: ${vehicle_accessory}` + }) + } + + has_door_count = true + } + + accessories.add(vehicle_accessory) + }) + } + + if ( + vehicle_type.wheel_count && + vehicle_type.wheel_count < min_wheel_count + ) { + warnings.push({ + path: '/data/vehicle_types/wheel_count', + key: 'unexpectedly_low_wheel_count', + message: `Unexpected wheel_count for ${vehicle_type.form_factor}: ${vehicle_type.wheel_count}` + }) + } + + if ( + vehicle_type.wheel_count && + vehicle_type.wheel_count > max_wheel_count + ) { + warnings.push({ + path: '/data/vehicle_types/wheel_count', + key: 'unexpectedly_high_wheel_count', + message: `Unexpected wheel_count for ${vehicle_type.form_factor}: ${vehicle_type.wheel_count}` + }) + } + + vehicle_types_ids.add(vehicle_type.vehicle_type_id) + }) + ;[...vehicle_types_ids].forEach((vehicle_type_id) => { + if ( + data.data.vehicle_types.filter( + (vehicle_type) => vehicle_type.vehicle_type_id === vehicle_type_id + ).length > 1 + ) { + errors.push({ + path: '/data/vehicle_types/vehicle_type_id', + key: 'duplicate_vehicle_type_id', + message: `Duplicate vehicle_type_id`, + vehicle_type_id: vehicle_type_id + }) + } + }) +} + +module.exports = { + checkVehicleTypeConsistency +} diff --git a/gbfs-validator/nonSchemaValidation/check_vehicles.js b/gbfs-validator/nonSchemaValidation/check_vehicles.js new file mode 100644 index 0000000..a8ef846 --- /dev/null +++ b/gbfs-validator/nonSchemaValidation/check_vehicles.js @@ -0,0 +1,82 @@ +const { getFileBody } = require('./utils') + +function getVehicleTypes(allFiles, lang) { + const body = getFileBody(allFiles, 'vehicle_types', lang) + + return body?.data?.vehicle_types +} + +function checkFreeBikeStatusIDs({ errors, data, lang, allFiles }) { + let ids = new Set() + + let vehicle_types_ids = new Set() + let vehicle_types = getVehicleTypes(allFiles, lang) || [] + vehicle_types.map((vehicle_type) => { + vehicle_types_ids.add(vehicle_type.vehicle_type_id) + }) + + data.data.bikes.map((bike) => { + if (ids.has(bike.bike_id)) { + errors.push({ + path: '/data/bikes/bike_id', + key: 'duplicate_bike_id', + message: `Duplicate bike_id`, + bike_id: bike.bike_id + }) + return + } + + if (bike.vehicle_type_id && !vehicle_types_ids.has(bike.vehicle_type_id)) { + errors.push({ + path: '/data/bikes/vehicle_type_id', + key: 'invalid_vehicle_type_id', + message: `Invalid vehicle_type_id`, + bike_id: bike.bike_id + }) + return + } + + ids.add(bike.bike_id) + }) +} + +function checkVehicleStatusIDs({ errors, data, lang, allFiles }) { + let ids = new Set() + + let vehicle_types_ids = new Set() + let vehicle_types = getVehicleTypes(allFiles, lang) || [] + vehicle_types.map((vehicle_type) => { + vehicle_types_ids.add(vehicle_type.vehicle_type_id) + }) + + data.data.vehicles.map((vehicle) => { + if (ids.has(vehicle.vehicle_id)) { + errors.push({ + path: '/data/vehicles/vehicle_id', + key: 'duplicate_vehicle_id', + message: `Duplicate vehicle_id`, + vehicle_id: vehicle.vehicle_id + }) + return + } + + ids.add(vehicle.vehicle_id) + + if ( + vehicle.vehicle_type_id && + !vehicle_types_ids.has(vehicle.vehicle_type_id) + ) { + errors.push({ + path: '/data/vehicles/vehicle_type_id', + key: 'invalid_vehicle_type_id', + message: `Invalid vehicle_type_id`, + vehicle_id: vehicle.vehicle_id + }) + } + }) +} + +module.exports = { + checkFreeBikeStatusIDs, + checkVehicleStatusIDs +} diff --git a/gbfs-validator/nonSchemaValidation/index.js b/gbfs-validator/nonSchemaValidation/index.js new file mode 100644 index 0000000..ee230d7 --- /dev/null +++ b/gbfs-validator/nonSchemaValidation/index.js @@ -0,0 +1,31 @@ +const { checkAdditionalProperties } = require('./additional_properties') +const { checkVehicleTypePricing } = require('./vehicle_type_pricing') +const { checkTranslatedStrings } = require('./translations') +const { checkTTL } = require('./ttl') +const { + checkStationInformationIDs, + checkStationStatusIDs, + checkStationStatusCounts +} = require('./check_stations') +const { + checkFreeBikeStatusIDs, + checkVehicleStatusIDs +} = require('./check_vehicles') +const { checkVehicleTypeConsistency } = require('./check_vehicle_types') +const { checkVehicleTypePricingPlanCosts } = require('./check_pricing_plans') +const { checkGeofencingZones } = require('./check_geofencing_zones') + +module.exports = { + checkAdditionalProperties, + checkVehicleTypePricing, + checkTTL, + checkTranslatedStrings, + checkVehicleTypeConsistency, + checkStationInformationIDs, + checkStationStatusIDs, + checkStationStatusCounts, + checkFreeBikeStatusIDs, + checkVehicleStatusIDs, + checkVehicleTypePricingPlanCosts, + checkGeofencingZones +} diff --git a/gbfs-validator/nonSchemaValidation/translations.js b/gbfs-validator/nonSchemaValidation/translations.js new file mode 100644 index 0000000..5bffc12 --- /dev/null +++ b/gbfs-validator/nonSchemaValidation/translations.js @@ -0,0 +1,113 @@ +const { getFileBody } = require('./utils') + +const TRANSLATED_FIELDS = { + system_information: [ + ['name'], + ['short_name'], + ['operator'], + ['attribution_organization_name'], + ['terms_url'], + ['privacy_url'] + ], + vehicle_types: [ + ['vehicle_types', 'name'], + ['vehicle_types', 'make'], + ['vehicle_types', 'model'], + ['vehicle_types', 'description'] + ], + station_information: [ + ['stations', 'name'], + ['stations', 'short_name'] + ], + system_regions: [['regions', 'name']], + system_pricing_plans: [ + ['plans', 'name'], + ['plans', 'description'] + ], + system_alerts: [ + ['alerts', 'url'], + ['alerts', 'summary'], + ['alerts', 'description'] + ], + geofencing_zones: [['geofencing_zones', 'features', 'properties', 'name']] +} + +function checkTranslations(errors, data, languages, path, prefix = []) { + if (!data) { + return + } + + if (path.length) { + if (Array.isArray(data)) { + for (const value of data) { + checkTranslations(errors, value, languages, path, prefix) + } + return + } + + if (typeof data !== 'object') { + return + } + + path = [...path] + let currentPath = path.shift() + + checkTranslations(errors, data[currentPath], languages, path, [ + ...prefix, + currentPath + ]) + return + } + + if (!Array.isArray(data)) { + return + } + + for (const lang of languages) { + const filtered = data.filter((a) => a.language === lang) + if (filtered.length === 0) { + errors.push({ + path: `/${prefix.join('/')}`, + key: 'missing_translation', + message: `Missing translation for ${lang}.` + }) + + continue + } + + if (filtered.length > 1) { + errors.push({ + path: `/${prefix.join('/')}`, + key: 'duplicate_translation', + message: `Duplicate translation for ${lang}.` + }) + + continue + } + + data = data.filter((a) => a.language !== lang) + } + + for (const value of data) { + errors.push({ + path: `/${prefix.join('/')}`, + key: 'unknown_language', + message: `Unknown language ${value.language}.` + }) + } +} + +function checkTranslatedStrings({ errors, file, data, lang, allFiles }) { + const translations = TRANSLATED_FIELDS[file] || [] + + const languages = + getFileBody(allFiles, 'system_information', lang)?.data?.languages || [] + + for (const path of translations) { + checkTranslations(errors, data.data, languages, path) + } +} + +module.exports = { + checkTranslatedStrings +} diff --git a/gbfs-validator/nonSchemaValidation/ttl.js b/gbfs-validator/nonSchemaValidation/ttl.js new file mode 100644 index 0000000..c5eeaae --- /dev/null +++ b/gbfs-validator/nonSchemaValidation/ttl.js @@ -0,0 +1,32 @@ + +function checkTTL({ errors, file, data }) { + const realtime = [ + 'free_bike_status', + 'station_status', + 'vehicle_status' + ].includes(file) + + const now = Math.floor(new Date().getTime() / 1000) + const fiveMinutesAgo = now - 5 * 60 + + const last_updated = data.last_updated + + let allowed_skew_seconds = 10 + if (last_updated > now + allowed_skew_seconds) { + errors.push({ + path: '/last_updated', + key: 'last_updated_future', + message: `Last update is in the future.` + }) + } + + if (realtime && last_updated < fiveMinutesAgo) { + errors.push({ + path: '/last_updated', + key: 'last_updated_outdated', + message: `Last update is older than 5 minutes.` + }) + } + } + + module.exports = { checkTTL } \ No newline at end of file diff --git a/gbfs-validator/nonSchemaValidation/utils.js b/gbfs-validator/nonSchemaValidation/utils.js new file mode 100644 index 0000000..9049e43 --- /dev/null +++ b/gbfs-validator/nonSchemaValidation/utils.js @@ -0,0 +1,17 @@ +function getFileBody(allFiles, type, lang) { + let file = allFiles.find( + (file) => file.type === type + ) + + let body = file?.body + + if (Array.isArray(body)) { + body = body.find((b) => b.lang === lang) + } + + return body?.body +} + +module.exports = { + getFileBody +} diff --git a/gbfs-validator/nonSchemaValidation/vehicle_type_pricing.js b/gbfs-validator/nonSchemaValidation/vehicle_type_pricing.js new file mode 100644 index 0000000..710eb67 --- /dev/null +++ b/gbfs-validator/nonSchemaValidation/vehicle_type_pricing.js @@ -0,0 +1,72 @@ +const { getFileBody } = require('./utils') + +function checkVehicleTypePricing({ errors, data, version, lang, allFiles }) { + const systemPricingPlans = getFileBody(allFiles, 'system_pricing_plans', lang) + + + const plans = systemPricingPlans?.data?.plans + + for (const [index, vehicle_type] of data.data.vehicle_types.entries()) { + if (!plans) { + if ( + vehicle_type.default_pricing_plan_id || + vehicle_type.pricing_plan_ids?.length + ) { + errors.push({ + path: '/data/vehicle_types/' + index, + key: 'missing_system_pricing_plans', + message: `Missing system_pricing_plans.` + }) + } + return + } + + if (version === '3.0-RC') { + if (!vehicle_type.default_pricing_plan_id) { + errors.push({ + path: '/data/vehicle_types/' + index, + key: 'missing_default_pricing_plan_id', + message: `Missing default_pricing_plan_id.` + }) + + return + } + } + + let plan_ids = [] + if (vehicle_type.pricing_plan_ids) { + plan_ids = vehicle_type.pricing_plan_ids + } else if (vehicle_type.default_pricing_plan_id) { + plan_ids = [vehicle_type.default_pricing_plan_id] + } + + for (const plan_id of plan_ids) { + let plan = plans.find((p) => p.plan_id === plan_id) + + if (!plan) { + errors.push({ + path: '/data/vehicle_types/' + index, + key: 'unknown_plan_id', + message: `plan_id not found in system_pricing_plans.`, + plan_id + }) + continue + } + + if (vehicle_type.default_reserve_time === undefined) { + if ( + plan.reservation_price_per_min || + plan.reservation_price_flat_rate + ) { + errors.push({ + path: '/data/vehicle_types/' + index, + key: 'default_reserve_time', + message: `Missing default_reserve_time.` + }) + } + } + } + } +} + +module.exports = { checkVehicleTypePricing } diff --git a/gbfs-validator/versions/v1.0.js b/gbfs-validator/versions/v1.0.js index 50b5c8e..950f8e1 100644 --- a/gbfs-validator/versions/v1.0.js +++ b/gbfs-validator/versions/v1.0.js @@ -1,16 +1,67 @@ +const o = require('../nonSchemaValidation') + module.exports = { gbfsRequired: false, - files: options => { + files: (options) => { return [ - { file: 'system_information', required: true }, - { file: 'station_information', required: options.docked }, - { file: 'station_status', required: options.docked }, - { file: 'free_bike_status', required: options.freefloating }, - { file: 'system_hours', required: false }, - { file: 'system_calendar', required: false }, - { file: 'system_regions', required: false }, - { file: 'system_pricing_plans', required: false }, - { file: 'system_alerts', required: false } + { + file: 'system_information', + required: true, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'station_information', + required: options.docked, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkStationInformationIDs + ] + }, + { + file: 'station_status', + required: options.docked, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkStationStatusIDs, + o.checkStationStatusCounts + ] + }, + { + file: 'free_bike_status', + required: options.freefloating, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkFreeBikeStatusIDs + ] + }, + { + file: 'system_hours', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_calendar', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_regions', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_pricing_plans', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_alerts', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + } ] } } diff --git a/gbfs-validator/versions/v1.1.js b/gbfs-validator/versions/v1.1.js index 44544bc..9a33855 100644 --- a/gbfs-validator/versions/v1.1.js +++ b/gbfs-validator/versions/v1.1.js @@ -1,17 +1,72 @@ +const o = require('../nonSchemaValidation') + module.exports = { gbfsRequired: false, - files: options => { + files: (options) => { return [ - { file: 'gbfs_versions', required: false }, - { file: 'system_information', required: true }, - { file: 'station_information', required: options.docked }, - { file: 'station_status', required: options.docked }, - { file: 'free_bike_status', required: options.freefloating }, - { file: 'system_hours', required: false }, - { file: 'system_calendar', required: false }, - { file: 'system_regions', required: false }, - { file: 'system_pricing_plans', required: false }, - { file: 'system_alerts', required: false } + { + file: 'gbfs_versions', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_information', + required: true, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'station_information', + required: options.docked, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkStationInformationIDs + ] + }, + { + file: 'station_status', + required: options.docked, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkStationStatusIDs, + o.checkStationStatusCounts + ] + }, + { + file: 'free_bike_status', + required: options.freefloating, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkFreeBikeStatusIDs + ] + }, + { + file: 'system_hours', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_calendar', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_regions', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_pricing_plans', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_alerts', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + } ] } } diff --git a/gbfs-validator/versions/v2.0.js b/gbfs-validator/versions/v2.0.js index 86fb3aa..b175f40 100644 --- a/gbfs-validator/versions/v2.0.js +++ b/gbfs-validator/versions/v2.0.js @@ -1,17 +1,72 @@ +const o = require('../nonSchemaValidation') + module.exports = { gbfsRequired: true, - files: options => { + files: (options) => { return [ - { file: 'gbfs_versions', required: false }, - { file: 'system_information', required: true }, - { file: 'station_information', required: options.docked }, - { file: 'station_status', required: options.docked }, - { file: 'free_bike_status', required: options.freefloating }, - { file: 'system_hours', required: false }, - { file: 'system_calendar', required: false }, - { file: 'system_regions', required: false }, - { file: 'system_pricing_plans', required: false }, - { file: 'system_alerts', required: false } + { + file: 'gbfs_versions', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_information', + required: true, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'station_information', + required: options.docked, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkStationInformationIDs + ] + }, + { + file: 'station_status', + required: options.docked, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkStationStatusIDs, + o.checkStationStatusCounts + ] + }, + { + file: 'free_bike_status', + required: options.freefloating, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkFreeBikeStatusIDs + ] + }, + { + file: 'system_hours', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_calendar', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_regions', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_pricing_plans', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_alerts', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + } ] } } diff --git a/gbfs-validator/versions/v2.1.js b/gbfs-validator/versions/v2.1.js index d84e669..7006638 100644 --- a/gbfs-validator/versions/v2.1.js +++ b/gbfs-validator/versions/v2.1.js @@ -1,19 +1,92 @@ +const o = require('../nonSchemaValidation') + module.exports = { gbfsRequired: true, - files: options => { + files: (options) => { return [ - { file: 'gbfs_versions', required: false }, - { file: 'system_information', required: true }, - { file: 'vehicle_types', required: false }, // @TODO Conditionally REQUIRED complexe - { file: 'station_information', required: options.docked }, - { file: 'station_status', required: options.docked }, - { file: 'free_bike_status', required: options.freefloating }, - { file: 'system_hours', required: false }, - { file: 'system_calendar', required: false }, - { file: 'system_regions', required: false }, - { file: 'system_pricing_plans', required: false }, - { file: 'system_alerts', required: false }, - { file: 'geofencing_zones', required: false } + { + file: 'gbfs_versions', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_information', + required: true, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'vehicle_types', + required: false, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkVehicleTypePricing, + o.checkVehicleTypeConsistency, + o.checkVehicleTypePricingPlanCosts + ] + }, + { + file: 'station_information', + required: options.docked, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkStationInformationIDs + ] + }, + { + file: 'station_status', + required: options.docked, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkStationStatusIDs, + o.checkStationStatusCounts + ] + }, + { + file: 'free_bike_status', + required: options.freefloating, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkFreeBikeStatusIDs + ] + }, + { + file: 'system_hours', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_calendar', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_regions', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_pricing_plans', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_alerts', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'geofencing_zones', + required: false, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkGeofencingZones + ] + } ] } } diff --git a/gbfs-validator/versions/v2.2.js b/gbfs-validator/versions/v2.2.js index d84e669..7006638 100644 --- a/gbfs-validator/versions/v2.2.js +++ b/gbfs-validator/versions/v2.2.js @@ -1,19 +1,92 @@ +const o = require('../nonSchemaValidation') + module.exports = { gbfsRequired: true, - files: options => { + files: (options) => { return [ - { file: 'gbfs_versions', required: false }, - { file: 'system_information', required: true }, - { file: 'vehicle_types', required: false }, // @TODO Conditionally REQUIRED complexe - { file: 'station_information', required: options.docked }, - { file: 'station_status', required: options.docked }, - { file: 'free_bike_status', required: options.freefloating }, - { file: 'system_hours', required: false }, - { file: 'system_calendar', required: false }, - { file: 'system_regions', required: false }, - { file: 'system_pricing_plans', required: false }, - { file: 'system_alerts', required: false }, - { file: 'geofencing_zones', required: false } + { + file: 'gbfs_versions', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_information', + required: true, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'vehicle_types', + required: false, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkVehicleTypePricing, + o.checkVehicleTypeConsistency, + o.checkVehicleTypePricingPlanCosts + ] + }, + { + file: 'station_information', + required: options.docked, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkStationInformationIDs + ] + }, + { + file: 'station_status', + required: options.docked, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkStationStatusIDs, + o.checkStationStatusCounts + ] + }, + { + file: 'free_bike_status', + required: options.freefloating, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkFreeBikeStatusIDs + ] + }, + { + file: 'system_hours', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_calendar', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_regions', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_pricing_plans', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_alerts', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'geofencing_zones', + required: false, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkGeofencingZones + ] + } ] } } diff --git a/gbfs-validator/versions/v2.3.js b/gbfs-validator/versions/v2.3.js index d84e669..7006638 100644 --- a/gbfs-validator/versions/v2.3.js +++ b/gbfs-validator/versions/v2.3.js @@ -1,19 +1,92 @@ +const o = require('../nonSchemaValidation') + module.exports = { gbfsRequired: true, - files: options => { + files: (options) => { return [ - { file: 'gbfs_versions', required: false }, - { file: 'system_information', required: true }, - { file: 'vehicle_types', required: false }, // @TODO Conditionally REQUIRED complexe - { file: 'station_information', required: options.docked }, - { file: 'station_status', required: options.docked }, - { file: 'free_bike_status', required: options.freefloating }, - { file: 'system_hours', required: false }, - { file: 'system_calendar', required: false }, - { file: 'system_regions', required: false }, - { file: 'system_pricing_plans', required: false }, - { file: 'system_alerts', required: false }, - { file: 'geofencing_zones', required: false } + { + file: 'gbfs_versions', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_information', + required: true, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'vehicle_types', + required: false, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkVehicleTypePricing, + o.checkVehicleTypeConsistency, + o.checkVehicleTypePricingPlanCosts + ] + }, + { + file: 'station_information', + required: options.docked, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkStationInformationIDs + ] + }, + { + file: 'station_status', + required: options.docked, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkStationStatusIDs, + o.checkStationStatusCounts + ] + }, + { + file: 'free_bike_status', + required: options.freefloating, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkFreeBikeStatusIDs + ] + }, + { + file: 'system_hours', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_calendar', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_regions', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_pricing_plans', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_alerts', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'geofencing_zones', + required: false, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkGeofencingZones + ] + } ] } } diff --git a/gbfs-validator/versions/v3.0-RC.js b/gbfs-validator/versions/v3.0-RC.js index ac1ba25..f0b88bd 100644 --- a/gbfs-validator/versions/v3.0-RC.js +++ b/gbfs-validator/versions/v3.0-RC.js @@ -1,17 +1,100 @@ +const o = require('../nonSchemaValidation') + module.exports = { gbfsRequired: true, - files: options => { + files: (options) => { return [ - { file: 'gbfs_versions', required: false }, - { file: 'system_information', required: true }, - { file: 'vehicle_types', required: false }, - { file: 'station_information', required: options.docked }, - { file: 'station_status', required: options.docked }, - { file: 'vehicle_status', required: options.freefloating }, - { file: 'system_regions', required: false }, - { file: 'system_pricing_plans', required: false }, - { file: 'system_alerts', required: false }, - { file: 'geofencing_zones', required: false } + { + file: 'gbfs_versions', + required: false, + nonSchemaRules: [o.checkAdditionalProperties, o.checkTTL] + }, + { + file: 'system_information', + required: true, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkTranslatedStrings + ] + }, + { + file: 'vehicle_types', + required: false, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkTranslatedStrings, + o.checkVehicleTypePricing, + o.checkVehicleTypeConsistency, + o.checkVehicleTypePricingPlanCosts + ] + }, + { + file: 'station_information', + required: options.docked, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkTranslatedStrings, + o.checkStationInformationIDs + ] + }, + { + file: 'station_status', + required: options.docked, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkStationStatusIDs, + o.checkStationStatusCounts + ] + }, + { + file: 'vehicle_status', + required: options.freefloating, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkVehicleStatusIDs + ] + }, + { + file: 'system_regions', + required: false, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL + ] + }, + { + file: 'system_pricing_plans', + required: false, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkTranslatedStrings + ] + }, + { + file: 'system_alerts', + required: false, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkTranslatedStrings + ] + }, + { + file: 'geofencing_zones', + required: false, + nonSchemaRules: [ + o.checkAdditionalProperties, + o.checkTTL, + o.checkTranslatedStrings, + o.checkGeofencingZones + ] + } ] } } diff --git a/website/src/components/Result.vue b/website/src/components/Result.vue index 10f80b6..7a190ed 100644 --- a/website/src/components/Result.vue +++ b/website/src/components/Result.vue @@ -14,7 +14,12 @@ const props = defineProps({ }) const errorsCountFormated = computed(() => { - return new Intl.NumberFormat().format(props.result.summary.errorsCount) + return new Intl.NumberFormat().format(props.result.summary.summaryErrorCount) +}) +const warningCountFormated = computed(() => { + return new Intl.NumberFormat().format( + props.result.summary.summaryWarningCount + ) }) @@ -49,6 +54,12 @@ const errorsCountFormated = computed(() => { Valid ! +
+ + GBFS feed contains warnings
+ {{ warningCountFormated }} warnings +
+

Detail per files

diff --git a/website/src/components/SubResult.vue b/website/src/components/SubResult.vue index 0cd4f1f..783cc0a 100644 --- a/website/src/components/SubResult.vue +++ b/website/src/components/SubResult.vue @@ -17,10 +17,16 @@ function errorsCountFormated(value) {
-
{{ file.file }}
+
+
{{ file.file }}
+   + +
Missing file. Recommended @@ -32,37 +38,117 @@ function errorsCountFormated(value) {
-
+
- -
Missing file {{ lang.lang }}/{{ file.file }}
-
- -
- Show errors + + {{ + errorsCountFormated( + lang.summary.errors.length + + lang.summary.nonSchemaErrors.length + ) + }} + errors + + + in {{ lang.url }} . + + +
    +
  • -  {{ errorsCountFormated(file.errorsCount) }} errors - in {{ lang.url }} -
- -
{{ lang.errors }}
-
+ {{ item.message }} + + {{ item.key }} + +
    +
  • + {{ path.path }} (x{{ path.count }}) +
  • +
+ + + +
+ Show errors details +
{{ [...(lang.errors || []), ...(lang.nonSchemaErrors || [])  ] }}
+
+
+
+ +
+ + {{ errorsCountFormated(lang.summary.nonSchemaWarnings.length) }} + warnings + + in {{ lang.url }} . + +
    +
  • + {{ item.message }} + {{ item.key }} +
      +
    • + {{ path.path }} (x{{ path.count }}) +
    • +
    +
  • +
+ +
+ Show warnings details +
{{ lang.nonSchemaWarnings }}
+
- {{ file.errorsCount }} errors in {{ file.url }} + {{ file.errorsCount }} errors in + {{ file.url }} .
{{ file.errors }}