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

Support HATEOAS #1187

Open
SenseiMarv opened this issue Oct 23, 2024 · 8 comments
Open

Support HATEOAS #1187

SenseiMarv opened this issue Oct 23, 2024 · 8 comments
Labels
feature 🚀 New feature or request RSVP 👍👎 Explicit request for reactions to gauge interest in resolving this issue

Comments

@SenseiMarv
Copy link

Description

Our API uses Spring HATEOAS (short explanation: https://en.wikipedia.org/wiki/HATEOAS). So a typical API response body might look like this:

{
    "account": {
        "account_number": 12345,
        "balance": {
            "currency": "usd",
            "value": 100.00
        },
        "_links": {
            "deposits": {
                "href": "https://myhost/accounts/12345/deposits"
            },
            "withdrawals": {
                "href": "https://myhost/accounts/12345/withdrawals"
            },
            "transfers": {
                "href": "https://myhost/accounts/12345/transfers"
            },
            "close-requests": {
                "href": "https://myhost/accounts/12345/close-requests"
            },
        }
    }
}

Note the added _links at the end of the response body. This contains several valid links (URLs) to other endpoints. These endpoints normally use Path Parameters, but are already fully resolved here.

If we now want to use Hey API, we run into the problem that we already have a fully valid link, but there is no way to use it in the generated code. We would have to somehow parse our valid URL, extract the path parameters and then pass them to the generated functions with the path props. This is really cumbersome and involves introducing parsing that could fall apart with any change to the API.

Is there any way to add support for HATEOAS so that we can use our links directly? A cheap solution would be to support passing of a URL to the generated functions, which as far as I can see has already been requested: #452.

@SenseiMarv SenseiMarv added the feature 🚀 New feature or request label Oct 23, 2024
@mrlubos mrlubos added the RSVP 👍👎 Explicit request for reactions to gauge interest in resolving this issue label Oct 23, 2024
@mrlubos
Copy link
Member

mrlubos commented Oct 23, 2024

Hi @SenseiMarv, let me put a vote label on this feature. As for your use case, what are you currently doing/using? I'm also curious why are you requesting this feature vs using another tool that presumably handles HATEOAS already? Thanks!

@olewehrmeyer
Copy link

Hey there, chipping in with an extra add-on to this request 😄 Adding on to that, supporting that those URLs are "partially" filled out, yet still a template would be amazing too.

E.g. having an OpenAPI like this (sticking loosely with the bank account example):

  /accounts/{accountId}/transactions:
    parameters:
      - name: accountId
        in: path
        required: true
        schema:
          type: string
    get:
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
        - name: offset
          in: query
          schema:
            type: integer
        - name: sender
          in: query
          schema:
            type: string
          description: Filters transactions by sender including this string

we could get a response from our backend linking to this endpoint that looks like this:

{
  "_links": {
    "next": {
      "href": "https://myhost/accounts/123456/transactions?limit=10&offset=30{&sender}"
      "templated": true
    },
    ...
  },
  ...
}

The idea here would then to still take the sender for the query from the options, but ignore the path.accountId, query.limit and query.offset as they are already set in the provided link/URL.

Probably would require making all of those options of the request optional though as one can never know what parameters are already pre-filled (at least not at code-generation-time).

@SenseiMarv
Copy link
Author

Thanks for the lightning fast response! We are currently trying out Hey API to see if we can use API client generation for our OpenAPI files. We use our OpenAPI files as a single source of truth and do not want to write/generate them using contracts with tools like ts-rest or Effect.

Hey API is particularly interesting as it provides native TanStack Query integration and soon Zod integration. As we may build our production software on TanStack Query in the future and already use Zod heavily for form validation, this library brings a lot to the table. At the moment we write our frontend client code ourselves, but it might be interesting to move to Codegen.

We are currently trying out Hey API with a small prototype and have run into this hurdle. It is a bit of a blocker for us as we do not want to parse our valid URLs to extract the path parameters. The only alternative would be to copy/edit the generated code to take our links - which would defeat the purpose of Codegen.
We also tried another project first: openapi-ts, but didn't like the output from that. The output here is very logical, readable and easy to work with. If there are other tools you have in mind that we should use instead, we are open to recommendations :) We looked at all the solutions linked here and at openapi-zod-client, but none of them looked as promising as Hey API.

@mrlubos
Copy link
Member

mrlubos commented Oct 23, 2024

@SenseiMarv this seems like a quite large feature to implement so it might take a while unless there's an enormous interest in it. If resolving #452 would unblock you, please let me know and I can prioritise that one

@SenseiMarv
Copy link
Author

SenseiMarv commented Oct 23, 2024

@mrlubos Absolutely understandable if this takes longer to support if it is complex to implement. Luckily, the minimal workaround to enable us to still use our links is very simple :) Changing the Codegen to instead of generating this

/**
 * Find pet by ID
 * Returns a single pet
 */
