swift-openapi-generator icon indicating copy to clipboard operation
swift-openapi-generator copied to clipboard

Generator plugin for customizing the OpenAPI -> Swift name creation

Open czechboy0 opened this issue 1 year ago • 10 comments

As evidenced by #21 and #107, the names of generated Swift types is an important topic for adopters, so we would like to explore a more ambitious idea of creating the concept of "generator plugins", which would allow an adopter to customize just a single part of the generator logic, without having to reimplement the rest.

This issue is narrowly scoped to see how we could implement a generator plugin system that provides the following optional API for an adopter:

/// Returns a name that can be used as a Swift identifier for the provided OpenAPI name.
///
/// Might involve steps like removing characters not allowed in Swift identifiers, and more.
func swiftNameForOpenAPIName(_ openAPIName: String) throws -> String

The API above and the mechanics of integration is all very much the subject of this exploration.

Some immediate questions:

  • how does the adopter provide the plugin? (As a Swift package, a Swift script, a snippet of Swift code, ...?)
  • how can this work while using the generator as a SwiftPM plugin itself?
  • what should the exact spelling of the API call be?
  • should the naming function also have access to the whole OpenAPI document?

czechboy0 avatar Jul 11 '23 15:07 czechboy0

I like this idea as it provides a holistic solution. What's clear from the discussions we've had so far is that it's going to be very hard to satisfy everyone regardless of the effort we put into some idiomatic mapping.

This puts the adopter in control, allowing them to get what they want. We can still do something deterministic to get Swift-safe identifiers where needed, but whatever strategy we choose there needn't sacrifice on completeness for style.

/// Returns a name that can be used as a Swift identifier for the provided OpenAPI name.
///
/// Might involve steps like removing characters not allowed in Swift identifiers, and more.
func swiftNameForOpenAPIName(_ openAPIName: String) throws -> String

We might need something a bit more expressive than this as an API because how an adopter wants to map something depends very much on what that something is: schema, property, operation (Swift type, property, method, respectively).

We could consider a set of mapping functions for all the distinct classes of entities we think adopters would like to map. Alternatively, could we do something with keypaths?

(Calling it out explicitly) I suppose we don't want to start exposing our structured intermediate representation at this point?

simonjbeaumont avatar Jul 11 '23 16:07 simonjbeaumont

We might need something a bit more expressive than this as an API because how an adopter wants to map something depends very much on what that something is: schema, property, operation (Swift type, property, method, respectively).

Good point, how about (modulo spelling bikeshedding, let's focus on the nature of the inputs and outputs for now).


enum IdentifierRole {
  case type
  case property
  case variable
  case method
}

/// Returns a name that can be used as a Swift identifier for the provided OpenAPI name.
///
/// Might involve steps like removing characters not allowed in Swift identifiers, and more.
func swiftNameForOpenAPIName(_ openAPIName: String, role: IdentifierRole) throws -> String

(Calling it out explicitly) I suppose we don't want to start exposing our structured intermediate representation at this point?

Not unless we see evidence that the above isn't enough. We want to keep these plugins very narrowly focused, and if there are two distinct use cases, we should design two plugin points instead, each with the most appropriate inputs and outputs.

We also need to keep in mind that these extension points should really be focused on tasks that cannot be achieved by either modifying the input OpenAPI document, or adding extension to the output generated Swift code. To start, we should consider those out of scope, as they have a workaround that composes well with the rest of the ecosystem.

That's in contrast to the OpenAPI -> Swift names assignment, which is unique to the generator, and cannot be worked around, so in my mind, it justifies an ambitious solution, such as the concept of generator plugins.

czechboy0 avatar Jul 11 '23 17:07 czechboy0

In case the user does not implement this, but still has some illegal characters, do we have a fallback implementation to replace everything and emit a warning or let it fail outright?

denil-ct avatar Jul 11 '23 17:07 denil-ct

In case the user does not implement this, but still has some illegal characters, do we have a fallback implementation to replace everything and emit a warning or let it fail outright?

We'll have default conversion logic that tries to avoid conflicts, but might not be super pretty. It's important that by default, the safe thing happens, and adopters can easily pick up random valid OpenAPI docs and generate code for them. Only if they really want to customize the generated identifiers would they need to go and provide the plugin. Follows Swift's progressive disclosure of complexity principles.

czechboy0 avatar Jul 11 '23 18:07 czechboy0

should the naming function also have access to the whole OpenAPI document?

With the proposed API, the generator plugin will not have any idea on any previously generated names, potentially leading to a conflict. In this case, what can the user do to prevent this from happening other than generating conflict free names, which wouldn't be pretty. So, either we can have a list of all previously generated names, to avoid a potential conflict, or give access to the entire document, and the user is free to do whatever they want.

denil-ct avatar Jul 12 '23 17:07 denil-ct

I hacked together a prototype of such an extensions system last night: https://github.com/apple/swift-openapi-generator/compare/main...czechboy0:swift-openapi-generator:hd-extensions-prototype

czechboy0 avatar Aug 03 '23 20:08 czechboy0

I also would like this package to provide some more-popular functions/logics to users, by default. For example users shouldn't need to implement snake_case -> camelCase themselves, manually. There could also be a public function which converts swift-unsafe names to safe names and hand it to users, so users can more-easily implement their custom name-mapping function.

MahdiBM avatar Nov 10 '23 08:11 MahdiBM

Since more opinionated mappings will inevitably result in conflicts, I'm not sure we'd want to have them in the core package. But the plugin architecture would allow other packages to customize the mapping, just like transports and middlewares are also separate packages.

czechboy0 avatar Nov 10 '23 09:11 czechboy0

I don't think "opinionated" is the right word for snake_case/camelCase 😅

It's trivial for someone like me to find prepared-code from different packages for each conversion (e.g. Foundation coders have both) but most people will have a decent amount of difficulty implementing any of these conversions correctly, and will likely end up with flawed logic.

I would say all three conversions of swift-unsafe -> swift-safe, snake_case -> camelCase and camelCase -> snake_case would be popular enough for this package to try to provide to users.

MahdiBM avatar Nov 10 '23 17:11 MahdiBM

It does have to be opinionated - there's quite a lot of complexity of turning arbitrary identifiers into identifiers safe for Swift, while minimizing the possibility of a conflict. See SOAR-0001, the easy cases are easy, but we need to avoid conflicts without having global knowledge of all the identifiers in the document: https://forums.swift.org/t/proposal-ready-for-implementation-soar-0001-improved-openapi-swift-name-mapping/65890

Since we explored this in depth, we know there isn't an easy solution here, which is why I see the only way we'd consider allowing customization (which is API-breaking for generated code, so has to be done carefully) through external plugins that we don't maintain. Because the author of such a plugin will inevitably have to deal with issues filed by users who hit conflicts and will look for a "small change" in the algorithm, however every such change is API-breaking, so such a plugin would likely either have to not evolve at all, or release a major version every time the logic changes at all.

Since we want to avoid such churn in the core package, an external plugin would really be the only viable way here. And it will have to be opinionated, as a simple question of 'how do you stringify "+1" and "-1" without conflicting' has several reasonable answers.

czechboy0 avatar Nov 10 '23 20:11 czechboy0