-
-
Notifications
You must be signed in to change notification settings - Fork 165
How to create my own client? #671
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
Comments
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? |
@douglasg14b are you able to show roughly what kind of API you're hoping to use? |
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 |
@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 |
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 |
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 |
Now that I've done this, there are a few things this lib could do to make such extensibility easier:
A bundled up "client" that can be instantiated would do essentially what this does Bonus:
|
@douglasg14b
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 |
I can of course pass a custom base URL to my fetch implementation |
That's pretty cool @omridevk. Still think we need a separate client for those? |
nope, this solution seems to be good enough at least for our needs. |
also created this PR: |
I'm actually unclear how what's being described here is different from setting |
Hey, im having a similar question, the lib generates an My question is, in a server env where memory is shared in a long running process, couldn't 2 or more separate requests that want to use the client call |
other libraries either allow you to instantiate a new class instance with the config parameter in the constructor, or they provide a factory function that creates another client object with the setup you pass to the factory function. |
@bombillazo my assumption was that you'd call |
We're using With a singleton pattern, we may run the risk of one request setting the config and another one coming in and calling |
As here you can generate an instantiable client already to solve the problem, my own contribution to the thread is just about some deprecations around the current API |
Thanks @Nick-Lucas. To your earlier comment, I think it's not bugged anymore RE @bombillazo can you confirm your use case is for server-to-server communication? If you're using the generated SDKs on the client, is there ever a concern about the size of the generated bundle? |
We're using it server to server, in our case bundle size isn't a huge concern. I understand now why it is the way it is right now, based on your assumption. |
@bombillazo let me know if using |
Okay, got it. Yeah, the only path forward, for now, is to pass If the codegen eventually generates code that allows for multiple client instances of the same SDK, believe me, that will be a game changer for backend development. |
Any chance you've got a repository I could play with to verify the issues and test any potential solutions when I get around to it? |
Unfortunately no, but we're using Hono for our backend server. I only anticipated the issue after looking at the produced code, it runs without problems but I noticed there was a single instance of the client which can be a problem in server environments. Some solutions that come to my mind require what I mentioned earlier: either generate a FullAPI class that envelopes all the API services with a constructor that can be used to set the client options and have the current client be an internal property of the FullAPI, and one can call Or have a function that somehow creates and returns a new object that can store the config internally and has access to all the API methods. This one sounds less performant since depending on how its done would require to mount the object every time with teh API function methods and setting up the common config to use. |
yeah I think |
Right now the code generates an index.ts file with an exported const object called client created with the createClient function. All API methods function generated (both class and flat) are internally pointing to that const object . When the server app runs, it'll create the singleton (that specific instance object) and no matter where we import the client from in our logic, it'll use that same object. So the code gen solution would be a whole different approach from Front End where each app essentially runs "isolated" on each browser tab and that client isn't really shared and is only used within the context of that app "session" . |
I'm willing to help and contribute too. I'm interested in this library, so far I've liked what I've seen. |
Your description checks out. As it is, are you blocked at using the generated output? |
Have you tried setting "name" and checking the output of that? |
@mrlubos Not blocked perse, we can still use the client passing headers on every single method call, fetch will set the headers individually as expected. @Nick-Lucas Hey, how would changing the name of the client help? |
It generates a named and instantiable client, a class you can It's an older option from the original library which hasn't been adapted yet to the new codegen approach but I use it at work for holding several instances |
Oh, let me try it out, but if it continues to internally use a client object singleton I doubt it'll solve the problem. I'll confirm. |
@Nick-Lucas Wow, I think that is a solution in the right direction; it essentially creates the instantiable wrapper class I alluded to previously as a potential solution. It does the configuring of each internal service with the initial config. However, I see various issues on how it currently outputs:
/**
* Generated sdk.gen.ts
*/
export class MySDK {
public readonly customers: Customers;
public readonly request: BaseHttpRequest;
constructor(config?: Partial<OpenAPIConfig>, HttpRequest: HttpRequestConstructor = ) {
this.request = new HttpRequest({
BASE: config?.BASE ?? 'https://sandbox.dev.clover.com',
VERSION: config?.VERSION ?? '3.0',
WITH_CREDENTIALS: config?.WITH_CREDENTIALS ?? false,
CREDENTIALS: config?.CREDENTIALS ?? 'include',
TOKEN: config?.TOKEN,
USERNAME: config?.USERNAME,
PASSWORD: config?.PASSWORD,
HEADERS: config?.HEADERS,
ENCODE_PATH: config?.ENCODE_PATH,
interceptors: {
request: config?.interceptors?.request ?? new Interceptors(),
response: config?.interceptors?.response ?? new Interceptors(),
},
});
this.customers = new Customers(this.request);
}
}
/**
* Generated service.gen.ts
*/
//... imports here
export const client = createClient(createConfig());
export class Customers {
constructor(public readonly httpRequest: BaseHttpRequest) {}
/**
* Get a list of customers
* Gives information for every customer of a merchant by default.
*/
public getCustomers<ThrowOnError extends boolean = false>(
options: Options<CustomersGetCustomersData, ThrowOnError>,
) {
return (options?.client ?? client).get<
CustomersGetCustomersResponse,
CustomersGetCustomersError,
ThrowOnError
>({
...options,
url: '/v3/merchants/{mId}/customers',
});
}
} |
@bombillazo your config settings would be helpful here |
Im using the js API to autogen programmatically, here it is: await createClient({
client: '@hey-api/client-fetch',
input: './service/myapi/sdk/myapi.openapi.json',
name: 'MySDK',
output: {
path: './service/myapi/client',
},
services: {
asClass: true,
methodNameBuilder: (operation) => {
return camelCase(operation.id?.split('.').pop() || operation.name);
},
name: '{{name}}',
},
}); |
Can you try one of the older clients like Axios or Fetch? It's not preferred going forward but given the named client hasn't been given much attention lately it wouldn't be a surprise to see it's incompatible with the decoupled client |
Also, wanted to say I appreciate the help and engagement on this! So, using classic Fetch definitely fixed most of those observations! As you said, the new decoupled I tried using the Now I see it all connect together: /**
* Generated sdk.gen.ts
*/
export class MySDK {
public readonly customers: Customers;
public readonly request: BaseHttpRequest;
constructor(
config?: Partial<OpenAPIConfig>,
HttpRequest: HttpRequestConstructor = FetchHttpRequest
) {
this.request = new HttpRequest({
BASE: config?.BASE ?? "https://sandbox.dev.clover.com",
VERSION: config?.VERSION ?? "3.0",
WITH_CREDENTIALS: config?.WITH_CREDENTIALS ?? false,
CREDENTIALS: config?.CREDENTIALS ?? "include",
TOKEN: config?.TOKEN,
USERNAME: config?.USERNAME,
PASSWORD: config?.PASSWORD,
HEADERS: config?.HEADERS,
ENCODE_PATH: config?.ENCODE_PATH,
interceptors: {
request: config?.interceptors?.request ?? new Interceptors(),
response: config?.interceptors?.response ?? new Interceptors(),
},
});
this.customers = new Customers(this.request);
}
}
/**
* Generated service.gen.ts
*/
//... imports here
export class Customers {
constructor(public readonly httpRequest: BaseHttpRequest) {}
/**
* Get a list of customers
* Gives information for every customer of a merchant by default.
* @param data The data for the request.
* @param data.mId Merchant Id
* @param data.filter Filter fields: [customerSince, firstName, lastName, emailAddress, phoneNumber, marketingAllowed, fullName, id, deletedTime]
* @param data.expand Expandable fields: [addresses, emailAddresses, phoneNumbers, cards, metadata]
* @returns unknown
* @throws ApiError
*/
public getCustomers(
data: CustomersGetCustomersData
): CancelablePromise<CustomersGetCustomersResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v3/merchants/{mId}/customers",
path: {
mId: data.mId,
},
query: {
filter: data.filter,
expand: data.expand,
},
});
}
}
|
In conclusion, I think a good approach would be to rename |
Seems like core folder is not generated if you use @hey-api/client-fetch & name together. client: 'legacy/axios', gives a Typescript error but generates the core folder. |
Running into the same problem. What is the best path to create bundled client? I need to add some custom authorization for signed jwts for each endpoint and having a main client entry point seems like the best approach here. I tried a few of the suggestions in this thread but none worked out for me so far for my use case. |
fwiw, I added a very basic wrapper api client that just forwarded my services to the client class. openapi-ts.config.ts:import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
client: {
bundle: true,
name: '@hey-api/client-fetch'
},
input: './app_store_connect_api_openapi.json',
output: './src/app_store_connect_api',
services: {
asClass: false,
}
}); client.ts:import * as services from './app_store_connect_api/services.gen';
import { generateAuthToken } from "./auth";
import fs from 'fs';
/**
* Options for configuring the App Store Connect client.
* @see https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api
*/
interface AppStoreConnectOptions {
/**
* The issuer ID associated with the private key.
*/
issuerId?: string;
/**
* The ID of the private key.
*/
privateKeyId?: string;
/**
* The private key in PEM format.
*/
privateKey?: string;
/**
* The path to the private key file.
*/
privateKeyFile?: string;
/**
* A bearer token can be provided directly, which will be used instead of generating a new token
*/
bearerToken?: string;
/**
* The time (in seconds) until the token expires (default 10 minutes)
*/
expirationTime?: number;
}
export default class AppStoreConnectClient {
private appStoreConnectOptions: AppStoreConnectOptions;
private bearerToken: string | null = null;
private bearerTokenGeneratedAt = 0;
api = services;
constructor(appStoreConnectOptions: AppStoreConnectOptions) {
if (!appStoreConnectOptions) {
throw new Error('AppStoreConnectOptions is required');
}
this.appStoreConnectOptions = appStoreConnectOptions;
services.client.setConfig({ baseUrl: 'https://api.appstoreconnect.apple.com' });
services.client.interceptors.request.use(async (request, _options): Promise<Request> => {
request.headers.set('Authorization', `Bearer ${await this.getToken()}`);
return request;
});
}
private async getToken() {
const defaultExpirationTime = 600; // 10 minutes
if (this.appStoreConnectOptions.bearerToken) {
this.bearerToken = this.appStoreConnectOptions.bearerToken;
} else {
if (this.appStoreConnectOptions.privateKeyId &&
this.appStoreConnectOptions.issuerId &&
(this.appStoreConnectOptions.privateKey || this.appStoreConnectOptions.privateKeyFile)) {
if (!this.bearerToken || this.bearerTokenGeneratedAt + (this.appStoreConnectOptions.expirationTime ?? defaultExpirationTime) * 1000 < Date.now()) {
if (this.appStoreConnectOptions.privateKeyFile) {
this.appStoreConnectOptions.privateKey = await fs.promises.readFile(this.appStoreConnectOptions.privateKeyFile, 'utf8');
}
this.bearerToken = await generateAuthToken({
privateKeyId: this.appStoreConnectOptions.privateKeyId,
issuerId: this.appStoreConnectOptions.issuerId,
privateKey: this.appStoreConnectOptions.privateKey!,
expirationTime: this.appStoreConnectOptions.expirationTime,
});
}
} else {
throw new Error('Bearer token or private key information is required to generate a token');
}
}
return this.bearerToken;
}
}
export { AppStoreConnectOptions, AppStoreConnectClient }; sdk caller:async function getLatestAppStoreBuildNumber(bundleId: string): Promise<number> {
const { data: response, error } = await appStoreConnectClient.api.buildsGetCollection({
query: {
'fields[apps]': ['bundleId'],
'filter[app]': [bundleId],
sort: ['-version'],
limit: 1,
}
});
if (error) {
throw new Error(`Error fetching builds: ${JSON.stringify(error)}`);
}
if (!response) {
return 0;
}
if (response.data.length === 0) {
return 0;
}
return Number(response.data[0].attributes.version);
} notes:while overall easy to generate api client, it isn't very easy to interact with it. for example, when calling the service endpoints, the optional query parameters and data aren't really formatted for simple function calls. Seems a services binding layer is still required if you want sdk consumers to interact with it with ease. |
I've been reading for an hour and don't understand your needs. Can I ask you some questions? createClient() // client1
createClient() //. client2 Then when calling the method of apiGetUser({
client: client1
}) I want to understand your needs to avoid not knowing how to solve such problems in the future |
I think you should be good @greenking19, this thread is around functionality that's not natively supported by this package. If what you have works for you, there should be no need for a custom client |
This is a good suggestion I think. The single named client is the only reason I can't switch away from using "name" and legacy client to the newer one. |
After experimenting a little this is also something I am missing in the capabilities. I have a scenario where there are multiple instances of the same API at different URLs that I might need to interact with at the same time. Therefore, it would be great to just new up an instance of the API for each and interact with them independently and not worry about implicit client settings or having to pass a client into each call. Something like this would be great, fully encapsulate the client and its settings inside this instance of the class representing the API. const apiInstance1 = new MyGeneratedApi({url: 'my/new/url'});
const apiInstance2 = new MyGeneratedApi({url: 'my/new/url'});
apiInstance1.search(...)
apiInstance2.search(...) Currently, as a consumer, you have to know far too much about the implementation than should be needed. For example import { client, search } from 'my-api';
// set intsance one URL
client.setConfig(...)
// query instance one
search(...)
// set instance two URL
client.setConfig(...)
// query instance two
search(...)
// or
import { createClient } from '@hey-api/client-fetch';
const clientInstanceOne = createClient({
...
});
const clientInstanceTwo = createClient({
...
});
search({..., client: clientInstanceOne});
search({..., client: clientInstanceTwo}); Neither of the above are great... -- @douglasg14b FWIW your workaround is nice and works but should just be the way it works IMO. |
Hey hey, let's move this discussion to the other issue, I've posted an update there. Custom clients in progress! #1213 (comment) |
This adds an option asInstance that makes the sdk class instantiable. Specifically: 1. Methods are instance methods instead of static 2. The sdk takes a client as a parameter in the constructor, and sdk methods use the instance of the client. This allows creating multiple instances of the sdk that work independently. My use case is using a client during server side rendering where each sdk instance is authenticated with the end-user's auth token which has limited permissions (as opposed to using a single client with admin permissions). Contributes hey-api#671
I opened a PR that allows instantiating an sdk with a client. So you can have multiple instances: export class DefaultService {
constructor(public client: Client) { }
/**
* Foo
*/
public foo<ThrowOnError extends boolean = false>(options: Options<FooData, ThrowOnError>) {
return (options.client ?? this.client).post<FooResponse, FooError, ThrowOnError>({
url: '/foo',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
}
... |
Description
This is similar to #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 theiroptions
. It would be nice to expose a more pure client SDK.Thanks!
The text was updated successfully, but these errors were encountered: