Skip to content

fix: namespace JSON schemas per OCPP version so they stop shadowing each other on a shared classpath - #112

Merged
pbourseau merged 6 commits into
IZIVIA:devfrom
juherr:juherr/json-schema-classpath-collision
Oct 2, 2026
Merged

pbourseau merged 6 commits into
IZIVIA:devfrom
juherr:juherr/json-schema-classpath-collision

Conversation

@juherr

@juherr juherr commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Fix #111

Problem

OcppJsonValidator resolves schemas as bare classpath resources by action name. ocpp-1-5-json, ocpp-1-6-json and ocpp-2-0-json all shipped theirs at the resources root under overlapping names (57 shared between 1.6 and 2.0, 24 between 1.5 and 1.6). On a shared classpath — toolkit, hence every published consumer — getResourceAsStream returns the oldest jar's file, so a valid OCPP 2.0.1 Authorize request was rejected against the 1.6 AuthorizeRequest schema. Per-module suites never saw it because each module only has its own schemas on its classpath.

Changes

fix(ocpp-1-5-json,ocpp-1-6-json,ocpp-2-0-json,toolkit): namespace JSON schemas per OCPP version

  • Schemas move into ocpp15/, ocpp16/ and ocpp20/ (the OCA licence notice of ocpp-1-6-json and ocpp-2-0-json follows them), as ocpp-1-2-json already does with ocpp12/ since feat: add OCPP 1.2 support over JSON/WebSocket #107. Plain git mv, no schema content changed; none of them uses an external $ref, so the move is safe.
  • Ocpp15JsonParser, Ocpp16JsonParser and Ocpp20JsonParser pass their folder to OcppJsonValidator.
  • ocpp-1-6-security ships no schemas of its own; the security-whitepaper ones live in ocpp-1-6-json and follow it into ocpp16/.
  • New JsonSchemaIsolationTest in toolkit, the only place the collision is observable. One payload per version, each valid in its own version but rejected by the older schema that used to shadow it. The 2.0 and 1.6 cases fail on dev before the fix.

refactor(ocpp-json)!: type the schema folder and report a missing schema in the call error

  • OcppJsonValidator takes an OcppSchemaFolder enum, one entry per version, so folders are distinct and well-formed by construction; the four per-parser constants go away.
  • A schema missing from the classpath raises SchemaNotFoundException, an OcppParserException, so the call error carries ErrorDetail(code="schema", detail="<folder>/<Action>.json") instead of a bare internal error. Loading stays lazy: parsers are built per connection.
  • The JsonSchemaFactory is built once per validator, the schema stream is closed if parsing throws, and kotlin("test-junit") is dropped from ocpp-json (JUnit Platform runner, no vintage engine).

fix(ocpp-2-0-json): ship the Get15118EVCertificate schemas

  • Found by the new resolution test: the module shipped 126 of the 128 OCPP 2.0.1 schemas, so that action failed with validation enabled.

test: pin every action to a shipped schema

  • SchemaResolutionTest in each json module parses an empty payload for every Actions entry, request and response, and rejects an INTERNAL_ERROR.

Breaking change

OcppJsonValidator(specVersion) no longer compiles. Pass the folder holding the schemas, e.g. OcppJsonValidator(SpecVersion.VersionFlag.V4, OcppSchemaFolder.OCPP_1_6). The four bundled parsers are already updated; only code that instantiates OcppJsonValidator directly is affected.

Verification

  • :ocpp-json, :ocpp-1-2-json, :ocpp-1-5-json, :ocpp-1-6-json, :ocpp-2-0-json, :ocpp-1-6-security, :ocpp-transport-websocket, :toolkit test suites green (707 tests; the 4 skips in toolkit are the pre-existing ExampleTest gated on a local SteVe).
  • Built jars checked: 48 / 78 / 128 schemas under their folder, none at the jar root.

…N schemas per OCPP version

OcppJsonValidator resolves schemas as classpath resources by action name,
and the 1.5, 1.6 and 2.0 modules all shipped theirs at the resources root
under overlapping names (57 shared between 1.6 and 2.0, 24 between 1.5 and
1.6). On a shared classpath such as toolkit, getResourceAsStream returns
the oldest jar's file, so a valid OCPP 2.0.1 Authorize request was
rejected against the 1.6 AuthorizeRequest schema.

Move each module's schemas (and their licence notice) into ocpp15/,
ocpp16/ and ocpp20/, and pass the folder from the parsers, as ocpp-1-2-json
already does with ocpp12/. ocpp-1-6-security ships no schemas of its own;
the security-whitepaper ones live in ocpp-1-6-json and follow it.

Add JsonSchemaIsolationTest in toolkit, the only place the collision is
observable: one payload per version, each valid in its own version but
rejected by the older schema that used to shadow it.

