diff --git a/data.schema.json b/data.schema.json index 7224c33..c9321b4 100644 --- a/data.schema.json +++ b/data.schema.json @@ -1,6 +1,6 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://atlas.cuhacking.com/data.schema.json", + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://atlas.cuhacking.com/schema/0.1/data.schema.json", "title": "Atlas Map Data Schema Definition", "description": "Data used in project Atlas", "type": "object", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce5d6b0..e49f9b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ ios = "14.0" detekt = "1.16.0" kotlin = "1.5.10" -ktor = "1.6.0" +ktor = "1.6.1" spatialk = "0.1.1" sqldelight = "1.6.0-SNAPSHOT" coroutines = "1.5.0-native-mt" @@ -66,8 +66,9 @@ mapbox-android = "com.mapbox.mapboxsdk:mapbox-android-sdk:9.6.1" logback = "ch.qos.logback:logback-classic:1.2.3" material = "com.google.android.material:material:1.3.0-beta01" turbine = "app.cash.turbine:turbine:0.3.0" -clikt = "com.github.ajalt.clikt:clikt:3.0.1" +clikt = "com.github.ajalt.clikt:clikt:3.2.0" klock = "com.soywiz.korlibs.klock:klock:2.1.2" +json-schema = "com.github.everit-org.json-schema:org.everit.json.schema:1.12.3" [bundles] androidx-runtime = ["androidx-core", "androidx-appCompat", "androidx-constraintLayout", "androidx-lifecycle"] diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 6f8381b..656144b 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -6,6 +6,10 @@ plugins { id("com.github.johnrengelman.shadow") } +repositories { + maven(url = "https://jitpack.io") +} + kotlin { dependencies { implementation(libs.ktor.server.core) @@ -13,6 +17,7 @@ kotlin { implementation(libs.logback) implementation(libs.clikt) implementation(libs.spatialk.geojson) + implementation(libs.json.schema) testImplementation(libs.ktor.server.test) testImplementation(libs.junit) diff --git a/server/src/main/kotlin/com/cuhacking/atlas/server/Server.kt b/server/src/main/kotlin/com/cuhacking/atlas/server/Server.kt index a208108..eb5857c 100644 --- a/server/src/main/kotlin/com/cuhacking/atlas/server/Server.kt +++ b/server/src/main/kotlin/com/cuhacking/atlas/server/Server.kt @@ -3,14 +3,19 @@ package com.cuhacking.atlas.server import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.types.int import com.github.ajalt.clikt.parameters.types.path -import io.github.dellisd.spatialk.geojson.FeatureCollection.Companion.toFeatureCollection import io.ktor.application.* import io.ktor.features.* -import io.ktor.server.engine.* import io.ktor.server.cio.* +import io.ktor.server.engine.* +import org.everit.json.schema.ValidationException +import org.everit.json.schema.loader.SchemaLoader +import org.json.JSONObject +import org.json.JSONTokener +import kotlin.io.path.readText class Server : CliktCommand() { private val file by argument(help = "Path to directory containing data files").path( @@ -21,18 +26,44 @@ class Server : CliktCommand() { @Suppress("MagicNumber") val port by option("--port", help = "Port for the server to listen on").int().default(8080) + private val test by option( + "--test", + "-t", + help = "Validate data against the schema without starting the server" + ).flag() + override fun run() { - verifyData() + if (!verifyData()) { + throw RuntimeException() + } - embeddedServer(CIO, port, module = { - install(AutoHeadResponse) - dataModuleFactory(file) - }).start(wait = true) + if (!test) { + embeddedServer(CIO, port, module = { + install(AutoHeadResponse) + install(CallLogging) + dataModuleFactory(file) + }).start(wait = true) + } } - internal fun verifyData() { - // Will throw error if file is not a valid FeatureCollection - file.toFile().readText().toFeatureCollection() + private fun verifyData(): Boolean { + var valid = true + + val stream = javaClass.classLoader.getResourceAsStream("data.schema.json") + val rawSchema = JSONObject(JSONTokener(stream)) + val schema = SchemaLoader.load(rawSchema) + + val json = file.readText() + + try { + schema.validate(JSONObject(json)) + } catch (e: ValidationException) { + valid = false + println(e.message) + e.causingExceptions.map(ValidationException::message).forEach(::println) + } + + return valid } } diff --git a/server/src/main/resources/data.schema.json b/server/src/main/resources/data.schema.json new file mode 100644 index 0000000..c9321b4 --- /dev/null +++ b/server/src/main/resources/data.schema.json @@ -0,0 +1,317 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://atlas.cuhacking.com/schema/0.1/data.schema.json", + "title": "Atlas Map Data Schema Definition", + "description": "Data used in project Atlas", + "type": "object", + "properties": { + "type": { + "const": "FeatureCollection" + }, + "features": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/feature-room-polygon" + } + ] + } + } + }, + "definitions": { + "feature-room-polygon": { + "type": "object", + "properties": { + "type": { + "const": "Feature" + }, + "geometry": { + "$ref": "#/definitions/geometry-polygon" + }, + "properties": { + "type": "object", + "allOf": [ + { + "properties": { + "type": { + "const": "room" + } + } + }, + { + "$ref": "#/definitions/props-common" + }, + { + "$ref": "#/definitions/props-room-polygon" + } + ] + } + } + }, + "coordinate": { + "type": "array", + "title": "GeoJSON Coordinate", + "items": { + "type": "number" + }, + "minItems": 2, + "maxItems": 3 + }, + "geometry-point": { + "type": "object", + "title": "GeoJSON Point Geometry", + "properties": { + "type": { + "const": "Point" + }, + "coordinates": { + "$ref": "#/definitions/coordinate" + } + }, + "required": [ + "type", + "coordinates" + ] + }, + "geometry-multipoint": { + "type": "object", + "title": "GeoJSON MultiPoint Geometry", + "properties": { + "type": { + "const": "MultiPoint" + }, + "coordinates": { + "type": "array", + "items": { + "$ref": "#/definitions/coordinate" + } + } + }, + "required": [ + "type", + "coordinates" + ] + }, + "geometry-linestring": { + "type": "object", + "title": "GeoJSON LineString Geometry", + "properties": { + "type": { + "const": "LineString" + }, + "coordinates": { + "type": "array", + "items": { + "$ref": "#/definitions/coordinate" + } + } + }, + "required": [ + "type", + "coordinates" + ] + }, + "geometry-multilinestring": { + "type": "object", + "title": "GeoJSON MultiLineString Geometry", + "properties": { + "type": { + "const": "MultiLineString" + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/coordinate" + } + } + } + }, + "required": [ + "type", + "coordinates" + ] + }, + "geometry-polygon": { + "type": "object", + "title": "GeoJSON Polygon Geometry", + "properties": { + "type": { + "const": "Polygon" + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/coordinate" + } + } + } + }, + "required": [ + "type", + "coordinates" + ] + }, + "geometry-multipolygon": { + "type": "object", + "title": "GeoJSON MultiPolygon Geometry", + "properties": { + "type": { + "const": "MultiPolygon" + }, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/coordinate" + } + } + } + } + }, + "required": [ + "type", + "coordinates" + ] + }, + "geometry-geometrycollection": { + "type": "object", + "title": "GeoJSON GeometryCollection", + "properties": { + "type": { + "const": "GeometryCollection" + }, + "geometries": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/geometry-point" + }, + { + "$ref": "#/definitions/geometry-multipoint" + }, + { + "$ref": "#/definitions/geometry-linestring" + }, + { + "$ref": "#/definitions/geometry-multilinestring" + }, + { + "$ref": "#/definitions/geometry-polygon" + }, + { + "$ref": "#/definitions/geometry-multipolygon" + } + ] + } + } + }, + "required": [ + "type", + "coordinates" + ] + }, + "props-common": { + "type": "object", + "title": "Common Feature Properties", + "description": "Properties used across all features", + "properties": { + "fid": { + "type": "number", + "title": "Feature ID", + "description": "A unique integer identifying this feature." + }, + "search": { + "type": "boolean", + "title": "Include in Search", + "description": "Whether or not to include this feature in search results.", + "default": false + }, + "building": { + "type": [ + "string", + "null" + ], + "title": "Building", + "description": "The two-letter building code of the building this feature is located in, if applicable." + }, + "floor": { + "type": "string", + "title": "Floor", + "description": "An identifier for the floor this feature is located on within a building.", + "$comment": "This value is typically just a number, but can be a letter in some cases such as 'M' for a mezzanine level.", + "examples": [ + "1", + "12", + "M" + ] + }, + "searchTags": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Search Tags", + "description": "A list of tags to be included in search matching for this feature, like alternate or commonly-used names." + } + }, + "required": [ + "fid" + ] + }, + "props-room-polygon": { + "type": "object", + "description": "Polygonal shape of a room", + "properties": { + "roomId": { + "type": [ + "string", + "null" + ], + "title": "Room ID", + "description": "The identifier for the room, excluding the building code.", + "$comment": "When displayed, the building code should be automatically prepended from the common props.", + "examples": [ + "2200", + "1204D" + ] + }, + "roomName": { + "type": [ + "string", + "null" + ], + "title": "Room Name", + "description": "The formal name of the room, if applicable. Alternative, or commonly-used names should be specified in the searchTags prop." + }, + "roomType": { + "enum": [ + null, + "classroom", + "lecture-hall", + "office", + "washroom", + "public", + "elevator", + "maintenance", + "hallway", + "staircase" + ], + "title": "Room Type", + "description": "The type of room being displayed, from a set list of types. 'null' specifies a solid space that isn't really a room, but is still represented as one." + } + }, + "required": [ + "roomType" + ] + } + } +} \ No newline at end of file diff --git a/server/src/test/kotlin/com/cuhacking/atlas/server/ServerTests.kt b/server/src/test/kotlin/com/cuhacking/atlas/server/ServerTests.kt index a479fcf..8f7b736 100644 --- a/server/src/test/kotlin/com/cuhacking/atlas/server/ServerTests.kt +++ b/server/src/test/kotlin/com/cuhacking/atlas/server/ServerTests.kt @@ -16,7 +16,6 @@ import java.util.* import kotlin.test.assertEquals class ServerTests { - private val data = Paths.get("src/test/resources/data.json") private val attr = Files.readAttributes(data, BasicFileAttributes::class.java) private val lastModified = @@ -40,11 +39,9 @@ class ServerTests { } } - @Test(expected = SerializationException::class) + @Test(expected = RuntimeException::class) fun `server throws on invalid data content`() { val server = Server() - server.parse(arrayOf("src/test/resources/bad.json")) - - server.verifyData() + server.parse(listOf("-t", "src/test/resources/bad.json")) } } diff --git a/server/src/test/resources/bad.json b/server/src/test/resources/bad.json index faaf8f4..32c54aa 100644 --- a/server/src/test/resources/bad.json +++ b/server/src/test/resources/bad.json @@ -1,3 +1,14 @@ { - "someProperty": [] + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + }, + "geometry": { + "type": "Polygon", + "coordinates": [] + } + } + ] } \ No newline at end of file