├── .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> = 52 | restParam.sepBy(token(",")).map { it.associate { it } } 53 | 54 | internal val restTerm: Parser> = 55 | sequence( 56 | restMethod, 57 | quotedString, 58 | restParams, 59 | { m: RESTMethod, u: String, p: Map -> Term.REST(u, m, p) } 60 | ) 61 | private val termConstant: Parser> = or( 62 | integer.map { n -> Term.Number(n) }, 63 | quotedString.map { s -> Term.String(s) }, 64 | attributeName.map { a -> Term.Attribute(a) }, 65 | requestTerm, 66 | restTerm 67 | ) 68 | 69 | internal val term: Parser> = OperatorTable>() 70 | .infixl(bop("+", { lhs, rhs -> Term.Expr(lhs, Operator.PLUS, rhs) }), 20) 71 | .infixl(bop("-", { lhs, rhs -> Term.Expr(lhs, Operator.MINUS, rhs) }), 20) 72 | .infixl(bop("*", { lhs, rhs -> Term.Expr(lhs, Operator.MULTIPLY, rhs) }), 21) 73 | .infixl(bop("/", { lhs, rhs -> Term.Expr(lhs, Operator.DIVIDE, rhs) }), 21) 74 | .infixl(bop("~=", { lhs, rhs -> Term.Expr(lhs, Operator.REGEX, rhs) }), 22) 75 | .prefix(uop("string", { term -> Term.Coerce(term, ValueType.STRING) }), 1) 76 | .prefix(uop("number", { term -> Term.Coerce(term, ValueType.NUMBER) }), 1) 77 | .prefix(uop("boolean", { term -> Term.Coerce(term, ValueType.BOOLEAN) }), 1) 78 | .build(termConstant) 79 | 80 | internal val attribute: Parser = 81 | sequence( 82 | attributeName, 83 | token("=").next(term), 84 | { name, value -> Attribute(name, value) } 85 | ) 86 | 87 | internal val decision: Parser = or( 88 | token("permit").retn(Decision.Permit), 89 | token("deny").retn(Decision.Deny) 90 | ) 91 | 92 | internal val equalCondition: Parser> = 93 | sequence( 94 | term, 95 | token("=").next(term), 96 | { lhs, rhs -> Condition.Equal(lhs, rhs) } 97 | ) 98 | 99 | internal val notEqualCondition: Parser> = 100 | sequence( 101 | term, 102 | token("!=").next(term), 103 | { lhs, rhs -> Condition.Not(Condition.Equal(lhs, rhs)) } 104 | ) 105 | 106 | internal val greaterCondition: Parser> = 107 | sequence( 108 | term, 109 | token(">").next(term), 110 | { lhs, rhs -> Condition.Greater(lhs, rhs) } 111 | ) 112 | 113 | internal val lessCondition: Parser> = 114 | sequence( 115 | term, 116 | token("<").next(term), 117 | { lhs, rhs -> Condition.Greater(rhs, lhs) } 118 | ) 119 | 120 | internal val greaterOrEqualCondition: Parser> = 121 | sequence( 122 | term, 123 | token(">=").next(term), 124 | { lhs, rhs -> Condition.Or(Condition.Equal(lhs, rhs), Condition.Greater(lhs, rhs)) } 125 | ) 126 | 127 | internal val lessOrEqualCondition: Parser> = 128 | sequence( 129 | term, 130 | token("<=").next(term), 131 | { lhs, rhs -> Condition.Or(Condition.Equal(lhs, rhs), Condition.Greater(rhs, lhs)) } 132 | ) 133 | 134 | internal fun compareCondition(): Parser> = or( 135 | equalCondition, 136 | notEqualCondition, 137 | greaterCondition, 138 | lessCondition, 139 | greaterOrEqualCondition, 140 | lessOrEqualCondition 141 | ) 142 | 143 | internal val condition = OperatorTable>() 144 | .prefix(uop("not", { c -> Condition.Not(c) }), 11) 145 | .infixl(bop("and", { l, r -> Condition.And(l, r) }), 10) 146 | .infixl(bop("or", { l, r -> Condition.Or(l, r) }), 9) 147 | .build(compareCondition()) 148 | 149 | private val ruleRef = Parser.newReference>() 150 | 151 | internal val alwaysRule: Parser> = 152 | token("always").next(decision).map { d -> Rule.Always(d) } 153 | 154 | internal val neverRule: Parser> = 155 | token("never").retn(Rule.Never()) 156 | 157 | internal val whenRule: Parser> = 158 | sequence( 159 | decision, 160 | token("when").next(condition), 161 | { d, c -> Rule.When(c, d) } 162 | ) 163 | 164 | internal val branchRule: Parser> = 165 | sequence( 166 | token("if").next(condition), 167 | ruleRef.lazy(), 168 | (token("else").next(ruleRef.lazy())).optional(Rule.Never()), 169 | { c, tr, fr -> Rule.Branch(c, tr, fr) } 170 | ) 171 | 172 | internal val majorityRule: Parser> = 173 | sequence( 174 | token("majority").next(decision), 175 | listBlock(ruleRef.lazy()), 176 | { d, rs -> Rule.Majority(d, rs) } 177 | ) 178 | 179 | internal val allRule: Parser> = 180 | sequence( 181 | token("all").next(decision), 182 | listBlock(ruleRef.lazy()), 183 | { d, rs -> Rule.All(d, rs) } 184 | ) 185 | 186 | internal val anyRule: Parser> = 187 | sequence( 188 | token("any").next(decision), 189 | listBlock(ruleRef.lazy()), 190 | { d, rs -> Rule.Any(d, rs) } 191 | ) 192 | 193 | internal val exclusiveRule: Parser> = 194 | token("exclusive").next(listBlock(ruleRef.lazy())).map { rs -> Rule.OneOf(rs) } 195 | 196 | internal val rule: Parser> = or( 197 | alwaysRule, 198 | neverRule, 199 | whenRule, 200 | branchRule, 201 | majorityRule, 202 | allRule, 203 | anyRule, 204 | exclusiveRule 205 | ).apply { 206 | ruleRef.set(this) 207 | } 208 | 209 | val ruleSet: Parser> = 210 | sequence( 211 | attribute.many(), 212 | rule.many(), 213 | { a, r -> RuleSet(a, r) } 214 | ) 215 | -------------------------------------------------------------------------------- /app/compiler/src/main/kotlin/uk/neilgall/rulesapp/ToJSON.kt: -------------------------------------------------------------------------------- 1 | package uk.neilgall.rulesapp 2 | 3 | import org.json.JSONArray 4 | import org.json.JSONObject 5 | 6 | fun Attribute.toJSON(): JSONObject = JSONObject(mapOf( 7 | "name" to name, 8 | "value" to (value as Term).toJSON() 9 | )) 10 | 11 | fun Term.toJSON(): JSONObject = JSONObject(when (this) { 12 | is Term.String -> mapOf( 13 | "type" to "string", 14 | "value" to value 15 | ) 16 | is Term.Number -> mapOf( 17 | "type" to "number", 18 | "value" to value 19 | ) 20 | is Term.Attribute -> mapOf( 21 | "type" to "attribute", 22 | "name" to value.name 23 | ) 24 | is Term.Expr -> mapOf( 25 | "type" to op.s, 26 | "lhs" to lhs.toJSON(), 27 | "rhs" to rhs.toJSON() 28 | ) 29 | is Term.Coerce -> mapOf( 30 | "type" to "coerce", 31 | "from" to value.toJSON(), 32 | "to" to toType.name 33 | ) 34 | is Term.Request -> mapOf( 35 | "type" to "request", 36 | "key" to key 37 | ) 38 | is Term.REST<*> -> mapOf( 39 | "type" to "rest", 40 | "method" to method.name, 41 | "url" to url, 42 | "params" to JSONObject(params.mapValues { (it.value as Attribute).name }) 43 | ) 44 | }) 45 | 46 | fun Decision.toJSON() = this.name 47 | 48 | fun Condition.toJSON(): JSONObject = JSONObject(when (this) { 49 | is Condition.Not -> mapOf( 50 | "type" to "not", 51 | "condition" to condition.toJSON() 52 | ) 53 | is Condition.And -> mapOf( 54 | "type" to "and", 55 | "lhs" to lhs.toJSON(), 56 | "rhs" to rhs.toJSON() 57 | ) 58 | is Condition.Or -> mapOf( 59 | "type" to "or", 60 | "lhs" to lhs.toJSON(), 61 | "rhs" to rhs.toJSON() 62 | ) 63 | is Condition.Equal -> mapOf( 64 | "type" to "equal", 65 | "lhs" to lhs.toJSON(), 66 | "rhs" to rhs.toJSON() 67 | ) 68 | is Condition.Greater -> mapOf( 69 | "type" to "greater", 70 | "lhs" to lhs.toJSON(), 71 | "rhs" to rhs.toJSON() 72 | ) 73 | }) 74 | 75 | fun Rule.toJSON(): JSONObject = JSONObject(when (this) { 76 | is Rule.Always -> mapOf( 77 | "type" to "always", 78 | "decision" to decision.toJSON() 79 | ) 80 | is Rule.Never -> mapOf( 81 | "type" to "never", 82 | "decision" to decision.toJSON() 83 | ) 84 | is Rule.When -> mapOf( 85 | "type" to "when", 86 | "condition" to condition.toJSON(), 87 | "decision" to decision.toJSON() 88 | ) 89 | is Rule.Branch -> mapOf( 90 | "type" to "branch", 91 | "condition" to condition.toJSON(), 92 | "true" to trueRule.toJSON(), 93 | "false" to falseRule.toJSON() 94 | ) 95 | is Rule.Majority -> mapOf( 96 | "type" to "majority", 97 | "decision" to decision.toJSON(), 98 | "rules" to JSONArray(rules.map { it.toJSON() }) 99 | ) 100 | is Rule.All -> mapOf( 101 | "type" to "all", 102 | "decision" to decision.toJSON(), 103 | "rules" to JSONArray(rules.map { it.toJSON() }) 104 | ) 105 | is Rule.Any -> mapOf( 106 | "type" to "any", 107 | "decision" to decision.toJSON(), 108 | "rules" to JSONArray(rules.map { it.toJSON() }) 109 | ) 110 | is Rule.OneOf -> mapOf( 111 | "type" to "one-of", 112 | "rules" to JSONArray(rules.map { it.toJSON() }) 113 | ) 114 | }) 115 | 116 | fun RuleSet.toJSON(): JSONObject = JSONObject(mapOf( 117 | "attributes" to JSONArray(attributes.map { it.toJSON() }), 118 | "rules" to JSONArray(rules.map { it.toJSON() }) 119 | )) 120 | -------------------------------------------------------------------------------- /app/compiler/src/main/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/compiler/src/test/kotlin/uk/neilgall/rulesapp/ParserSpec.kt: -------------------------------------------------------------------------------- 1 | package uk.neilgall.rulesapp 2 | 3 | import io.kotlintest.matchers.shouldEqual 4 | import io.kotlintest.specs.StringSpec 5 | 6 | private fun s(s: String) = Term.String(s) 7 | private fun n(n: Int) = Term.Number(n) 8 | private fun a(a: String) = Term.Attribute(a) 9 | 10 | class TermParserSpec : StringSpec({ 11 | "string" { 12 | parse(term, "\"foo\"") shouldEqual Term.String("foo") 13 | } 14 | 15 | "number" { 16 | parse(term, "123") shouldEqual Term.Number(123) 17 | } 18 | 19 | "attribute" { 20 | parse(term, "foo") shouldEqual Term.Attribute("foo") 21 | } 22 | 23 | "plus expression" { 24 | parse(term, "1 + 2") shouldEqual Term.Expr(Term.Number(1), Operator.PLUS, Term.Number(2)) 25 | } 26 | 27 | "minus expression" { 28 | parse(term, "9 - 5") shouldEqual Term.Expr(Term.Number(9), Operator.MINUS, Term.Number(5)) 29 | } 30 | 31 | "times expression" { 32 | parse(term, "foo * 7") shouldEqual Term.Expr(Term.Attribute("foo"), Operator.MULTIPLY, Term.Number(7)) 33 | } 34 | 35 | "divide expression" { 36 | parse(term, "100 / 5") shouldEqual Term.Expr(Term.Number(100), Operator.DIVIDE, Term.Number(5)) 37 | } 38 | 39 | "complex expression" { 40 | parse(term, "3 + foo * 6") shouldEqual Term.Expr( 41 | Term.Number(3), 42 | Operator.PLUS, 43 | Term.Expr(Term.Attribute("foo"), Operator.MULTIPLY, Term.Number(6)) 44 | ) 45 | } 46 | 47 | "coercion" { 48 | parse(term, "string 3") shouldEqual Term.Coerce(Term.Number(3), ValueType.STRING) 49 | } 50 | 51 | "request term" { 52 | parse(term, "request \"A.B.C\"") shouldEqual Term.Request("A.B.C") 53 | } 54 | 55 | "rest term" { 56 | parse(term, "GET \"http://foo.bar\" user=UserName, pass=Password") shouldEqual 57 | Term.REST("http://foo.bar", RESTMethod.GET, mapOf("user" to "UserName", "pass" to "Password")) 58 | } 59 | }) 60 | 61 | class AttributeParserSpec : StringSpec({ 62 | "attribute names" { 63 | parse(attributeName, "foo") shouldEqual "foo" 64 | parse(attributeName, "foo_bar") shouldEqual "foo_bar" 65 | parse(attributeName, "foo123") shouldEqual "foo123" 66 | } 67 | 68 | "string constant" { 69 | parse(attribute, "foo = \"bar\"") shouldEqual Attribute("foo", Term.String("bar")) 70 | } 71 | 72 | "number constant" { 73 | parse(attribute, "foo = 123") shouldEqual Attribute("foo", Term.Number(123)) 74 | } 75 | }) 76 | 77 | class DecisionParserSpec : StringSpec({ 78 | "decisions" { 79 | parse(decision, "permit") shouldEqual Decision.Permit 80 | parse(decision, "deny") shouldEqual Decision.Deny 81 | } 82 | }) 83 | 84 | class ConditionParserSpec : StringSpec({ 85 | "equal" { 86 | parse(condition, "foo = bar") shouldEqual Condition.Equal(a("foo"), a("bar")) 87 | } 88 | 89 | "greater" { 90 | parse(condition, "foo > \"bar\"") shouldEqual Condition.Greater(a("foo"), s("bar")) 91 | } 92 | 93 | "not equal" { 94 | parse(condition, "99 != 99") shouldEqual Condition.Not(Condition.Equal(n(99), n(99))) 95 | } 96 | 97 | "less" { 98 | parse(condition, "foo < bar") shouldEqual Condition.Greater(a("bar"), a("foo")) 99 | } 100 | 101 | "not" { 102 | parse(condition, "not a = b") shouldEqual Condition.Not(Condition.Equal(a("a"), a("b"))) 103 | } 104 | 105 | "and" { 106 | parse(condition, "foo = 1 and bar = 2") shouldEqual Condition.And( 107 | Condition.Equal(a("foo"), n(1)), 108 | Condition.Equal(a("bar"), n(2)) 109 | ) 110 | } 111 | }) 112 | 113 | class RuleParserSpec : StringSpec({ 114 | "always permit" { 115 | parse(rule, "always permit") shouldEqual Rule.Always(Decision.Permit) 116 | } 117 | 118 | "always deny" { 119 | parse(rule, "always deny") shouldEqual Rule.Always(Decision.Deny) 120 | } 121 | 122 | "never" { 123 | parse(rule, "never") shouldEqual Rule.Never() 124 | } 125 | 126 | "when" { 127 | parse(rule, "permit when abc = \"def\"") shouldEqual Rule.When( 128 | Condition.Equal(a("abc"), s("def")), 129 | Decision.Permit 130 | ) 131 | parse(rule, "deny when 23 > 22") shouldEqual Rule.When( 132 | Condition.Greater(n(23), n(22)), 133 | Decision.Deny 134 | ) 135 | } 136 | 137 | "one-leg branch" { 138 | parse(rule, "if \"abc\" = \"def\" always deny") shouldEqual Rule.Branch( 139 | Condition.Equal(s("abc"), s("def")), 140 | Rule.Always(Decision.Deny), 141 | Rule.Never() 142 | ) 143 | } 144 | 145 | "two-leg branch" { 146 | parse(rule, "if 2 > 1 always permit else always deny") shouldEqual Rule.Branch( 147 | Condition.Greater(n(2), n(1)), 148 | Rule.Always(Decision.Permit), 149 | Rule.Always(Decision.Deny) 150 | ) 151 | } 152 | 153 | "majority" { 154 | parse(rule, "majority permit { always permit, always deny }") shouldEqual Rule.Majority( 155 | Decision.Permit, 156 | listOf(Rule.Always(Decision.Permit), Rule.Always(Decision.Deny)) 157 | ) 158 | } 159 | 160 | "any" { 161 | parse(rule, "any permit { always permit, always deny }") shouldEqual Rule.Any( 162 | Decision.Permit, 163 | listOf(Rule.Always(Decision.Permit), Rule.Always(Decision.Deny)) 164 | ) 165 | } 166 | 167 | "all" { 168 | parse(rule, "all deny { always permit, always deny }") shouldEqual Rule.All( 169 | Decision.Deny, 170 | listOf(Rule.Always(Decision.Permit), Rule.Always(Decision.Deny)) 171 | ) 172 | } 173 | 174 | "exclusive" { 175 | parse(rule, "exclusive { always permit, deny when a = b }") shouldEqual Rule.OneOf( 176 | listOf(Rule.Always(Decision.Permit), 177 | Rule.When(Condition.Equal(Term.Attribute("a"), Term.Attribute("b")), Decision.Deny) 178 | ) 179 | ) 180 | } 181 | 182 | "complex rule" { 183 | parse(rule, """ 184 | if foo = "bar" 185 | exclusive { 186 | permit when a > b, 187 | deny when a <= b 188 | } 189 | else 190 | always deny 191 | """) shouldEqual Rule.Branch( 192 | Condition.Equal(Term.Attribute("foo"), Term.String("bar")), 193 | Rule.OneOf(listOf( 194 | Rule.When(Condition.Greater(Term.Attribute("a"), Term.Attribute("b")), Decision.Permit), 195 | Rule.When(Condition.Or( 196 | Condition.Equal(Term.Attribute("a"), Term.Attribute("b")), 197 | Condition.Greater(Term.Attribute("b"), Term.Attribute("a")) 198 | ), Decision.Deny) 199 | )), 200 | Rule.Always(Decision.Deny) 201 | ) 202 | } 203 | }) 204 | 205 | class RuleSetParserSpec : StringSpec({ 206 | "ruleset" { 207 | val foo = Attribute("foo", Term.String("foo")) 208 | val bar = Attribute("bar", Term.Request("bar")) 209 | 210 | parse(ruleSet, """ 211 | foo = "foo" 212 | bar = request "bar" 213 | 214 | any permit { 215 | permit when foo = bar, 216 | permit when foo = 123 217 | } 218 | 219 | """).resolve() shouldEqual RuleSet( 220 | // attributes 221 | listOf(foo, bar), 222 | 223 | // rules` 224 | listOf(Rule.Any(Decision.Permit, 225 | listOf( 226 | Rule.When(Condition.Equal(Term.Attribute(foo), Term.Attribute(bar)), Decision.Permit), 227 | Rule.When(Condition.Equal(Term.Attribute(foo), Term.Number(123)), Decision.Permit) 228 | ) as List>) 229 | )) 230 | } 231 | 232 | }) -------------------------------------------------------------------------------- /app/compiler/src/test/kotlin/uk/neilgall/rulesapp/ToJSONSpec.kt: -------------------------------------------------------------------------------- 1 | package uk.neilgall.rulesapp 2 | 3 | import io.kotlintest.matchers.Matcher 4 | import io.kotlintest.matchers.Result 5 | import io.kotlintest.matchers.should 6 | import io.kotlintest.specs.StringSpec 7 | import org.json.JSONObject 8 | 9 | private fun beJSON(s: String) = object : Matcher { 10 | override fun test(value: JSONObject): Result { 11 | val expJson = JSONObject(s) 12 | return if (value.toMap() == expJson.toMap()) { 13 | Result(passed = true, message = "match") 14 | } else { 15 | Result(passed = false, message = "expected\n${expJson.toString(2)}\nbut got\n${value.toString(2)}") 16 | } 17 | } 18 | } 19 | 20 | class AttributeToJSONSpec : StringSpec({ 21 | "string attribute" { 22 | Attribute("foo", Term.String("bar")).toJSON() should beJSON(""" 23 | {"name":"foo","value":{"type":"string","value":"bar"}} 24 | """) 25 | } 26 | 27 | "number attribute" { 28 | Attribute("foo", Term.Number(132)).toJSON() should beJSON(""" 29 | {"name":"foo","value":{"type":"number","value":132}} 30 | """) 31 | } 32 | }) 33 | 34 | class ConditionToJSONSpec : StringSpec({ 35 | "equals condition" { 36 | Condition.Equal( 37 | Term.String("bar"), 38 | Term.String("xyz") 39 | ).toJSON() should beJSON(""" 40 | {"type":"equal", 41 | "lhs":{"type":"string","value":"bar"}, 42 | "rhs":{"type":"string","value":"xyz"}} 43 | """) 44 | } 45 | 46 | "greater condition" { 47 | Condition.Greater( 48 | Term.Number(100), 49 | Term.Number(99) 50 | ).toJSON() should beJSON(""" 51 | {"type":"greater", 52 | "lhs":{"type":"number","value":100}, 53 | "rhs":{"type":"number","value":99}} 54 | """) 55 | } 56 | 57 | "not condition" { 58 | Condition.Not( 59 | Condition.Equal( 60 | Term.Number(123), 61 | Term.Number(234) 62 | ) 63 | ).toJSON() should beJSON(""" 64 | {"type":"not","condition":{ 65 | "type":"equal", 66 | "lhs":{"type":"number","value":123}, 67 | "rhs":{"type":"number","value":234} 68 | }} 69 | """) 70 | } 71 | 72 | "and condition" { 73 | Condition.And( 74 | Condition.Equal( 75 | Term.String("foo"), 76 | Term.String("qux") 77 | ), 78 | Condition.Greater( 79 | Term.Number(42), 80 | Term.Number(43) 81 | ) 82 | ).toJSON() should beJSON(""" 83 | {"type":"and", 84 | "lhs":{"type":"equal","lhs":{"type":"string","value":"foo"},"rhs":{"type":"string","value":"qux"}}, 85 | "rhs":{"type":"greater","lhs":{"type":"number","value":42},"rhs":{"type":"number","value":43}} 86 | } 87 | """) 88 | } 89 | 90 | "or condition" { 91 | Condition.Or( 92 | Condition.Equal( 93 | Term.String("foo"), 94 | Term.String("qux") 95 | ), 96 | Condition.Greater( 97 | Term.Number(42), 98 | Term.Number(43) 99 | ) 100 | ).toJSON() should beJSON(""" 101 | {"type":"or", 102 | "lhs":{"type":"equal","lhs":{"type":"string","value":"foo"},"rhs":{"type":"string","value":"qux"}}, 103 | "rhs":{"type":"greater","lhs":{"type":"number","value":42},"rhs":{"type":"number","value":43}} 104 | } 105 | """) 106 | } 107 | }) 108 | 109 | class RuleToJSONSpec : StringSpec({ 110 | "always rule" { 111 | Rule.Always(Decision.Permit).toJSON() should beJSON(""" 112 | {"type":"always","decision":"Permit"} 113 | """) 114 | } 115 | 116 | "never rule" { 117 | Rule.Never(Decision.Permit).toJSON() should beJSON(""" 118 | {"type":"never","decision":"Permit"} 119 | """) 120 | } 121 | 122 | "when rule" { 123 | Rule.When( 124 | Condition.Equal( 125 | Term.Attribute(Attribute("foo", Term.Request("q"))), 126 | Term.String("bar") 127 | ), 128 | Decision.Permit 129 | ).toJSON() should beJSON(""" 130 | {"type":"when","decision":"Permit", 131 | "condition":{ 132 | "type":"equal", 133 | "rhs":{"type":"string","value":"bar"}, 134 | "lhs":{"type":"attribute","name":"foo"} 135 | }} 136 | """) 137 | } 138 | 139 | "branch rule" { 140 | Rule.Branch( 141 | Condition.Equal( 142 | Term.Number(123), 143 | Term.Number(234) 144 | ), 145 | Rule.Always(Decision.Permit), 146 | Rule.Always(Decision.Deny) 147 | ).toJSON() should beJSON(""" 148 | {"type":"branch", 149 | "condition":{ 150 | "type":"equal", 151 | "lhs":{"type":"number","value":123}, 152 | "rhs":{"type":"number","value":234} 153 | }, 154 | "true":{"type":"always","decision":"Permit"}, 155 | "false":{"type":"always","decision":"Deny"} 156 | }} 157 | """) 158 | } 159 | 160 | "majority rule" { 161 | Rule.Majority( 162 | Decision.Deny, 163 | listOf(Rule.Always(Decision.Permit), Rule.Always(Decision.Deny)) 164 | ).toJSON() should beJSON(""" 165 | {"type":"majority","decision":"Deny","rules":[ 166 | {"type":"always","decision":"Permit"},{"type":"always","decision":"Deny"}]} 167 | """) 168 | } 169 | 170 | "all rule" { 171 | Rule.All( 172 | Decision.Deny, 173 | listOf(Rule.Always(Decision.Permit), Rule.Always(Decision.Deny)) 174 | ).toJSON() should beJSON(""" 175 | {"type":"all","decision":"Deny","rules":[ 176 | {"type":"always","decision":"Permit"},{"type":"always","decision":"Deny"}]} 177 | """) 178 | } 179 | 180 | "any rule" { 181 | Rule.Any( 182 | Decision.Deny, 183 | listOf(Rule.Always(Decision.Permit), Rule.Always(Decision.Deny)) 184 | ).toJSON() should beJSON(""" 185 | {"type":"any","decision":"Deny","rules":[ 186 | {"type":"always","decision":"Permit"},{"type":"always","decision":"Deny"}]} 187 | """) 188 | } 189 | }) -------------------------------------------------------------------------------- /app/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | compiler: 4 | image: "rulesapp-compiler:latest" 5 | ports: 6 | - "8080:8080" 7 | engine: 8 | image: "rulesapp-engine:latest" 9 | ports: 10 | - "8090:8080" 11 | extra_hosts: 12 | external: "192.168.122.1" 13 | -------------------------------------------------------------------------------- /app/engine/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build: 2 | # gradlew :engine:assemble 3 | # docker build -t rulesapp-engine -f engine/Dockerfile . 4 | 5 | FROM openjdk:latest 6 | 7 | WORKDIR /opt/rulesapp/engine 8 | COPY build/libs/rulesapp-engine-*.jar engine.jar 9 | 10 | CMD java -jar engine.jar 11 | 12 | -------------------------------------------------------------------------------- /app/engine/engine.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/engine/src/main/kotlin/uk/neilgall/rulesapp/Eval.kt: -------------------------------------------------------------------------------- 1 | package uk.neilgall.rulesapp 2 | 3 | data class Eval(val t: T?, val e: Throwable?, val d: String, val c: List>) { 4 | constructor(t: T, d: String = "", c: List> = listOf()) : this(t, null, d, c) 5 | constructor(e: Throwable, d: String = "", c: List> = listOf()) : this(null, e, d, c) 6 | 7 | fun map(f: (T) -> U, d: String = "map"): Eval = 8 | if (t != null) 9 | Eval(f(t), d, listOf(this)) 10 | else 11 | Eval(null, e, d, listOf(this)) 12 | 13 | fun flatMap(f: (T) -> Eval, d: String = "flatMap"): Eval = 14 | if (t != null) 15 | f(t).addChildren(d, listOf(this)) 16 | else 17 | Eval(null, e, d, listOf(this)) 18 | 19 | fun combine(u: Eval, f: (T, U) -> V, d: String = "combine"): Eval = 20 | flatMap({ t -> u.map({ f(t, it) }, d) }, d) 21 | 22 | fun orThrow(): T = t ?: throw e!! 23 | 24 | private fun addChildren(d: String, c: List>): Eval { 25 | return Eval(t, e, if (d == this.d) d else "${this.d}, $d", this.c + c) 26 | } 27 | } -------------------------------------------------------------------------------- /app/engine/src/main/kotlin/uk/neilgall/rulesapp/Evaluator.kt: -------------------------------------------------------------------------------- 1 | package uk.neilgall.rulesapp 2 | 3 | typealias Request = Map 4 | 5 | fun Map.traverse(f: (V) -> Eval): Eval> = 6 | try { 7 | Eval(mapValues { e -> f(e.value).orThrow() }) 8 | } catch (t: Throwable) { 9 | Eval(t) 10 | } 11 | 12 | fun Attribute.reduce(r: Request): Eval = 13 | (value as Term).reduce(r) 14 | 15 | fun Term.reduce(r: Request): Eval = when (this) { 16 | is Term.String -> Eval(Value.String(value)) 17 | is Term.Number -> Eval(Value.Number(value)) 18 | is Term.Attribute -> value.reduce(r) 19 | is Term.Coerce -> value.reduce(r).map({ it.coerce(toType) }, "to $toType") 20 | is Term.Expr -> when (op) { 21 | Operator.PLUS -> lhs.reduce(r).combine(rhs.reduce(r), { x, y -> x + y }, "+") 22 | Operator.MINUS -> lhs.reduce(r).combine(rhs.reduce(r), { x, y -> x - y }, "-") 23 | Operator.MULTIPLY -> lhs.reduce(r).combine(rhs.reduce(r), { x, y -> x * y }, "*") 24 | Operator.DIVIDE -> lhs.reduce(r).combine(rhs.reduce(r), { x, y -> x / y }, "/") 25 | Operator.REGEX -> lhs.reduce(r).combine(rhs.reduce(r), { x, y -> x.regexMatch(y) }, "regex") 26 | } 27 | is Term.Request -> { 28 | val v = r[key] 29 | if (v != null) 30 | Eval(Value.String(v)) 31 | else 32 | Eval(NoSuchElementException("Missing parameter $key")) 33 | } 34 | is Term.REST<*> -> 35 | params.traverse { (it as Attribute).reduce(r) } 36 | .map({ doREST(url, method, it) }) 37 | .map(Value::String) 38 | } 39 | 40 | fun Condition.reduce(r: Request): Eval = when (this) { 41 | is Condition.Not -> condition.reduce(r).map({ !it }, "not") 42 | is Condition.And -> lhs.reduce(r).combine(rhs.reduce(r), { x, y -> x && y }, "and") 43 | is Condition.Or -> lhs.reduce(r).combine(rhs.reduce(r), { x, y -> x || y }, "or") 44 | is Condition.Equal -> lhs.reduce(r).combine(rhs.reduce(r), { x, y -> x.equals(y) }, "==") 45 | is Condition.Greater -> lhs.reduce(r).combine(rhs.reduce(r), { x, y -> x > y }, ">") 46 | } 47 | 48 | fun Rule.reduce(r: Request): Eval = when (this) { 49 | is Rule.Always -> Eval(decision, d = "always") 50 | is Rule.Never -> Eval(Decision.Undecided, d = "never") 51 | is Rule.When -> condition.reduce(r).map({ if (it) decision else Decision.Undecided }, "when") 52 | is Rule.Branch -> condition.reduce(r).flatMap({ if (it) trueRule.reduce(r) else falseRule.reduce(r) }, "branch") 53 | is Rule.Majority -> { 54 | val reductions = rules.map { it.reduce(r) } 55 | val matches = reductions.filter { it.t == decision } 56 | Eval(if (matches.size > reductions.size / 2) decision else Decision.Undecided) 57 | } 58 | is Rule.All -> Eval(if (rules.all { it.reduce(r).t == decision }) decision else Decision.Undecided) 59 | is Rule.Any -> Eval(if (rules.any { it.reduce(r).t == decision }) decision else Decision.Undecided) 60 | is Rule.OneOf -> { 61 | val reductions = rules.map { it.reduce(r) } 62 | val decided = reductions.filter { it.t != Decision.Undecided } 63 | if (decided.size == 1) decided[0] else Eval(Decision.Undecided) 64 | } 65 | } 66 | 67 | fun Eval<*>.toMap(): Map = 68 | mapOf("value" to orThrow(), "description" to d, "children" to c.map { it.toMap() }) 69 | 70 | fun RuleSet.evaluate(request: Request) = rules.map { it.reduce(request).toMap() } 71 | -------------------------------------------------------------------------------- /app/engine/src/main/kotlin/uk/neilgall/rulesapp/FromJSON.kt: -------------------------------------------------------------------------------- 1 | package uk.neilgall.rulesapp 2 | 3 | import org.json.JSONObject 4 | 5 | private fun JSONObject.getList(key: String, builder: (JSONObject) -> T): List = 6 | getJSONArray(key).map { builder(it as JSONObject) } 7 | 8 | private fun JSONObject.getMap(key: String, builder: (Any) -> T): Map = 9 | getJSONObject(key).toMap().mapValues { builder(it.value) } 10 | 11 | private fun JSONObject.name() = getString("name") 12 | private fun JSONObject.type() = getString("type") 13 | private fun JSONObject.decision(key: String = "decision") = Decision.valueOf(getString(key)) 14 | 15 | fun JSONObject.toAttribute(): Attribute = 16 | Attribute(name(), getJSONObject("value").toTerm()) 17 | 18 | fun JSONObject.toTerm(): Term = when(type()) { 19 | "string" -> Term.String(getString("value")) 20 | "number" -> Term.Number(getInt("value")) 21 | "attribute" -> Term.Attribute(getString("name")) 22 | "request" -> Term.Request(getString("key")) 23 | "rest" -> Term.REST(getString("url"), 24 | RESTMethod.valueOf(getString("method")), 25 | getMap("params", { it as String })) 26 | "coerce" -> Term.Coerce(getJSONObject("from").toTerm(), ValueType.valueOf(getString("to"))) 27 | else -> Term.Expr(getJSONObject("lhs").toTerm(), Operator.valueOf(type()), getJSONObject("rhs").toTerm()) 28 | } 29 | 30 | fun JSONObject.toCondition(): Condition = when (type()) { 31 | "not" -> Condition.Not(getJSONObject("condition").toCondition()) 32 | "and" -> Condition.And(getJSONObject("lhs").toCondition(), getJSONObject("rhs").toCondition()) 33 | "or" -> Condition.Or(getJSONObject("lhs").toCondition(), getJSONObject("rhs").toCondition()) 34 | "equal" -> Condition.Equal(getJSONObject("lhs").toTerm(), getJSONObject("rhs").toTerm()) 35 | "greater" -> Condition.Greater(getJSONObject("lhs").toTerm(), getJSONObject("rhs").toTerm()) 36 | else -> throw IllegalArgumentException("Invalid Condition '${toString()}'") 37 | } 38 | 39 | fun JSONObject.toRule(): Rule = when (type()) { 40 | "always" -> Rule.Always(decision()) 41 | "never" -> Rule.Never(decision()) 42 | "when" -> Rule.When(getJSONObject("condition").toCondition(), decision()) 43 | "branch" -> Rule.Branch(getJSONObject("condition").toCondition(), getJSONObject("true").toRule(), getJSONObject("false").toRule()) 44 | "majority" -> Rule.Majority(decision(), getList("rules", JSONObject::toRule)) 45 | "all" -> Rule.All(decision(), getList("rules", JSONObject::toRule)) 46 | "any" -> Rule.Any(decision(), getList("rules", JSONObject::toRule)) 47 | "one-of" -> Rule.OneOf(getList("rules", JSONObject::toRule)) 48 | else -> throw IllegalArgumentException("Invalid Rule '${toString()}'") 49 | } 50 | 51 | fun JSONObject.toRuleSet(): RuleSet = 52 | RuleSet( 53 | attributes = getList("attributes", JSONObject::toAttribute), 54 | rules = getList("rules", JSONObject::toRule) 55 | ) -------------------------------------------------------------------------------- /app/engine/src/main/kotlin/uk/neilgall/rulesapp/Main.kt: -------------------------------------------------------------------------------- 1 | package uk.neilgall.rulesapp 2 | 3 | import org.json.JSONArray 4 | import org.json.JSONObject 5 | import org.springframework.boot.SpringApplication 6 | import org.springframework.boot.autoconfigure.SpringBootApplication 7 | import org.springframework.web.bind.annotation.RequestBody 8 | import org.springframework.web.bind.annotation.RequestMapping 9 | import org.springframework.web.bind.annotation.RequestMethod 10 | import org.springframework.web.bind.annotation.RestController 11 | 12 | @RestController 13 | open class EngineController { 14 | 15 | private var ruleSet: RuleSet? = null 16 | 17 | @RequestMapping("/status") 18 | fun status(): String = "ok" 19 | 20 | @RequestMapping("/load", method = [RequestMethod.POST]) 21 | fun load(@RequestBody json: String): String { 22 | ruleSet = JSONObject(json).toRuleSet().resolve() 23 | return "ok" 24 | } 25 | 26 | @RequestMapping("/query", method = [RequestMethod.POST]) 27 | fun execute(@RequestBody attributes: Map): String { 28 | val request = attributes.mapValues { it.value.toString() } 29 | val results = ruleSet?.evaluate(request) ?: listOf() 30 | return JSONArray(results).toString() 31 | } 32 | } 33 | 34 | @SpringBootApplication 35 | open class Application 36 | 37 | fun main(args: Array) { 38 | SpringApplication.run(Application::class.java, *args) 39 | } 40 | -------------------------------------------------------------------------------- /app/engine/src/main/kotlin/uk/neilgall/rulesapp/REST.kt: -------------------------------------------------------------------------------- 1 | package uk.neilgall.rulesapp 2 | 3 | import com.github.kittinunf.fuel.Fuel 4 | 5 | typealias Params = List> 6 | 7 | private fun get(url: String) = 8 | Fuel.get(url).responseString().third.get() 9 | 10 | private fun post(url: String, params: Params) = 11 | Fuel.post(url, params).responseString().third.get() 12 | 13 | private fun put(url: String, params: Params) = 14 | Fuel.put(url, params).responseString().third.get() 15 | 16 | private fun delete(url: String, params: Params) = 17 | Fuel.delete(url, params).responseString().third.get() 18 | 19 | private fun Map.format(): Params = map { Pair(it.key, it.value.toString()) } 20 | 21 | fun doREST(url: String, method: RESTMethod, params: Map): String = when(method) { 22 | RESTMethod.GET -> get(url) 23 | RESTMethod.POST -> post(url, params.format()) 24 | RESTMethod.PUT -> put(url, params.format()) 25 | RESTMethod.DELETE -> delete(url, params.format()) 26 | } 27 | -------------------------------------------------------------------------------- /app/engine/src/main/kotlin/uk/neilgall/rulesapp/Value.kt: -------------------------------------------------------------------------------- 1 | package uk.neilgall.rulesapp 2 | 3 | import javafx.scene.chart.NumberAxisBuilder 4 | 5 | sealed class Value { 6 | data class String(val value: kotlin.String) : Value() { 7 | override fun equals(that: Value): Boolean = when (that) { 8 | is String -> value == that.value 9 | is Number -> value == that.value.toString() 10 | } 11 | 12 | override operator fun compareTo(that: Value): Int = when (that) { 13 | is String -> value.compareTo(that.value) 14 | is Number -> value.compareTo(that.value.toString()) 15 | } 16 | 17 | override operator fun plus(that: Value): Value = when (that) { 18 | is String -> Value.String(value + that.value) 19 | is Number -> Value.String(value + that.value.toString()) 20 | } 21 | 22 | override fun minus(that: Value): Value = throw IllegalArgumentException() 23 | override fun times(that: Value): Value = throw IllegalArgumentException() 24 | override fun div(that: Value): Value = throw IllegalArgumentException() 25 | 26 | override fun regexMatch(that: Value): Value = when (that) { 27 | is String -> Value.String(Regex(that.value).find(value)?.value ?: "") 28 | else -> throw IllegalArgumentException() 29 | } 30 | 31 | override fun coerce(toType: ValueType): Value = when (toType) { 32 | ValueType.STRING -> this 33 | ValueType.NUMBER -> Value.Number(value.toInt()) 34 | ValueType.BOOLEAN -> Value.Number(value.toInt()) //TODO 35 | } 36 | 37 | override fun toString(): kotlin.String = value 38 | } 39 | 40 | data class Number(val value: Int) : Value() { 41 | override fun equals(that: Value): Boolean = when (that) { 42 | is Number -> value == that.value 43 | is String -> value == that.value.toInt() 44 | } 45 | 46 | override operator fun compareTo(that: Value): Int = when (that) { 47 | is Number -> value.compareTo(that.value) 48 | is String -> value.compareTo(that.value.toInt()) 49 | } 50 | 51 | override fun plus(that: Value): Value = when (that) { 52 | is Number -> Value.Number(value + that.value) 53 | is String -> Value.Number(value + that.value.toInt()) 54 | } 55 | 56 | override fun minus(that: Value): Value = when (that) { 57 | is Number -> Value.Number(value - that.value) 58 | is String -> Value.Number(value - that.value.toInt()) 59 | } 60 | 61 | override fun times(that: Value): Value = when (that) { 62 | is Number -> Value.Number(value * that.value) 63 | is String -> Value.Number(value * that.value.toInt()) 64 | } 65 | 66 | override fun div(that: Value): Value = when (that) { 67 | is Number -> Value.Number(value / that.value) 68 | is String -> Value.Number(value / that.value.toInt()) 69 | } 70 | 71 | override fun coerce(toType: ValueType): Value = when (toType) { 72 | ValueType.STRING -> Value.String(value.toString()) 73 | ValueType.NUMBER -> this 74 | ValueType.BOOLEAN -> this //TODO 75 | } 76 | 77 | override fun regexMatch(that: Value): Value = throw IllegalArgumentException() 78 | 79 | override fun toString(): kotlin.String = value.toString() 80 | } 81 | 82 | abstract fun equals(that: Value): Boolean 83 | abstract operator fun compareTo(that: Value): Int 84 | abstract operator fun plus(that: Value): Value 85 | abstract operator fun minus(that: Value): Value 86 | abstract operator fun times(that: Value): Value 87 | abstract operator fun div(that: Value): Value 88 | abstract fun regexMatch(that: Value): Value 89 | abstract fun coerce(toType: ValueType): Value 90 | } 91 | -------------------------------------------------------------------------------- /app/engine/src/test/kotlin/uk/neilgall/rulesapp/EvaluatorSpec.kt: -------------------------------------------------------------------------------- 1 | package uk.neilgall.rulesapp 2 | 3 | import io.kotlintest.matchers.shouldBe 4 | import io.kotlintest.matchers.shouldEqual 5 | import io.kotlintest.specs.StringSpec 6 | 7 | class TermEvaluatorSpec : StringSpec({ 8 | "string" { 9 | Term.String("foo").reduce(mapOf()).orThrow() shouldEqual Value.String("foo") 10 | } 11 | 12 | "number" { 13 | Term.Number(123).reduce(mapOf()).orThrow() shouldEqual Value.Number(123) 14 | } 15 | 16 | "string attribute" { 17 | Term.Attribute(Attribute("foo", Term.String("bar"))).reduce(mapOf()).orThrow() shouldEqual Value.String("bar") 18 | } 19 | 20 | "number attribute" { 21 | Term.Attribute(Attribute("qux", Term.Number(42))).reduce(mapOf()).orThrow() shouldEqual Value.Number(42) 22 | } 23 | 24 | "request attribute" { 25 | Term.Attribute(Attribute("foo", Term.Request("xyz"))).reduce(mapOf("xyz" to "42")).orThrow() shouldEqual Value.String("42") 26 | } 27 | 28 | "arithmetic" { 29 | Term.Expr(Term.Number(6), Operator.MULTIPLY, Term.Number(7)).reduce(mapOf()).orThrow() shouldEqual Value.Number(42) 30 | } 31 | }) 32 | 33 | class ConditionEvaluatorSpec : StringSpec({ 34 | "equals" { 35 | Condition.Equal(Term.Number(42), Term.Number(42)).reduce(mapOf()).orThrow() shouldBe true 36 | Condition.Equal(Term.Number(42), Term.Number(43)).reduce(mapOf()).orThrow() shouldBe false 37 | } 38 | 39 | "equals with mismatched types" { 40 | Condition.Equal(Term.Number(42), Term.String("42")).reduce(mapOf()).orThrow() shouldBe true 41 | Condition.Equal(Term.String("42"), Term.Number(42)).reduce(mapOf()).orThrow() shouldBe true 42 | } 43 | 44 | "greater" { 45 | Condition.Greater(Term.Number(42), Term.Number(5)).reduce(mapOf()).orThrow() shouldBe true 46 | Condition.Greater(Term.Number(42), Term.Number(75)).reduce(mapOf()).orThrow() shouldBe false 47 | } 48 | 49 | "greater with mismatched types" { 50 | Condition.Greater(Term.Number(42), Term.String("5")).reduce(mapOf()).orThrow() shouldBe true 51 | Condition.Greater(Term.Number(42), Term.String("75")).reduce(mapOf()).orThrow() shouldBe false 52 | } 53 | 54 | "not" { 55 | Condition.Not( 56 | Condition.Equal(Term.Number(42), Term.Number(42)) 57 | ).reduce(mapOf()).orThrow() shouldBe false 58 | } 59 | 60 | "and" { 61 | Condition.And( 62 | Condition.Equal(Term.Number(42), Term.Number(42)), 63 | Condition.Equal(Term.String("foo"), Term.String("foo")) 64 | ).reduce(mapOf()).orThrow() shouldBe true 65 | 66 | Condition.And( 67 | Condition.Equal(Term.Number(42), Term.Number(43)), 68 | Condition.Equal(Term.String("foo"), Term.String("foo")) 69 | ).reduce(mapOf()).orThrow() shouldBe false 70 | 71 | Condition.And( 72 | Condition.Equal(Term.Number(42), Term.Number(42)), 73 | Condition.Equal(Term.String("foo"), Term.String("bar")) 74 | ).reduce(mapOf()).orThrow() shouldBe false 75 | 76 | Condition.And( 77 | Condition.Equal(Term.Number(42), Term.Number(43)), 78 | Condition.Equal(Term.String("foo"), Term.String("bar")) 79 | ).reduce(mapOf()).orThrow() shouldBe false 80 | } 81 | 82 | "or" { 83 | Condition.Or( 84 | Condition.Equal(Term.Number(42), Term.Number(42)), 85 | Condition.Equal(Term.String("foo"), Term.String("foo")) 86 | ).reduce(mapOf()).orThrow() shouldBe true 87 | 88 | Condition.Or( 89 | Condition.Equal(Term.Number(42), Term.Number(43)), 90 | Condition.Equal(Term.String("foo"), Term.String("foo")) 91 | ).reduce(mapOf()).orThrow() shouldBe true 92 | 93 | Condition.Or( 94 | Condition.Equal(Term.Number(42), Term.Number(42)), 95 | Condition.Equal(Term.String("foo"), Term.String("bar")) 96 | ).reduce(mapOf()).orThrow() shouldBe true 97 | 98 | Condition.Or( 99 | Condition.Equal(Term.Number(42), Term.Number(43)), 100 | Condition.Equal(Term.String("foo"), Term.String("bar")) 101 | ).reduce(mapOf()).orThrow() shouldBe false 102 | } 103 | }) 104 | 105 | class RuleEvaluatorSpec : StringSpec({ 106 | "always" { 107 | Rule.Always(Decision.Permit).reduce(mapOf()).orThrow() shouldEqual Decision.Permit 108 | Rule.Always(Decision.Deny).reduce(mapOf()).orThrow() shouldEqual Decision.Deny 109 | } 110 | 111 | "never" { 112 | Rule.Never(Decision.Permit).reduce(mapOf()).orThrow() shouldEqual Decision.Undecided 113 | Rule.Never(Decision.Deny).reduce(mapOf()).orThrow() shouldEqual Decision.Undecided 114 | } 115 | 116 | "when" { 117 | Rule.When( 118 | Condition.Equal(Term.Number(42), Term.Number(42)), 119 | Decision.Permit 120 | ).reduce(mapOf()).orThrow() shouldEqual Decision.Permit 121 | 122 | Rule.When( 123 | Condition.Equal(Term.Number(42), Term.Number(43)), 124 | Decision.Permit 125 | ).reduce(mapOf()).orThrow() shouldEqual Decision.Undecided 126 | } 127 | 128 | "branch" { 129 | Rule.Branch( 130 | Condition.Equal(Term.Number(42), Term.Number(42)), 131 | Rule.Always(Decision.Permit), 132 | Rule.Always(Decision.Deny) 133 | ).reduce(mapOf()).orThrow() shouldEqual Decision.Permit 134 | 135 | Rule.Branch( 136 | Condition.Equal(Term.Number(42), Term.Number(43)), 137 | Rule.Always(Decision.Permit), 138 | Rule.Always(Decision.Deny) 139 | ).reduce(mapOf()).orThrow() shouldEqual Decision.Deny 140 | } 141 | 142 | "majority" { 143 | Rule.Majority( 144 | Decision.Permit, 145 | listOf(Rule.Always(Decision.Permit), 146 | Rule.Always(Decision.Permit), 147 | Rule.Always(Decision.Deny) 148 | ) 149 | ).reduce(mapOf()).orThrow() shouldEqual Decision.Permit 150 | 151 | Rule.Majority( 152 | Decision.Permit, 153 | listOf(Rule.Always(Decision.Permit), 154 | Rule.Always(Decision.Deny), 155 | Rule.Always(Decision.Deny) 156 | ) 157 | ).reduce(mapOf()).orThrow() shouldEqual Decision.Undecided 158 | } 159 | 160 | "all" { 161 | Rule.All( 162 | Decision.Permit, 163 | listOf(Rule.Always(Decision.Permit), 164 | Rule.Always(Decision.Permit), 165 | Rule.Always(Decision.Permit) 166 | ) 167 | ).reduce(mapOf()).orThrow() shouldEqual Decision.Permit 168 | 169 | Rule.All( 170 | Decision.Permit, 171 | listOf(Rule.Always(Decision.Permit), 172 | Rule.Always(Decision.Permit), 173 | Rule.Always(Decision.Deny) 174 | ) 175 | ).reduce(mapOf()).orThrow() shouldEqual Decision.Undecided 176 | } 177 | 178 | "any" { 179 | Rule.Any( 180 | Decision.Permit, 181 | listOf(Rule.Always(Decision.Deny), 182 | Rule.Always(Decision.Deny), 183 | Rule.Always(Decision.Deny) 184 | ) 185 | ).reduce(mapOf()).orThrow() shouldEqual Decision.Undecided 186 | 187 | Rule.Any( 188 | Decision.Permit, 189 | listOf(Rule.Always(Decision.Deny), 190 | Rule.Always(Decision.Permit), 191 | Rule.Always(Decision.Deny) 192 | ) 193 | ).reduce(mapOf()).orThrow() shouldEqual Decision.Permit 194 | } 195 | }) -------------------------------------------------------------------------------- /app/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neilgall/pytest-docker-flask/4c03f4ca38e33b4b38f63796eccf211a48f93203/app/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Jun 11 20:21:50 BST 2018 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.7-all.zip 7 | -------------------------------------------------------------------------------- /app/gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /app/gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /app/settings.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * This file was generated by the Gradle 'init' task. 3 | * 4 | * The settings file is used to specify which projects to include in your build. 5 | * 6 | * Detailed information about configuring a multi-project build in Gradle can be found 7 | * in the user guide at https://docs.gradle.org/4.7/userguide/multi_project_builds.html 8 | */ 9 | 10 | rootProject.name = 'app' 11 | include ':common', ':compiler', ':engine' 12 | 13 | -------------------------------------------------------------------------------- /docker/example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import docker as dockerlib 3 | 4 | docker = dockerlib.from_env() 5 | 6 | class DockerContainer: 7 | def __init__(self, image_name): 8 | self._image_name = image_name 9 | 10 | def __enter__(self): 11 | self._container = docker.containers.run(image=self._image_name, detach=True) 12 | return self._container 13 | 14 | def __exit__(self, *args): 15 | self._container.kill() 16 | self._container.remove() 17 | 18 | if __name__ == '__main__': 19 | with DockerContainer("rulesapp-compiler") as compiler: 20 | try: 21 | for line in compiler.logs(stream=True): 22 | print(">> %s" % line) 23 | except InterruptedError as e: 24 | pass 25 | -------------------------------------------------------------------------------- /outline.md: -------------------------------------------------------------------------------- 1 | # Outline 2 | 3 | * Intro 4 | * Three unrelated things 5 | * Motivation 6 | * Describe decision engine 7 | * Describe testing problem 8 | 9 | * Docker 10 | * Show apps running in Docker 11 | * `docker run --rm -p8080:8080 rulesapp-compiler` 12 | * `docker run --rm -p8090:8080 --add-host="external:" rulesapp-engine` 13 | * Use Insomnia to communicate 14 | 15 | * Flask 16 | * Great for test web services 17 | * Demo minimal web app 18 | * Demo banking service with test rules 19 | 20 | * Pytest 21 | * Lack of ceremony 22 | * Dependency injection via fixtures 23 | * Fixtures as temporary state 24 | * Can fixtures run docker containers? 25 | 26 | * Docker SDK for Python 27 | * Fine control over docker container - like docker-compose 28 | * Great fit for Python context manager 29 | * Quick demo 30 | -------------------------------------------------------------------------------- /pytest/app-tests/conftest.py: -------------------------------------------------------------------------------- 1 | from framework import DockerContainer, RestClient, ContentType 2 | from munch import munchify 3 | import pytest 4 | import random 5 | 6 | def _dockerised_rest_app(tmpdir, name, client_class=RestClient): 7 | port = random.randint(40000, 50000) 8 | with DockerContainer(tmpdir, name, ports={'8080/tcp':port}) as app: 9 | client = client_class('localhost', port) 10 | client.wait_for_ready() 11 | yield client 12 | 13 | @pytest.fixture(scope='module') 14 | def compiler(tmpdir_factory): 15 | class CompilerClient(RestClient): 16 | def compile(self, text): 17 | return self.post('/compile', content_type=ContentType.TEXT, data=text).json() 18 | 19 | tmpdir = tmpdir_factory.mktemp('compiler') 20 | yield from _dockerised_rest_app(tmpdir, 'rulesapp-compiler', CompilerClient) 21 | 22 | @pytest.fixture(scope='module') 23 | def engine(tmpdir_factory): 24 | class EngineClient(RestClient): 25 | def load(self, json): 26 | return self.post('/load', content_type=ContentType.JSON, json=json).text == 'ok' 27 | def query(self, json): 28 | return munchify(self.post('/query', content_type=ContentType.JSON, json=json).json()) 29 | 30 | tmpdir = tmpdir_factory.mktemp('engine') 31 | yield from _dockerised_rest_app(tmpdir, 'rulesapp-engine', EngineClient) 32 | -------------------------------------------------------------------------------- /pytest/app-tests/framework/__init__.py: -------------------------------------------------------------------------------- 1 | from .docker import DockerContainer 2 | from .rest_client import ContentType, RestClient 3 | from .service import Service -------------------------------------------------------------------------------- /pytest/app-tests/framework/docker.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import docker as dockerlib 3 | import json 4 | import os 5 | import re 6 | import requests 7 | from .networking import get_host_ip 8 | 9 | _export_logs_on_exit=True 10 | _export_filesystem_on_exit=False 11 | 12 | _docker = dockerlib.from_env() 13 | 14 | # Create a global bridge network which all docker containers connect to, 15 | # so by default they can talk to each other by hostname 16 | _network = _docker.networks.create('test-network', driver='bridge') 17 | atexit.register(_docker.networks.prune) 18 | 19 | class DockerContainer: 20 | """ 21 | A context manager which runs a docker container in its context 22 | """ 23 | def __init__(self, tmpdir, name, tag = None, run_id='', **kwargs): 24 | """ 25 | tmpdir - a temporary directory for configuration and output files 26 | name - the name of the container to run 27 | tag - the tag for the container to run 28 | run_id - a unique ID per run; useful if the same container is invoked multiple times per test 29 | kwargs - further arguments to docker.containers.run() 30 | """ 31 | self._tmpdir = tmpdir 32 | self._name = name 33 | self._tag = ':' + tag if tag is not None else '' 34 | self._run_id = str(run_id) 35 | self._args = kwargs 36 | self._container = None 37 | 38 | def __enter__(self): 39 | self.start() 40 | 41 | def __exit__(self, exc_type, exc_value, traceback): 42 | self.stop() 43 | 44 | def _image_name(self): 45 | return '%s%s' % (self._name, self._tag) 46 | 47 | def _container_name(self): 48 | return '%s%s' % (re.sub(r'[^0-9a-zA-Z]+', '_', self._name), self._run_id) 49 | 50 | def _rerun_docker_command(self): 51 | "Generate an equivalent docker command to rerun this container for debugging" 52 | cmd = ['docker', 'run', '--network', 'host'] 53 | for (k,v) in self._args.get('environment', {}).items(): 54 | cmd.extend(['-e', '"%s=%s"' % (k,v)]) 55 | for (k,v) in self._args.get('volumes', {}).items(): 56 | cmd.extend(['-v', '"%s:%s"' % (k, v['bind'])]) 57 | for (k,v) in self._args.get('ports', {}).items(): 58 | cmd.extend(['-p', '%s:%s"' % (k.split('/')[0], str(v))]) 59 | cmd.append(self._image_name()) 60 | return " ".join(cmd) 61 | 62 | def _container_info(self): 63 | "Generate the docker container info contents" 64 | return { 65 | 'image': self._image_name(), 66 | **self._args 67 | } 68 | 69 | def _write_tmpfile(self, base, ext, text): 70 | os.makedirs(self._tmpdir, exist_ok=True) 71 | f = self._tmpdir / ('%s%s.%s' % (base, self._run_id, ext)) 72 | f.write_text(text, encoding='utf8') 73 | 74 | def start(self): 75 | assert self._container is None 76 | 77 | container_info = json.dumps(self._container_info(), indent=2) 78 | self._write_tmpfile('container', 'json', container_info) 79 | self._write_tmpfile('docker', 'cmd', self._rerun_docker_command()) 80 | 81 | for container in _docker.containers.list(): 82 | if container.name == self._container_name(): 83 | container.remove() 84 | break 85 | 86 | try: 87 | _docker.images.get(self._image_name()) 88 | except dockerlib.errors.ImageNotFound: 89 | _docker.images.pull(self._image_name()) 90 | 91 | self._container = _docker.containers.run( 92 | image=self._image_name(), 93 | name=self._container_name(), 94 | detach=True, 95 | network=_network.name, 96 | extra_hosts = { 'services': get_host_ip() }, 97 | **self._args) 98 | 99 | def stop(self): 100 | root = 'container%s' % self._run_id 101 | (self._tmpdir / root + '.status').write(self._container.status) 102 | self._container.stop(timeout=1) 103 | if _export_logs_on_exit: 104 | (self._tmpdir / root + '.stdout').write(self._container.logs(stdout=True, stderr=False)) 105 | (self._tmpdir / root + '.stderr').write(self._container.logs(stderr=True, stdout=False)) 106 | if _export_filesystem_on_exit: 107 | with (self._tmpdir / root + '.tar').open('wb') as f: 108 | for chunk in self._container.export(): f.write(chunk) 109 | self._container.remove() 110 | self._container = None 111 | 112 | def wait(self): 113 | self._container.wait() 114 | 115 | def get_stdout(self): 116 | return self._container.logs(stdout=True, stderr=False) 117 | -------------------------------------------------------------------------------- /pytest/app-tests/framework/networking.py: -------------------------------------------------------------------------------- 1 | from socket import * 2 | 3 | def get_host_ip(): 4 | """Get the IP address of the host""" 5 | s = socket(AF_INET, SOCK_DGRAM) 6 | try: 7 | # doesn't need to be reachable 8 | s.connect(('1.1.1.1', 1)) 9 | return s.getsockname()[0] 10 | except: 11 | return '127.0.0.1' 12 | finally: 13 | s.close() 14 | 15 | if __name__ == "__main__": 16 | print(get_host_ip()) 17 | -------------------------------------------------------------------------------- /pytest/app-tests/framework/rest_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import time 4 | 5 | import logging 6 | import http.client 7 | http.client.HTTPConnection.debuglevel = 1 8 | 9 | class ContentType: 10 | TEXT = 'text/plain' 11 | JSON = 'application/json' 12 | BINARY = 'application/octet-stream' 13 | 14 | class RestClient: 15 | """ 16 | An API client 17 | """ 18 | def __init__(self, host, port): 19 | self._base = "http://%s:%d" % (host, port) 20 | self._session = requests.Session() 21 | 22 | def __repr__(self): 23 | return "RestClient{base=%s}" % self._base 24 | 25 | def _url(self, path): 26 | sep = '' if len(path) > 0 and path[0] == '/' else '/' 27 | return self._base + sep + path 28 | 29 | def get_base_url(self): 30 | return self._base 31 | 32 | def wait_for_ready(self, timeout=30): 33 | """ 34 | Poll the system status API until it succeeds. 35 | """ 36 | expiry = time.time() + timeout 37 | while time.time() < expiry: 38 | try: 39 | self.get("/status") 40 | return 41 | except Exception as e: 42 | time.sleep(1) 43 | raise TimeoutError("timed out waiting for %s to be ready" % self._base) 44 | 45 | def get(self, path, allow_errors = False, **kwargs): 46 | "Perform a GET request." 47 | rsp = self._session.get(self._url(path), **kwargs) 48 | assert allow_errors or rsp.status_code < 300, "bad response from GET %s: %s" % (path, rsp) 49 | return rsp 50 | 51 | def post(self, path, content_type=ContentType.JSON, headers={}, allow_errors=False, **kwargs): 52 | "Perform a POST request" 53 | all_headers = { 'Content-Type': content_type } 54 | all_headers.update(headers) 55 | rsp = self._session.post(self._url(path), headers=all_headers, **kwargs) 56 | assert allow_errors or rsp.status_code < 300, "bad response from POST %s: %s" % (path, rsp) 57 | return rsp 58 | 59 | def put(self, path, content_type=ContentType.JSON, headers={}, allow_errors=False, **kwargs): 60 | "Perform a PUT request" 61 | all_headers = { 'Content-Type': content_type } 62 | all_headers.update(headers) 63 | rsp = self._session.put(self._url(path), headers=all_headers, **kwargs) 64 | assert allow_errors or rsp.status_code < 300, "bad response from PUT %s: %s" % (path, rsp) 65 | return rsp 66 | 67 | def delete(self, path, allow_errors=False, **kwargs): 68 | "Perform a DELETE request" 69 | rsp = self._session.delete(self._url(path), **kwargs) 70 | assert allow_errors or rsp.status_code < 300, "bad response from DELETE %s: %s" % (path, rsp) 71 | return rsp 72 | 73 | def download_get(self, path, file, **kwargs): 74 | "Perform a GET request, downloading the content to the given file-like object" 75 | rsp = self._session.get(self._url(path), **kwargs) 76 | assert rsp.status_code < 300, "bad response from GET %s: %s" % (path, rsp) 77 | file.write_binary(rsp.content) 78 | -------------------------------------------------------------------------------- /pytest/app-tests/framework/service.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import random 3 | import requests 4 | import threading 5 | import time 6 | import wsgiref 7 | 8 | class _Instrumented: 9 | "WSGI middleware for instrumenting an embedded service." 10 | def __init__(self, app): 11 | self._app = app 12 | self.invocations = [] 13 | 14 | def __call__(self, environ, start_response): 15 | rsp = self._app(environ, start_response) 16 | self.invocations.append((environ, rsp)) 17 | return rsp 18 | 19 | class Service: 20 | """ 21 | Provides a context manager server lifecycle around a flask-defined API. 22 | Create a Flask APP as normal then use as a context manager, e.g: 23 | 24 | with Service(flask_app) as svc: 25 | ... 26 | 27 | Inside the `with` block the `svc` object has one property `invocations` 28 | which is a list of `(environ, response)` pairs for each invocation of 29 | the service. 30 | """ 31 | def __init__(self, flask_app, port=None): 32 | self._flask_app = flask_app 33 | self._port = port or random.randint(50000,60000) 34 | self._server = None 35 | 36 | # add a shutdown hook to the Flask app 37 | @flask_app.route('/service-control', methods=['GET','DELETE']) 38 | def service_control(): 39 | if flask.request.method == 'GET': 40 | return "ok" 41 | elif flask.request.method == 'DELETE': 42 | flask.request.environ.get('werkzeug.server.shutdown')() 43 | return 'bye!' 44 | else: 45 | return "err" 46 | 47 | def url(self, path, localhost=False): 48 | host = 'localhost' if localhost else 'services' 49 | sep = '' if path.startswith('/') else '/' 50 | return 'http://%s:%d%s%s' % (host, self._port, sep, path) 51 | 52 | def flask_thread(self): 53 | self._flask_app.run(host='0.0.0.0', port=self._port) 54 | 55 | def _start_server(self): 56 | server = threading.Thread(target=self.flask_thread) 57 | server.start() 58 | timeout = time.time() + 5 59 | while time.time() < timeout: 60 | if not server.is_alive(): 61 | time.sleep(1) 62 | else: 63 | try: 64 | rsp = requests.get('http://localhost:%d/service-control' % self._port, timeout=1) 65 | assert rsp.text == "ok" 66 | return server 67 | except: 68 | pass 69 | 70 | def _stop_server(self, server): 71 | rsp = requests.delete('http://localhost:%d/service-control' % self._port) 72 | assert rsp.text == 'bye!' 73 | server.join() 74 | 75 | def __repr__(self): 76 | return "Service:%s" % self._flask_app.name 77 | 78 | def __enter__(self): 79 | assert self._server is None 80 | instrumented = _Instrumented(self._flask_app.wsgi_app) 81 | self._original_wsgi_app = self._flask_app.wsgi_app 82 | self._flask_app.wsgi_app = instrumented 83 | self._server = self._start_server() 84 | instrumented.invocations = [] # clear out the start request 85 | return self._flask_app.wsgi_app 86 | 87 | def __exit__(self, exc_type, exc_value, traceback): 88 | self._stop_server(self._server) 89 | self._flask_app.wsgi_app = self._original_wsgi_app 90 | self._server = None 91 | 92 | if __name__ == "__main__": 93 | app = flask.Flask("test") 94 | 95 | @app.route("/foo/") 96 | def foo(bar): 97 | return "hello " + bar 98 | 99 | svc = Service(app) 100 | with svc: 101 | rsp = requests.get(svc.url('/foo/test', localhost=True)) 102 | assert rsp.text == "hello test" 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /pytest/app-tests/test_compiler.py: -------------------------------------------------------------------------------- 1 | def test_compiler(compiler): 2 | result = compiler.compile('always permit') 3 | 4 | assert result == { 5 | 'attributes': [], 6 | 'rules': [{ 7 | 'type': 'always', 8 | 'decision': 'Permit' 9 | }] 10 | } 11 | -------------------------------------------------------------------------------- /pytest/app-tests/test_end_to_end.py: -------------------------------------------------------------------------------- 1 | def test_end_to_end(compiler, engine): 2 | """ 3 | Simple end-to-end test invoking the compiler and running the resulting code in the engine 4 | """ 5 | compiled = compiler.compile('always permit') 6 | assert engine.load(compiled) 7 | 8 | result = engine.query({}) 9 | assert result[0].value == 'Permit' 10 | -------------------------------------------------------------------------------- /pytest/app-tests/test_request.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | @pytest.fixture(scope='module') 4 | def engine_with_rules(compiler, engine): 5 | compiled = compiler.compile(''' 6 | myValue = request "myValue" 7 | 8 | exclusive { 9 | permit when myValue = "foo", 10 | deny when myValue = "bar" 11 | } 12 | ''') 13 | assert engine.load(compiled) 14 | return engine 15 | 16 | def test_foo_permits(engine_with_rules): 17 | result = engine_with_rules.query({'myValue': 'foo'}) 18 | assert result[0].value == 'Permit' 19 | 20 | def test_bar_denies(engine_with_rules): 21 | result = engine_with_rules.query({'myValue': 'bar'}) 22 | assert result[0].value == 'Deny' 23 | 24 | def test_no_rule_for_other_values(engine_with_rules): 25 | result = engine_with_rules.query({'myValue': 'qq'}) 26 | assert result[0].value == 'Undecided' 27 | -------------------------------------------------------------------------------- /pytest/app-tests/test_service.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from framework import Service 3 | import pytest 4 | 5 | @pytest.fixture 6 | def simple_service(): 7 | """ 8 | Provides a simple service running in the pytest process 9 | """ 10 | app = Flask('simple-service') 11 | 12 | @app.route('/hello') 13 | def main(): 14 | return 'ok' 15 | 16 | return Service(app) 17 | 18 | def test_using_service(compiler, engine, simple_service): 19 | """ 20 | End-to-end test including a call to a local service 21 | """ 22 | compiled = compiler.compile(f''' 23 | result = GET "{simple_service.url('/hello')}" 24 | if result = "ok" always permit else always deny 25 | ''') 26 | 27 | assert engine.load(compiled) 28 | 29 | with simple_service as service: 30 | result = engine.query({}) 31 | assert result[0].value == 'Permit' 32 | assert len(service.invocations) == 1 33 | 34 | -------------------------------------------------------------------------------- /pytest/examples/test_failures.py: -------------------------------------------------------------------------------- 1 | def test_badding(): 2 | assert 1 + 1 == 3 3 | 4 | -------------------------------------------------------------------------------- /pytest/examples/test_fixture.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | @pytest.fixture 4 | def hello_file(tmpdir): 5 | path = tmpdir / 'hello.txt' 6 | with path.open('wt') as f: 7 | f.write('hello world') 8 | return path 9 | 10 | def test_hello(hello_file): 11 | with hello_file.open('rt') as f: 12 | assert f.read() == 'hello world' 13 | -------------------------------------------------------------------------------- /pytest/examples/test_hello.py: -------------------------------------------------------------------------------- 1 | def test_adding(): 2 | assert 1 + 1 == 2 3 | -------------------------------------------------------------------------------- /pytest/examples/test_module_fixture.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | @pytest.fixture(scope='module') 4 | def hello_file(tmpdir_factory): 5 | tmpdir = tmpdir_factory.mktemp('module-scope') 6 | path = tmpdir / 'hello.txt' 7 | with path.open('wt') as f: 8 | f.write('hello world') 9 | yield path 10 | path.remove() 11 | 12 | def test_hello(hello_file): 13 | print(hello_file) 14 | with hello_file.open('rt') as f: 15 | assert 'hello' in f.read() 16 | 17 | def test_world(hello_file): 18 | print(hello_file) 19 | with hello_file.open('rt') as f: 20 | assert 'world' in f.read() 21 | -------------------------------------------------------------------------------- /pytest/examples/test_tmpdir.py: -------------------------------------------------------------------------------- 1 | def test_can_write_files(tmpdir): 2 | with (tmpdir / 'foo.txt').open("wt") as f: 3 | f.write("hello world") 4 | -------------------------------------------------------------------------------- /pytest/examples/test_yield.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | @pytest.fixture 4 | def hello_file(tmpdir): 5 | path = tmpdir / 'hello.txt' 6 | with path.open('wt') as f: 7 | f.write('hello world') 8 | yield path 9 | path.remove() 10 | 11 | def test_hello(hello_file): 12 | print(hello_file) 13 | with hello_file.open('rt') as f: 14 | assert f.read() == 'hello world' 15 | -------------------------------------------------------------------------------- /pytest/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = app-tests 3 | 4 | -------------------------------------------------------------------------------- /services/bank.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from flask import Flask, request 3 | import json 4 | 5 | app = Flask("bank") 6 | 7 | _accounts = { 8 | "10011002": { 9 | "name": "Alice", 10 | "balance": 100 11 | }, 12 | "10011003": { 13 | "name": "Bob", 14 | "balance": 200 15 | } 16 | } 17 | 18 | @app.route("/account-details", methods=["POST"]) 19 | def account_details(): 20 | account = _accounts.get(request.form['account']) 21 | return json.dumps(account) 22 | 23 | @app.route("/account-balance", methods=["POST"]) 24 | def account_balance(): 25 | print(request.form) 26 | account = _accounts.get(request.form['account']) 27 | return str(account["balance"]) 28 | 29 | if __name__ == "__main__": 30 | app.run("0.0.0.0", port=5000, debug=True) 31 | -------------------------------------------------------------------------------- /services/hello.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from flask import Flask 3 | 4 | app = Flask("hello") 5 | 6 | @app.route("/hello") 7 | def hello(): 8 | return "ok" 9 | 10 | app.run(host='0.0.0.0') 11 | 12 | 13 | -------------------------------------------------------------------------------- /services/my_value.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from flask import Flask 3 | 4 | app = Flask("myValue") 5 | 6 | @app.route("/myValue") 7 | def hello(): 8 | return "foo" 9 | 10 | app.run(host='0.0.0.0', debug=True) 11 | 12 | 13 | -------------------------------------------------------------------------------- /slides.odp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neilgall/pytest-docker-flask/4c03f4ca38e33b4b38f63796eccf211a48f93203/slides.odp -------------------------------------------------------------------------------- /test-data/test.rules: -------------------------------------------------------------------------------- 1 | foo = const "foo" 2 | bar = request "bar" 3 | 4 | any permit 5 | permit when foo = bar, 6 | permit when foo = "qux" 7 | 8 | 9 | --------------------------------------------------------------------------------