export const getPetById = <ThrowOnError extends boolean = false>(
  options: Options<GetPetByIdData, ThrowOnError>,
) => {
  return (options?.client ?? client).get<
    ThrowOnError,
    GetPetByIdResponse,
    GetPetByIdError
  >({
    ...options,
    url: '/pet/{petId}',
  });
};

To allow overriding the url by placing the spread options below

/**
 * Find pet by ID
 * Returns a single pet
 */
export const getPetById = <ThrowOnError extends boolean = false>(
  options: Options<GetPetByIdData, ThrowOnError>,
) => {
  return (options?.client ?? client).get<
    ThrowOnError,
    GetPetByIdResponse,
    GetPetByIdError
  >({
    url: '/pet/{petId}',
    ...options,
  });
};

And then changing the OptionsBase type

type OptionsBase<ThrowOnError extends boolean> = Omit<RequestOptionsBase<ThrowOnError>, 'url'> & {
    /**
     * You can provide a client instance returned by `createClient()` instead of
     * individual options. This might be also useful if you want to implement a
     * custom client.
     */
    client?: Client;
};

to no longer omit url (PS: I think url should actually be set to optional here and not only the omit dropped. It will be given by default, but it could be overridden, so it should be optionally assignable):

type OptionsBase<ThrowOnError extends boolean> = RequestOptionsBase<ThrowOnError> & {
    /**
     * You can provide a client instance returned by `createClient()` instead of
     * individual options. This might be also useful if you want to implement a
     * custom client.
     */
    client?: Client;
};

Is enough so we can define our own url in case we have a HATEOAS link:

    const { data: pet } = await getPetById({
      url: '/pet/{petId}',
      path: {
        // random id 1-10
        petId: Math.floor(Math.random() * (10 - 1 + 1) + 1),
      },
    });

(I've used the linked StackBlitz demo from the homepage for my example here)

@mrlubos
Copy link
Member

mrlubos commented Oct 23, 2024

This all looks pretty straightforward and no objection to these changes. So no need for #452 at all?

And can you just explain for me, how will you know which function to call with your custom URL?

@SenseiMarv
Copy link
Author

@mrlubos Good to hear! Upon a second look, it seems like #452 is going into a slightly different direction of wanting access to the URLs by having Hey API export them. This is, as we already clarified, more about "overriding" them.

To stay with the Petstore example from the demo on the homepage:

Let's imagine that the response body of getPetById changes slightly. It now follows HATEOAS and includes a link to delete the fetched pet. The response body would then look something like this:

{
    // ...
    "_links": {
        "delete": {
            "href": "https://petstore3.swagger.io/api/v3/pet/5"
        }
    }
}

The link provided can now be used for the delete endpoint and will delete the pet we've just fetched:

  const [pet, setPet] = useState<Pet>();

  const onFetchPet = async () => {
    const { data: pet } = await getPetById({
      path: {
        // random id 1-10
        petId: Math.floor(Math.random() * (10 - 1 + 1) + 1),
      },
    });
    setPet(pet);
  };

  const onDeletePet = async () => {
    await deletePet({
      url: pet._links.delete.href,
    });
    setPet(undefined);
  };

So, in general, you know where to use the provided links by semantics or as defined in the API docs. I hope this answers your question?

Two notes about this:

  1. As noted in the PS in my comment above, the OptionsBase type needs to be adapted for this. url needs to be made optional as we know it will be set in the generated code, it just COULD be overridden if neccessary. This could be done either by appending Partial<Pick<RequestOptionsBase<ThrowOnError>, 'url'>> or by changing RequestOptionsBase to take a second generic that controls whether url should be required or not depending on the generic "flag" and then using that optional flag in OptionsBase. If the second example is confusing in text form, here is an example in code (using an unrelated topic) of how this could be done:
type OptionalFlag = 'optional';

export type Embedded<
  TItem,
  Optional extends OptionalFlag | undefined = undefined,
> = Optional extends OptionalFlag
  ? { _embedded?: { items: TItem[] } }
  : { _embedded: { items: TItem[] } };

// And then the optional flag can be used like this: Embedded<{ name: string }, 'optional'>;
  1. At the moment, path is still required. I think this should remain so as not to break the default workflow where it is indeed required. However, the problem with this is that the delete example above cannot fully work this way, as it would still need to provide path. I'm not sure yet what would be the best solution for this. One option would be to just provide any dummy data to satisfy the required prop, since the path data shouldn't go anywhere with the overridden url. But this is not the nicest solution. Perhaps you have another idea. Theoretically, though, the dummy data would suffice.

@SenseiMarv
Copy link
Author

After thinking about it, one comment about my note 2 above: path is actually still necessary, even when technically not using it's values. But when using the TanStack Query integration, it becomes relevant since it is being used for the queryKey generation. The full url isn't used right now and if this stays like that, the created keys wouldn't be unique. If we pass the url for the path though, we will still get unique queries.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature 🚀 New feature or request RSVP 👍👎 Explicit request for reactions to gauge interest in resolving this issue
Projects
None yet
Development

No branches or pull requests

3 participants