├── .gitignore
├── app
├── .gitignore
├── app.iml
├── build.gradle
├── build.sh
├── common
│ ├── common.iml
│ └── src
│ │ └── main
│ │ └── kotlin
│ │ └── uk
│ │ └── neilgall
│ │ └── rulesapp
│ │ ├── AST.kt
│ │ └── Resolver.kt
├── compiler
│ ├── Dockerfile
│ ├── compiler.iml
│ └── src
│ │ ├── main
│ │ ├── kotlin
│ │ │ └── uk
│ │ │ │ └── neilgall
│ │ │ │ └── rulesapp
│ │ │ │ ├── Lexer.kt
│ │ │ │ ├── Main.kt
│ │ │ │ ├── Parser.kt
│ │ │ │ └── ToJSON.kt
│ │ └── resources
│ │ │ └── log4j2.xml
│ │ └── test
│ │ └── kotlin
│ │ └── uk
│ │ └── neilgall
│ │ └── rulesapp
│ │ ├── ParserSpec.kt
│ │ └── ToJSONSpec.kt
├── docker-compose.yml
├── engine
│ ├── Dockerfile
│ ├── engine.iml
│ └── src
│ │ ├── main
│ │ └── kotlin
│ │ │ └── uk
│ │ │ └── neilgall
│ │ │ └── rulesapp
│ │ │ ├── Eval.kt
│ │ │ ├── Evaluator.kt
│ │ │ ├── FromJSON.kt
│ │ │ ├── Main.kt
│ │ │ ├── REST.kt
│ │ │ └── Value.kt
│ │ └── test
│ │ └── kotlin
│ │ └── uk
│ │ └── neilgall
│ │ └── rulesapp
│ │ └── EvaluatorSpec.kt
├── gradle
│ └── wrapper
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── settings.gradle
├── docker
└── example.py
├── outline.md
├── pytest
├── app-tests
│ ├── conftest.py
│ ├── framework
│ │ ├── __init__.py
│ │ ├── docker.py
│ │ ├── networking.py
│ │ ├── rest_client.py
│ │ └── service.py
│ ├── test_compiler.py
│ ├── test_end_to_end.py
│ ├── test_request.py
│ └── test_service.py
├── examples
│ ├── test_failures.py
│ ├── test_fixture.py
│ ├── test_hello.py
│ ├── test_module_fixture.py
│ ├── test_tmpdir.py
│ └── test_yield.py
└── pytest.ini
├── services
├── bank.py
├── hello.py
└── my_value.py
├── slides.odp
└── test-data
└── test.rules
/.gitignore:
--------------------------------------------------------------------------------
1 | .venv
2 | .vscode
3 | .pytest_cache
4 | __pycache__
5 | scratch
6 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | out
3 | lib
4 | build
5 | .gradle
6 |
7 |
--------------------------------------------------------------------------------
/app/app.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | buildscript {
2 | ext {
3 | kotlinVersion = '1.2.41'
4 | log4jVersion = "2.11.0"
5 | springBootVersion = "2.0.2.RELEASE"
6 | }
7 | repositories {
8 | mavenCentral()
9 | }
10 | dependencies {
11 | classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
12 | classpath("org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion")
13 | }
14 | }
15 |
16 | subprojects {
17 | apply plugin: 'kotlin'
18 | apply plugin: 'idea'
19 |
20 |
21 | repositories {
22 | mavenCentral()
23 | }
24 |
25 | sourceCompatibility = 1.8
26 | targetCompatibility = 1.8
27 |
28 | dependencies {
29 | // Kotlin
30 | compile("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
31 | compile("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
32 | compile("org.jetbrains.kotlin:kotlin-noarg:$kotlinVersion")
33 |
34 | // Logging
35 | compile("org.apache.logging.log4j:log4j-api:$log4jVersion")
36 | compile("org.apache.logging.log4j:log4j-core:$log4jVersion")
37 |
38 | // JSON
39 | compile('org.json:json:20180130')
40 |
41 | // testing
42 | testCompile('io.kotlintest:kotlintest:2.0.5')
43 | }
44 |
45 | compileKotlin {
46 | kotlinOptions {
47 | jvmTarget = "1.8"
48 | }
49 | }
50 |
51 | compileTestKotlin {
52 | kotlinOptions {
53 | jvmTarget = "1.8"
54 | }
55 | }
56 | }
57 |
58 | project(':compiler') {
59 | apply plugin: 'org.springframework.boot'
60 | apply plugin: 'io.spring.dependency-management'
61 |
62 | dependencies {
63 | compile("org.springframework.boot:spring-boot-starter-web")
64 | compile 'org.jparsec:jparsec:3.0'
65 | compile project(':common')
66 | }
67 |
68 | bootJar {
69 | baseName = 'rulesapp-compiler'
70 | version = '0.1.0'
71 | }
72 |
73 | task docker(type: Exec, dependsOn: bootJar) {
74 | commandLine = "docker"
75 | args = ["build", "-t", bootJar.baseName, "-f", "${projectDir}/Dockerfile", "."]
76 | }
77 | }
78 |
79 | project(':engine') {
80 | apply plugin: 'org.springframework.boot'
81 | apply plugin: 'io.spring.dependency-management'
82 |
83 | repositories {
84 | jcenter()
85 | }
86 |
87 | dependencies {
88 | compile("org.springframework.boot:spring-boot-starter-web")
89 | compile("com.github.kittinunf.fuel:fuel:1.13.0")
90 | compile project(':common')
91 | }
92 |
93 | bootJar {
94 | baseName = 'rulesapp-engine'
95 | version = '0.1.0'
96 | }
97 |
98 | task docker(type: Exec, dependsOn: bootJar) {
99 | commandLine = "docker"
100 | args = ["build", "-t", bootJar.baseName, "-f", "${projectDir}/Dockerfile", "."]
101 | }
102 | }
--------------------------------------------------------------------------------
/app/build.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | ./gradlew clean bootJar
3 | docker build -t rulesapp-compiler -f compiler/Dockerfile .
4 | docker build -t rulesapp-engine -f engine/Dockerfile .
5 |
--------------------------------------------------------------------------------
/app/common/common.iml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/common/src/main/kotlin/uk/neilgall/rulesapp/AST.kt:
--------------------------------------------------------------------------------
1 | package uk.neilgall.rulesapp
2 |
3 | enum class RESTMethod {
4 | GET, PUT, POST, DELETE
5 | }
6 |
7 | enum class ValueType {
8 | STRING, NUMBER, BOOLEAN
9 | }
10 |
11 | typealias kString = kotlin.String
12 |
13 | data class Attribute(val name: String, val value: Term<*>) {
14 | fun map(f: (A) -> B): Attribute =
15 | Attribute(name, (value as Term).map(f))
16 | }
17 |
18 | enum class Operator(val s: String) {
19 | PLUS("+"),
20 | MINUS("-"),
21 | MULTIPLY("*"),
22 | DIVIDE("/"),
23 | REGEX("~=")
24 | }
25 |
26 | sealed class Term {
27 | data class String(val value: kString): Term()
28 | data class Number(val value: Int): Term()
29 | data class Request(val key: kString): Term()
30 | data class REST(val url: kString, val method: RESTMethod, val params: Map): Term()
31 | data class Attribute(val value: A): Term()
32 | data class Expr(val lhs: Term, val op: Operator, val rhs: Term): Term()
33 | data class Coerce(val value: Term, val toType: ValueType): Term()
34 |
35 | fun map(f: (A) -> B): Term = when(this) {
36 | is String -> String(value)
37 | is Number -> Number(value)
38 | is Request -> Request(key)
39 | is REST<*> -> REST(url, method, params.mapValues{ f(it.value as A) })
40 | is Attribute -> Attribute(f(value as A))
41 | is Expr -> Expr(lhs.map(f), op, rhs.map(f))
42 | is Coerce -> Coerce(value.map(f), toType)
43 | }
44 | }
45 |
46 | enum class Decision {
47 | Permit, Deny, Undecided
48 | }
49 |
50 | sealed class Rule {
51 | data class Never(val decision: Decision = Decision.Undecided): Rule()
52 | data class Always(val decision: Decision): Rule()
53 | data class When(val condition: Condition, val decision: Decision): Rule()
54 | data class Branch(val condition: Condition, val trueRule: Rule, val falseRule: Rule): Rule()
55 | data class Majority(val decision: Decision, val rules: List>): Rule()
56 | data class Any(val decision: Decision, val rules: List>): Rule()
57 | data class All(val decision: Decision, val rules: List>): Rule()
58 | data class OneOf(val rules: List>): Rule()
59 |
60 | fun map(f: (A) -> B): Rule = when(this) {
61 | is Always -> Always(decision)
62 | is Never -> Never(decision)
63 | is When -> When(condition.map(f), decision)
64 | is Branch -> Branch(condition.map(f), trueRule.map(f), falseRule.map(f))
65 | is Majority -> Majority(decision, rules.map { it.map(f) })
66 | is All -> All(decision, rules.map { it.map(f) })
67 | is Any -> Any(decision, rules.map { it.map(f) })
68 | is OneOf -> OneOf(rules.map { it.map(f) })
69 | }
70 | }
71 |
72 | sealed class Condition {
73 | data class Not(val condition: Condition): Condition()
74 | data class And(val lhs: Condition, val rhs: Condition): Condition()
75 | data class Or(val lhs: Condition, val rhs: Condition): Condition()
76 | data class Equal(val lhs: Term, val rhs: Term): Condition()
77 | data class Greater(val lhs: Term, val rhs: Term): Condition()
78 |
79 | fun map(f: (A) -> B): Condition = when(this) {
80 | is Not -> Not(condition.map(f))
81 | is And -> And(lhs.map(f), rhs.map(f))
82 | is Or -> Or(lhs.map(f), rhs.map(f))
83 | is Equal -> Equal(lhs.map(f), rhs.map(f))
84 | is Greater -> Greater(lhs.map(f), rhs.map(f))
85 | }
86 |
87 | }
88 |
89 | data class RuleSet(
90 | val attributes: List,
91 | val rules: List>
92 | ) {
93 | fun map(f: (A) -> B): RuleSet =
94 | RuleSet(attributes.map { it.map(f) },
95 | rules.map { it.map(f) })
96 | }
97 |
--------------------------------------------------------------------------------
/app/common/src/main/kotlin/uk/neilgall/rulesapp/Resolver.kt:
--------------------------------------------------------------------------------
1 | package uk.neilgall.rulesapp
2 |
3 | fun RuleSet.resolve(): RuleSet {
4 |
5 | val originalAttributesByName = attributes.associate({ it.name to it })
6 |
7 | val attributes = mutableMapOf()
8 |
9 | fun resolveAttribute(name: String): Attribute =
10 | attributes.getOrPut(name, {
11 | originalAttributesByName[name]?.map(::resolveAttribute) ?: throw NoSuchElementException(name)
12 | })
13 |
14 | return map(::resolveAttribute)
15 | }
16 |
--------------------------------------------------------------------------------
/app/compiler/Dockerfile:
--------------------------------------------------------------------------------
1 | # Build:
2 | # gradlew :compiler:assemble
3 | # docker build -t rulesapp-compiler -f compiler/Dockerfile .
4 |
5 | FROM openjdk:latest
6 |
7 | WORKDIR /opt/rulesapp/compiler
8 | COPY build/libs/rulesapp-compiler-*.jar compiler.jar
9 |
10 | CMD java -jar compiler.jar
11 |
12 |
--------------------------------------------------------------------------------
/app/compiler/compiler.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/compiler/src/main/kotlin/uk/neilgall/rulesapp/Lexer.kt:
--------------------------------------------------------------------------------
1 | package uk.neilgall.rulesapp
2 |
3 | import org.jparsec.*
4 | import org.jparsec.Parsers.or
5 |
6 | private val keywords = listOf(
7 | // type names
8 | "string", "number", "boolean",
9 |
10 | // term keywords
11 | "request", "rest",
12 |
13 | // rest methods
14 | "GET", "PUT", "POST", "DELETE",
15 |
16 | // decisions
17 | "permit", "deny",
18 |
19 | // rules
20 | "always", "never", "when", "if", "else", "majority", "all", "any", "exclusive",
21 |
22 | // conditions
23 | "not", "and", "or"
24 | )
25 |
26 | private val terminals: Terminals = Terminals
27 | .operators("=", "!=", ">", ">=", "<", "<=", "{", "}", "+", "-", "*", "/", "~=", ",")
28 | .words(Scanners.IDENTIFIER)
29 | .caseInsensitiveKeywords(keywords)
30 | .build()
31 |
32 | private val tokens = or(
33 | Terminals.IntegerLiteral.TOKENIZER,
34 | Terminals.StringLiteral.DOUBLE_QUOTE_TOKENIZER,
35 | terminals.tokenizer()
36 | )
37 |
38 | private val tokenDelimiter: Parser = Parsers.or(
39 | Scanners.WHITESPACES,
40 | Scanners.JAVA_BLOCK_COMMENT,
41 | Scanners.JAVA_LINE_COMMENT
42 | ).skipMany()
43 |
44 | fun parse(p: Parser, s: String): T =
45 | p.from(tokens, tokenDelimiter).parse(s)
46 |
47 | internal fun token(s: String): Parser = terminals.token(s)
48 |
49 |
--------------------------------------------------------------------------------
/app/compiler/src/main/kotlin/uk/neilgall/rulesapp/Main.kt:
--------------------------------------------------------------------------------
1 | package uk.neilgall.rulesapp
2 |
3 | import org.springframework.boot.SpringApplication
4 | import org.springframework.boot.autoconfigure.SpringBootApplication
5 | import org.springframework.web.bind.annotation.RequestBody
6 | import org.springframework.web.bind.annotation.RequestMapping
7 | import org.springframework.web.bind.annotation.RequestMethod
8 | import org.springframework.web.bind.annotation.RestController
9 |
10 | @RestController
11 | open class CompilerController {
12 |
13 | @RequestMapping("/status")
14 | fun status(): String = "ok"
15 |
16 | @RequestMapping("/compile", method = [RequestMethod.POST])
17 | fun compile(@RequestBody source: String): String {
18 | val parsed = parse(ruleSet, source).resolve()
19 | return parsed.toJSON().toString()
20 | }
21 |
22 | }
23 |
24 | @SpringBootApplication
25 | open class Application
26 |
27 | fun main(args: Array) {
28 | SpringApplication.run(Application::class.java, *args)
29 | }
--------------------------------------------------------------------------------
/app/compiler/src/main/kotlin/uk/neilgall/rulesapp/Parser.kt:
--------------------------------------------------------------------------------
1 | package uk.neilgall.rulesapp
2 |
3 | import org.jparsec.OperatorTable
4 | import org.jparsec.Parser
5 | import org.jparsec.Parsers.or
6 | import org.jparsec.Parsers.sequence
7 | import org.jparsec.Terminals
8 | import java.util.function.BinaryOperator
9 | import java.util.function.UnaryOperator
10 |
11 | // Strange issue with Java/Kotlin lambdas+generics interop needs these adapters
12 | private fun uop(t: String, f: (T) -> T): Parser> = token(t).retn(object : UnaryOperator {
13 | override fun apply(t: T): T = f(t)
14 | })
15 |
16 | private fun bop(t: String, f: (T, T) -> T): Parser> = token(t).retn(object : BinaryOperator {
17 | override fun apply(t: T, u: T): T = f(t, u)
18 | })
19 |
20 | internal val quotedString: Parser =
21 | Terminals.StringLiteral.PARSER.map { it.removeSurrounding("\"") }
22 |
23 | internal val integer: Parser =
24 | Terminals.IntegerLiteral.PARSER.map(String::toInt)
25 |
26 | private fun listBlock(p: Parser): Parser> =
27 | p.sepBy1(token(",")).between(token("{"), token("}"))
28 |
29 | internal val attributeName: Parser = or(
30 | Terminals.Identifier.PARSER,
31 | Terminals.StringLiteral.PARSER
32 | )
33 |
34 | internal val requestTerm: Parser> =
35 | token("request").next(quotedString).map { k -> Term.Request(k) }
36 |
37 | internal val restMethod = or(
38 | token("GET").retn(RESTMethod.GET),
39 | token("PUT").retn(RESTMethod.PUT),
40 | token("POST").retn(RESTMethod.POST),
41 | token("DELETE").retn(RESTMethod.DELETE)
42 | )
43 |
44 | internal val restParam: Parser> =
45 | sequence(
46 | attributeName,
47 | token("=").next(attributeName),
48 | { t, u -> t to u }
49 | )
50 |
51 | internal val restParams: Parser