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(() => {
{{ lang.errors }}
- {{ item.key }}
+
+ {{ path.path }}
(x{{ path.count }})
+ {{ [...(lang.errors || []), ...(lang.nonSchemaErrors || []) ] }}
+ {{ item.key }}
+ {{ path.path }}
(x{{ path.count }})
+ {{ lang.nonSchemaWarnings }}
+ {{ file.errors }}