quarkusio / quarkus

Quarkus: Supersonic Subatomic Java.
https://quarkus.io
Apache License 2.0
13.76k stars 2.68k forks source link

Resteasy - intercepting endpoint selection #25735

Open kucharzyk opened 2 years ago

kucharzyk commented 2 years ago

Description

Hello,

I would like to create multiple endpoints with the same url. Depending on request headers I would like to decide which method should be used. Currently I have one endpoint with conditional return statement but it is not ideal solution for me.

It would be nice to create multiple endpoints with same url i and annotate them with custom annotations. I was looking in docs and in the source code but I can't find interceptor which I could use. I think it is not yet possible.

My use case is related to Qute and Htmx library:

Depending on header presence I need to render whole page or only partial response.

    @GET
    @Path("/form")
    @Produces(MediaType.TEXT_HTML)
    public Uni<String> renderForm(@Context HttpHeaders headers) {
        if (headers.getHeaderString("HX-Boosted") != null) {
            return Templates.form().createUni();
        } else {
            return Templates.fullPageWithForm().createUni();
        }
    }

Instead of this code I would like to create two separate endpoints and annotate them.

    @GET
    @Path("/form")
    @Produces(MediaType.TEXT_HTML)
    public Uni<String> renderForm(@Context HttpHeaders headers) {
        return Templates.fullPageWithForm().createUni();
    }

    @GET
    @Path("/form")
    @Produces(MediaType.TEXT_HTML)
    @HtmxPartial
    public Uni<String> renderFormPartial(@Context HttpHeaders headers) {
        return Templates.form().createUni();
    }

