hey-api / openapi-ts

✨ Turn your OpenAPI specification into a beautiful TypeScript client

Home Page:https://heyapi.vercel.app

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Types for the methods conflict when the same operation name is defined in multiple routes

Stono opened this issue · comments

Description

Hey Hey,
Finally got round to do this migration, and have run into a situation where the input types for the parameters of the function on a service conflict, if the same operation id is used in multiple routes.

For example:

  • /api/some-route (operationId: get)
  • /api/some-other-route (operationId: get)

In both circumstances, the templated object is called GetData; however they can have different parameters, as they're operations on different routes. Subsequently the last-in wins.

Really, the data type for the operation needs to be unique to the path that it resides under, or not use a type at all (and just specify it inline on the method).

OpenAPI specification (optional)

I've attached the whole swagger doc, but the specific examples here are:

    "/api/services/{service}/scheduled-http-requests/{name}": {
      "get": {
        "operationId": "get",
        
    "/api/services/{service}": {
      "get": {
        "operationId": "get",
        
    "/api/services/{service}/scheduled-http-requests/{name}": {
      "get": {
        "operationId": "get",    

swagger.json

Configuration

{
    input: tmpLocation,
    httpClient: 'fetch',
    exportCore: true,
    output: {
      lint: false,
      path: destinationLib
    },
    services: {
      export: true,
      asClass: true
    },
    schemas: {
      export: false
    },
    name: 'swaggerClient',
    useOptions: true,
    request: require.resolve(
      '@at/webserver-dev/lib/building/generatedClientRequestHandler.ts'
    )
  }

System information (optional)

No response

Hey @Stono, welcome back! I agree that there's more that could be done to de-duplicate these, but the main issue is that your operation IDs should be unique to be compliant with the spec. How many conflicts do you have?

My understanding is operations should be unique per path, not globally?

This is particularly true because otherwise you'd end up with spurious client interfaces like "client.services.getServices' and 'client.whatever.getWhatever', instead of simply get on both (when using classes for services as I am).

these IDs must be unique among all operations described in your API

I understand this to contain all paths. Client-facing interfaces are a separate issue and I am open to ideas how to provide an API that would allow you to modify these to something readable

To answer the question directly though, quite a few across a few apps haha.

I could make operation ID unique if there was another means of maintaining the client method names as I described above. Any ideas?

Jynx :)

I'd like to understand why/how you use name and request parameters. In case you haven't seen, these have been deprecated and I plan to remove them. At least for name I can guess one of the reasons is the client-facing API as you provided above. I mentioned this in another thread, generating SDK like output sounds like its own use case once you consider tree-shaking which has been enabled in the latest release, but it works for the output without name parameter.

So I currently use request to inject our custom http client. As long as I can use a custom 'client', that'd be ok too. However the types seemed to make me pick a specific implementation rather than target my own.

Name was to just ensure a consistent top level client name during generation, as our custom code on top of it expects it to be in a certain location, with a certain name.

Tree shaking is not a concern for me (at all), I'm building server side client libraries so the nicer interface of classes and methods after operations was a perfect fit - and it worked well.

Having the data model for operations not be tied to the operationId would fix my original issue. Happy to try and come up with a PR for that, but not sure it fits with the intentions for this library?

I get the vibe that the direction of travel for this library is more client side JavaScript?

I don't want to bash square pegs into round holes so if that's the case I can totally fork or find an alternative - just let me know :)

the types seemed to make me pick a specific implementation rather than target my own

Can you explain more please? There's also a new Fetch API client if you haven't seen it, wonder if that would solve a portion of your problems.

You're right that it's currently focused more on the front-end. But that's only because the biggest gaps were there. There's a subset of users like you who want to use the SDK-like syntax, I'll focus on that at some point too.

Perhaps one thing that could help. Imagine you have 3 identical operations which are not identical. Well, you don't have to imagine, it's literally your spec. Now you want to import the payload/response type for each operation. How would you do that?

I won't give any hints because there's a number of approaches, want to hear how you think about this problem. Not even looking for a technical solution, just how would you deduplicate these types in a single file.

Can you explain more please?

Not totally sure what you need to know haha. We have a custom http client which is a fetch wrapper, it doesn't a bunch of stuff like set up specific undici.Agents per host (and caches), implements custom DNS caching, uses AsyncLocalStorage to transparently forward tracing headers from incoming requests etc. Does that help?

how would you deduplicate these types in a single file.

The single file certainly a challenge, i think you'd either have to use namespaces, eg:

export namespace SomeService {
  export type GetResponse = {
    somekey: string
  }
  export class Service {
    public async get(): Promise<GetResponse> {
      return Promise.resolve({ somekey: 'whatever' })
    }
  }
}

export namespace SomeOtherService {
  export type GetResponse = {
    somekey: string
  }
  export class Service {
    public async get(): Promise<GetResponse> {
      return Promise.resolve({ somekey: 'whatever' })
    }
  }
}

Or you'd have to prefix the model with the tag? eg:

export type SomeServiceGetResponse = {
  somekey: string
}
export class SomeService {
  public async get(): Promise<SomeServiceGetResponse> {
    return Promise.resolve({ somekey: 'whatever' })
  }
}

export type SomeOtherServiceGetResponse = {
  somekey: string
}
export class SomeOtherService {
  public async get(): Promise<SomeOtherServiceGetResponse> {
    return Promise.resolve({ somekey: 'whatever' })
  }
}

I think I prefer the namespaces.

I suppose my question back would be; what is the motivation for a single file? The separate file structure that used to exist meant i didn't have this conflict because each service was its own module/file.

You're right on the operationId btw:

Unique string used to identify the operation. The id MUST be unique among all operations described in the API. The operationId value is case-sensitive. Tools and libraries MAY use the operationId to uniquely identify an operation, therefore, it is RECOMMENDED to follow common programming naming conventions.

So the most "correct" approach here would be to find another way to express the method name in the swagger spec, but i can't think of how.

For what it's worth these operationIds are generated by the nest-swagger builder; if you don't specify them, they're simply the name of the method. This is the code for the client:

@ApiTags('scheduled-http-requests')
@Controller('/services/:service/scheduled-http-requests')
export class ScheduledHttpRequestsController {
  @Get('/:name')
  @ApiOperation({
    summary: 'Get an individual scheduled http request'
  })
  public async get(
    this: ScheduledHttpRequestsController,
    @Param('service') service: string,
    @Param('name') name: string
  ): Promise<JobItem> {
    ...
  }

The way you write the controllers in nest lends itself towards the fact operations are unique per route (controller), because they're defined within the class that is the overarching controller.

(this doesn't make it right according to the spec btw, just showing how i landed here).

nestjs/swagger#482 interesting reading. Our operationId factory is just using the method key (because it gave the nicest swagger client generation). There's nothing in the openapi schema that i think we could use to express the method name to be used in the client.

hmmm perhaps if operationId was always an aggregate of the controller and the operation (as is the nestjs default), for example the service was ScheduledHttpRequests, and the operation was Get, I could set the operationId to be ScheduledHttpRequestsGet.

The codegen could then be smart enough to know that ScheduledHttpRequests is spurious and just useGet in the method name.

This is still quite bespoke so perhaps the way to express this would be a builder function in the codegen config, for example:

    services: {
      export: true,
      asClass: true, 
      methodBuilder: (serviceName: string, operationId: string) => { return operationId.replace(serviceName, '') }
    },

BTW i think we've compounded quite a few issues into one in this thread haha! :D however I think this fixes all the problems because the uniqueness of operationId means the types would always be unique too?

Chucked a PR which works for me locally; it's pretty crude but it allows me to do:

    services: {
      export: true,
      asClass: true,
      operationId: true,
      methodNameBuilder: (service: string, operationId: string) => {
        return operationId.includes('Controller')
          ? operationId.split('Controller')[1]
          : operationId
      }
    },

This works in my setup because I can use the default swagger nest behaviour to set the operationId to ControllerMethod, and by convention all of our controllers end with Controller, so in the examples above i'd get ScheduledHttpRequestsControllerGet as the operationId, and then I just split it.

This means the client interface remains the same .get which is the same as the method name defined in the nestjs code, but the types being generated for the operation are prefixed with the full ScheduledHttpRequestsControllerGet.

No worries, we can always branch into new issues if needed. I left a comment on your pull request, otherwise could be merged in