diff --git a/gbfs-validator/__test__/fixtures/conditionnal_no_vehicle_type_id.js b/gbfs-validator/__test__/fixtures/conditionnal_no_vehicle_type_id.js new file mode 100644 index 0000000..8643ded --- /dev/null +++ b/gbfs-validator/__test__/fixtures/conditionnal_no_vehicle_type_id.js @@ -0,0 +1,82 @@ +const fastify = require('fastify') + +function build(opts = {}) { + const app = fastify(opts) + + app.get('/gbfs.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + en: { + feeds: [ + { + name: 'system_information', + url: `http://${request.hostname}/system_information.json` + }, + { + name: 'station_information', + url: `http://${request.hostname}/station_information.json` + }, + { + name: 'station_status', + url: `http://${request.hostname}/station_status.json` + }, + { + name: 'free_bike_status', + url: `http://${request.hostname}/free_bike_status.json` + } + ] + } + } + } + }) + + app.get('/system_information.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + system_id: 'shared_bike', + language: 'en', + name: 'Shared Bike USA', + timezone: 'UTC' + } + } + }) + + app.get('/free_bike_status.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + bikes: [ + { + bike_id: 'bike1', + last_reported: 1609866109, + lat: 12.345678, + lon: 56.789012, + is_reserved: false, + is_disabled: false + }, + { + bike_id: 'bike2', + last_reported: 1609866109, + lat: 12.345678, + lon: 56.789012, + is_reserved: false, + is_disabled: false, + vehicle_type_id: 'abc123' + } + ] + } + } + }) + + return app +} + +module.exports = build diff --git a/gbfs-validator/__test__/fixtures/conditionnal_vehicle_type_id.js b/gbfs-validator/__test__/fixtures/conditionnal_vehicle_type_id.js new file mode 100644 index 0000000..626eec3 --- /dev/null +++ b/gbfs-validator/__test__/fixtures/conditionnal_vehicle_type_id.js @@ -0,0 +1,141 @@ +const fastify = require('fastify') + +function build(opts = {}) { + const app = fastify(opts) + + app.get('/gbfs.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + en: { + feeds: [ + { + name: 'system_information', + url: `http://${request.hostname}/system_information.json` + }, + { + name: 'station_information', + url: `http://${request.hostname}/station_information.json` + }, + { + name: 'station_status', + url: `http://${request.hostname}/station_status.json` + }, + { + name: 'free_bike_status', + url: `http://${request.hostname}/free_bike_status.json` + }, + { + name: 'vehicle_types', + url: `http://${request.hostname}/vehicle_types.json` + } + ] + } + } + } + }) + + app.get('/system_information.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + system_id: 'shared_bike', + language: 'en', + name: 'Shared Bike USA', + timezone: 'UTC' + } + } + }) + + app.get('/vehicle_types.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + vehicle_types: [ + { + vehicle_type_id: 'abc123', + form_factor: 'bicycle', + 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', + form_factor: 'car', + propulsion_type: 'electric', + 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'] + } + ] + } + } + }) + + app.get('/free_bike_status.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + bikes: [ + { + bike_id: 'bike1', + last_reported: 1609866109, + lat: 12.345678, + lon: 56.789012, + is_reserved: false, + is_disabled: false + // missing vehicle_type_id + }, + { + bike_id: 'bike2', + last_reported: 1609866109, + lat: 12.345678, + lon: 56.789012, + is_reserved: false, + is_disabled: false, + vehicle_type_id: 'abc123' + }, + { + bike_id: 'car1', + last_reported: 1609866109, + lat: 12.345678, + lon: 56.789012, + is_reserved: false, + is_disabled: false, + vehicle_type_id: 'efg456' + // missing current_range_meters + } + ] + } + } + }) + + return app +} + +module.exports = build diff --git a/gbfs-validator/__test__/fixtures/conditionnal_vehicle_types_available.js b/gbfs-validator/__test__/fixtures/conditionnal_vehicle_types_available.js new file mode 100644 index 0000000..d0f03ea --- /dev/null +++ b/gbfs-validator/__test__/fixtures/conditionnal_vehicle_types_available.js @@ -0,0 +1,132 @@ +const fastify = require('fastify') + +function build(opts = {}) { + const app = fastify(opts) + + app.get('/gbfs.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + en: { + feeds: [ + { + name: 'system_information', + url: `http://${request.hostname}/system_information.json` + }, + { + name: 'station_information', + url: `http://${request.hostname}/station_information.json` + }, + { + name: 'station_status', + url: `http://${request.hostname}/station_status.json` + }, + { + name: 'vehicle_types', + url: `http://${request.hostname}/vehicle_types.json` + } + ] + } + } + } + }) + + app.get('/system_information.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + system_id: 'shared_bike', + language: 'en', + name: 'Shared Bike USA', + timezone: 'UTC' + } + } + }) + + app.get('/vehicle_types.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + vehicle_types: [ + { + vehicle_type_id: 'abc123', + form_factor: 'bicycle', + 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', + form_factor: 'car', + propulsion_type: 'electric', + 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'] + } + ] + } + } + }) + + app.get('/station_status.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + stations: [ + { + station_id: 'abc123', + num_bikes_available: 0, + is_installed: true, + is_renting: true, + is_returning: true, + last_reported: 1566224400 + // missing vehicle_types_available + }, + { + station_id: 'abc123', + num_bikes_available: 0, + is_installed: true, + is_renting: true, + is_returning: true, + last_reported: 1566224400, + vehicle_types_available: [ + { + vehicle_type_id: 'efg456', + count: 2 + } + ] + } + ] + } + } + }) + + return app +} + +module.exports = build diff --git a/gbfs-validator/__test__/fixtures/missing_vehicle_types.js b/gbfs-validator/__test__/fixtures/missing_vehicle_types.js new file mode 100644 index 0000000..9a63518 --- /dev/null +++ b/gbfs-validator/__test__/fixtures/missing_vehicle_types.js @@ -0,0 +1,74 @@ +const fastify = require('fastify') + +function build(opts = {}) { + const app = fastify(opts) + + app.get('/gbfs.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + en: { + feeds: [ + { + name: 'system_information', + url: `http://${request.hostname}/system_information.json` + }, + { + name: 'station_information', + url: `http://${request.hostname}/station_information.json` + }, + { + name: 'station_status', + url: `http://${request.hostname}/station_status.json` + }, + { + name: 'free_bike_status', + url: `http://${request.hostname}/free_bike_status.json` + } + ] + } + } + } + }) + + app.get('/system_information.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + system_id: 'shared_bike', + language: 'en', + name: 'Shared Bike USA', + timezone: 'UTC' + } + } + }) + + app.get('/free_bike_status.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + bikes: [ + { + bike_id: 'ghi789', + last_reported: 1609866109, + lat: 12.345678, + lon: 56.789012, + is_reserved: false, + is_disabled: false, + vehicle_type_id: 'abc123' + } + ] + } + } + }) + + return app +} + +module.exports = build diff --git a/gbfs-validator/__test__/fixtures/plan_id.js b/gbfs-validator/__test__/fixtures/plan_id.js new file mode 100644 index 0000000..df8ecf8 --- /dev/null +++ b/gbfs-validator/__test__/fixtures/plan_id.js @@ -0,0 +1,146 @@ +const fastify = require('fastify') + +function build(opts = {}) { + const app = fastify(opts) + + app.get('/gbfs.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.3-RC', + data: { + en: { + feeds: [ + { + name: 'system_information', + url: `http://${request.hostname}/system_information.json` + }, + { + name: 'station_information', + url: `http://${request.hostname}/station_information.json` + }, + { + name: 'station_status', + url: `http://${request.hostname}/station_status.json` + }, + { + name: 'free_bike_status', + url: `http://${request.hostname}/free_bike_status.json` + }, + { + name: 'system_pricing_plans', + url: `http://${request.hostname}/system_pricing_plans.json` + }, + { + name: 'vehicle_types', + url: `http://${request.hostname}/vehicle_types.json` + } + ] + } + } + } + }) + + app.get('/system_information.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.3-RC', + data: { + system_id: 'shared_bike', + language: 'en', + name: 'Shared Bike USA', + timezone: 'UTC' + } + } + }) + + app.get('/free_bike_status.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.3-RC', + data: { + bikes: [ + { + bike_id: 'ghi789', + last_reported: 1609866109, + lat: 12.345678, + lon: 56.789012, + is_reserved: false, + is_disabled: false, + vehicle_type_id: 'abc123' + } + ] + } + } + }) + + app.get('/system_pricing_plans.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.3-RC', + data: { + plans: [ + { + plan_id: 'p1', + name: 'Basic', + currency: 'USD', + price: 0, + is_taxable: false, + description: 'Basic plan' + } + ] + } + } + }) + + app.get('/vehicle_types.json', async function(request, reply) { + return { + last_updated: 1566224400, + ttl: 0, + version: '2.3-RC', + data: { + vehicle_types: [ + { + vehicle_type_id: 'abc123', + form_factor: 'bicycle', + 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', + form_factor: 'car', + propulsion_type: 'electric', + 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'] + } + ] + } + } + }) + + return app +} + +module.exports = build diff --git a/gbfs-validator/__test__/gbfs.test.js b/gbfs-validator/__test__/gbfs.test.js index fbdd5c0..ddfc37f 100644 --- a/gbfs-validator/__test__/gbfs.test.js +++ b/gbfs-validator/__test__/gbfs.test.js @@ -154,7 +154,7 @@ describe('checkAutodiscovery method', () => { }) }) -describe('checkFile method', () => { +describe('getFile method', () => { let gbfsFeedServer beforeAll(async () => { @@ -169,7 +169,7 @@ describe('checkFile method', () => { return gbfsFeedServer.close() }) - test('should check file using gbfs.json url', () => { + test('should get file using gbfs.json url', () => { const url = `http://${gbfsFeedServer.server.address().address}:${ gbfsFeedServer.server.address().port }` @@ -188,29 +188,24 @@ describe('checkFile method', () => { } } - return gbfs.checkFile('2.2', 'system_information', true).then(result => { + return gbfs.getFile('system_information', true).then(result => { expect(result).toMatchObject({ - languages: expect.any(Array), + body: expect.any(Array), required: true, - exists: true, - file: 'system_information.json', - hasErrors: false + type: 'system_information' }) - result.languages.forEach(l => { + result.body.forEach(l => { expect(l).toMatchObject({ - errors: false, exists: true, lang: 'en', - url: `http://${gbfsFeedServer.server.address().address}:${ - gbfsFeedServer.server.address().port - }/autodiscovery/system_information.json` + body: expect.any(Object) }) }) }) }) - test('should check file do not exist using gbfs.json url', () => { + test('should get file do not exist using gbfs.json url', () => { const url = `http://${gbfsFeedServer.server.address().address}:${ gbfsFeedServer.server.address().port }` @@ -229,18 +224,16 @@ describe('checkFile method', () => { } } - return gbfs.checkFile('2.2', 'do_not_exist', true).then(result => { + return gbfs.getFile('do_not_exist', true).then(result => { expect(result).toMatchObject({ - languages: expect.any(Array), + body: expect.any(Array), required: true, - exists: false, - file: 'do_not_exist.json', - hasErrors: true + type: 'do_not_exist' }) - result.languages.forEach(l => { + result.body.forEach(l => { expect(l).toMatchObject({ - errors: false, + body: null, exists: false, lang: 'en', url: null @@ -249,44 +242,158 @@ describe('checkFile method', () => { }) }) - test('should check file without autodiscovery', () => { + test('should get file without autodiscovery', () => { const url = `http://${gbfsFeedServer.server.address().address}:${ gbfsFeedServer.server.address().port }` const gbfs = new GBFS(`${url}`) - return gbfs.checkFile('2.2', 'system_information', true).then(result => { + return gbfs.getFile('system_information', true).then(result => { expect(result).toMatchObject({ required: true, exists: true, - file: 'system_information.json', - errors: [ - { - instancePath: '/data', - keyword: 'required', - message: "must have required property 'language'", - params: { - missingProperty: 'language' - }, - schemaPath: '#/properties/data/required' - } - ] + type: 'system_information', + body: expect.any(Object) }) }) }) - test('should check file do not exist without autodiscovery', () => { + test('should get file do not exist without autodiscovery', () => { const url = `http://${gbfsFeedServer.server.address().address}:${ gbfsFeedServer.server.address().port }` const gbfs = new GBFS(`${url}/gbfs.json`) - return gbfs.checkFile('2.2', 'do_not_exist', true).then(result => { + return gbfs.getFile('do_not_exist', true).then(result => { expect(result).toMatchObject({ + body: null, required: true, + errors: expect.any(Error), exists: false, - file: 'do_not_exist.json', - errors: expect.any(Error) + type: 'do_not_exist' + }) + }) + }) +}) + +describe('validationFile method', () => { + let gbfsFeedServer + + beforeAll(async () => { + gbfsFeedServer = require('./fixtures/server')() + + await gbfsFeedServer.listen() + + return gbfsFeedServer + }) + + afterAll(() => { + return gbfsFeedServer.close() + }) + + test('should validate file with no lang', () => { + const gbfs = new GBFS(`http://localhost/gbfs.json`) + + const result = gbfs.validationFile( + { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + en: { + feeds: [ + { + name: 'system_information', + url: `http://localhost/system_information.json` + }, + { + name: 'station_information', + url: `http://localhost/station_information.json` + }, + { + name: 'station_status', + url: `http://localhost/station_status.json` + }, + { + name: 'free_bike_status', + url: `http://localhost/free_bike_status.json` + } + ] + } + } + }, + '2.2', + 'gbfs', + true, + {} + ) + + expect(result).toMatchObject({ + required: true, + errors: false, + exists: true, + file: 'gbfs.json', + url: 'http://localhost/gbfs.json/gbfs.json' + }) + }) + + test('should validate file with lang', () => { + const gbfs = new GBFS(`http://localhost/gbfs.json`) + + const result = gbfs.validationFile( + [ + { + body: { + last_updated: 1566224400, + ttl: 0, + version: '2.2', + data: { + en: { + feeds: [ + { + name: 'system_information', + url: `http://localhost/system_information.json` + }, + { + name: 'station_information', + url: `http://localhost/station_information.json` + }, + { + name: 'station_status', + url: `http://localhost/station_status.json` + }, + { + name: 'free_bike_status', + url: `http://localhost/free_bike_status.json` + } + ] + } + } + }, + exists: true, + lang: 'en' + } + ], + '2.2', + 'gbfs', + true, + {} + ) + + expect(result).toMatchObject({ + languages: expect.any(Array), + required: true, + exists: true, + file: 'gbfs.json', + hasErrors: false + }) + + result.languages.forEach(l => { + expect(l).toMatchObject({ + body: expect.any(Object), + exists: true, + lang: 'en', + errors: false }) }) }) @@ -313,6 +420,8 @@ describe('validation method', () => { }` const gbfs = new GBFS(`${url}/gbfs.json`) + expect.assertions(1) + return gbfs.validation().then(result => { expect(result).toMatchObject({ summary: expect.objectContaining({ @@ -325,3 +434,237 @@ describe('validation method', () => { }) }) }) + +describe('conditional vehicle_types file', () => { + let gbfsFeedServer + + beforeAll(async () => { + gbfsFeedServer = require('./fixtures/missing_vehicle_types')() + + await gbfsFeedServer.listen() + + return gbfsFeedServer + }) + + afterAll(() => { + return gbfsFeedServer.close() + }) + + test('should validate feed', () => { + const url = `http://${gbfsFeedServer.server.address().address}:${ + gbfsFeedServer.server.address().port + }` + const gbfs = new GBFS(`${url}/gbfs.json`) + + expect.assertions(1) + + return gbfs.validation().then(result => { + expect(result).toMatchObject({ + summary: expect.objectContaining({ + version: { detected: '2.2', validated: '2.2' } + }), + files: expect.arrayContaining([ + expect.objectContaining({ + file: 'vehicle_types.json', + exists: false, + required: true + }) + ]) + }) + }) + }) +}) + +describe('conditional required vehicle_type_id', () => { + let gbfsFeedServer + + beforeAll(async () => { + gbfsFeedServer = require('./fixtures/conditionnal_vehicle_type_id')() + + await gbfsFeedServer.listen() + + return gbfsFeedServer + }) + + afterAll(() => { + return gbfsFeedServer.close() + }) + + test('should validate feed', () => { + const url = `http://${gbfsFeedServer.server.address().address}:${ + gbfsFeedServer.server.address().port + }` + const gbfs = new GBFS(`${url}/gbfs.json`) + + expect.assertions(1) + + return gbfs.validation().then(result => { + expect(result).toMatchObject({ + summary: expect.objectContaining({ + version: { detected: '2.2', validated: '2.2' }, + hasErrors: true, + errorsCount: 2 + }), + files: expect.arrayContaining([ + expect.objectContaining({ + file: 'free_bike_status.json', + languages: expect.arrayContaining([ + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + instancePath: '/data/bikes/0', + message: + "'vehicle_type_id' is required for this vehicle type" + }), + expect.objectContaining({ + instancePath: '/data/bikes/2', + message: + "must have required property 'current_range_meters'" + }) + ]) + }) + ]) + }) + ]) + }) + }) + }) +}) + +describe('conditional no required vehicle_type_id', () => { + let gbfsFeedServer + + beforeAll(async () => { + gbfsFeedServer = require('./fixtures/conditionnal_no_vehicle_type_id')() + + await gbfsFeedServer.listen() + + return gbfsFeedServer + }) + + afterAll(() => { + return gbfsFeedServer.close() + }) + + test('should validate feed', () => { + const url = `http://${gbfsFeedServer.server.address().address}:${ + gbfsFeedServer.server.address().port + }` + const gbfs = new GBFS(`${url}/gbfs.json`) + + expect.assertions(1) + + return gbfs.validation().then(result => { + expect(result).toMatchObject({ + summary: expect.objectContaining({ + version: { detected: '2.2', validated: '2.2' } + }), + files: expect.arrayContaining([ + expect.objectContaining({ + file: 'vehicle_types.json', + exists: false, + required: true + }) + ]) + }) + }) + }) +}) + +describe('conditional required vehicle_types_available', () => { + let gbfsFeedServer + + beforeAll(async () => { + gbfsFeedServer = require('./fixtures/conditionnal_vehicle_types_available')() + + await gbfsFeedServer.listen() + + return gbfsFeedServer + }) + + afterAll(() => { + return gbfsFeedServer.close() + }) + + test('should validate feed', () => { + const url = `http://${gbfsFeedServer.server.address().address}:${ + gbfsFeedServer.server.address().port + }` + const gbfs = new GBFS(`${url}/gbfs.json`) + + expect.assertions(1) + + return gbfs.validation().then(result => { + const file = result.files.find(f => f.file === 'station_status.json') + const errors = file.languages.map(l => l.errors) + + expect(errors).toMatchObject([ + [ + { + instancePath: '/data/stations/0', + schemaPath: '#/properties/data/properties/stations/items/required', + keyword: 'required', + params: { + missingProperty: 'vehicle_types_available' + }, + message: "must have required property 'vehicle_types_available'" + } + ] + ]) + }) + }) +}) + +describe('conditional plan_id', () => { + let gbfsFeedServer + + beforeAll(async () => { + gbfsFeedServer = require('./fixtures/plan_id')() + + await gbfsFeedServer.listen() + + return gbfsFeedServer + }) + + afterAll(() => { + return gbfsFeedServer.close() + }) + + test('should validate feed', () => { + const url = `http://${gbfsFeedServer.server.address().address}:${ + gbfsFeedServer.server.address().port + }` + const gbfs = new GBFS(`${url}/gbfs.json`) + + expect.assertions(1) + + return gbfs.validation().then(result => { + const file = result.files.find(f => f.file === 'vehicle_types.json') + const errors = file.languages.map(l => l.errors) + + expect(errors).toMatchObject([ + [ + { + instancePath: '/data/vehicle_types/0/default_pricing_plan_id', + schemaPath: '#/properties/data/properties/vehicle_types/items/properties/default_pricing_plan_id/enum', + keyword: 'enum', + params: { + allowedValues: ['p1'] + }, + message: "must be equal to one of the allowed values" + }, + { + instancePath: '/data/vehicle_types/1', + schemaPath: + '#/properties/data/properties/vehicle_types/items/required', + keyword: 'required', + params: { + missingProperty: 'default_pricing_plan_id' + }, + message: "must have required property 'default_pricing_plan_id'" + } + ] + ]) + }) + }) +}) diff --git a/gbfs-validator/gbfs.js b/gbfs-validator/gbfs.js index f6fd0d0..6f9abe7 100644 --- a/gbfs-validator/gbfs.js +++ b/gbfs-validator/gbfs.js @@ -43,6 +43,116 @@ function countErrors(file) { return count } +function getPartialSchema(version, partial, data = {}) { + let partialSchema + + try { + partialSchema = require(`./versions/partials/v${version}/${partial}.js`)( + data + ) + } catch (error) { + return null + } + + return partialSchema +} + +function getVehicleTypes({ body }) { + if (Array.isArray(body)) { + return body.reduce((acc, lang) => { + lang.body?.data?.vehicle_types.map(vt => { + if (!acc.find(f => f.vehicle_type_id === vt.vehicle_type_id)) { + acc.push({ + vehicle_type_id: vt.vehicle_type_id, + form_factor: vt.form_factor, + propulsion_type: vt.propulsion_type + }) + } + }) + + return acc + }, []) + } else { + return body?.data?.vehicle_types.map(vt => ({ + vehicle_type_id: vt.vehicle_type_id, + form_factor: vt.form_factor, + propulsion_type: vt.propulsion_type + })) + } +} + +function getPricingPlans({ body }) { + if (Array.isArray(body)) { + return body.reduce((acc, lang) => { + lang.body?.data?.plans.map(pp => { + if (!acc.find(f => f.plan_id === pp.plan_id)) { + acc.push(pp) + } + }) + + return acc + }, []) + } else { + return body?.data?.plans + } +} + +function hadVehiclesId({ body }) { + if (Array.isArray(body)) { + return body.some(lang => lang.body.data.bikes.find(b => b.vehicle_type_id)) + } else { + return body.data.bikes.some(b => b.vehicle_type_id) + } +} + +function hasStationId({ body }) { + if (Array.isArray(body)) { + return body.some(lang => lang.body.data.bikes.find(b => b.station_id)) + } else { + return body.data.bikes.some(b => b.station_id) + } +} + +function hasPricingPlanId({ body }) { + if (Array.isArray(body)) { + return body.some(lang => lang.body.data.bikes.find(b => b.pricing_plan_id)) + } else { + return body.data.bikes.some(b => b.pricing_plan_id) + } +} + +function hasRentalUris({ body }, key, store) { + if (Array.isArray(body)) { + return body.some(lang => + lang.body.data[key].find(b => b.rental_uris?.[store]) + ) + } else { + return body.data[key].some(b => b.rental_uris?.[store]) + } +} + +function fileExist(file) { + if (!file) { + return false + } + + if (file.exists) { + return true + } else if (Array.isArray(file.body)) { + return file.body.some(lang => lang.exists) + } + + return false +} + +function isGBFSFileRequire(version) { + if (!version) { + return false + } else { + return require(`./versions/v${version}`).gbfsRequired + } +} + class GBFS { constructor( url, @@ -88,7 +198,7 @@ class GBFS { if (typeof body !== 'object') { return { recommanded: true, - required: this.isGBFSFileRequire(this.options.version), + required: isGBFSFileRequire(this.options.version), errors: false, exists: false, file: `gbfs.json`, @@ -98,18 +208,19 @@ class GBFS { } this.autoDiscovery = body - const errors = this.validateFile( + const { errors, schema } = this.validateFile( this.options.version || body.version || '1.0', 'gbfs', this.autoDiscovery ) return { + schema, errors, url, version: body.version, recommanded: true, - required: this.isGBFSFileRequire( + required: isGBFSFileRequire( this.options.version || body.version || '1.0' ), exists: true, @@ -121,7 +232,7 @@ class GBFS { return { url, recommanded: true, - required: this.isGBFSFileRequire(this.options.version), + required: isGBFSFileRequire(this.options.version), errors: false, exists: false, file: `gbfs.json`, @@ -143,18 +254,19 @@ class GBFS { this.autoDiscovery = body - const errors = this.validateFile( + const { errors, schema } = this.validateFile( this.options.version || body.version || '1.0', 'gbfs', this.autoDiscovery ) return { + schema, errors, url: this.url, version: body.version || '1.0', recommanded: true, - required: this.isGBFSFileRequire( + required: isGBFSFileRequire( this.options.version || body.version || '1.0' ), exists: true, @@ -172,7 +284,7 @@ class GBFS { return { url: this.url, recommanded: true, - required: this.isGBFSFileRequire(this.options.version), + required: isGBFSFileRequire(this.options.version), errors: false, exists: false, file: `gbfs.json`, @@ -181,19 +293,20 @@ class GBFS { }) } - validateFile(version, file, data) { + validateFile(version, file, data, options) { let schema try { schema = require(`./versions/schemas/v${version}/${file}`) } catch (e) { + console.log(e) throw new Error('can not require') } - return validate(schema, data) + return validate(schema, data, options) } - checkFile(version, type, required) { + getFile(type, required) { if (this.autoDiscovery) { const urls = Object.entries(this.autoDiscovery.data).map(key => { return Object.assign( @@ -209,32 +322,32 @@ class GBFS { ? got .get(lang.url, this.gotOptions) .json() - .then(body => ({ - errors: this.validateFile(version, type, body), - exists: true, - lang: lang.lang, - url: lang.url - })) + .then(body => { + return { + body, + exists: true, + lang: lang.lang, + url: lang.url + } + }) .catch(() => ({ - errors: null, + body: null, exists: false, lang: lang.lang, url: lang.url })) : { - errors: false, + body: null, exists: false, lang: lang.lang, url: null } ) - ).then(languages => { + ).then(bodies => { return { - languages, + body: bodies, required, - exists: languages.reduce((acc, l) => acc && l.exists, true), - file: `${type}.json`, - hasErrors: hasErrors(languages, required) + type } }) } else { @@ -242,22 +355,49 @@ class GBFS { .get(`${this.url}/${type}.json`, this.gotOptions) .json() .then(body => ({ + body, required, - errors: this.validateFile(version, type, body), exists: true, - file: `${type}.json`, - url: `${this.url}/${type}.json` + type })) .catch(err => ({ + body: null, required, errors: required ? err : null, exists: false, - file: `${type}.json`, - url: `${this.url}/${type}.json` + type })) } } + validationFile(body, version, type, required, options) { + if (Array.isArray(body)) { + body = body.filter(b => b.exists || b.required).map(b => ({ + ...b, + ...this.validateFile(version, type, b.body, options) + })) + + return { + languages: body, + required, + exists: body.length + ? body.reduce((acc, l) => acc && l.exists, true) + : false, + file: `${type}.json`, + hasErrors: hasErrors(body, required) + } + } else { + return { + required, + ...this.validateFile(version, type, body, options), + + exists: !!body, + file: `${type}.json`, + url: `${this.url}/${type}.json` + } + } + } + getToken() { return got .post(this.auth.oauthClientCredentialsGrant.tokenUrl, { @@ -289,46 +429,154 @@ class GBFS { } } - let files = require(`./versions/v${this.options.version || - gbfsResult.version}.js`).files(this.options) + const gbfsVersion = this.options.version || gbfsResult.version - return Promise.all([ - Promise.resolve(gbfsResult), - ...files.map(f => - this.checkFile( - this.options.version || gbfsResult.version, - f.file, - f.required - ) + let files = require(`./versions/v${gbfsVersion}.js`).files(this.options) + + const t = await Promise.all( + files.map(f => this.getFile(f.file, f.required)) + ) + + const vehicleTypesFile = t.find(a => a.type === 'vehicle_types') + const freeBikeStatusFile = t.find(a => a.type === 'free_bike_status') + const stationInformationFile = t.find(a => a.type === 'station_information') + const stationPricingPlans = t.find(a => a.type === 'system_pricing_plans') + + let vehicleTypes, + pricingPlans, + freeBikeStatusHasVehicleId, + hasIosRentalUris, + hasAndroidRentalUris, + hasBikesStationId, + hasBikesPricingPlanId + + const result = [gbfsResult] + + if (fileExist(vehicleTypesFile)) { + vehicleTypes = getVehicleTypes(vehicleTypesFile) + } + + if (fileExist(freeBikeStatusFile)) { + freeBikeStatusHasVehicleId = hadVehiclesId(freeBikeStatusFile) + hasIosRentalUris = hasRentalUris(freeBikeStatusFile, 'bikes', 'ios') + hasAndroidRentalUris = hasRentalUris( + freeBikeStatusFile, + 'bikes', + 'android' ) - ]).then(result => { - const files = result.map(file => ({ - ...file, - errorsCount: countErrors(file) - })) + hasBikesStationId = hasStationId(freeBikeStatusFile) + hasBikesPricingPlanId = hasPricingPlanId(freeBikeStatusFile) + } - return { - summary: { - version: { - detected: result[0].version, - validated: this.options.version || result[0].version - }, - hasErrors: hasErrors(result), - errorsCount: files.reduce((acc, file) => { - acc += file.errorsCount - return acc - }, 0) - }, - files + if (fileExist(stationInformationFile)) { + hasIosRentalUris = + hasIosRentalUris || + hasRentalUris(stationInformationFile, 'stations', 'ios') + hasAndroidRentalUris = + hasAndroidRentalUris || + hasRentalUris(stationInformationFile, 'stations', 'android') + } + + if (fileExist(stationPricingPlans)) { + pricingPlans = getPricingPlans(stationPricingPlans) + } + + t.forEach(f => { + const addSchema = [] + let required = f.required + + switch (f.type) { + case 'station_status': + if (vehicleTypes && vehicleTypes.length) { + const partial = getPartialSchema( + gbfsVersion, + 'required_vehicle_types_available', + { + vehicleTypes + } + ) + if (partial) { + addSchema.push(partial) + } + } + break + case 'free_bike_status': + if (vehicleTypes && vehicleTypes.length) { + const partial = getPartialSchema( + gbfsVersion, + 'required_vehicle_type_id', + { + vehicleTypes + } + ) + if (partial) { + addSchema.push(partial) + } + } + break + case 'vehicle_types': + if (freeBikeStatusHasVehicleId || hasBikesStationId) { + required = true + } + if (pricingPlans && pricingPlans.length) { + const partial = getPartialSchema(gbfsVersion, 'pricing_plan_id', { + pricingPlans + }) + + if (partial) { + addSchema.push(partial) + } + } + + break + case 'system_pricing_plans': + if (hasBikesPricingPlanId) { + required = true + } + break + case 'system_information': + if (hasAndroidRentalUris || hasIosRentalUris) { + const partial = getPartialSchema( + gbfsVersion, + 'required_store_uri', + { + ios: hasIosRentalUris, + android: hasAndroidRentalUris + } + ) + if (partial) { + addSchema.push(partial) + } + } + default: + break } + + result.push( + this.validationFile(f.body, gbfsVersion, f.type, required, { + addSchema + }) + ) }) - } - isGBFSFileRequire(version) { - if (!version) { - return false - } else { - return require(`./versions/v${version}.js`).gbfsRequired + const filesResult = result.map(file => ({ + ...file, + errorsCount: countErrors(file) + })) + + return { + summary: { + version: { + detected: result[0].version, + validated: this.options.version || result[0].version + }, + hasErrors: hasErrors(result), + errorsCount: filesResult.reduce((acc, file) => { + acc += file.errorsCount + return acc + }, 0) + }, + files: filesResult } } } diff --git a/gbfs-validator/package.json b/gbfs-validator/package.json index 37f20c9..695d908 100644 --- a/gbfs-validator/package.json +++ b/gbfs-validator/package.json @@ -9,9 +9,12 @@ "test": "jest" }, "dependencies": { - "ajv": "^8.5.0", - "ajv-formats": "^2.1.0", - "got": "^11.8.2" + "ajv": "^8.9.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "fast-json-patch": "^3.1.0", + "got": "^11.8.2", + "json-merge-patch": "^1.0.2" }, "devDependencies": { "fastify": "^3.20.2", diff --git a/gbfs-validator/validate.js b/gbfs-validator/validate.js index 04cb7fc..bb6a05f 100644 --- a/gbfs-validator/validate.js +++ b/gbfs-validator/validate.js @@ -1,12 +1,57 @@ const Ajv = require('ajv') const addFormats = require('ajv-formats') +const ajvErrors = require('ajv-errors') +const jsonpatch = require('fast-json-patch') +const jsonmerge = require('json-merge-patch') -module.exports = function validate(schema, object) { - const ajv = new Ajv({ allErrors: true }) +module.exports = function validate(schema, object, options = {}) { + const ajv = new Ajv({ allErrors: true, strict: false }) + ajvErrors(ajv) addFormats(ajv) - const validate = ajv.compile(schema) + let document = JSON.parse(JSON.stringify(schema)) + + options.addSchema?.map(add => { + if (add.$patch) { + if (add.$patch.source.$ref !== document.$id) { + throw new Error( + `Source of patch (${ + add.$patch.source.$ref + }) is not the same as the document (${document.$id})` + ) + } + + document = jsonpatch.applyPatch(document, add.$patch.with).newDocument + } + + if (add.$merge) { + if (add.$merge.source.$ref !== document.$id) { + throw new Error( + `Source of merge (${ + add.$merge.source.$ref + }) is not the same as the document (${document.$id})` + ) + } + + document = jsonmerge.apply(document, add.$merge.with) + } + }) + + let validate = ajv.compile(document) + const valid = validate(object) - // console.log(object, valid, validate.errors) - return valid ? false : validate.errors + + if (valid) { + return { + schema: document, + errors: false + } + } else { + return { + schema: document, + errors: validate.errors.filter( + e => !['$patch', '$merge', 'if'].includes(e.keyword) + ) + } + } } diff --git a/gbfs-validator/versions/partials/v2.1/required_store_uri.js b/gbfs-validator/versions/partials/v2.1/required_store_uri.js new file mode 100644 index 0000000..f167af2 --- /dev/null +++ b/gbfs-validator/versions/partials/v2.1/required_store_uri.js @@ -0,0 +1,61 @@ +module.exports = ({ android = false, ios = false }) => { + const r = { + $id: 'required_ios_store_uri.json#', + $patch: { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.1/gbfs.md#system_informationjson' + }, + with: [ + { + op: 'add', + path: '/properties/data/required/0', + value: 'rental_apps' + } + ] + }, + $merge: { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.1/gbfs.md#system_informationjson' + }, + with: { + properties: { + data: { + properties: { + rental_apps: { + required: [], + properties: { + ios: { + required: [] + }, + android: { + required: [] + } + } + } + } + } + } + } + } + } + + if (ios) { + r.$merge.with.properties.data.properties.rental_apps.required.push('ios') + r.$merge.with.properties.data.properties.rental_apps.properties.ios.required.push( + 'store_uri' + ) + } + + if (android) { + r.$merge.with.properties.data.properties.rental_apps.required.push( + 'android' + ) + r.$merge.with.properties.data.properties.rental_apps.properties.android.required.push( + 'store_uri' + ) + } + + return r +} diff --git a/gbfs-validator/versions/partials/v2.1/required_vehicle_type_id.js b/gbfs-validator/versions/partials/v2.1/required_vehicle_type_id.js new file mode 100644 index 0000000..c3923e9 --- /dev/null +++ b/gbfs-validator/versions/partials/v2.1/required_vehicle_type_id.js @@ -0,0 +1,64 @@ +module.exports = ({ vehicleTypes }) => { + const res = { + $id: 'required_vehicle_type_id.json#' + } + + const motorVehicleTypes = vehicleTypes.filter(vt => + ['electric_assist', 'electric', 'combustion'].includes(vt.propulsion_type) + ) + + if (motorVehicleTypes.length) { + res.$merge = { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.1/gbfs.md#free_bike_statusjson' + }, + with: { + properties: { + data: { + properties: { + bikes: { + items: { + errorMessage: { + required: { + vehicle_type_id: + "'vehicle_type_id' is required for this vehicle type" + } + }, + if: { + properties: { + vehicle_type_id: { + enum: motorVehicleTypes.map(vt => vt.vehicle_type_id) + } + }, + // "required" so it only trigger "then" when "vehicle_type_id" is present. + required: ['vehicle_type_id'] + }, + then: { + required: ['current_range_meters'] + } + } + } + } + } + } + } + } + } + + res.$patch = { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.1/gbfs.md#free_bike_statusjson' + }, + with: [ + { + op: 'add', + path: '/properties/data/properties/bikes/items/required/0', + value: 'vehicle_type_id' + } + ] + } + + return res +} diff --git a/gbfs-validator/versions/partials/v2.1/required_vehicle_types_available.js b/gbfs-validator/versions/partials/v2.1/required_vehicle_types_available.js new file mode 100644 index 0000000..b90d020 --- /dev/null +++ b/gbfs-validator/versions/partials/v2.1/required_vehicle_types_available.js @@ -0,0 +1,47 @@ +module.exports = ({ vehicleTypes }) => { + return { + $id: 'required_vehicle_types_available.json#', + $merge: { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.1/gbfs.md#station_statusjson' + }, + with: { + properties: { + data: { + properties: { + stations: { + items: { + properties: { + vehicle_types_available: { + items: { + properties: { + vehicle_type_id: { + enum: vehicleTypes.map(vt => vt.vehicle_type_id) + } + } + } + } + } + } + } + } + } + } + } + }, + $patch: { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.1/gbfs.md#station_statusjson' + }, + with: [ + { + op: 'add', + path: '/properties/data/properties/stations/items/required/0', + value: 'vehicle_types_available' + } + ] + } + } +} diff --git a/gbfs-validator/versions/partials/v2.2/required_store_uri.js b/gbfs-validator/versions/partials/v2.2/required_store_uri.js new file mode 100644 index 0000000..f9c1a02 --- /dev/null +++ b/gbfs-validator/versions/partials/v2.2/required_store_uri.js @@ -0,0 +1,61 @@ +module.exports = ({ android = false, ios = false }) => { + const r = { + $id: 'required_ios_store_uri.json#', + $patch: { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.2/gbfs.md#system_informationjson' + }, + with: [ + { + op: 'add', + path: '/properties/data/required/0', + value: 'rental_apps' + } + ] + }, + $merge: { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.2/gbfs.md#system_informationjson' + }, + with: { + properties: { + data: { + properties: { + rental_apps: { + required: [], + properties: { + ios: { + required: [] + }, + android: { + required: [] + } + } + } + } + } + } + } + } + } + + if (ios) { + r.$merge.with.properties.data.properties.rental_apps.required.push('ios') + r.$merge.with.properties.data.properties.rental_apps.properties.ios.required.push( + 'store_uri' + ) + } + + if (android) { + r.$merge.with.properties.data.properties.rental_apps.required.push( + 'android' + ) + r.$merge.with.properties.data.properties.rental_apps.properties.android.required.push( + 'store_uri' + ) + } + + return r +} diff --git a/gbfs-validator/versions/partials/v2.2/required_vehicle_type_id.js b/gbfs-validator/versions/partials/v2.2/required_vehicle_type_id.js new file mode 100644 index 0000000..908d935 --- /dev/null +++ b/gbfs-validator/versions/partials/v2.2/required_vehicle_type_id.js @@ -0,0 +1,64 @@ +module.exports = ({ vehicleTypes }) => { + const res = { + $id: 'required_vehicle_type_id.json#' + } + + const motorVehicleTypes = vehicleTypes.filter(vt => + ['electric_assist', 'electric', 'combustion'].includes(vt.propulsion_type) + ) + + if (motorVehicleTypes.length) { + res.$merge = { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.2/gbfs.md#free_bike_statusjson' + }, + with: { + properties: { + data: { + properties: { + bikes: { + items: { + errorMessage: { + required: { + vehicle_type_id: + "'vehicle_type_id' is required for this vehicle type" + } + }, + if: { + properties: { + vehicle_type_id: { + enum: motorVehicleTypes.map(vt => vt.vehicle_type_id) + } + }, + // "required" so it only trigger "then" when "vehicle_type_id" is present. + required: ['vehicle_type_id'] + }, + then: { + required: ['current_range_meters'] + } + } + } + } + } + } + } + } + } + + res.$patch = { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.2/gbfs.md#free_bike_statusjson' + }, + with: [ + { + op: 'add', + path: '/properties/data/properties/bikes/items/required/0', + value: 'vehicle_type_id' + } + ] + } + + return res +} diff --git a/gbfs-validator/versions/partials/v2.2/required_vehicle_types_available.js b/gbfs-validator/versions/partials/v2.2/required_vehicle_types_available.js new file mode 100644 index 0000000..3b3ce9e --- /dev/null +++ b/gbfs-validator/versions/partials/v2.2/required_vehicle_types_available.js @@ -0,0 +1,47 @@ +module.exports = ({ vehicleTypes }) => { + return { + $id: 'required_vehicle_types_available.json#', + $merge: { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.2/gbfs.md#station_statusjson' + }, + with: { + properties: { + data: { + properties: { + stations: { + items: { + properties: { + vehicle_types_available: { + items: { + properties: { + vehicle_type_id: { + enum: vehicleTypes.map(vt => vt.vehicle_type_id) + } + } + } + } + } + } + } + } + } + } + } + }, + $patch: { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.2/gbfs.md#station_statusjson' + }, + with: [ + { + op: 'add', + path: '/properties/data/properties/stations/items/required/0', + value: 'vehicle_types_available' + } + ] + } + } +} diff --git a/gbfs-validator/versions/partials/v2.3-RC/pricing_plan_id.js b/gbfs-validator/versions/partials/v2.3-RC/pricing_plan_id.js new file mode 100644 index 0000000..022ff7e --- /dev/null +++ b/gbfs-validator/versions/partials/v2.3-RC/pricing_plan_id.js @@ -0,0 +1,42 @@ +module.exports = ({ pricingPlans }) => { + return { + $id: 'pricing_plan_id.json#', + $merge: { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.3-RC/gbfs.md#vehicle_typesjson-added-in-v21-rc' + }, + with: { + properties: { + data: { + properties: { + vehicle_types: { + items: { + properties: { + default_pricing_plan_id: { + enum: pricingPlans.map(p => p.plan_id) + } + } + } + } + } + } + } + } + }, + $patch: { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.3-RC/gbfs.md#vehicle_typesjson-added-in-v21-rc' + }, + with: [ + { + op: 'add', + path: + '/properties/data/properties/vehicle_types/items/required/0', + value: 'default_pricing_plan_id' + } + ] + } + } +} diff --git a/gbfs-validator/versions/partials/v2.3-RC/required_store_uri.js b/gbfs-validator/versions/partials/v2.3-RC/required_store_uri.js new file mode 100644 index 0000000..15ca8f2 --- /dev/null +++ b/gbfs-validator/versions/partials/v2.3-RC/required_store_uri.js @@ -0,0 +1,61 @@ +module.exports = ({ android = false, ios = false }) => { + const r = { + $id: 'required_ios_store_uri.json#', + $patch: { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.3-RC/gbfs.md#system_informationjson' + }, + with: [ + { + op: 'add', + path: '/properties/data/required/0', + value: 'rental_apps' + } + ] + }, + $merge: { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.3-RC/gbfs.md#system_informationjson' + }, + with: { + properties: { + data: { + properties: { + rental_apps: { + required: [], + properties: { + ios: { + required: [] + }, + android: { + required: [] + } + } + } + } + } + } + } + } + } + + if (ios) { + r.$merge.with.properties.data.properties.rental_apps.required.push('ios') + r.$merge.with.properties.data.properties.rental_apps.properties.ios.required.push( + 'store_uri' + ) + } + + if (android) { + r.$merge.with.properties.data.properties.rental_apps.required.push( + 'android' + ) + r.$merge.with.properties.data.properties.rental_apps.properties.android.required.push( + 'store_uri' + ) + } + + return r +} diff --git a/gbfs-validator/versions/partials/v2.3-RC/required_vehicle_type_id.js b/gbfs-validator/versions/partials/v2.3-RC/required_vehicle_type_id.js new file mode 100644 index 0000000..8a7e752 --- /dev/null +++ b/gbfs-validator/versions/partials/v2.3-RC/required_vehicle_type_id.js @@ -0,0 +1,64 @@ +module.exports = ({ vehicleTypes }) => { + const res = { + $id: 'required_vehicle_type_id.json#' + } + + const motorVehicleTypes = vehicleTypes.filter(vt => + ['electric_assist', 'electric', 'combustion'].includes(vt.propulsion_type) + ) + + if (motorVehicleTypes.length) { + res.$merge = { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.3-RC/gbfs.md#free_bike_statusjson' + }, + with: { + properties: { + data: { + properties: { + bikes: { + items: { + errorMessage: { + required: { + vehicle_type_id: + "'vehicle_type_id' is required for this vehicle type" + } + }, + if: { + properties: { + vehicle_type_id: { + enum: motorVehicleTypes.map(vt => vt.vehicle_type_id) + } + }, + // "required" so it only trigger "then" when "vehicle_type_id" is present. + required: ['vehicle_type_id'] + }, + then: { + required: ['current_range_meters'] + } + } + } + } + } + } + } + } + } + + res.$patch = { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.3-RC/gbfs.md#free_bike_statusjson' + }, + with: [ + { + op: 'add', + path: '/properties/data/properties/bikes/items/required/0', + value: 'vehicle_type_id' + } + ] + } + + return res +} diff --git a/gbfs-validator/versions/partials/v2.3-RC/required_vehicle_types_available.js b/gbfs-validator/versions/partials/v2.3-RC/required_vehicle_types_available.js new file mode 100644 index 0000000..54ac2c7 --- /dev/null +++ b/gbfs-validator/versions/partials/v2.3-RC/required_vehicle_types_available.js @@ -0,0 +1,47 @@ +module.exports = ({ vehicleTypes }) => { + return { + $id: 'required_vehicle_types_available.json#', + $merge: { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.3-RC/gbfs.md#station_statusjson' + }, + with: { + properties: { + data: { + properties: { + stations: { + items: { + properties: { + vehicle_types_available: { + items: { + properties: { + vehicle_type_id: { + enum: vehicleTypes.map(vt => vt.vehicle_type_id) + } + } + } + } + } + } + } + } + } + } + } + }, + $patch: { + source: { + $ref: + 'https://github.com/NABSA/gbfs/blob/v2.3-RC/gbfs.md#station_statusjson' + }, + with: [ + { + op: 'add', + path: '/properties/data/properties/stations/items/required/0', + value: 'vehicle_types_available' + } + ] + } + } +} diff --git a/gbfs-validator/versions/schemas/.github/workflows/validate_json.yml b/gbfs-validator/versions/schemas/.github/workflows/validate_json.yml new file mode 100644 index 0000000..cd25db4 --- /dev/null +++ b/gbfs-validator/versions/schemas/.github/workflows/validate_json.yml @@ -0,0 +1,18 @@ +name: Validate JSON Schema files +on: [push] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install jsonschema + run: pip install jsonschema==3.2.0 + - run: | + for i in **/*.json; do + echo "Validating $i" + jsonschema $i + done diff --git a/gbfs-validator/versions/schemas/README.md b/gbfs-validator/versions/schemas/README.md new file mode 100644 index 0000000..e079302 --- /dev/null +++ b/gbfs-validator/versions/schemas/README.md @@ -0,0 +1,2 @@ +# gbfs-json-schema +JSON Schema for [General Bikeshare Feed](https://github.com/NABSA/gbfs/blob/master/gbfs.md) Specification(GBFS) feeds, managed by MobilityData. The [gbfs-validator](https://github.com/MobilityData/gbfs-validator) links directly to them. diff --git a/gbfs-validator/versions/schemas/v1.1/gbfs_versions.json b/gbfs-validator/versions/schemas/v1.1/gbfs_versions.json index 1d319ad..d3fb9dc 100644 --- a/gbfs-validator/versions/schemas/v1.1/gbfs_versions.json +++ b/gbfs-validator/versions/schemas/v1.1/gbfs_versions.json @@ -40,7 +40,7 @@ "The semantic version of the feed in the form X.Y", "type": "string", "enum": [ - "1.1", + "1.1" ] }, "url": { diff --git a/gbfs-validator/versions/schemas/v2.0/gbfs_versions.json b/gbfs-validator/versions/schemas/v2.0/gbfs_versions.json index 49f369f..b8c7bbb 100644 --- a/gbfs-validator/versions/schemas/v2.0/gbfs_versions.json +++ b/gbfs-validator/versions/schemas/v2.0/gbfs_versions.json @@ -41,7 +41,7 @@ "type": "string", "enum": [ "1.1", - "2.0", + "2.0" ] }, "url": { diff --git a/gbfs-validator/versions/schemas/v2.1/gbfs_versions.json b/gbfs-validator/versions/schemas/v2.1/gbfs_versions.json index 998c750..73e4416 100644 --- a/gbfs-validator/versions/schemas/v2.1/gbfs_versions.json +++ b/gbfs-validator/versions/schemas/v2.1/gbfs_versions.json @@ -42,7 +42,7 @@ "enum": [ "1.1", "2.0", - "2.1", + "2.1" ] }, "url": { diff --git a/gbfs-validator/versions/schemas/v2.2/gbfs_versions.json b/gbfs-validator/versions/schemas/v2.2/gbfs_versions.json index ed0965d..2066926 100644 --- a/gbfs-validator/versions/schemas/v2.2/gbfs_versions.json +++ b/gbfs-validator/versions/schemas/v2.2/gbfs_versions.json @@ -43,7 +43,7 @@ "1.1", "2.0", "2.1", - "2.2", + "2.2" ] }, "url": { diff --git a/gbfs-validator/versions/schemas/v2.3-RC/station_status.json b/gbfs-validator/versions/schemas/v2.3-RC/station_status.json index f3eda04..932061a 100644 --- a/gbfs-validator/versions/schemas/v2.3-RC/station_status.json +++ b/gbfs-validator/versions/schemas/v2.3-RC/station_status.json @@ -62,7 +62,7 @@ "minimum": 0 } }, - "required": ["vehicle_type_id", "count"], + "required": ["vehicle_type_id", "count"] } }, "num_bikes_disabled": { diff --git a/package.json b/package.json index f8c7fdd..40b6e72 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "eslint-plugin-node": "^5.2.0", "eslint-plugin-promise": "^3.4.0", "eslint-plugin-vue": "^4.0.0", - "netlify-lambda": "^1.3.0" + "netlify-lambda": "^2.0.15" }, "workspaces": [ "website", diff --git a/website/src/components/DownloadSchema.vue b/website/src/components/DownloadSchema.vue new file mode 100644 index 0000000..52652bd --- /dev/null +++ b/website/src/components/DownloadSchema.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/website/src/components/SubResult.vue b/website/src/components/SubResult.vue index 084fee2..fde2e76 100644 --- a/website/src/components/SubResult.vue +++ b/website/src/components/SubResult.vue @@ -17,6 +17,9 @@