Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Consumer Kotlin DSL API proposal #1352

Open
tlinkowski opened this issue Apr 28, 2021 · 12 comments
Open

Consumer Kotlin DSL API proposal #1352

tlinkowski opened this issue Apr 28, 2021 · 12 comments

Comments

@tlinkowski
Copy link

Hi, I'd like to propose an API for consumer Kotlin DSL (in case you considered implementing one).

Flavors

There are two flavors, although they differ only slightly:

  • given vs. {} (in providerStates)
  • uponReceiving vs. request
  • willRespondWith vs. response

BDD flavor

    @Pact(consumer = "consumer")
    fun bddPact(builder: PactDslWithProvider): RequestResponsePact = builder.toKotlinDsl()
        .interaction {
            providerStates {
                given("sample state 1")
                given("sample state 2") {
                    "someId" - "someValue"
                    "anotherId" - 20
                }
            }
            uponReceiving("sample request") {
                method = "POST"
                path("/v1/sample/resources/{resourceId}") {
                    "resourceId" - uuid("439caf8d-c672-4596-b3c3-9d3bde5c980b")
                }
                query {
                    "someType" - enum<SomeType>("FOO")
                    "specialMode" - boolean(true)
                }
                headers {
                    "Content-Type" - equalTo("application/json")
                    "RequestId" - string("7a324886-a60b-467a-a0df-ecc5fc7a72aa")
                }
                body = obj {
                    "contextId" - uuid("439caf8d-c672-4596-b3c3-9d3bde5c980b")
                    "items" - array(eachLike = obj {
                        "name" - string("sample item")
                        "count" - integer(2)
                    })
                }
            }
            willRespondWith {
                status = 200
                headers {
                    "Content-Type" - equalTo("application/json")
                }
                body = obj {
                    "resourceId" - uuid("d2884c7c-2231-4d18-826b-c0746b81b962")
                }
            }
        }
        .toPact()

Declarative flavor

    @Pact(consumer = "consumer")
    fun declarativePact(builder: PactDslWithProvider): RequestResponsePact = builder.toKotlinDsl()
        .interaction {
            providerStates {
                "sample state 1" {}
                "sample state 2" {
                    "someId" - "someValue"
                    "anotherId" - 20
                }
            }
            request("sample request") {
                method = "POST"
                path("/v1/sample/resources/{resourceId}") {
                    "resourceId" - uuid("439caf8d-c672-4596-b3c3-9d3bde5c980b")
                }
                query {
                    "someType" - enum<SomeType>("FOO")
                    "specialMode" - boolean(true)
                }
                headers {
                    "Content-Type" - equalTo("application/json")
                    "RequestId" - string("7a324886-a60b-467a-a0df-ecc5fc7a72aa")
                }
                body = obj {
                    "contextId" - uuid("439caf8d-c672-4596-b3c3-9d3bde5c980b")
                    "items" - array(eachLike = obj {
                        "name" - string("sample item")
                        "count" - integer(2)
                    })
                }
            }
            response {
                status = 200
                headers {
                    "Content-Type" - equalTo("application/json")
                }
                body = obj {
                    "resourceId" - uuid("d2884c7c-2231-4d18-826b-c0746b81b962")
                }
            }
        }
        .toPact()

Full list of body symbols

        private val objectBody = pactBody {
            obj {
                "equalTo01" - equalTo(1)
                "string03" - string("str03")
                "stringLike04" - string("str04", like = Regex("\\w+"))
                "stringWithin05" - string("str05", within = listOf("str05"))
                "SampleEnum06" - enum<SampleEnum>("FOO")
                "int07" - integer(7)
                "long08" - integer(8L)
                "decimal09" - decimal("0.09")
                "boolean10" - boolean(true)
                "uuid11" - uuid("11111111-c672-4596-b3c3-9d3bde5c980b")
                "date12" - date("2012-12-12")
                "time13" - time("13:13:13")
                "dateTime14_sec" - dateTime("2014-04-14T14:14:14Z")
                "dateTime14_milli" - dateTime("2014-04-14T14:14:14.141Z")
                "obj15" - obj {
                    "obj15_string1" - string("str15.1")
                    "obj15_int2" - integer(152)
                }
                "arrayOf16" - arrayOf(
                    string("arrayOf16_string1"),
                    integer(162)
                )
                "eachLike17" - array(eachLike = string("eachLike17_string1"))
                "eachLike18" - array(
                    minSize = 2, maxSize = 4, numberOfExamples = 3,
                    eachLike = string("eachLike18_string1")
                )
                "eachLike19" - array(eachLike = obj {
                    "eachLike19_obj_string1" - string("str19.1")
                    "eachLike19_obj_int2" - integer(192)
                })
                "eachLike20" - array(
                    minSize = 2, maxSize = 4, numberOfExamples = 3,
                    eachLike = obj {
                        "eachLike20_obj_string1" - string("str20.1")
                        "eachLike20_obj_int2" - integer(202)
                    }
                )
                "eachLike21" - array(
                    eachLike = arrayOf(
                        string("eachLike21_arrayOf_string1"),
                        integer(212)
                    )
                )
                "eachLike22" - array(
                    minSize = 2, maxSize = 4, numberOfExamples = 3,
                    eachLike = arrayOf(
                        string("eachLike22_arrayOf_string1"),
                        integer(222)
                    )
                )
                "map23" - map(
                    keyLike = "map23_key",
                    valueLike = string("map23_string1")
                )
                "map24" - map(
                    keyLike = "map24_key",
                    valueLike = obj {
                        "map24_obj_string1" - string("str24.1")
                        "map24_obj_int2" - integer(242)
                    }
                )
                "map25" - map(
                    keyLike = "map25_key",
                    valueLike = array(eachLike = obj {
                        "map25_eachLike_obj_string1" - string("str25.1")
                        "map25_eachLike_obj_int2" - integer(252)
                    })
                )
                "emptyArray26" - emptyArray()
            }
        }

