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

Quarkus Rest empty @QueryParam is handled differently #44885

Open
ia3andy opened this issue Dec 3, 2024 · 68 comments · May be fixed by #47064
Open

Quarkus Rest empty @QueryParam is handled differently #44885

ia3andy opened this issue Dec 3, 2024 · 68 comments · May be fixed by #47064
Labels
area/rest kind/question Further information is requested

Comments

@ia3andy
Copy link
Contributor

ia3andy commented Dec 3, 2024

Describe the bug

I initially discovered this in code.quarkus unit tests. For example when using ?a= with a a List, the list size is not 0 while it was 1 before (with "") as content.

Same with String, before it was an empty string, not it's not defined.

Expected behavior

not sure but we need to clarify if it's an expected change

Actual behavior

it assumes ?a= is the same as ?

How to Reproduce?

reproducer-validation.zip

This reproducer works on 3.17

Output of uname -a or ver

No response

Output of java -version

No response

Quarkus version or git rev

main (3.18)

Build tool (ie. output of mvnw --version or gradlew --version)

No response

Additional information

No response

@ia3andy ia3andy added the kind/bug Something isn't working label Dec 3, 2024
@ia3andy
Copy link
Contributor Author

ia3andy commented Dec 3, 2024

It seems the change is not in validation but in how the empty query param is treated
Before (in 3.17-), ?a= meant a="", in main it's the same as not having a
This makes the validation constraint fail since there is a default value.

I have another failing test on a similar case where ?e= and e is a list before it was a List.of("") and now it's a List.of().

@ia3andy
Copy link
Contributor Author

ia3andy commented Dec 3, 2024

THis started to fail on Nov 18 and was working on Nov 15

@ia3andy
Copy link
Contributor Author

ia3andy commented Dec 3, 2024

here is a new reproducer clearly showing that empty query params are now handled differently:

reproducer-empty-queryparam.zip

@ia3andy ia3andy changed the title Quarkus Rest validation check on @NotEmpty @QueryParam is not working anymore Quarkus Rest empty @QueryParam is handled differently Dec 3, 2024
Copy link

quarkus-bot bot commented Dec 3, 2024

/cc @FroMage (rest), @stuartwdouglas (rest)

@FroMage
Copy link
Member

FroMage commented Dec 3, 2024

This change was intentional, and explained in #42468 (comment)

If you have use-cases that require the old behaviour, let us know, because we could not think of any.

@geoand
Copy link
Contributor

geoand commented Dec 3, 2024

Thanks for spotting that @FroMage!

I had completely forgot about that one...

@ia3andy
Copy link
Contributor Author

ia3andy commented Dec 3, 2024

@FroMage let's close this when this is in the migraition guide

@ia3andy ia3andy closed this as completed Dec 3, 2024
@ia3andy
Copy link
Contributor Author

ia3andy commented Dec 3, 2024

Thanks @FroMage @geoand @cescoffier

@geoand geoand added kind/question Further information is requested and removed kind/bug Something isn't working labels Dec 3, 2024
@geoand geoand marked this as a duplicate of #46177 Feb 11, 2025
@Saphri
Copy link

Saphri commented Feb 11, 2025

@FroMage @geoand @cescoffier
I maintain a huge old legacy system... we actually use the empty string to signal something different than a null.

Think of this as a form of mutation that is submittet... null means no processing required here (ie leave it untouched)... empty string means delete it.. thats the logic that the system has implemented 😄

How this is totally broken with 3.18.x

This also is a result of using generated Rest Clients. As the methods generated have all params, and this forces the developers to fill something in.. so a lot of nulls normally for all the optional params. The REST clients dont send nulls, but they do send empty strings

@gsmet
Copy link
Member

gsmet commented Feb 11, 2025

I had a look at what initially triggered the issue. From what I could see, it wasn't related to strings and then the fix affected strings to improve consistency.

I agree it's not exactly easy to find a consistent way of doing things but I must admit I'm a bit worried about this change for strings.
Do we know how other REST layers behave? RESTEasy Classic and Spring?