Fixes IZIVIA#111
Now that every version module namespaces its schemas, the empty-folder
default only leaves the classpath collision open for a future module.
Make schemaFolder mandatory and reject blank values at construction.

BREAKING CHANGE: OcppJsonValidator(specVersion) no longer compiles; pass
the resource folder holding the schemas, e.g.
OcppJsonValidator(SpecVersion.VersionFlag.V4, "ocpp16").

@pbourseau pbourseau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed with the branch checked out; all claims below were checked by running the builds.

Verification I could reproduce

  • The bug is real and the new test catches it: applying JsonSchemaIsolationTest + the toolkit build change onto dev gives 4 tests, 2 failures (the 2.0 and 1.6 cases). All 4 pass on this branch.
  • :ocpp-1-2-json, :ocpp-1-5-json, :ocpp-1-6-json, :ocpp-2-0-json, :ocpp-1-6-security green (328 tests, 0 failures); :ocpp-json and :toolkit green.
  • The git mv lost nothing: 36 / 48 / 79 / 127 resource files before and after per module, identical basenames, and no file left at a src/main/resources root anywhere in the repo.

The diagnosis and the fix look right to me. Inline notes below, plus two points that have no diff line to hang on:

ocpp-json/build.gradle.kts still declares testImplementation(kotlin("test-junit")). ocpp-json/src/test was empty before this PR, so this is the first code to land in that source set. :ocpp-json:dependencies --configuration testRuntimeClasspath resolves kotlin-test-junit:2.4.0 -> junit:junit:4.13.2, and the runner is useJUnitPlatform() with junit-jupiter-engine but no junit-vintage-engine. A contributor who writes kotlin.test.Test or org.junit.Test in this module gets a test that compiles and silently never runs. coreProject() already supplies jupiter-api, jupiter-params, strikt and mockk, so the line can go.

Description nit: it says the OCA licence notice moves into ocpp15/, ocpp16/ and ocpp20/. There is only one in ocpp-1-6-json and ocpp-2-0-json; ocpp-1-5-json has none (and had none before).

private val schemaFolder: String
) {
init {
require(schemaFolder.isNotBlank()) { "schemaFolder must name the resource folder holding the schemas" }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isNotBlank() leaves a gap that bites exactly where it is hardest to notice: a trailing slash or surrounding whitespace passes this guard.

OcppJsonValidator(V4, "ocpp16/") builds ocpp16//Authorize.json. I checked this on a JVM with a minimal jar and an exploded copy of the same tree:

--- exploded directory on classpath ---
[ocpp16/Authorize.json]   -> FOUND
[ocpp16//Authorize.json]  -> FOUND
[ ocpp16 /Authorize.json] -> null
--- jar on classpath ---
[ocpp16/Authorize.json]   -> FOUND
[ocpp16//Authorize.json]  -> null
[ ocpp16 /Authorize.json] -> null

So a folder with a trailing slash resolves under gradle test and in the IDE, and returns null from the published jar. Green CI, every message rejected in production. schemaFolder.trim().trimEnd('/') in the init block closes it.

Since the constructor signature is breaking anyway, the deeper option is to take a type instead of a String. Something like enum class OcppSchemaFolder(val path: String) { OCPP_1_2("ocpp12"), OCPP_1_5("ocpp15"), OCPP_1_6("ocpp16"), OCPP_2_0("ocpp20") } removes this require, both blank-folder test cases, the whitespace and trailing-slash hazards, and the four hand-copied SCHEMA_FOLDER constants at once, and makes it impossible for a new version module to reuse an existing folder or fall back to the root. (The two existing OcppVersion enums in ocpp-wamp and ocpp-transport are not visible from ocpp-json, so this would want its own type.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took the enum. OcppSchemaFolder (29b19ae) replaces the String: one entry per version, so blank, whitespace and trailing-slash values are unrepresentable and the four SCHEMA_FOLDER constants go away with the require and both blank-folder tests.

val file = "$schemaFolder/$action.json"
val factory: JsonSchemaFactory = JsonSchemaFactory.getInstance(specVersion)
val input: InputStream? = Thread.currentThread().contextClassLoader.getResourceAsStream(file)
val input: InputStream = checkNotNull(Thread.currentThread().contextClassLoader.getResourceAsStream(file)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better than Jackson's argument "in" is null, but this message never reaches a caller going through a parser.

getJsonSchema is reached from OcppJsonParser.parseAnyFromString, whose body is wrapped in a blanket catch (e: Exception). IllegalStateException is not an OcppParserException, so it lands in that catch and comes back as JsonMessage(msgType=CALL_ERROR, errorCode=INTERNAL_ERROR) with the text buried in a stack trace inside errorDetails. Every message for that action gets a generic protocol error and nothing names the missing schema.

OcppJsonValidatorTest.names the missing schema when it is not on the classpath exercises the validator directly and never through a parser, so the suite looks like it covers this when it does not.

Resolving the schemas eagerly at parser construction, or rethrowing as an OcppParserException so it survives the catch, would make a misconfigured folder fail loudly instead of degrading to INTERNAL_ERROR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rethrown as SchemaNotFoundException : OcppParserException (29b19ae), so the parser's catch keeps it and the call error carries ErrorDetail(code="schema", detail="ocpp16/Heartbeat.json"). I did not go for eager loading: WebsocketServer builds a parser per connection, so it would front-load every schema on each one. OcppJsonValidatorTest now also drives a minimal parser subclass end to end and asserts the detail survives the catch; it was red before the change.

private fun getJsonSchema(action: String): JsonSchema {
val file = if (schemaFolder.isEmpty()) "$action.json" else "$schemaFolder/$action.json"
val file = "$schemaFolder/$action.json"
val factory: JsonSchemaFactory = JsonSchemaFactory.getInstance(specVersion)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not introduced here, but the function is being touched: the factory is rebuilt on every cache miss. A long-lived Ocpp20JsonParser that sees the full action set builds 126 JsonSchemaFactory instances, each re-registering the draft meta-schema. Hoisting it to a private val next to config would make it one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hoisted to a private val next to config (29b19ae).

val input: InputStream = checkNotNull(Thread.currentThread().contextClassLoader.getResourceAsStream(file)) {
"Schema $file not found on the classpath"
}
return factory.getSchema(input, config)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stream is not closed if getSchema throws (malformed schema file, unsupported $schema draft). Now that checkNotNull guarantees non-null, input.use { factory.getSchema(it, config) } costs nothing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done with input.use { factory.getSchema(it, config) } (29b19ae).

}

private companion object {
const val SCHEMA_FOLDER = "ocpp16"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This constant and the resource directory are now the contract the whole fix rests on, and nothing checks that they agree. No test in any of the four json modules iterates Actions.entries / Actions.values().

Rename src/main/resources/ocpp16, or add an Actions entry without its schema, and every module suite stays green; JsonSchemaIsolationTest stays green too, since it only exercises Authorize and BootNotification. The break then shows up in production, for that one action, as the swallowed INTERNAL_ERROR described on OcppJsonValidator.kt.

A per-module test asserting that every Actions entry resolves $SCHEMA_FOLDER/<camelCase>Request.json and ...Response.json would pin it, and would also catch the trailing-slash case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added SchemaResolutionTest in each of the four json modules (bbafc09): @EnumSource(Actions::class), one empty-payload parse per action for request and response, rejecting an INTERNAL_ERROR. Verified red by hiding HeartbeatResponse.json from 1.6. It immediately caught a real gap: 2.0 shipped 126 of the 128 spec schemas, Get15118EVCertificate request and response were missing, so that action failed on dev with validation on. Added in 58ebcc6 with a round-trip test.

}

@Test
fun `accepts a schema folder`() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one asserts nothing, and "ocpp16" reads as if it were checking a real folder. ocpp-json does not depend on ocpp-1-6-json, and schema loading is lazy, so the constructor never touches the classpath: replacing "ocpp16" with "x" keeps it green.

All it covers is the negation of the require, which the parameterized test above already covers. Either drop it, or use a literal that does not suggest a folder that is not there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped. With the enum there is nothing left to assert; the remaining validator test targets SchemaNotFoundException and the missing path.

/**
* Every ocpp-*-json module ships schemas under the same action names, and OcppJsonValidator resolves
* them off the classpath. The collision is only observable where several versions share a classpath,
* i.e. here in toolkit. Each payload below is valid in its own version but rejected by the schema of

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Each payload below is valid in its own version but rejected by the schema of the older version that would otherwise shadow it" holds for two of the four. I applied this file and the toolkit build change onto dev: 4 tests, 2 failures, the 2.0 and 1.6 cases.

The 1.5 case passes on dev because classpath order already favours ocpp-1-5-json, and the 1.2 case cannot fail at all since ocpp12/ was namespaced in #107. Both are still worth keeping as forward-looking guards, but someone reading this doc will take all four for reproductions and may drop one of the new testImplementation lines in toolkit/build.gradle.kts without realising it weakens the guard. Worth saying which two reproduce the collision and which two are guards.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded the class doc (bbafc09): 2.0 and 1.6 reproduce #111, 1.5 and 1.2 are guards, and each case needs its module on the toolkit test classpath.

…ema in the call error

A String folder still let a trailing slash or surrounding whitespace through:
"ocpp16/" resolves from an exploded directory (gradle test, IDE) and returns
null from the published jar. Replace it with OcppSchemaFolder, one entry per
version, so the folders are distinct and well-formed by construction, and drop
the blank-folder guard and the four hand-copied constants with it.

A missing schema used to land in OcppJsonParser's blanket catch as a bare
INTERNAL_ERROR with the file name buried in a stack trace. Raise
SchemaNotFoundException, an OcppParserException, so the call error carries the
schema path in its details. Loading stays lazy: parsers are built per
connection and eager loading would front-load every schema on each one.

Also build the JsonSchemaFactory once per validator instead of once per cache
miss, close the schema stream if parsing throws, and drop kotlin("test-junit")
from ocpp-json now that it has tests: the runner is JUnit Platform without a
vintage engine, so JUnit 4 tests would compile and silently never run.

BREAKING CHANGE: OcppJsonValidator takes an OcppSchemaFolder instead of a
String, e.g. OcppJsonValidator(SpecVersion.VersionFlag.V4, OcppSchemaFolder.OCPP_1_6).
The OCPP 2.0.1 part 3 set has 128 schemas; the module shipped 126. With
validation enabled every Get15118EVCertificate request and response failed
with an internal error. Add the two files from the specification, with the
"$id" removed like the others.
Nothing checked that a parser's schema folder and the resources agree: renaming
the folder or adding an action without its schema kept every suite green and
broke in production for that one action. SchemaResolutionTest parses an empty
payload for every Actions entry, request and response, and rejects an
INTERNAL_ERROR, which is how a missing schema now surfaces. It is what caught
the Get15118EVCertificate gap.

Also state in JsonSchemaIsolationTest which two cases reproduce IZIVIA#111 and which
two are guards, so nobody drops a toolkit test dependency thinking it is unused.
@juherr

juherr commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough pass. All inline threads answered and addressed in 29b19ae, 58ebcc6 and bbafc09. On the two points without a diff line: kotlin("test-junit") is gone from ocpp-json/build.gradle.kts (29b19ae), and the description now says which modules carry the licence notice. The per-action resolution test also turned up a pre-existing gap, Get15118EVCertificate had no schema in ocpp-2-0-json, fixed in 58ebcc6.

@pbourseau pbourseau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at bbafc09. All nine points are addressed, and the exhaustive test earned its keep: ocpp-2-0-json shipping 126 of the 128 OCPP 2.0.1 schemas is a production bug in its own right, independent of the classpath collision this PR set out to fix. Nice catch.

Checked on this head: :ocpp-json, the four ocpp-*-json modules, :ocpp-1-6-security and :toolkit are green, 701 tests. OcppSchemaFolder makes the trailing-slash case unrepresentable, the factory is built once, the stream is closed, the JUnit 4 dependency is gone, and SchemaNotFoundException does carry the schema path into the call error — the new parser test proves it.

One thing that path still drops, inline.

* Raised when a schema is missing from the classpath. Extends [OcppParserException] so that
* [OcppJsonParser] reports the schema in the returned call error instead of a bare internal error.
*/
class SchemaNotFoundException(schema: String) : OcppParserException(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only OcppParserException subclass that does not carry messageId.

Every sibling in utils/Exceptions.kt — ValidationException, FormatViolationException, ActionRequestNullOrUnknownException, MessageTypeException, MalformedOcppMessageException, ActionResponseNotSpecifiedException — declares override val messageId: String? and forwards it. This one takes the OcppParserException default of null, and OcppJsonParser.jsonMessage then substitutes "Unknown".

Measured on this head, parsing [2,"REQ-42","Heartbeat",{}] against a missing schema:

sent msgId=REQ-42 | returned msgId=Unknown | errorCode=INTERNAL_ERROR

OCPP-J requires a CALL_ERROR to echo the MessageId of the CALL it answers, so the charging station cannot correlate this one and waits out its timeout instead. It was equally "Unknown" before, through the blanket catch — but routing this through OcppParserException was precisely about making the call error usable, and the id is available at the call site: validateJson has jsonMessage.msgId, so catching and rethrowing with it (or threading it through isValidObject) would finish the job.

reports the missing schema in the call error returned by a parser already sends "messageId" and asserts the details — one more assertion there would pin it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, that was half a fix. isValidObject now takes the messageId (no default, so a caller cannot drop it) and SchemaNotFoundException forwards it like its siblings (e099434). The parser-level test asserts msgId == "messageId" on the call error; it failed with Unknown before the change.

SchemaNotFoundException was the only OcppParserException without a messageId,
so OcppJsonParser answered with msgId "Unknown" and the charging station could
not correlate the CALL_ERROR with its CALL. Thread the id through
isValidObject, where every parser has it, and pin it in the parser-level test.
@sonarqubecloud

Copy link
Copy Markdown

@pbourseau
pbourseau merged commit a635e98 into IZIVIA:dev Oct 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: OCPP 2.0.1 payloads are validated against the OCPP 1.6 schemas when several versions share a classpath

2 participants