PS. This is an API we implemented in my company (it's a wrapper over Pact DSL). We chose the declarative flavor. Unfortunately, I can't share the implementation without a permission from my employer.
PPS. I could share corresponding Pact DSLs, if you were interested.

@uglyog
Copy link
Member

uglyog commented May 9, 2021

I think this would be a great enhancement. One thing I would consider is to not wrap the existing DSL as it has been written based on the constraints imposed by Java. I think creating the Kotlin DSL as a first class citizen would be better.

@abubics thoughts?

@tlinkowski
Copy link
Author

I think this would be a great enhancement.

Cool 😃

One thing I would consider is to not wrap the existing DSL

Sure. We wrote it this way because we didn't want to "touch the internals", but if it were to be added here, I agree that it should be a first-class citizen (like Groovy DSL).

@abubics
Copy link
Contributor

abubics commented May 12, 2021

Great concept, I love it 🎉
Not too sure about overloading the minus operator, it's a bit surprising (maybe a new infix fun would serve the DSL concept well? idk).

As for Kotlin first-class, that also sounds great . . . the Java classes end up a bit clunky/brittle, so whatever we can do to avoid repeating/using those is a good step.

@abubics
Copy link
Contributor

abubics commented May 12, 2021

For reference, this is my current set of helper wrappers, much less DSL-style, but lightweight enough:

fun pactBuilder() = ConsumerPactBuilder
  .consumer("REDACTED-api-test-consumer")
  .hasPactWith("REDACTED-api")
fun buildPact(builder: PactDslWithProvider.() -> PactDslResponse): RequestResponsePact = pactBuilder().builder().toPact()

operator fun <R> BasePact.invoke(auth: Boolean = true, createCall: ApiClient.() -> Call<R>): R? {
  println()
  val result: PactVerificationResult = runConsumerTest(
    this,
    MockProviderConfig.createDefault(),
    object : PactTestRun<Response<R>> {
      override fun run(mockServer: MockServer, context: PactTestExecutionContext?): Response<R> {
        val client = buildClient<ApiClient>(mockServer.getUrl(), auth)
        return client.createCall().execute()
      }
    }
  )
  println("testPact result: $result")
  return ((result as? Ok)?.result as? Response<R>?)?.body()
}

fun PactDslRequestWithPath.jsonHeader() =
  matchHeader("Content-Type", "^application/json.*$", "application/json; charset utf-8")

fun PactDslRequestWithPath.authHeader() =
  matchHeader("Authorization", "^Basic .*$", "Basic base64string")

fun oneOf(vararg values: String): String = values.joinToString("|")

fun LambdaDslObject.localDateTime(fieldName: String, example: String): LambdaDslObject = datetime(
  fieldName,
  "YYYY-MM-DD'T'HH:mm:SS",
  LocalDateTime.parse(example).toInstant(ZoneOffset.of("+11:00"))
)

fun LambdaDslJsonBody.arrayLike(fieldName: String, eachBuilder: LambdaDslObject.() -> Unit): LambdaDslObject =
  minArrayLike(fieldName, 1) { eachBuilder(it) }

infix fun PactDslRequestWithPath.jsonBody(bodyBuilder: LambdaDslJsonBody.() -> Unit): PactDslRequestWithPath =
  body(LambdaDsl.newJsonBody(bodyBuilder).build())

infix fun PactDslResponse.jsonBody(bodyBuilder: LambdaDslJsonBody.() -> Unit): PactDslResponse =
  body(LambdaDsl.newJsonBody(bodyBuilder).build())

As you can see, there's a bit of repetition where the Java DSL has a few classes with very similar interfaces, because they're not quite a mutable builder pattern.

@tlinkowski
Copy link
Author

Great concept, I love it 🎉

Thank you 😊

Not too sure about overloading the minus operator, it's a bit surprising (maybe a new infix fun would serve the DSL concept well? idk).

We considered many options before we came up with minus (e.g. other operators like ..., or infix functions like by or of), but the minus was clearest by far to us (at least for readability).

We even considered using delegated properties (val string03 by string("str03")), but it was a hack (limited possible names, triggered IDE warnings about name hiding), and it didn't really improve readability.

In general, we put ease of reading first, and ease of writing second (where ease of writing was supposed to significantly surpass that of Groovy DSL, anyway).

@TimothyJones
Copy link
Contributor

Nice work! I really like the idea of a first class kotlin DSL, especially one that is idiomatic to Kotlin.

You might also be interested in the guideline here:

  • The DSLs should be as similar as possible, within the idioms of the language, to the original Ruby wording to help create a consistent Pact experience, and minimise overhead when switching between languages.

I'm nervous about the operator overloading (to me, it hinders readability, because I read it as "Content-Type" minus like("whatever"), and I'm not sure what that means). Also, I'm not sure that introducing an overloaded operator is very idomatic for kotlin (maybe it's just the kotlin that I read).