@Saphri
Copy link

Saphri commented Feb 11, 2025

Take these two examples:

path?myQueryParam=&anotherParam=foo
and
path?anotherParam=foo

For me there is a big difference in those two urls, with the first having the intention of submitting a value for that param. So myQueryParam should not be null in both (and IMO any @default should only kick in on the second one)

@FroMage
Copy link
Member

FroMage commented Feb 11, 2025

The original behaviour was mostly unintentional, non-specified, and not consistent.

You can still obtain the information that the query included one key with no value from the Vert.x request.

If there are multiple users who want to obtain this information, we could add a new API as a new feature to get that info, but so far I think this is the first time someone asks for that.

@Saphri
Copy link

Saphri commented Feb 11, 2025

Using that Vert.x request as a workaround might be easy enough. But finding all places, to use this workaround, that now breaks in 100's of endpoints will be a major pain here

@FroMage
Copy link
Member

FroMage commented Feb 11, 2025

Sorry :(

You could also turn these String parameters into a new type OptionalString via a ParamConverter, and make that type have set/empty/not-set states. But that would still mean rewriting the endpoints, so not much more helpful.

@FroMage
Copy link
Member

FroMage commented Feb 11, 2025

Or… perhaps you can write a request filter that turns foo= into foo=<marker> and then you can refactor your calls from StringUtil.isEmpty(param) into one that treats <marker> specially?

@Saphri
Copy link

Saphri commented Feb 11, 2025

Would it be possible with a config to enable legacy behavior and some kind of log message that we can look for and fix? I much rather fix the code correct than doing lots of workarounds.. and just make 3.19.x the cutoff?

@FroMage
Copy link
Member

FroMage commented Feb 14, 2025

In which case do you expect a log message? Runtime based on the values passed?

@Saphri
Copy link

Saphri commented Feb 16, 2025

A flag to enable legacy behaviour, in itself, would not help us find all the places where this impacts us.

Any time a param would be null and not empty string, it should emit a log message. " will be null with new behaviour on endpoint ". We would still need to analyse the exact impact on that endpoint, but it will likely reduce the number of endpoints greatly that we have to go over

@FroMage
Copy link
Member

FroMage commented Feb 17, 2025

So let me rephrase this: are you looking for a build-time warning whenever we see a String parameter, or a runtime warning when we see an empty string parameter value?

@Saphri
Copy link

Saphri commented Feb 18, 2025

When we encounter query parameters like this:
path?myQueryParam=&anotherParam=foo

It could issue a message that myQueryParam is no longer an empty string but null.. at runtime yes
This will help alot with finding all the places where this breaking change with query parameters have an effect at runtime. Because one of the big issues with this breaking change, is that the compiler cannot catch this change... so you get the suprise at runtime.. likely as a production issue for future me 😄

Now, I think, there should be a flag to enable this, so its not spamming these messages at default

@FroMage
Copy link
Member

FroMage commented Mar 18, 2025

@olivierbeltrandocintoo: find comments inline in bold:

Parameter type HTTP value Deserialized value
String null
foo= "" // empty semantic this is the old behaviour
foo=a "a"
foo=a&foo=b "b" // last put in the map overwrites
String! throw
List<String> null this will break a lot of existing code that rely on collections never being null
foo= [] // empty semantic
foo=a ["a"]
foo=a&foo=b ["b"] // last put in the map overwrites This is really not how HTTP works for parameters
foo=a,b ["a","b"] // here allow customtization for separator
foo=, ["",""] // "universal" result of doing a split by comma on ,
List<String>! throw

The , Separator is across languages the separator.

Not for HTTP parameters, which REST is really standardised on. We do support it, but in addition to the HTTP semantics, not as a replacement for it.

As for your primitive table, I am not sure if you meant int (the primitive) or Integer (the boxed type).

In any case, deserialising foo= to something else than 0 (for primitives) or null (for non-String types) [which is what's happening ATM] would also break a lot of existing code, and we have to remember we're all here because I broke existing code and we should not fix this by breaking other existing code, especially when the reason is not very convincing.

Throwing a 400 when the type is incompatible (say a for an int or 23 for a Date) is fine. But throwing a 400 for an empty value (foo=) for either a primitive or a non-String type would be hard to justify.

geoand added a commit to geoand/quarkus that referenced this issue Mar 18, 2025
This makes it possible for things like Kotlin Serialization to work
properly.

Fixes: quarkusio#44885
geoand added a commit to geoand/quarkus that referenced this issue Mar 19, 2025
This makes it possible for things like Kotlin Serialization to work
properly.

Fixes: quarkusio#44885
geoand added a commit to geoand/quarkus that referenced this issue Mar 19, 2025
This makes it possible for things like Kotlin Serialization to work
properly.

Fixes: quarkusio#44885
@geoand geoand closed this as completed in b5e90b6 Mar 19, 2025
@FroMage
Copy link
Member

FroMage commented Mar 19, 2025

Closed by accident.

@FroMage FroMage reopened this Mar 19, 2025
@FroMage
Copy link
Member

FroMage commented Mar 25, 2025

I'm making progress on this. I have the non-parameter-container version ready already. Hopefully by the end of the week I can get parameter containers to also work. Perhaps.

@FroMage
Copy link
Member

FroMage commented Mar 25, 2025

This is a bit all over the place. I thought I could afford to copy the code for Optional since it's also a wrapper type, but it's special-cased to support types like set/list but for example, not arrays. So ATM Optional<String[]> does not work. I'm not fixing this, will wait for someone else to raise the issue, but the support for wrapper types is not recursive properly in that all the types that are wrapped by it are special-cased :(

@FroMage
Copy link
Member

FroMage commented Mar 27, 2025

Optional<String[]>

Nevermind, thinking about this, this type makes no sense to support 😅

@Sgitario
Copy link
Contributor

Sgitario commented Mar 28, 2025

We're hitting the same issue here, but with the over-complication that we can't change the API because it's auto-generated by the OpenApi extension. Let me explain further this use case:

  • We have an API spec that uses a Query param which is an Enum type:
  /api/rhsm-subscriptions/v1/capacity/products/{product_id}/{metric_id}:
    description: 'Operations for capacity report for a given account and product'
    parameters:
      - name: product_id
        in: path
        required: true
        schema:
          $ref: "#/components/schemas/ProductId"
        description: "The ID for the product we wish to query"
    get:
      summary: "Fetch a capacity report for an account and product."
      operationId: getCapacityReportByMetricId
      parameters:
        - name: usage
          in: query
          schema:
            $ref: '#/components/schemas/UsageType'
          description: "Include only capacity for the specified usage level."

Where the UsageType is:

    UsageType:
      description: "Describes the usage that the report was made against. Not set if it wasn't
          specified as a query parameter."
      type: string
      enum: [ "", Production, Development/Test, Disaster Recovery, _ANY ]

The autogenerated API is:

@RegisterRestClient
@RegisterProvider(ApiExceptionMapper.class)
@Path("/api/rhsm-subscriptions/v1/capacity/products/{product_id}/{metric_id}")
public interface CapacityApi  {

    /**
     * Fetch a capacity report for an account and product.
     *
     */
    @GET
    
    @Produces({ "application/vnd.api+json" })
    CapacityReportByMetricId getCapacityReportByMetricId(@PathParam("product_id") ProductId productId, @QueryParam("usage") UsageType usage) throws ApiException, ProcessingException;
}

We use a custom param converter provider (see implementation here) but in Quarkus, this converter is not even called because of this line.

  • We are migrating some services that use this API spec from Spring Boot to Quarkus. In Spring Boot, this worked fine perfectly because when the query "usage" is unset, it returns null, and when the query "usage" is set with empty value, it returns the UsageType.EMPTY.
  • In Quarkus, it always return null.

Note that I don't follow how a query param that always returns null regardless if it was unset or empty goes in the direction of being more consistent... (I didn't follow all the conversation though), but unless there is a workaround that we could apply for all query params, this is blocking us to migrate our services to Quarkus :(

@FroMage
Copy link
Member

FroMage commented Mar 28, 2025

Thanks @Sgitario, I had not thought about openapi generation and custom parameter converters that actually do support empty values.

I was working hard to complete support for the Parameter<T> type which allows differentiating between absent/empty/set values, and I have it working for query parameters. I was going to add support for form parameters too, because that seems equally logical to allow it there (let's deal with cookies, matrix, path and headers in the future), but ran into issues due to multipart having specific code paths (as always). So I would not have enough time to fix this before I go on PTO.

Now, given what you said, I'm inclined to propose a different strategy:

  • We revert the changes that led to removing empty values. So we'd be back to null | "" | "..." for strings, [], [""], ["..."] for collections of strings, and 0 (absent value) | 0 (empty value) | 0 (set value) for primitives (:puke:) and null (absent value) | <exception> (empty value) | 2025 (set value) for dates and other types whose converter does not allow empty values.
  • We still introduce Parameter<T> for people who want to deal with empty values for any kind of type: string, primitives, dates in a sane and consistent manner
  • We document the usage of Parameter if anybody wants to deal with empty values, making sure that regular users don't fall into the madness that is the JAX-RS default in those cases

The problem is that this will likely have to wait for my return in three weeks :-/

WDYT @geoand ?

@geoand
Copy link
Contributor

geoand commented Mar 28, 2025

We revert the changes that led to removing empty values

I am not sure this is a good thing to do TBH as I believe it will lead to even more confusion

@geoand
Copy link
Contributor

geoand commented Mar 28, 2025

The way I see it, we should just include the new API when we have it (the sooner the better) in a next release of 3.20 LTS and tell people that really rely on the old (undocumented) behavior to wait

@FroMage
Copy link
Member

FroMage commented Mar 28, 2025

I see your point. That's still a possibility.

However, I don't know how we can deal with openapi generators, it's likely they won't be able to target this new API. Although it depends on whether openapi can distinguish element types and whether they allow for empty values or not.

I used to think only String allowed empty values (as opposed to say primitives, or date/time) but the example with a custom parameter converter that does support empty values was not one I had thought about.

With the current behaviour, and even the addition of the Parameter<T> API, this sort of custom parameter converter won't be possible, because:

  • With the current behaviour, we remove empty values, so the parameter converter isn't called
  • With the Parameter<CustomParam> API, the framework will detect the empty parameter and make a special Parameter.empty() instance which won't call the parameter converter. So the fact that it's empty is actionable by the user. but it's further up the chain. Well, perhaps it doesn't matter too much.

@geoand
Copy link
Contributor

geoand commented Mar 28, 2025

I don't see a way to please everyone to be honest and I am -1 for causing even more confusion by flip-flopping

@Sgitario
Copy link
Contributor

@FroMage many thanks for the explanation about what originally motivated the changes. I reverted the changes where we switched this API to Quarkus, so our team can wait for a fix for 3 weeks (also I'm happy to help here).

I don't see a way to please everyone to be honest and I am -1 for causing even more confusion by flip-flopping

@geoand since the original behaviour changed and there are no alternatives, I see this like a major regression issue. Note that many users might have not noticed this yet, but expect query params to be treated like null and empty values differently.

Sgitario added a commit to RedHatInsights/rhsm-subscriptions that referenced this issue Mar 28, 2025
…tly to swatch-contracts" (#4310)

This reverts commit 2859248.

Jira issue: SWATCH-3419

## Description
We're reverting the changes in
#4292 because
Quarkus does not distinguish between empty and null. This issue was
introduced in Quarkus 3.18 and reported by
quarkusio/quarkus#44885.

Note that before reverting the changes, I tried to make this query param
optional, but the openapi does not generate it as optional.

Other solutions imply to use the raw request param to check what was the
original query value (either unset or empty), but I see this solution as
flaky/workaround (since there are many query types affected). Let's wait
for what is the reply from Quarkus about this issue, since they seem to
be keen to not revert what caused the issue).

## Testing
Regression testing.
@geoand
Copy link
Contributor

geoand commented Mar 28, 2025

@geoand since the original behaviour changed and there are no alternatives, I see this like a major regression issue. Note that many users might have not noticed this yet, but expect query params to be treated like null and empty values differently.

But there will be soon.

Note that many users might have not noticed this yet, but expect query params to be treated like null and empty values differently.

I'm sympathetic to that, but we also had users that expected the exact opposite (which is why we changed it). The fact is that the old behavior was an emergent property of the implementation, not something that was thought out or specified.

@Sgitario
Copy link
Contributor

not something that was thought out or specified.

Even when it was not thought or specified like this, it's how it was working before and how users have been using Quarkus so far.
To give more arguments, empty and null values really mean different things in an API viewpoint: Consider an API endpoint that filters users by name:

  • ?name= (empty) might mean “search for users with no name.”
  • ?name=null might mean “ignore the name filter.” or “use default behavior.”

Changing that, it's about forcing users/devs to start using optionals (the ones that noticed this critical change in behaviour) and it's against the consistency and stability :/

@FroMage
Copy link
Member

FroMage commented Mar 28, 2025

there are no alternatives

There are workarounds, currently. We've documented one here: #44885 (comment)

?name=null might mean “ignore the name filter.” or “use default behavior.”

I assume you mean …? (no query param name nor value)

Changing that, it's about forcing users/devs to start using optionals

I'm not sure what you mean about optionals here, but I don't think it's the java.lang.Optional type. I guess you refer to an extra parameter to specify whether you want an empty query parameter as opposed to an absent one.

@FroMage
Copy link
Member

FroMage commented Mar 28, 2025

I don't see a way to please everyone to be honest and I am -1 for causing even more confusion by flip-flopping

I am sympathetic to that, though I suppose it depends a bit on how this can be adapted for OpenAPI generators, perhaps @phillip-kruger knows if in open-api we can detect that some query parameter types allow for an empty value or not (as opposed to an absent state).

I would rather do as you suggest and move on from the previous behaviour. But I'm genuinely surprised by the number of reactions to this change. I did not expect that. I also did not expect that some parameter converters allowed for empty values.

So, I'm a bit torn between both options. And when torn, I would tend to trust @geoand's opinion. Who knows, perhaps when I come back in three weeks we'll even have a clear path forward :)

@Sgitario
Copy link
Contributor

Changing that, it's about forcing users/devs to start using optionals

I undestood from the thread, that another solution was to start using java.lang.Optional for the query parameters that needed to differentiate between null and empty.

But say optionals, or say implement something like this: #44885 (comment). It's forcing users for an implementation change that they might not even be aware of that this changed.

@geoand
Copy link
Contributor

geoand commented Mar 28, 2025

I did not expect that

Same!

@phillip-kruger
Copy link
Member

/cc @MikeEdgar

@FroMage
Copy link
Member

FroMage commented Mar 28, 2025

I undestood from the thread, that another solution was to start using java.lang.Optional for the query parameters that needed to differentiate between null and empty.

Ah OK. Well, that's the new Parameter<T> API. Optional only has two states: empty or set, we're not using null values for Optional types because that leads to madness. Parameter<T> has three states (and is never null itself, just like Optional values): absent/cleared/set.

You do need a new API, but on the other hand, the previous behaviour:

  • did not work for non-String types (except the very special case of having a custom parameter converter which did handle empty values) such as primitives or date/time
  • led to frankly silly things such as [""]: list with one element containing an empty string to indicate "cleared collection"
  • was not used by a lot of use-cases: while many people showed up here to complain, far more users did not. The ability to differenciate between a cleared and absent value is not extremely common, hence why nobody even noticed you could not do that for non-String types (* minus the exception noted above)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area/rest kind/question Further information is requested
Projects
None yet
9 participants