hey-api / openapi-ts

✨ Turn your OpenAPI specification into a beautiful TypeScript client
https://heyapi.vercel.app
MIT License
635 stars 44 forks source link

How to create my own client? #671

Open douglasg14b opened 2 weeks ago

douglasg14b commented 2 weeks ago

Description

This is similar to https://github.com/hey-api/openapi-ts/issues/425

We need to be able to pass envs to our custom client, and even wrap the API services within the client itself so they are accessible as properties.

Are there docs or examples outlining the best ways to go about this? The deprecation docs are unclear on how clients replaces or supplements those options. or how one goes about constructing their own custom client 🤔

The surface area is awkward in that the API calls are not pure, they leak the abstraction back up to the caller who has to specify a body property on their options. It would be nice to expose a more pure client SDK.

Thanks!

douglasg14b commented 2 weeks ago

Honestly, just giving library consumers the ability to pass in an env or client config themselves to define the baseUrl would be enough to get rolling. Is this possible without a custom client?

Similar to what we see here: https://github.com/ferdikoomen/openapi-typescript-codegen/wiki/Client-instances

Or if there is a straight forward way to wrap the client and set it's global config at runtime?

mrlubos commented 2 weeks ago

@douglasg14b are you able to show roughly what kind of API you're hoping to use?

Nick-Lucas commented 2 weeks ago

We also make heavy use in my team of the "name" option, now with asClass, to generate client classes for each of our APIs.

It's very useful because we can new up and inject client instances into our BFF, and also pass around services polymorphically.

It hasn't been entirely clear to me what the deprecation notice for "name" is about, since at face value that's the only way to generate a instantiable client for injection or connect to 2 instances of the same API within one application - mutating the global OpenApi object to configure the API is a step backwards in that regard

mrlubos commented 2 weeks ago

@Nick-Lucas We will want to handle those use cases for sure. The main issue with name is it's not obvious when to use it/what it does. It's a completely different way of generating clients with its own set of problems. And if you have two clients, they're 99% identical. There will be a cleaner approach to this, it just hasn't been prioritised

Nick-Lucas commented 2 weeks ago

@Nick-Lucas We will want to handle those use cases for sure. The main issue with name is it's not obvious when to use it/what it does. It's a completely different way of generating clients with its own set of problems. And if you have two clients, they're 99% identical. There will be a cleaner approach to this, it just hasn't been prioritised

Understood! Well if I can help to figure this out I'll be happy to

Right now setting name: 'MyClient', services: { asClass: true } is working fine as before, though if you don't enable asClass it's bugged right now and imports a non-existent DefaultService for the class. That probably deserves its own bug ticket though

mrlubos commented 2 weeks ago

Pretty sure someone reported it already, there might be an issue open. This illustrates perfectly the challenge of maintaining such a large API surface. I wanted to bring that feature in, but adding it to every configuration would take way longer and I know I want to rework "named" clients, so I didn't bother spending way more time on adding it to them

douglasg14b commented 2 weeks ago

are you able to show roughly what kind of API you're hoping to use?

yeah!

Essentially my goal is an API client that's namespaced, the user can pass in options once, and can reference an instantiated version of.

// Somewhere

export const someApiClient = createSomeApiClient({ baseUrl: '' });

// elsewear:

import someApiClient  from '@/something/client';`

...

await someApiClient.feature.endpoint();

And it by type safe throughout.


I have achieved that with:

import * as services from './services.gen';
import type { CamelCase } from 'type-fest';

type RemoveServiceSuffix<S extends string | number | symbol> = S extends `${infer Prefix}Service` ? Prefix : S;

type ApiClient<T extends object> = {
    [Service in keyof T as CamelCase<RemoveServiceSuffix<Service>>]: T[Service];
};

type ApiClientOptions = {
    baseUrl: string;
};

/** This creates a client SDK from the generated services, adding them as properties to the client and automatically filling in the provided baseUrl */
const createApiClient = <T extends object>(services: T, options: ApiClientOptions): ApiClient<T> => {
    const apiClient = {};

    for (const serviceName in services) {
        const serviceClass = (services as any)[serviceName];

        const serviceClient: { [methodName: string]: any } = {};
        for (const methodName of Object.getOwnPropertyNames(serviceClass)) {
            if (methodName !== 'constructor' && typeof (serviceClass as any)[methodName] === 'function') {
                serviceClient[methodName] = (args) => {
                    return (serviceClass as any)[methodName]({ path: args?.path, body: args?.body, ...options });
                };
            }
        }

        // Convert PascalCase to camelCase for the service name, remove "Service" from name
        const camelCaseServiceName = serviceName.charAt(0).toLowerCase() + serviceName.slice(1).replace(/Service$/, '');
        apiClient[camelCaseServiceName] = serviceClient;
    }

    return apiClient as ApiClient<T>;
};

export type CreateClientOptions = {
    baseUrl: string;
};

export function createClient({ baseUrl }: CreateClientOptions) {
    return createApiClient(services, { baseUrl });
}

This is of course, a bit of a hack given all the any casting, however it works since we separate the type definition from the actual runtime client object. anyways.

douglasg14b commented 2 weeks ago

Now that I've done this, there are a few things this lib could do to make such extensibility easier:

  1. An option to bundle all the services & endpoints into a single object
  2. An option to not use static methods
  3. Option to have a different codegen rollup file other than index.ts (Which is generated so I can't export this client from it)

A bundled up "client" that can be instantiated would do essentially what this does

Bonus:

omridevk commented 2 weeks ago

@douglasg14b Using the new fetch client, I was able to create a sort of my own client that uses either "Got" or "Ky"

import {createClient as createFetchClient} from '@hey-api/client-fetch'

export async function createClient(
  options: Parameters<typeof createFetchClient>[0] & {httpClient: Got | KyInstance},
) {
  const {httpClient} = options
  const client = createFetchClient({
    ...options,
    fetch: createFetch(httpClient),
  })
  return client
}

export function createFetch(client: Got | KyInstance) {
  return async (request: Request) => {
    const result = await client[request.method?.toLowerCase() as 'get'](request.url.toString(), {
      throwHttpErrors: false,
      headers: normalizeHeaders(request.headers) as unknown as Headers,
      ...(request.body && {json: await request.json()}),
    }).json()
    const response = new Response(JSON.stringify(result), {})
    return response
  }
}

this is just a POC but it seems to be working, there are probably some edge case, but the idea is that you can use the new fetch client and just provide your own fetch implementation

omridevk commented 2 weeks ago

I can of course pass a custom base URL to my fetch implementation

mrlubos commented 2 weeks ago

That's pretty cool @omridevk. Still think we need a separate client for those?

omridevk commented 2 weeks ago

nope, this solution seems to be good enough at least for our needs.

omridevk commented 2 weeks ago

also created this PR: https://github.com/7nohe/openapi-react-query-codegen/pull/125/files so we can use this version with react-query

Nick-Lucas commented 2 weeks ago

I'm actually unclear how what's being described here is different from setting name and asClass. You already get a named client class with all the services attached as class instances, and can pass all your options in to the constructor.