@tlinkowski
Copy link
Author

The DSLs should be as similar as possible, within the idioms of the language

Yes, thank you! I believe this actually rules the "declarative" flavor out.

I'm nervous about the operator overloading (to me, it hinders readability...

Yes, it might be confusing at first, but once you get used to reading - as a dash (not a minus) in this context - like dashes here - it reads quite natural :)

Also, I'm not sure that introducing an overloaded operator is very idiomatic for kotlin

You can actually see this even in official Kotlin DSL docs: https://kotlinlang.org/docs/type-safe-builders.html


Having said all that, I understand the doubts - what worked in the context of my company (declarative flavor, overloaded minus) might not be the best option here (here, it may rather be: BDD flavor, infix function?).

Like I said, we aimed for highest readability, and the declarative flavor with overloaded minus seemed most readable to us :)

@abubics
Copy link
Contributor

abubics commented May 18, 2021

There are declarative and BDD-ish DSLs (in JS for example), so I don't see that first point actively ruling this out :) One would effectively be a simple alias/wrapper of the other, it's not a huge lib overhead.

Since the intuitiveness of the - is coming up a bit, maybe its worth putting forward a simple style (with mapOf, for example, matching the JS object style), alongside this body DSL? Not to push all the burden onto you, anyone (including me) could work on it.

In the end, whoever contributes something useful early is likely to gain some adoption, so I don't expect this conversation to really slow you down 😇 it's nice to get the opportunity to provide some feedback, and try to keep the platforms somewhat in sync.

My final note is related to spec versions . . . whatever we end up with should tick all the boxes for at least v2 if not v3 or v4. I'm not sure if this is missing any v2 stuff, but I can't see a keyLike/eachKeyLike at the top obj {} level, which also prompts me to wonder what the difference is between that and the map(...) inner helper.

@tlinkowski
Copy link
Author

maybe its worth putting forward a simple style (with mapOf, for example, matching the JS object style), alongside this body DSL?

You mean sth like this?

obj(
  "foo" to string("Foo"),
  "bar" to obj(
     "baz" to integer(1)
   )
)

?

I don't expect this conversation to really slow you down

If you mean myself implementing this, I must admit I'd love doing so (I implemented the one for my company), but I'm sorry to say there's no way I'll find the time to do it in the coming months :(

I'm not sure if this is missing any v2 stuff

What I've shown are the symbols that we needed. We didn't include some of them because we simply didn't feel we'd need them (like datetimeExpression or id). They could of course easily be added.

I can't see a keyLike/eachKeyLike at the top obj {} level, which also prompts me to wonder what the difference is between that and the map(...) inner helper.

Yes, here I actually have a question: does it make any sense to use keyLike together with "regular" field definitions? I assumed it doesn't and hence we modeled it separately - initially as map(keyLike, valueLike), but eventually we changed into:

               obj(
                    eachNameLike = "map23_key",
                    eachValueLike = string("map23_string1")
                )

to make it more JSON-like (JSON doesn't really have maps - they are just objects).

@uglyog
Copy link
Member

uglyog commented May 23, 2021

I'm adding a consumer/kotlin module to the project. Anyone what to contribute a test to start this off? Then we can flesh out the implementation to get the test to work.

Does anyone think we need a provider specific module?

uglyog pushed a commit that referenced this issue May 23, 2021
@abubics
Copy link
Contributor

abubics commented May 31, 2021

The junit5spring lib hasn't given me any Kotlin issues 👍 idk about other provider libs 🙃

@YOU54F
Copy link
Member

YOU54F commented May 6, 2022

Hey team,

The empty kotlin module created as part of this thread caused an empty readme page on the docs.pact.io page here 👇🏾

https://docs.pact.io/implementation_guides/jvm/consumer/kotlin

Do we want to take some of the good stuff started and share, either in that example module readme/project, or in another format?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants