Skip to content

Commit

Permalink
Support to load json schema documents which are implemented in yaml.
Browse files Browse the repository at this point in the history
## Change details

 * add snakeyaml depencency
 * add `parseStringIntoRawSchema(string): IJsonValue` function, which first tries to parse its input as json, then as yaml
 * reworked `SchemaLoader(string)` constructor and `SchemaClient#getParsed()` to use `parseStringIntoRawSchema()`
 * added internal `loadFromYaml(node: org.yaml.snakeyaml.nodes.Node)` for mapping from snakeyaml `Node` to `IJsonValue`
  • Loading branch information
erosb committed Oct 6, 2024
1 parent ef94c91 commit 93a32ac
Show file tree
Hide file tree
Showing 10 changed files with 234 additions and 13 deletions.
9 changes: 9 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@
</exclusions>
</dependency>



<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>2.3</version>
</dependency>


<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
Expand Down
9 changes: 4 additions & 5 deletions src/main/kotlin/com/github/erosb/jsonsKema/SchemaClient.kt
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
package com.github.erosb.jsonsKema

import org.yaml.snakeyaml.Yaml
import java.io.*
import java.net.URI
import java.net.URL
import java.nio.charset.StandardCharsets
import java.util.*


fun interface SchemaClient {
fun get(uri: URI): InputStream

fun getParsed(uri: URI): IJsonValue {
var string: String? = null
try {
val reader = BufferedReader(InputStreamReader(get(uri)))
val string = reader.readText()
return JsonParser(string, uri)()
string = reader.readText()
return parseStringIntoRawSchema(string, uri)
} catch (ex: UncheckedIOException) {
throw JsonDocumentLoadingException(uri, ex)
} catch (ex: JsonParseException) {
throw JsonDocumentLoadingException(uri, ex)
}
}
}
Expand Down
26 changes: 25 additions & 1 deletion src/main/kotlin/com/github/erosb/jsonsKema/SchemaLoader.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.github.erosb.jsonsKema

