Skip to content
This repository has been archived by the owner on Aug 30, 2023. It is now read-only.

Commit

Permalink
Specify and implement “extract from UVCI”-operation (#57)
Browse files Browse the repository at this point in the history
Constituent squashed commits:

* expand the CertLogic specification with a domain-specific operation

* fix misspellings ("UCVI") in texts + add clarification to specification

* (fix wrong invocations of Mocha + rename scripts to action-based)

* implement the new operation in the JS-evaluator (and -validator)

That includes both unit tests, and test cases in the test suite.

* restructure Kotlin sources a bit

* fix some minor things with the JS impl. and specification

* fix an inconsistency w.r.t. reporting about time units

* add `extractFromUVCI` operation to Kotlin evaluator and validator

+ add/transpile unit tests + some restructuring and adding of `internal` keywords.
+ supported specification

* fix and align some textual details

* added some more info regarding the rationale in the design decisions

* add a note about providing/implementing custom operations
  • Loading branch information
dslmeinte authored Aug 3, 2021
1 parent b1c6453 commit 9decd38
Show file tree
Hide file tree
Showing 24 changed files with 657 additions and 103 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# IntelliJ/IDEA:
/.idea
.idea
*.iml

# results of running validations:
/out


9 changes: 7 additions & 2 deletions certlogic/certlogic-js/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`

2 changes: 1 addition & 1 deletion certlogic/certlogic-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions certlogic/certlogic-js/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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": {
Expand Down
17 changes: 16 additions & 1 deletion certlogic/certlogic-js/src/evaluator.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`)
Expand Down
17 changes: 17 additions & 0 deletions certlogic/certlogic-js/src/internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}


52 changes: 51 additions & 1 deletion certlogic/certlogic-js/src/test/test-internals.ts
Original file line number Diff line number Diff line change
@@ -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"


Expand Down Expand Up @@ -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")
})

})

Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
22 changes: 20 additions & 2 deletions certlogic/certlogic-js/src/validation/format-validator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isInt } from "../internals"

import { ValidationError } from "./typings"
import { timeUnits } from "../typings"


const validateVar = (expr: any, values: any): ValidationError[] => {
Expand Down Expand Up @@ -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
}
Expand All @@ -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") {
Expand Down Expand Up @@ -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`)
Expand Down
7 changes: 6 additions & 1 deletion certlogic/certlogic-kotlin/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

2 changes: 1 addition & 1 deletion certlogic/certlogic-kotlin/README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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\": \"<path>\" }")
Expand Down Expand Up @@ -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 <T:Comparable<T>> compare(operator: String, args: List<T>): 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")
Expand Down Expand Up @@ -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
Expand All @@ -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\"")
}
}
Expand Down
Loading

0 comments on commit 9decd38

Please sign in to comment.