This is approach can be implemented for example in Spring (https://github.com/wimdeblauwe/htmx-spring-boot-thymeleaf). It would like to recreate something similar in Resteasy but I need intercept endpoint choose decision.

I think there will be much more use cases for it. For example we could create API annotated with @APIv2 without touching old endpoints and serve new API depending on request headers.

@geoand @Ladicek @maxandersen

Implementation ideas

No response

geoand commented 2 years ago

Just checking whether @FroMage has not already done something along these ends for Renarde.

quarkus-bot[bot] commented 2 years ago

/cc @FroMage, @stuartwdouglas

geoand commented 2 years ago

This is a very interesting feature IMO.

geoand commented 2 years ago

Just checking whether @FroMage has not already done something along these ends for Renarde.

@FroMage, any input here?

geoand commented 2 years ago

I am also wondering whether it makes sense to do something Htmx specific, or provide the ability for user code to decide which Resource method should be executed...

I'm personally leaning towards the former

kucharzyk commented 2 years ago

@geoand Quarkus shouldn't have anything specific to htmx. It should be generic feature. End user will decide how to use it

geoand commented 2 years ago

I don't agree, for a couple reasons:

ia3andy commented 2 years ago

FYI this is how it was solved in the example we are creating for Quinoa, I guess the same would be working: https://github.com/TeHMoroS/quinoa-request-control-discussion/blob/main/src/main/java/dev/dnadesigned/quinoa/TemplateGlobalVariables.java

and the template: https://github.com/TeHMoroS/quinoa-request-control-discussion/blob/main/src/main/resources/templates/common/base.html

ia3andy commented 2 years ago

And here is an ongoing discussion about Quarkus with Htmx (using NodeJS and Quinoa and Renarde) https://github.com/quarkiverse/quarkus-quinoa/discussions/113

FroMage commented 2 years ago

Hi,

So, this is very interesting. Lemme learn more about htmx, but meanwhile I can already say you could simplify your code:

    @GET
    @Path("/form")
    public TemplateInstance renderForm() {
        return Templates.fullPageWithForm();
    }

    @GET
    @Path("/form")
    @HtmxPartial
    public TemplateInstance renderFormPartial() {
        return Templates.form();
    }

Now, lemme first go read up on htmx, but one example of Turbo I've seen from Rails wasn't exactly what you're showing here with full page containing element X versus partial render of X. The example I'd seen was with a full page containing an order, which had comments, and the comments where rendered as partials (which I understand to be tags in the context of Qute), so you could render the whole page, or offload to the comment partials. Let's spit out some pseudo-code here:

public class Order extends PanacheEntity {
 @OneToMany(mappedBy = "order")
 public List<Comment> comments;
}

public class Comment extends PanacheEntity {
 @ManyToOne
 public Order order;
 public String text;
}

public class Orders extends Controller {
    public static class Templates {
// full page
        public static native TemplateInstance order(Order order);
// partial
        public static native TemplateInstance comments(Order order);
    }

    public TemplateInstance order(@RestPath long id) {
        return Templates.order(Order.findById(id));
    }

    public TemplateInstance comments(@RestPath long id) {
        return Templates.comments(Order.findById(id));
    }
}

In templates/Orders/order.html:

{#extend main.html}
{#set title "Order "+order.id}

{#comments order/}
{/extend}

In templates/tags/comments.html:

{@model.Order order/}

<ul>
{#for comment in comments}
 <li>{comment.text}</li>
{/for}
</ul>

But this doesn't need any special support on the RR side, aside from perhaps allowing type-safe template tags.

FroMage commented 2 years ago

FYI this is how it was solved in the example we are creating for Quinoa, I guess the same would be working: https://github.com/TeHMoroS/quinoa-request-control-discussion/blob/main/src/main/java/dev/dnadesigned/quinoa/TemplateGlobalVariables.java

and the template: https://github.com/TeHMoroS/quinoa-request-control-discussion/blob/main/src/main/resources/templates/common/base.html

This is clever.

Side-note, this is how I used global vars too (via CDI), but recent releases added support for them explicitely: https://quarkus.io/guides/qute-reference#global_variables

geoand commented 2 years ago

But this doesn't need any special support on the RR side

How is

    public TemplateInstance order(@RestPath long id) {
        return Templates.order(Order.findById(id));
    }

    public TemplateInstance comments(@RestPath long id) {
        return Templates.comments(Order.findById(id));
    }

How are these two dissambiguated? Are they different paths?

For htmlx, the dissambiguation happens because there is a special HTTP header.

FroMage commented 2 years ago

They're not, but I don't think they need to be. Oh, this is renarde, so they have @Path("order") and @Path("comments") added.

FroMage commented 2 years ago

I need to look more into this, because in the demo I saw, it didn't seem to work quite like that.

geoand commented 2 years ago

Hm... in the few code samples I saw things were controlled via some special HTTP headers.

geoand commented 2 years ago

@FroMage do you look into htmx perhaps? It would be great to figure this out so we can move forward with supporting it

geoand commented 2 years ago

Just to add a tiny note in case we do decide to go down the route of providing some kind of API that would allow for selection, that MediaTypeMapper essentially does what we are discussing by matching the media types mentioned in the request to the media types declared by the method. Of course if we do decide to go down this route, we would not want to expose the internal types and APIs, but some more restricted and easier to use form.

FroMage commented 2 years ago

OK, so I finally read up on Turbo and was a bit perplexed by their docs, which I find confusing, as it implied you didn't have to modify your endpoint at all: you could just return the entire page with the normal endpoint and as long as you had a <turbo-frame id="message_1"> that matched in there, it would drop the rest of the page and swap the matching frame with the new content. Which means that the endpoint could be the same, and returning a full page or not was a matter of rendering performance. The client would behave the same.

But the endpoint would be the same, and so its cost would be the same as well, say if it fetched stuff from the DB.

Micronaut has a similar approach: https://micronaut-projects.github.io/micronaut-views/latest/guide/#turbo where the endpoint is shared:

@Produces(MediaType.TEXT_HTML)
@TurboFrameView("form")
@View("edit")
@Get
Map<String, Object> index() {
    return Collections.singletonMap("message",
    new Message(1L, "My message title", "My message content"));
}

But the views differ, because here is the full view, identified by @View("edit") which I guess matches if there's no special header:

<!DOCTYPE html>
<html>
<head>
    <title>Edit</title>
</head>
<body>
<h1>Editing message</h1>
<turbo-frame id="message_$message.getId()">
#parse("views/form.vm")
</turbo-frame>
</body>
</html>

And here's the partial view, used by the full view as an include, specified by the @TurboFrameView("form") if the header matches, I guess:

<form action="/messages/$message.getId()">
    <input name="name" type="text" value="$message.getName()">
    <textarea name="content">$message.getContent()</textarea>
    <input type="submit">
</form>

So in this case it's obvious we're not rendering the same template, but it's still the same endpoint. This doesn't really match what I had understood in the case where we're rendering several messages from a single page. Also I've no idea how they can surround the partial template with the required <turbo-frame id="message_1"> element with that setup.

I also realise that htmx and Turbo appear to be different ways to achieve something comparable, but with different tech.

FroMage commented 2 years ago

From the Request/Response header docs of Htmx it appears that at a minimum we should be providing an API to read/write Htmx headers.

So, yeah, Htmx and Turbo appear to be completely different technologies that happen to have a similar backend decomposition solution. We should probably do two separate extensions but keep the solution/API similar.

geoand commented 2 years ago

Thanks for the insights @FroMage

kucharzyk commented 2 years ago

Wouldn’t it be better to create only support for conditional routes in resteasy. With such flexible foundation everybody could create solution for him with few lines of code.

After that we could think about supporting specific frameworks.

geoand commented 2 years ago

We'll see. If we do do that , I mentioned above what needs to be done.

FroMage commented 2 years ago

This annotation you're describing is very much a generalisation of content-type matching. If Htmx had a Accept: text/htmx, text/html header we would be able to write:

  @GET
    @Path("/form")
    @Produces(MediaType.TEXT_HTML)
    public Uni<String> renderForm(@Context HttpHeaders headers) {
        return Templates.fullPageWithForm().createUni();
    }

    @GET
    @Path("/form")
    @Produces("text/htmx")
    public Uni<String> renderFormPartial(@Context HttpHeaders headers) {
        return Templates.form().createUni();
    }

And call this a day. In fact, we have request negociation/matching for URI,HTTP method,Accept and Content-Type, but that's pretty much it, they're all hard-coded. There's no user code that could help select methods, ATM.

Adding an API for that for any user code would mean having conditional routing for rules that are not hard-coded. It would complexify our routing/matching algo (unless we can rewrite it using this new abstraction) but it would also be less efficient than static routing which we try to promote.

Now, I'd like to see a real use-case for having separate endpoints, and not simply return the same full document as advertised by Turbo (they strip the outer elements) or do the filtering in the views like Andy showed.

I'm thinking about real different logic.

geoand commented 2 years ago

I'd like to see a real use-case for having separate endpoints

+1

ia3andy commented 2 years ago

The filtering in the template makes quite some sense:

I am currently evaluating a possible Quarkiverse extension to make it all integrated and easy to use: It would allow built-in assets processing (css purging, svg image optim, sass support, ... ) through Quinoa, and avoid all the logic related to making htmx work. Comaptible with RESTEasy Reactive and Renarde.

geoand commented 2 years ago

@ia3andy @FroMage ww should probably get together and figure something like this out. I feel like there is a good opportunity here

maxandersen commented 2 years ago

maybe i'm missing something but isn't the use case of having a endpoint that can either serve a json model response vs htmx client ui rendered response what gives quite different logic and thus having two different methods is nice ?

geoand commented 2 years ago

Yeah, but ideally you want that to be dead simple

Ladicek commented 2 years ago

Now, I'd like to see a real use-case for having separate endpoints, and not simply return the same full document as advertised by Turbo (they strip the outer elements) or do the filtering in the views like Andy showed.

Isn't that "use case" obvious? Don't run code you don't need. If you return the full view, you most likely run code you don't need. If you filter the output in the view, you either run code you don't need (same as previous option), or you let the view assume the role of the controller.

(If that feels like "premature optimization" to some, I'm gonna claim this is not optimization at all. Not running code you don't need, when you know you don't need to run the code, that's just common sense.)

Of course the argument above makes no sense when rendering the desired part of the view requires running 99% of the code you'd have to run for the full view. In my experience, that's seldom the case, but I admit my experience is mostly from rather dynamic websites with multiple independent parts contributing to the resulting page.