import org.yaml.snakeyaml.Yaml
import java.io.StringReader
import java.net.URI
import java.net.URISyntaxException
import java.net.URLDecoder
Expand Down Expand Up @@ -122,6 +124,27 @@ internal data class LoadingContext(

internal typealias KeywordLoader = (context: LoadingContext) -> Schema?

private val yamlSupport = runCatching {
Class.forName("org.yaml.snakeyaml.Yaml")
}.isSuccess

internal fun parseStringIntoRawSchema(string: String, documentSource: URI? = null): IJsonValue {
try {
return JsonParser(string, documentSource)()
} catch (ex: JsonParseException) {
if (yamlSupport) {
try {
return loadFromYaml(Yaml().compose(StringReader(string)))
} catch (e: RuntimeException) {
if (ex.location.lineNumber == 1 && ex.location.position == 1) {
throw YamlDocumentLoadingException(documentSource ?: URI(DEFAULT_BASE_URI), e)
}
}
}
throw ex
}
}

class SchemaLoader(
val schemaJson: IJsonValue,
val config: SchemaLoaderConfig = createDefaultConfig()
Expand All @@ -131,6 +154,7 @@ class SchemaLoader(

@JvmStatic
fun forURL(url: String): SchemaLoader {

val schemaJson = createDefaultConfig().schemaClient.getParsed(URI(url))
return SchemaLoader(
schemaJson = schemaJson,
Expand All @@ -144,7 +168,7 @@ class SchemaLoader(

constructor(schemaJson: IJsonValue) : this(schemaJson, createDefaultConfig()) {}

constructor(schemaJson: String) : this(JsonParser(schemaJson)(), createDefaultConfig()) {}
constructor(schemaJson: String) : this(parseStringIntoRawSchema(schemaJson), createDefaultConfig()) {}

private val regexpFactory: RegexpFactory = JavaUtilRegexpFactory()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ data class JsonTypeMismatchException(
val expectedType: String = cause.expectedType,
val actualType: String = cause.actualType,
val location: SourceLocation = cause.location
) : SchemaLoadingException(cause.message ?: "", cause) {}
) : SchemaLoadingException(cause.message ?: "", cause)

data class JsonDocumentLoadingException(val uri: URI, override val cause: Throwable? = null): SchemaLoadingException(cause?.message ?: "", cause);
open class SchemaDocumentLoadingException(open val uri: URI, override val cause: Throwable? = null): SchemaLoadingException(cause?.message ?: "", cause)

data class JsonDocumentLoadingException(override val uri: URI, override val cause: Throwable? = null): SchemaDocumentLoadingException(uri, cause)

data class YamlDocumentLoadingException(override val uri: URI, override val cause: Throwable? = null): SchemaDocumentLoadingException(uri, cause)
47 changes: 47 additions & 0 deletions src/main/kotlin/com/github/erosb/jsonsKema/YamlJsonObject.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.github.erosb.jsonsKema

import org.yaml.snakeyaml.nodes.MappingNode
import org.yaml.snakeyaml.nodes.Node
import org.yaml.snakeyaml.nodes.ScalarNode
import org.yaml.snakeyaml.nodes.SequenceNode
import org.yaml.snakeyaml.nodes.Tag

internal fun loadFromYaml(node: Node, ptr: JsonPointer = JsonPointer()): JsonValue {
val location = SourceLocation(
node.startMark.line + 1,
node.startMark.column + 1,
ptr,
null,
)
when (node) {
is ScalarNode -> {
if (node.tag == Tag.NULL) {
return JsonNull(location)
} else if (node.tag == Tag.STR) {
return JsonString(node.value, location)
} else if (node.tag == Tag.BOOL) {
val value = node.value.lowercase() in listOf("yes", "y", "on", "true")
return JsonBoolean(value, location)
} else if (node.tag == Tag.INT) {
return JsonNumber(node.value.toInt(), location)
} else if (node.tag == Tag.FLOAT) {
return JsonNumber(node.value.toDouble(), location)
}
}
is MappingNode -> {
val props = node.value.map {
val nextPtr = ptr + (it.keyNode as ScalarNode).value
loadFromYaml(it.keyNode).requireString() as JsonString to loadFromYaml(it.valueNode, nextPtr)
}.toMap()
return JsonObject(props, location)
}
is SequenceNode -> {
val items = node.value.mapIndexed { index, childNode ->
val childPtr = ptr + index.toString()
loadFromYaml(childNode, childPtr)
}
return JsonArray(items, location)
}
}
TODO("unhandled type ${node.javaClass} / ${node.tag}")
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,10 @@ class SchemaLoadingFailureTest {
}
""".trimIndent(),
URI("classpath://xml") to """
<?xml version="1.0">
<project>
</project>
x:
- [[[[
[[[[]y
""".trimIndent()
))
)
Expand All @@ -148,8 +149,6 @@ class SchemaLoadingFailureTest {
fail("did not throw exception")
} catch (ex: AggregateSchemaLoadingException) {
ex.causes.forEach { println(it.javaClass.simpleName) }

ex.printStackTrace(System.out)
}
}
}
111 changes: 111 additions & 0 deletions src/test/kotlin/com/github/erosb/jsonsKema/SnakeYamlTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.github.erosb.jsonsKema

import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.nodes.MappingNode
import org.yaml.snakeyaml.nodes.Node
import org.yaml.snakeyaml.nodes.ScalarNode
import org.yaml.snakeyaml.nodes.SequenceNode
import org.yaml.snakeyaml.parser.ParserException
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.StringReader


class SnakeYamlTest {

@Test
fun readNull() {
val yaml = Yaml().compose(StringReader("null"))
val actual = loadFromYaml(yaml)
assertThat(actual).isEqualTo(JsonNull(
SourceLocation(1, 1, JsonPointer())
))
}

@Test
fun readString() {
val yaml = Yaml().compose(StringReader("""
"null"
"""))
val actual = loadFromYaml(yaml)
assertThat(actual).isEqualTo(JsonString("null",
SourceLocation(1, 1, JsonPointer())
))
}

@Test
fun readObject() {
val yaml = Yaml().compose(StringReader("""
propA: val-a
propB: null
""".trimIndent()))

val actual = loadFromYaml(yaml)
assertThat(actual).usingRecursiveComparison().isEqualTo(JsonObject(mapOf(
JsonString("propA", SourceLocation(1, 1, JsonPointer())) to JsonString("val-a", SourceLocation(1, 8, JsonPointer("propA"))),
JsonString("propB", SourceLocation(2, 1, JsonPointer())) to JsonNull(SourceLocation(2, 9, JsonPointer("propB")))
), SourceLocation(1, 1, JsonPointer())))
}

@Test
fun readSequence() {
val yaml = Yaml().compose(StringReader("""
- null
- "asd"
- true
""".trimIndent()))

val actual = loadFromYaml(yaml)
assertThat(actual).isEqualTo(JsonArray(listOf(
JsonNull(),
JsonString("asd"),
JsonBoolean(true)
)))
}

@Test
fun readBooleans() {
val yaml = Yaml().compose(StringReader("[yes, true, ON, No, false, off]"))
val actual = loadFromYaml(yaml)
assertThat(actual).isEqualTo(JsonArray(listOf(
JsonBoolean(true), JsonBoolean(true), JsonBoolean(true),
JsonBoolean(false), JsonBoolean(false), JsonBoolean(false)
)))
}

@Test
fun loadSchemaFromYaml() {
val schema = SchemaLoader.forURL("classpath://yaml/schema.yml")
}

@Test
fun loadMalformedYamlSchema() {
assertThatThrownBy { SchemaLoader.forURL("classpath://yaml/malformed.yml") }
.isInstanceOf(YamlDocumentLoadingException::class.java)
}

@Test
fun loadSchemaFromYamlString() {
val schema = SchemaLoader("""
$schema: https://json-schema.org/draft/2020-12/schema
type: object
additionalProperties: false
properties:
str:
type: string
num:
type: number
minimum: 0.5
int:
type: integer
maximum: 1
bool:
type: boolean
nullish:
type: "null"
""".trimIndent())()
}
}
8 changes: 8 additions & 0 deletions src/test/resources/yaml/hello.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
myObj:
prop: value
arr:
- 1
- 3
- null
nullProp:
objProp: {}
3 changes: 3 additions & 0 deletions src/test/resources/yaml/malformed.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
x:
- [[[[
[[[[]y
17 changes: 17 additions & 0 deletions src/test/resources/yaml/schema.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
$schema: https://json-schema.org/draft/2020-12/schema
type: object
additionalProperties: false
properties:
str:
type: string
num:
type: number
minimum: 0
int:
type: integer
maximum: 1
bool:
type: boolean
nullish:
type: null

0 comments on commit 93a32ac

Please sign in to comment.