#Utoipa type management

25 messages · Page 1 of 1 (latest)

thick ibex
#

Small question for peoples doing APIs : how do you manage your docs for OpenAPI?
For example, i have an Axum handler with a Result<User, ApiError>, except that ApiError is a generic struct mapped from my infra and domain errors. This works very well, but at the OpenAPI level, i find myself to document everything manually for each responses possible because utoipa isn't capable to infer data.

unreal lodge
thick ibex
#

at least the errors related to the business logic 🤔

#

things like returning a "forbidden" error when a user isn't allowed to do a specific action

unreal lodge
#

But that can’t be returned from every endpoint

#

so I think it makes more sense to just document which endpoints it can be returned from

thick ibex
#

well yes, but isn't it a bit verbose to specify all the possible errors for each endpoint?

unreal lodge
#

If there is a large class of endpoint it can be returned from, I have previously hacked around this with tags

#
// We (ab)use the `auth` tag to mean “we support an UNAUTHORIZED response”.
if let Some(tags) = &mut operation.tags {
    if let Some(i) = tags.iter().position(|t| t == "auth") {
        tags.remove(i);
        add_response(
            operation,
            StatusCode::UNAUTHORIZED,
            "Authorization was required, but not provided.",
        );
    }
}
``` `add_response` takes in an `&mut openapi::path::Operation` and adds the given response
#

You can iterate over all operations like so: rs for path in openapi.paths.paths.values_mut() { for operation in path_operations_mut(path) { } } where you have ```rs
fn path_operations_mut(
path: &mut openapi::PathItem,
) -> impl Iterator<Item = &mut openapi::path::Operation> {
[].into_iter()
.chain(&mut path.get)
.chain(&mut path.put)
.chain(&mut path.post)
.chain(&mut path.delete)
.chain(&mut path.options)
.chain(&mut path.head)
.chain(&mut path.patch)
.chain(&mut path.trace)
}

#

so you just have to put tags = ["auth"] and you get the response automatically

north kernel
#

If your main problem is that you keep having to rewrite a lot of the the same boilerplate code over and over e.g.

#[utoipa::path(
  // ...
  responses(
    (
      status = 404,
      // ... a lot of repetition here
    )
  )
)]

then I basically ran into the same problem as you, and there is a solution. I agree with Sabrina that it's probably not a good idea to just always add a set of blanket errors, because your docs will basically be wrong. I instead surgically "applied" things to endpoints, but what I propose is slightly different (and more strongly typed) than the solution with tags. Yes, slightly more setup work perhaps, but I found it well worth the initial hassle, especially considering that once you segment your possible responses into these types (as you'll see in a moment), it becomes wildly easy to apply them (and modify or improve all of them at once!).

Here is my solution that I'm pretty okay with for now: certain parts of the utoipa annotation allow you to provide a type that implements a specific trait related to that part of the annotation, e.g. a response (IntoResponses). Instead of having to manually put in all the data each time, simply define a struct like this

pub struct InternalServerError;

impl utoipa::IntoResponses for InternalServerError {
    fn responses() -> BTreeMap<String, utoipa::openapi::RefOr<utoipa::openapi::response::Response>> {
        let internal_error_response = ResponseBuilder::new()
            .description("Internal server error.")
            .build();

        ResponsesBuilder::new()
            .response("500", internal_error_response)
            .build()
            .into()
    }
}

and then use this utoipa response "type" in your utoipa::path like

#[utoipa::path(
  get,
  path = "/foo",
  responses(
    path::to::InternalServerError 
    // you can also locally import the type and just use InternalServerError of course
  )
)]
// [etc.]
#

At this point, you're almost free to do almost anything you want, because you have the type system to play with.

thick ibex
#

Yeah, I see

#

I have to say, your implementation is pretty solid. I might inspire myself on it. 👀

north kernel
#

Well, the actual endpoint response type stays the same as far as I can tell; the response "documentation types" you add to your utoipa annotation are fully separate (and only for utoipa)

thick ibex
#

Oh, ok

north kernel
thick ibex
#

So there is an actual difference between both types then

north kernel
#

The endpoint return type (e.g. your Result<impl IntoResponse, ApiError>) depends on the framework you're using (in your case axum)
The rules of that library will dictate what you can return from your endpoint. In your case you implemented axum::IntoResponse for ApiError, which allows you to return Result<impl IntoResponse, ApiError> directly from your endpoints (at least as far as I can tell, other libraries I've used use the same pattern).

But that return type doesn't influence the documentation generated by utoipa! (Well, I think there's some feature flag that infers some stuff from your axum handler, but I can't imagine how it could infer enough for these cases.)

With the approach above, what you're doing is creating a separate set of utoipa-specific types that you then use as shortcuts in your #[utoipa::path(...)] on each endpoint. Those types are fully separate from the "run-time" types; I guess we could informally call them "documentation-time" types, if you will (not sure if they actually get erased in the end, but I guess it's not impactful either way).

thick ibex
#

I see 🤔

north kernel
#

I suppose it would be possible to merge the two types (and implement e.g. utoipa::IntoResponses) just directly on e.g. your ApiError, but I think that would get muddy and limiting very quickly.

unreal lodge
#

The main limitation of the IntoResponse approach is that I believe you can’t attach multiple responses to the same status code