nomisRev / arrow-ktor

Arrow ❤️ Ktor
Apache License 2.0
14 stars 0 forks source link

Extracting Spine's Arrow helpers to this repository #1

Open CLOVIS-AI opened 1 month ago

CLOVIS-AI commented 1 month ago

Spine is an alpha-quality library for declaring Ktor endpoints in common code, with no code generation etc:

object Api : RootResource("v1")

object Users : StaticResource("users", parent = Api) {
    class ListParameters(data: ParameterStorage) : Parameters(data) {
        val includeInactive by parameter(default = true)
    }

    val list by get()
        .parameters(::ListParameters)
        .response<User>()

    val create by post()
        .request<User>()
        .response<User>()
}

// Server-side
routing {
    route(Users.create) {
        require(!users.exists(body.email)) { "A user already exists with this email" }
        respond(users.create(body))
    }
}

// Client-side
client.request(Users.create, User("…", "…"))

Of course, I want Spine to play well with Arrow Core. To achieve this, I have a few helpers that look like:

// Server-side
routing {
    route(Users.create) {
        raise { // enter the world of Raise
            ensure(!users.exists(body.email)) { HttpFailure("A user already exists with this email", HttpStatusCode.UnprocessableEntity) }
            …
        }
    }
}

This helper is available standalone without the rest of Spine (dev.opensavvy.spine:arrow-server-independent).

Since you're starting development of a proper Ktor/Arrow integration, I think it would be beneficial that this part was extracted to your repository. Since both libraries are almost equivalent, I can almost replace mine by yours and be done with it.

The main difference is the Raise receiver… Mine is

class HttpFailure(
    val body: Any,
    val code: HttpStatusCode,
    val type: TypeInfo,
)

inline fun <reified Out : Any> HttpFailure(
    body: Out,
    code: HttpStatusCode,
) = HttpFailure(body, code, typeInfo<Out>())

(source)

whereas, this library allows raising the content and the status code separately:

suspend context(Raise<HttpStatusCode>, Raise<OutgoingContent>) PipelineContext<Unit, ApplicationCall>.() -> Unit

I'm curious, why this choice? I'm not sure I understand what benefits this brings, nor how would it be used.

nomisRev commented 1 month ago

Hey @CLOVIS-AI,

Like I mentioned on Slack, this was a bit experimental and I wasn't very concerned about anything except the validation DSL but my here is my train of thought.

OutgoingContent offers the same behavior as HttpFailure, or HttpFailure could also refactored to be OutgoingContent (it think).

In this case Raise<HttpStatusCode> could also be covered by Raise<OutgoingContent>. I like the fact that you can do raise(HttpStatusCode.SomethingWentWrong) but it wouldn't allow calling suspend fun Raise<HttpStatusCode>.bla(): Unit unless wrapped in withError(::toOutgoingContent) { }. So my idea was to allow short-circuiting with Ktor types directly, such that you can program against Ktor types, and not custom types.

I'm very intrigued, and my goal is just to offer a "opinionated way of building functional micro services", so I would like use-case specific features, in separate modules. I'd be more than happy to collaborate, and move it to arrow-kt/arrow-ktor is there is enough interest to contribute.

CLOVIS-AI commented 4 weeks ago

I'm not familiar with the internals of Ktor, and I don't really understand how OutgoingContent is supposed to be use. As a user, I really just want to be able to write:

raise(…error body…, …error code…)

my goal is just to offer a "opinionated way of building functional micro services"

The goal of Spine is specifically "opinionated way to declare endpoints that are being used my multiple micro services/clients". I want Spine to play well with the broader ecosystem, including your own extensions :)

move it to arrow-kt/arrow-ktor is there is enough interest to contribute.

As you prefer ; personally, whether it is under your namespace or Arrow's isn't really important, but it may have an impact on ease of adoption in the future.

nomisRev commented 4 weeks ago

Oh, you should check OutgoingContent it's the contract/base-class for sending stuff through call.respond. I kind-of like it. I've been using it since forever.

For example (defined in Ktor itself): TextContent("Hello World!", HttpStatusCode.OK). So having Raise<OutgoingContent> allows you to do raise(TextContent("User $id not found!", HttpStatusCode.NotFound)). OR with your withError 😉

withError({ error -> TextContent(error.message, error.code) }) {
  raise(MyError("User $id not found!", 404))
}

I felt that re-using the existing base class from Ktor would feel the most natural. I try to re-use library types as much as possible when creating integrations like this. Requires less learning, but also less work from library such as docs.

CLOVIS-AI commented 4 weeks ago

But then you have to choose which OutgoingContent implementation to use yourself, no? You lose all the helpers around KotlinX.Serialization etc :thinking:

I like the syntax with TextContent, but you can't returned anything other than text with it, right?

with withError

That's not a coincidence! I originally had the idea when writing DTOs for error types… it was painful :sweat_smile:

nomisRev commented 4 weeks ago

Yes, but the idea is that the library can also include their own versions of OutgoingContent including HttpFailure but Raise just relies on the base type and benefit from the covariance from Raise.

So you can use Ktor builtins, library builtins, or your own variations. I'm not 100% sure, but I think KotlinX (Json) also just goes into TextContent, or maybe straight into ByteArrayContent.

CLOVIS-AI commented 4 weeks ago

Ah, that's a good point. Theorically, I could implement my own thing as a subtype of OutgoingContent, and use that. I'll try it and see what happens.