diff --git a/.gitignore b/.gitignore index 6679bc0..784afff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ # IntelliJ/IDEA: -/.idea +.idea *.iml # results of running validations: /out + diff --git a/certlogic/certlogic-js/CHANGELOG.md b/certlogic/certlogic-js/CHANGELOG.md index 051212f..bed301a 100644 --- a/certlogic/certlogic-js/CHANGELOG.md +++ b/certlogic/certlogic-js/CHANGELOG.md @@ -1,12 +1,17 @@ # Change log +## 0.9.0 + +* Implement the `extractFromUVCI` operation + + ## 0.8.2 -* Integrate everything from the `certlogic-validation` NPM package back into this `certlogic-js` NPM package. +* Integrate everything from the `certlogic-validation` NPM package back into this `certlogic-js` NPM package ## 0.8.1 * Invalid data access (`var` operation) on `null` now also throws -* Fixed bugs in `certlogic-validation` +* Fix bugs in `certlogic-validation` diff --git a/certlogic/certlogic-js/README.md b/certlogic/certlogic-js/README.md index 61ee091..49b40b6 100644 --- a/certlogic/certlogic-js/README.md +++ b/certlogic/certlogic-js/README.md @@ -4,7 +4,7 @@ It is a [specified](https://github.com/ehn-dcc-development/dgc-business-rules/blob/main/certlogic/specification/README.md) subset of [JsonLogic](https://jsonlogic.com/), extended with necessary custom operations - e.g. for working with dates. It's part of the efforts surrounding the [Digital COVID Certificate](https://ec.europa.eu/info/live-work-travel-eu/coronavirus-response/safe-covid-19-vaccines-europeans/eu-digital-covid-certificate_en), and as such serves as the basis for defining _interchangeable_ validation rules on top of the DCC. -This NPM package consists of an implementation of CertLogic in JavaScript(/TypeScript) which is compatible with version **1.1.0** of [the specification](https://github.com/ehn-dcc-development/dgc-business-rules/tree/main/certlogic/specification/README.md). +This NPM package consists of an implementation of CertLogic in JavaScript(/TypeScript) which is compatible with version **1.2.0** of [the specification](https://github.com/ehn-dcc-development/dgc-business-rules/tree/main/certlogic/specification/README.md). ## API diff --git a/certlogic/certlogic-js/package.json b/certlogic/certlogic-js/package.json index be1ba39..74e80a2 100644 --- a/certlogic/certlogic-js/package.json +++ b/certlogic/certlogic-js/package.json @@ -1,6 +1,6 @@ { "name": "certlogic-js", - "version": "0.8.2", + "version": "0.9.0", "description": "Implementation of CertLogic in TypeScript (including validation).", "keywords": [ "json", @@ -21,10 +21,10 @@ "main": "dist/index.js", "scripts": { "build": "tsc", - "build-watch": "tsc --watch --incremental", + "watch-build": "tsc --watch --incremental", "pretest": "npm run build", - "test": "mocha dist/test", - "test-watch": "mocha --watch dist/test", + "test": "mocha --recursive dist/test", + "watch-test": "mocha --watch --recursive dist/test", "clean": "rm -rf dist/ && rm -rf node_modules/ && rm -rf package-lock.json && rm -rf yarn.lock" }, "bin": { diff --git a/certlogic/certlogic-js/src/evaluator.ts b/certlogic/certlogic-js/src/evaluator.ts index 7286949..c44b77d 100644 --- a/certlogic/certlogic-js/src/evaluator.ts +++ b/certlogic/certlogic-js/src/evaluator.ts @@ -1,5 +1,5 @@ import { CertLogicExpression, TimeUnit, timeUnits } from "./typings" -import { isDate, isFalsy, isInt, isTruthy, plusTime } from "./internals" +import { extractFromUVCI, isDate, isFalsy, isInt, isTruthy, plusTime } from "./internals" const evaluateVar = (value: any, data: any): any => { @@ -192,6 +192,18 @@ const evaluateReduce = (operand: CertLogicExpression, lambda: CertLogicExpressio } +const evaluateExtractFromUVCI = (operand: CertLogicExpression, index: CertLogicExpression, data: any): string | null => { + const evalOperand = evaluate(operand, data) + if (!(evalOperand === null || typeof evalOperand === "string")) { + throw new Error(`"UVCI" argument (#1) of "extractFromUVCI" must be either a string or null`) + } + if (!isInt(index)) { + throw new Error(`"index" argument (#2) of "extractFromUVCI" must be an integer`) + } + return extractFromUVCI(evalOperand, index) +} + + export const evaluate = (expr: CertLogicExpression, data: any): any => { if (typeof expr === "string" || isInt(expr) || typeof expr === "boolean") { return expr @@ -231,6 +243,9 @@ export const evaluate = (expr: CertLogicExpression, data: any): any => { if (operator === "reduce") { return evaluateReduce(values[0], values[1], values[2], data) } + if (operator === "extractFromUVCI") { + return evaluateExtractFromUVCI(values[0], values[1], data) + } throw new Error(`unrecognised operator: "${operator}"`) } throw new Error(`invalid CertLogic expression: ${expr}`) diff --git a/certlogic/certlogic-js/src/internals.ts b/certlogic/certlogic-js/src/internals.ts index 2bce56e..9748c49 100644 --- a/certlogic/certlogic-js/src/internals.ts +++ b/certlogic/certlogic-js/src/internals.ts @@ -85,3 +85,20 @@ export const plusTime = (dateTimeLikeStr: string, amount: number, unit: TimeUnit return dateTime } + +const optionalPrefix = "URN:UVCI:" +/** + * @returns The fragment with given index from the UVCI string + * (see Annex 2 in the [UVCI specification](https://ec.europa.eu/health/sites/default/files/ehealth/docs/vaccination-proof_interoperability-guidelines_en.pdf)), + * or `null` when that fragment doesn't exist. + */ +export const extractFromUVCI = (uvci: string | null, index: number): string | null => { + if (uvci === null || index < 0) { + return null + } + const prefixlessUvci = uvci.startsWith(optionalPrefix) ? uvci.substring(optionalPrefix.length) : uvci + const fragments = prefixlessUvci.split(/[/#:]/) + return index < fragments.length ? fragments[index] : null +} + + diff --git a/certlogic/certlogic-js/src/test/test-internals.ts b/certlogic/certlogic-js/src/test/test-internals.ts index 0111cc1..bb31ec8 100644 --- a/certlogic/certlogic-js/src/test/test-internals.ts +++ b/certlogic/certlogic-js/src/test/test-internals.ts @@ -1,6 +1,6 @@ const { equal, isFalse, isTrue } = require("chai").assert -import { dateFromString, isFalsy, isTruthy, plusTime } from "../internals" +import { dateFromString, extractFromUVCI, isFalsy, isTruthy, plusTime } from "../internals" import { TimeUnit } from "../typings" @@ -147,3 +147,53 @@ describe("plusTime", () => { }) + +describe("extractFromUVCI", () => { + + it("returns null on null operand", () => { + equal(extractFromUVCI(null, -1), null) + equal(extractFromUVCI(null, 0), null) + equal(extractFromUVCI(null, 1), null) + }) + + it("works correctly on an empty string", () => { + equal(extractFromUVCI("", -1), null) + equal(extractFromUVCI("", 0), "") + equal(extractFromUVCI("", 1), null) + }) + + it("foo/bar::baz#999lizards (without optional prefix)", () => { + const uvci = "foo/bar::baz#999lizards" + equal(extractFromUVCI(uvci, -1), null) + equal(extractFromUVCI(uvci, 0), "foo") + equal(extractFromUVCI(uvci, 1), "bar") + equal(extractFromUVCI(uvci, 2), "") // not null, but still falsy + equal(extractFromUVCI(uvci, 3), "baz") + equal(extractFromUVCI(uvci, 4), "999lizards") + equal(extractFromUVCI(uvci, 5), null) + }) + + it("foo/bar::baz#999lizards (with optional prefix)", () => { + const uvci = "URN:UVCI:foo/bar::baz#999lizards" + equal(extractFromUVCI(uvci, -1), null) + equal(extractFromUVCI(uvci, 0), "foo") + equal(extractFromUVCI(uvci, 1), "bar") + equal(extractFromUVCI(uvci, 2), "") // not null, but still falsy + equal(extractFromUVCI(uvci, 3), "baz") + equal(extractFromUVCI(uvci, 4), "999lizards") + equal(extractFromUVCI(uvci, 5), null) + }) + + // the example from the specification: + it("each separator adds a fragment", () => { + const uvci = "a::c/#/f" + equal(extractFromUVCI(uvci, 0), "a") + equal(extractFromUVCI(uvci, 1), "") + equal(extractFromUVCI(uvci, 2), "c") + equal(extractFromUVCI(uvci, 3), "") + equal(extractFromUVCI(uvci, 4), "") + equal(extractFromUVCI(uvci, 5), "f") + }) + +}) + diff --git a/certlogic/certlogic-js/src/test/validation/validate-testSuite.ts b/certlogic/certlogic-js/src/test/validation/validate-testSuite.ts index 949dd04..a2076cc 100644 --- a/certlogic/certlogic-js/src/test/validation/validate-testSuite.ts +++ b/certlogic/certlogic-js/src/test/validation/validate-testSuite.ts @@ -4,7 +4,7 @@ import { readdirSync, readFileSync } from "fs" import { validate } from "../../validation/index" -const testSuitesPath = join(__dirname, "../../../specification/testSuite") +const testSuitesPath = join(__dirname, "../../../../specification/testSuite") describe("test suites", () => { diff --git a/certlogic/certlogic-js/src/validation/format-validator.ts b/certlogic/certlogic-js/src/validation/format-validator.ts index 3c71008..eac53e6 100644 --- a/certlogic/certlogic-js/src/validation/format-validator.ts +++ b/certlogic/certlogic-js/src/validation/format-validator.ts @@ -1,6 +1,7 @@ import { isInt } from "../internals" import { ValidationError } from "./typings" +import { timeUnits } from "../typings" const validateVar = (expr: any, values: any): ValidationError[] => { @@ -76,8 +77,8 @@ const validatePlusTime = (expr: any, values: any[]): ValidationError[] => { if (values[1] !== undefined && !isInt(values[1])) { errors.push({ expr, message: `"amount" argument (#2) of "plusTime" must be an integer, but it is: ${values[1]}` }) } - if (values[2] !== undefined && [ "year", "month", "day", "hour" ].indexOf(values[2]) === -1) { // FIXME should be able to use certlogic-js.timeUnits! - throw new Error(`"unit" argument (#3) of "plusTime" must be a string 'day' or 'hour', but it is: ${values[2]}`) + if (values[2] !== undefined && timeUnits.indexOf(values[2]) === -1) { + throw new Error(`"unit" argument (#3) of "plusTime" must be a string equal to one of ${timeUnits.join(", ")}, but it is: ${values[2]}`) } return errors } @@ -91,6 +92,20 @@ const validateReduce = (expr: any, values: any[]): ValidationError[] => { return errors } +const validateExtractFromUVCI = (expr: any, values: any[]): ValidationError[] => { + const errors = [] + if (values.length !== 2) { + errors.push({ expr, message: `an "extractFromUVCI"-operation must have exactly 2 values/operands, but it has ${values.length}` }) + } + if (values[0] !== undefined) { + errors.push(...validate(values[0])) + } + if (values[1] !== undefined && !isInt(values[1])) { + errors.push({ expr, message: `"index" argument (#2) of "extractFromUVCI" must be an integer, but it is: ${values[1]}` }) + } + return errors +} + const validate = (expr: any): ValidationError[] => { const withError = (message: string): ValidationError[] => [ { expr, message } ] if (typeof expr === "string" || isInt(expr) || typeof expr === "boolean") { @@ -133,6 +148,9 @@ const validate = (expr: any): ValidationError[] => { if (operator === "reduce") { return validateReduce(expr, values) } + if (operator === "extractFromUVCI") { + return validateExtractFromUVCI(expr, values) + } return withError(`unrecognised operator: "${operator}"`) } return withError(`invalid CertLogic expression`) diff --git a/certlogic/certlogic-kotlin/CHANGELOG.md b/certlogic/certlogic-kotlin/CHANGELOG.md index b453957..901a7cc 100644 --- a/certlogic/certlogic-kotlin/CHANGELOG.md +++ b/certlogic/certlogic-kotlin/CHANGELOG.md @@ -1,8 +1,13 @@ # Change log +## 0.9.0 + +* Implement the `extractFromUVCI` operation + + ## 0.8.1 +* Implement a validator * Invalid data access (`var` operation) on `null` now also throws -* Implemented validator diff --git a/certlogic/certlogic-kotlin/README.md b/certlogic/certlogic-kotlin/README.md index c4daed9..272d3a4 100644 --- a/certlogic/certlogic-kotlin/README.md +++ b/certlogic/certlogic-kotlin/README.md @@ -1,7 +1,7 @@ # CertLogic in Kotlin This module contains the reference implementation of CertLogic, written in Kotlin/Java. -It's compatible with version **1.1.0** of the CertLogic specification. +It's compatible with version **1.2.0** of the CertLogic specification. Apart from test sources, it consists of the following files: diff --git a/certlogic/certlogic-kotlin/src/main/kotlin/eu/ehn/dcc/certlogic/certlogic.kt b/certlogic/certlogic-kotlin/src/main/kotlin/eu/ehn/dcc/certlogic/certlogic.kt index 6cc6edf..0ec89fb 100644 --- a/certlogic/certlogic-kotlin/src/main/kotlin/eu/ehn/dcc/certlogic/certlogic.kt +++ b/certlogic/certlogic-kotlin/src/main/kotlin/eu/ehn/dcc/certlogic/certlogic.kt @@ -4,26 +4,6 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.* -internal fun isFalsy(value: JsonNode): Boolean = when (value) { - is BooleanNode -> value == BooleanNode.FALSE - is NullNode -> true - is TextNode -> value.textValue().isEmpty() - is IntNode -> value.intValue() == 0 - is ArrayNode -> value.size() == 0 - is ObjectNode -> value.size() == 0 - else -> false -} - -internal fun isTruthy(value: JsonNode): Boolean = when (value) { - is BooleanNode -> value == BooleanNode.TRUE - is TextNode -> value.textValue().isNotEmpty() - is IntNode -> value.intValue() != 0 - is ArrayNode -> value.size() > 0 - is ObjectNode -> value.size() > 0 - else -> false -} - - internal fun evaluateVar(args: JsonNode, data: JsonNode): JsonNode { if (args !is TextNode) { throw RuntimeException("not of the form { \"var\": \"\" }") @@ -59,31 +39,6 @@ internal fun evaluateIf(guard: JsonNode, then: JsonNode, else_: JsonNode, data: } -internal fun intCompare(operator: String, l: Int, r: Int): Boolean = - when (operator) { - "<" -> l < r - ">" -> l > r - "<=" -> l <= r - ">=" -> l >= r - else -> throw RuntimeException("unhandled comparison operator \"$operator\"") - } - -internal fun > compare(operator: String, args: List): Boolean = - when (args.size) { - 2 -> intCompare(operator, args[0].compareTo(args[1]), 0) - 3 -> intCompare(operator, args[0].compareTo(args[1]), 0) && intCompare(operator, args[1].compareTo(args[2]), 0) - else -> throw RuntimeException("invalid number of operands to a \"$operator\" operation") - } - -internal fun comparisonOperatorForDateTimeComparison(operator: String): String = - when (operator) { - "after" -> ">" - "before" -> "<" - "not-after" -> "<=" - "not-before" -> ">=" - else -> throw RuntimeException("unhandled date-time comparison operator \"$operator\"") - } - internal fun evaluateInfix(operator: String, args: ArrayNode, data: JsonNode): JsonNode { when (operator) { "and" -> if (args.size() < 2) throw RuntimeException("an \"and\" operation must have at least 2 operands") @@ -194,6 +149,19 @@ internal fun evaluateReduce(operand: JsonNode, lambda: JsonNode, initial: JsonNo } +internal fun evaluateExtractFromUVCI(operand: JsonNode, index: JsonNode, data: JsonNode): JsonNode { + val evalOperand = evaluate(operand, data) + if (!(evalOperand is NullNode || evalOperand is TextNode)) { + throw RuntimeException("\"UVCI\" argument (#1) of \"extractFromUVCI\" must be either a string or null") + } + if (index !is IntNode) { + throw RuntimeException("\"index\" argument (#2) of \"extractFromUVCI\" must be an integer") + } + val result = extractFromUVCI(if (evalOperand is TextNode) evalOperand.asText() else null, index.intValue()) + return if (result == null) NullNode.instance else TextNode.valueOf(result) +} + + fun evaluate(expr: JsonNode, data: JsonNode): JsonNode = when (expr) { is TextNode -> expr is IntNode -> expr @@ -217,6 +185,7 @@ fun evaluate(expr: JsonNode, data: JsonNode): JsonNode = when (expr) { "!" -> evaluateNot(args[0], data) "plusTime" -> evaluatePlusTime(args[0], args[1], args[2], data) "reduce" -> evaluateReduce(args[0], args[1], args[2], data) + "extractFromUVCI" -> evaluateExtractFromUVCI(args[0], args[1], data) else -> throw RuntimeException("unrecognised operator: \"$operator\"") } } diff --git a/certlogic/certlogic-kotlin/src/main/kotlin/eu/ehn/dcc/certlogic/internals.kt b/certlogic/certlogic-kotlin/src/main/kotlin/eu/ehn/dcc/certlogic/internals.kt new file mode 100644 index 0000000..f25ccbf --- /dev/null +++ b/certlogic/certlogic-kotlin/src/main/kotlin/eu/ehn/dcc/certlogic/internals.kt @@ -0,0 +1,62 @@ +package eu.ehn.dcc.certlogic + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.* + + +internal fun isFalsy(value: JsonNode): Boolean = when (value) { + is BooleanNode -> value == BooleanNode.FALSE + is NullNode -> true + is TextNode -> value.textValue().isEmpty() + is IntNode -> value.intValue() == 0 + is ArrayNode -> value.size() == 0 + is ObjectNode -> value.size() == 0 + else -> false +} + +internal fun isTruthy(value: JsonNode): Boolean = when (value) { + is BooleanNode -> value == BooleanNode.TRUE + is TextNode -> value.textValue().isNotEmpty() + is IntNode -> value.intValue() != 0 + is ArrayNode -> value.size() > 0 + is ObjectNode -> value.size() > 0 + else -> false +} + + +internal fun intCompare(operator: String, l: Int, r: Int): Boolean = + when (operator) { + "<" -> l < r + ">" -> l > r + "<=" -> l <= r + ">=" -> l >= r + else -> throw RuntimeException("unhandled comparison operator \"$operator\"") + } + +internal fun > compare(operator: String, args: List): Boolean = + when (args.size) { + 2 -> intCompare(operator, args[0].compareTo(args[1]), 0) + 3 -> intCompare(operator, args[0].compareTo(args[1]), 0) && intCompare(operator, args[1].compareTo(args[2]), 0) + else -> throw RuntimeException("invalid number of operands to a \"$operator\" operation") + } + +internal fun comparisonOperatorForDateTimeComparison(operator: String): String = + when (operator) { + "after" -> ">" + "before" -> "<" + "not-after" -> "<=" + "not-before" -> ">=" + else -> throw RuntimeException("unhandled date-time comparison operator \"$operator\"") + } + + +internal val optionalPrefix = "URN:UVCI:" +internal fun extractFromUVCI(uvci: String?, index: Int): String? { + if (uvci == null || index < 0) { + return null + } + val prefixlessUvci = if (uvci.startsWith(optionalPrefix)) uvci.substring(optionalPrefix.length) else uvci + val fragments = prefixlessUvci.split(Regex("[/#:]")) + return if (index < fragments.size) fragments[index] else null +} + diff --git a/certlogic/certlogic-kotlin/src/main/kotlin/eu/ehn/dcc/certlogic/validator.kt b/certlogic/certlogic-kotlin/src/main/kotlin/eu/ehn/dcc/certlogic/validator.kt index a77e039..9d8f34f 100644 --- a/certlogic/certlogic-kotlin/src/main/kotlin/eu/ehn/dcc/certlogic/validator.kt +++ b/certlogic/certlogic-kotlin/src/main/kotlin/eu/ehn/dcc/certlogic/validator.kt @@ -58,7 +58,12 @@ internal fun validatePlusTime(expr: JsonNode, values: ArrayNode): List = + ( + if (values.size() == 2) + emptyList() + else + listOf(ValidationError(expr, "an \"extractFromUVCI\"-operation must have exactly 2 values/operands, but it has ${values.size()}")) + ) + + ( + if (values.has(0)) + validate(values[0]) + else + emptyList() + ) + + ( + if (values.has(1) && values[1] !is IntNode) + listOf(ValidationError(expr, "\"index\" argument (#2) of \"extractFromUVCI\" must be an integer, but it is: ${values[1]}")) + else + emptyList() + ) + + fun validate(expr: JsonNode): List { fun withError(message: String) = listOf(ValidationError(expr, message)) return when (expr) { @@ -106,6 +132,7 @@ fun validate(expr: JsonNode): List { "!" -> validateNot(expr, args) "plusTime" -> validatePlusTime(expr, args) "reduce" -> validateReduce(expr, args) + "extractFromUVCI" -> validateExtractFromUVCI(expr, args) else -> withError("unrecognised operator: \"$operator\"") } } diff --git a/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/JsonDateTimeTests.kt b/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/JsonDateTimeTests.kt index afa2788..a4c1445 100644 --- a/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/JsonDateTimeTests.kt +++ b/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/JsonDateTimeTests.kt @@ -6,7 +6,7 @@ import kotlin.test.assertEquals /** * Should be in sync with the `describe("parsing of dates/date-times", ...`-part of `test-internals.ts` from `certlogic-js`. */ -class JsonDateTimeTests { +internal class JsonDateTimeTests { fun check(dateTimeLike: String, expected: String, message: String? = null) { assertEquals(expected, JsonDateTime.fromString(dateTimeLike).asText(), message) diff --git a/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/PlusTimeTests.kt b/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/PlusTimeTests.kt index 766c759..f61ff58 100644 --- a/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/PlusTimeTests.kt +++ b/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/PlusTimeTests.kt @@ -7,7 +7,7 @@ import kotlin.test.assertEquals * Should be in sync with `describe("plusTime", ...`-part of `test-internals.ts` from `certlogic-js`, * except for the `it("yields comparable values"`-part of that which is already covered by the test suite. */ -class PlusTimeTests { +internal class PlusTimeTests { fun check(dateTimeLike: String, amount: Int, unit: TimeUnit, expected: String) { assertEquals(expected, JsonDateTime.fromString(dateTimeLike).plusTime(amount, unit).asText()) diff --git a/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/certlogicTests.kt b/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/certlogicTests.kt index 5df833e..8157b00 100644 --- a/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/certlogicTests.kt +++ b/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/certlogicTests.kt @@ -1,47 +1,15 @@ package eu.ehn.dcc.certlogic import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.* +import com.fasterxml.jackson.databind.node.NullNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue -import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import java.io.File internal class CertLogicTests { - @Test - fun `test isTruthy`() { - // (no undefined) - assertFalse(isTruthy(NullNode.instance)) - assertFalse(isTruthy(BooleanNode.FALSE)) - assertTrue(isTruthy(BooleanNode.TRUE)) - assertFalse(isTruthy(JsonNodeFactory.instance.arrayNode()), "empty array") - assertTrue(isTruthy(JsonNodeFactory.instance.arrayNode().add(TextNode.valueOf("foo"))), "non-empty array") - assertFalse(isTruthy(JsonNodeFactory.instance.objectNode()), "empty object") - assertTrue(isTruthy(JsonNodeFactory.instance.objectNode().put("foo", "bar")), "non-empty object") - assertTrue(isTruthy(TextNode.valueOf("foo"))) - assertFalse(isTruthy(TextNode.valueOf(""))) - assertTrue(isTruthy(IntNode.valueOf(42))) - assertFalse(isTruthy(IntNode.valueOf(0))) - } - - @Test - fun `test isFalsy`() { - // (no undefined) - assertTrue(isFalsy(NullNode.instance)) - assertTrue(isFalsy(BooleanNode.FALSE)) - assertFalse(isFalsy(BooleanNode.TRUE)) - assertTrue(isFalsy(JsonNodeFactory.instance.arrayNode()), "empty array") - assertFalse(isFalsy(JsonNodeFactory.instance.arrayNode().add(TextNode.valueOf("foo"))), "non-empty array") - assertTrue(isFalsy(JsonNodeFactory.instance.objectNode()), "empty object") - assertTrue(isTruthy(JsonNodeFactory.instance.objectNode().put("foo", "bar")), "non-empty object") - assertFalse(isFalsy(TextNode.valueOf("foo"))) - assertTrue(isFalsy(TextNode.valueOf(""))) - assertFalse(isFalsy(IntNode.valueOf(42))) - assertTrue(isFalsy(IntNode.valueOf(0))) - } - @Test fun `test all test suites from disk`() { allTestSuites().map { it.run() } diff --git a/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/internalTests.kt b/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/internalTests.kt new file mode 100644 index 0000000..e4fc650 --- /dev/null +++ b/certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/internalTests.kt @@ -0,0 +1,136 @@ +package eu.ehn.dcc.certlogic + +import com.fasterxml.jackson.databind.node.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +/** + * Should be in sync with the `describe("truthy and falsy", ...`-part of `test-internals.ts` from `certlogic-js`. + */ +internal class TruthyFalsyTests { + + @Test + fun `test isTruthy`() { + // (no undefined) + assertFalse(isTruthy(NullNode.instance)) + assertFalse(isTruthy(BooleanNode.FALSE)) + assertTrue(isTruthy(BooleanNode.TRUE)) + assertFalse(isTruthy(JsonNodeFactory.instance.arrayNode()), "empty array") + assertTrue( + isTruthy(JsonNodeFactory.instance.arrayNode().add(TextNode.valueOf("foo"))), + "non-empty array" + ) + assertFalse(isTruthy(JsonNodeFactory.instance.objectNode()), "empty object") + assertTrue(isTruthy(JsonNodeFactory.instance.objectNode().put("foo", "bar")), "non-empty object") + assertTrue(isTruthy(TextNode.valueOf("foo"))) + assertFalse(isTruthy(TextNode.valueOf(""))) + assertTrue(isTruthy(IntNode.valueOf(42))) + assertFalse(isTruthy(IntNode.valueOf(0))) + } + + @Test + fun `test isFalsy`() { + // (no undefined) + assertTrue(isFalsy(NullNode.instance)) + assertTrue(isFalsy(BooleanNode.FALSE)) + assertFalse(isFalsy(BooleanNode.TRUE)) + assertTrue(isFalsy(JsonNodeFactory.instance.arrayNode()), "empty array") + assertFalse( + isFalsy(JsonNodeFactory.instance.arrayNode().add(TextNode.valueOf("foo"))), + "non-empty array" + ) + assertTrue(isFalsy(JsonNodeFactory.instance.objectNode()), "empty object") + assertTrue(isTruthy(JsonNodeFactory.instance.objectNode().put("foo", "bar")), "non-empty object") + assertFalse(isFalsy(TextNode.valueOf("foo"))) + assertTrue(isFalsy(TextNode.valueOf(""))) + assertFalse(isFalsy(IntNode.valueOf(42))) + assertTrue(isFalsy(IntNode.valueOf(0))) + } + +} + + +/** + * Should be in sync with the `describe("extractFromUVCI", ...`-part of `test-internals.ts` from `certlogic-js`. + */ +internal class ExtractFromUVCI { + + fun checkForThat(uvci: String?, assertions: List>): Unit = + assertions.forEach { + assertEquals(it.second, extractFromUVCI(uvci, it.first)) + } + + @Test + fun `returns null on null operand`() { + checkForThat( + null, + listOf( + -1 to null, + 0 to null, + 1 to null + ) + ) + } + + @Test + fun `works correctly on an empty string`() { + checkForThat( + "", + listOf( + -1 to null, + 0 to "", + 1 to null + ) + ) + } + + @Test + fun `that thing that testers do (without optional prefix)`() { + checkForThat( + "foo/bar::baz#999lizards", + listOf( + -1 to null, + 0 to "foo", + 1 to "bar", + 2 to "", // not null, but still falsy + 3 to "baz", + 4 to "999lizards", + 5 to null + ) + ) + } + + @Test + fun `that thing that testers do (with optional prefix)`() { + checkForThat( + "URN:UVCI:foo/bar::baz#999lizards", + listOf( + -1 to null, + 0 to "foo", + 1 to "bar", + 2 to "", // not null, but still falsy + 3 to "baz", + 4 to "999lizards", + 5 to null + ) + ) + } + + // the example from the specification: + @Test + fun `each separator adds a fragment`() { + checkForThat( + "a::c/#/f", + listOf( + 0 to "a", + 1 to "", + 2 to "c", + 3 to "", + 4 to "", + 5 to "f" + ) + ) + } + +} + diff --git a/certlogic/specification/release-notes.md b/certlogic/specification/CHANGELOG.md similarity index 83% rename from certlogic/specification/release-notes.md rename to certlogic/specification/CHANGELOG.md index 7ca288e..45a6914 100644 --- a/certlogic/specification/release-notes.md +++ b/certlogic/specification/CHANGELOG.md @@ -1,7 +1,8 @@ -# Release notes +# Change log | Version | Date | Changes | |---|---|---| +| 1.2.0 | 20210729 | Add an `extractFromUVCI` operation. | 1.1.0 | 20210709 | Add “month” and “year” to the time units available for the `plusTime` operation. | 1.0.1 | 20210705 | Make parsing of dates and date-times (timestamps) more lax to not require a timezone offset. | 1.0.0 | 20210702 | (First explicitly versioned version of the specification.) diff --git a/certlogic/specification/README.md b/certlogic/specification/README.md index 6c9014d..cdfeca8 100644 --- a/certlogic/specification/README.md +++ b/certlogic/specification/README.md @@ -3,7 +3,7 @@ ## Version -The semantic version identification of this specification is: **1.1.0**. +The semantic version identification of this specification is: **1.2.0**. The version identification of implementations don't have to be in sync. Rather, implementations should specify with which version of the specification they're compatible. @@ -236,6 +236,34 @@ All other special array operations can be implemented using (only) a `reduce` op To be able to access values in the original data context, CertLogic *may* expand beyond JsonLogic at some point by also adding a key-value pair with key `"data"` to the data object passed to the ``, whose value is the original data context. +### Extract data from an UVCI (`extractFromUVCI`) + +A DCC can contain UVCIs. +Use cases exist which make it necessary to make decisions based on information contained in a UVCI. +For more background information on the UVCI format, and design decisions around this operation: see [here](../../documentation/design-choices.md#operation-extract-from-UVCI). + +An UVCI-extraction operation has the following form: + + { + "extractFromUVCI": [ + , + + ] + } + +The `` must be a string value, or `null`: anything else is an error. +The `` must be an integer. +If the operand is `null`, `null` will be returned. + +The `extractFromUVCI` operation tries to interpret the given operand (now assumed to be not `null`, and a string) as a UVCI string according to Annex 2 in the [UVCI specification](https://ec.europa.eu/health/sites/default/files/ehealth/docs/vaccination-proof_interoperability-guidelines_en.pdf). +It's *not* checked for compliance with this specification: see the [design decisions](../../documentation/design-choices.md#operation-extract-from-UVCI) for an explanation why that is. + +The string is split on separator characters (`/`, `#`, `:`) into string fragments. +The operation returns the string fragment with the given `` (0-based), or `null` if no fragment with that index exists. +The `URN:UVCI:` prefix is optional, and initial fragments `[ "URN", "UVCI" ]` will be ignored. +The string `"a::c/#/f"` contains 6 fragments: `"a"`, `""`, `"c"`, `""`, `""`, `"f"`. + + ## Other aspects @@ -266,3 +294,9 @@ Two JSON Schemas are provided as part of this specification: A validator is provided in the form of the [`certlogic-js/validation` NPM sub package](../certlogic-js/README.md). + +### Differences with JsonLogic implementations + +CertLogic is a subset of JsonLogic, but with custom operations that are specific to the domain of DCC added - currently: `plusTime`, and `extractFromUCVI`. +Implementors of the DCC validator using a JsonLogic implementation instead of a CertLogic implementation need to provide these custom operations to JsonLogic as well - see the [first paragraph of this document](../../documentation/implementations.md). + diff --git a/certlogic/specification/testSuite/extractFromUCVI.json b/certlogic/specification/testSuite/extractFromUCVI.json new file mode 100644 index 0000000..ed6405d --- /dev/null +++ b/certlogic/specification/testSuite/extractFromUCVI.json @@ -0,0 +1,212 @@ +{ + "name": "extractFromUVCI operation", + "cases": [ + { + "name": "index=-1", + "certLogicExpression": { + "extractFromUVCI": [ + { + "var": "" + }, + -1 + ] + }, + "assertions": [ + { + "data": null, + "expected": null + }, + { + "data": "", + "expected": null + }, + { + "data": "URN:UVCI:01:NL:187/37512422923", + "expected": null + } + ] + }, + { + "name": "index=0", + "certLogicExpression": { + "extractFromUVCI": [ + { + "var": "" + }, + 0 + ] + }, + "assertions": [ + { + "data": null, + "expected": null + }, + { + "data": "", + "expected": "" + }, + { + "data": "URN:UVCI:01:NL:187/37512422923", + "expected": "01" + } + ] + }, + { + "name": "index=1", + "certLogicExpression": { + "extractFromUVCI": [ + { + "var": "" + }, + 1 + ] + }, + "assertions": [ + { + "data": null, + "expected": null + }, + { + "data": "", + "expected": null + }, + { + "data": "URN:UVCI:01:NL:187/37512422923", + "expected": "NL" + }, + { + "data": "01:NL:187/37512422923", + "expected": "NL" + }, + { + "data": "URN:UVCI:01:AT:10807843F94AEE0EE5093FBC254BD813#B", + "expected": "AT" + }, + { + "data": "01:AT:10807843F94AEE0EE5093FBC254BD813#B", + "expected": "AT" + } + ] + }, + { + "name": "index=2", + "certLogicExpression": { + "extractFromUVCI": [ + { + "var": "" + }, + 2 + ] + }, + "assertions": [ + { + "data": "URN:UVCI:01:NL:187/37512422923", + "expected": "187" + }, + { + "data": "URN:UVCI:01:AT:10807843F94AEE0EE5093FBC254BD813#B", + "expected": "10807843F94AEE0EE5093FBC254BD813" + }, + { + "data": "foo/bar::baz#999lizards", + "expected": "" + } + ] + }, + { + "name": "index=3", + "certLogicExpression": { + "extractFromUVCI": [ + { + "var": "" + }, + 3 + ] + }, + "assertions": [ + { + "data": "URN:UVCI:01:NL:187/37512422923", + "expected": "37512422923" + }, + { + "data": "01:NL:187/37512422923", + "expected": "37512422923" + }, + { + "data": "URN:UVCI:01:AT:10807843F94AEE0EE5093FBC254BD813#B", + "expected": "B" + }, + { + "data": "01:AT:10807843F94AEE0EE5093FBC254BD813#B", + "expected": "B" + }, + { + "data": "foo/bar::baz#999lizards", + "expected": "baz" + }, + { + "data": "a::c/#/f", + "expected": "" + } + ] + }, + { + "name": "index=4", + "certLogicExpression": { + "extractFromUVCI": [ + { + "var": "" + }, + 4 + ] + }, + "assertions": [ + { + "data": "URN:UVCI:01:NL:187/37512422923", + "expected": null + }, + { + "data": "01:NL:187/37512422923", + "expected": null + }, + { + "data": "URN:UVCI:01:AT:10807843F94AEE0EE5093FBC254BD813#B", + "expected": null + }, + { + "data": "01:AT:10807843F94AEE0EE5093FBC254BD813#B", + "expected": null + }, + { + "data": "foo/bar::baz#999lizards", + "expected": "999lizards" + }, + { + "data": "a::c/#/f", + "expected": "" + } + ] + }, + { + "name": "index=5", + "certLogicExpression": { + "extractFromUVCI": [ + { + "var": "" + }, + 5 + ] + }, + "assertions": [ + { + "data": "foo/bar::baz#999lizards", + "expected": null + }, + { + "data": "a::c/#/f", + "expected": "f" + } + ] + } + ] +} diff --git a/documentation/design-choices.md b/documentation/design-choices.md index cf817e2..2f3d4ed 100644 --- a/documentation/design-choices.md +++ b/documentation/design-choices.md @@ -60,3 +60,26 @@ Regarding the relation of CertLogic with JsonLogic, and the DCC validation rules Phrased alternatively, every value should in principle have [DICOM Attribute Requirement Type](http://dicomlookup.com/type.asp) 2. + +## Design choices specifics + +## Operation: extract from UVCI + +An example use case for being able to extract information is the need to invalidate DCCs that have been issued by fraudulent pharmacies which can be identified by a certain part of the UVCI. +The UVCI technical format is clearly defined in Annex 2 in the [UVCI specification](https://ec.europa.eu/health/sites/default/files/ehealth/docs/vaccination-proof_interoperability-guidelines_en.pdf). +It has a limited degree of freedom within the format -in particular, the precise format of individual fragments is not pre-defined (outside of the characters allowed). +Also: the `URN:ICVI:` prefix is optional - e.g. UVCIs in DCCs issued by Luxembourg do not have the prefix. + +There are several reasons to support extracting information from the UVCI with a specific operation, instead of using a more generic regex-based operation: + +1. Regexes live in Pandora's box: they are extremely flexible but are not easy to use well, while it's easy to misuse them, either intentionally, or unintentionally. +2. The CertLogic domain-specific language should be kept small in terms of the ground it can cover, to keep its usage simple, and ensure the language is easy to test. +3. It's more difficult to assess whether a rule implemented using a generic but complex operation is GDPR-compliant than when it uses a simple, functionally-limited/restricted domain-specific operation. + +Note that this operation does *not* assume that the given string conforms to the UVCI format. +In particular, it will not check conformance, and will not error on a malformed UVCI. +The rationale for this is a combination of Postel's Law, and the fact that this operation could be used to check for/detect malformed UVCIs. + +Point in case: some DCCs have invalid prefixes like `urn:uvci:` (lowercase instead of uppercase-only), and `URN:UCI:` (missing `V`). +To detect such UVCIs, one can use the `extractFromUVCI` operation with indices 0 and 1, because the invalid prefixes will *not* be ignored, but become fragments. + diff --git a/documentation/implementations.md b/documentation/implementations.md index 8585daf..7cdbe56 100644 --- a/documentation/implementations.md +++ b/documentation/implementations.md @@ -1,7 +1,18 @@ # CertLogic implementations -Several implementations of CertLogic and compatible implementations of JsonLogic exist. -Links to these are: +Several implementations of CertLogic and compatible implementations of JsonLogic exist - links to these are given below. +Note that the compatibility of JsonLogic implementations does not necessarily include the custom operations, such as `plusTime`, and `extractFromUVCI`. +These custom operations: + +* Are defined in the [CertLogic specification](../certlogic/specification/README.md). +* Can be implemented by mimicking any of the existing implementations in + * [Java-/TypeScript](../certlogic/certlogic-js/src/internals.ts), + * [Kotlin](../certlogic/certlogic-kotlin/src/main/kotlin/eu/ehn/dcc/certlogic/internals.kt), + * or [Dart](../certlogic/certlogic-dart/lib/src/internals.dart). +* Can be tested using the [test suite](../certlogic/specification/testSuite), and by mimicking the various unit tests available, such as: + * in [Java-/TypeScript](../certlogic/certlogic-js/src/test/test-internals.ts) + * in [Kotlin](../certlogic/certlogic-kotlin/src/test/kotlin/eu/ehn/dcc/certlogic/internalTests.kt) + * in [Dart](../certlogic/certlogic-dart/test/internals_test.dart). ## JavaScript (TypeScript) diff --git a/jsonlogic/javascript/package.json b/jsonlogic/javascript/package.json index c38c9f9..e3b8526 100644 --- a/jsonlogic/javascript/package.json +++ b/jsonlogic/javascript/package.json @@ -4,7 +4,7 @@ "scripts": { "build": "tsc", "pretest": "npm run build", - "test": "mocha dist/test", + "test": "mocha --recursive dist/test", "prestart": "npm run build", "start": "node dist/filter-tests.js" },