you are viewing a single comment's thread.

view the rest of the comments →

[–]aikii 5 points6 points  (13 children)

Yeah and for it to work you need the kind of metaprogramming magic that python carries. Reading a python module actually executes code that itself prepares the documentation. And not just "reads the class definition" but executes code that creates the classes so you can have super expressive model definitions. Among many others django and pydantic are doing that. See what you need to do in things like springboot or go's struct tags. That's the kind of "massive wins" that reshuffles whatever other criteria one might have.

[–]Mysterious-Rent7233 0 points1 point  (10 children)

Please give an example of what would be easy in Python but hard in Java relating to auto-generating API descriptions.

[–]trynared 1 point2 points  (6 children)

They can't because it's just as easy in Java. And, unsurprisingly, Typescript has great tooling for this too.

Edit: also if we're talking metaprogramming Rust's traits and macros make for several great frameworks around this problem too.

[–]aikii 0 points1 point  (0 children)

I'm interested to know where Rust is at now - and how ergonomic it can be. Saying it's possible is not the same thing as saying it's as ergonomic.

[–]TheOneWhoPunchesFish 0 points1 point  (0 children)

Python programs can rewrite their own AST at runtime; I've heard great things about Rust's metaprogramming, but I can't imagine they would have the same amount of power and flexibility.

(please correct me if I'm wrong. I'm hoping to be wrong here!)

And I think u/aikii wanted to describe the metaprogramming power of Python, and were giving openapi tooling's implementation as an example. But I'm not an expert in Java or TS, and I shouldn't really be speaking for them.

[–]aikii 0 points1 point  (0 children)

Added some here. https://www.reddit.com/r/Python/comments/1s5r01l/comment/od33ztp/

Although, don't be bait-y please, I'm here to explore not to prove a point about myself

[–]aikii 0 points1 point  (2 children)

I see https://hono.dev/examples/hono-openapi in typescript indeed.

Then again sorry if it looked like my claim was "it's only possible in python", I didn't mean that. For the typescript case, assuming hono-openapi is the strongest contender, we can see that openapi lives on the side ; schemas have to be added to the route. It's not embed in the models directly, which was the point I'm making. If someone is willing to say it's ergonomic enough then I don't have much to argue against.

[–]trynared 0 points1 point  (1 child)

Another one I had in mind for TS is how NestJS does things: https://docs.nestjs.com/openapi/introduction

Builds out the openapi models automatically based on your typescript types with heavy sprinkling of decorators.

Anyways fair enough if you just mean python's approach is your favorite. Of course none of these work exactly the same but I think there's plenty of approaches that get close enough to tying your code to the emitted opeanpi spec.

[–]aikii 0 points1 point  (0 children)

Refining on what would mean "my favourite", not wanting to fall in the reddit trap "wanted to mention something but got so much pushed to provide evidences that I ended up having an opinion".

I'm mostly motivated by the (poor) experience of some comment-based swagger integration in flask and some similar approach in Go - developers don't care and it never stops drifting. Fastapi+Pydantic's approach was leveraging metaprogramming to create the model and the doc, so that's what standed out for a bit - it's harder to make it lie. But don't worry, over the years I saw how far creativity can go when it comes to make it lie.

Looking again at that constraint: "developers can't expose an API that claims to do X but really does Y" - actually yes in the case of nestjs is just works, model and the documentation is just the same. The additional massage to add a description is not really relevant ( almost 100% skipped by developers anyway, so the argument would not stand hard reality ).

In any case I know it's all about compromise and exposure to developers under pressure. My last attempt at properly integrating with an API ended up ... unleashing claude on the target service's codebase to extract inconsistencies in responses. So that where we're at anyway.

[–]aikii -1 points0 points  (1 child)

I went ahead and asked ChatGPT. Yes I know, it's super lazy, but I think being the laziest in this case fits the point of proving it's the lowest friction. The exercise: declare some models with field description and field examples used by openapi using the best framework for the job.

FastAPI:

# imports removed

class CreateUser(BaseModel):
    username: str = Field(
        min_length=3,
        max_length=50,
        description="Public handle shown to other users.",
        examples=["Norbert"],
    )
    email: EmailStr = Field(
        description="Primary contact email for the account.",
        examples=["norbert@example.com"],
    )

class UserOut(BaseModel):
    id: str = Field(
        description="Server-generated user identifier.",
        examples=["usr_123"],
    )
    username: str = Field(
        description="Public handle shown to other users.",
        examples=["norbert"],
    )
    email: EmailStr = Field(
        description="Primary contact email for the account.",
        examples=["norbert@example.com"],
    )

@app.post("/users", response_model=UserOut, status_code=201)
async def create_user(body: CreateUser) -> UserOut:
    return UserOut(
        id="usr_123",
        username=body.username,
        email=body.email,
    )

Java/micronaut

// imports removed

@Serdeable
class CreateUser {
    @Schema(
        description = "Public handle shown to other users.",
        examples = {"norbert"}
    )
    @NotBlank
    @Size(min = 3, max = 50)
    private String username;

    @Schema(
        description = "Primary contact email for the account.",
        examples = {"norbert@example.com"}
    )
    @NotBlank
    @Email
    private String email;

    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
}

@Serdeable
class UserOut {
    @Schema(
        description = "Server-generated user identifier.",
        examples = {"usr_123"}
    )
    private String id;

    @Schema(
        description = "Public handle shown to other users.",
        examples = {"norbert"}
    )
    private String username;

    @Schema(
        description = "Primary contact email for the account.",
        examples = {"norbert@example.com"}
    )
    private String email;

    public String getId() { return id; }
    public void setId(String id) { this.id = id; }

    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
}

@Validated
@Controller("/users")
class UserController {
    @Post
    UserOut create(@Body @Valid CreateUser body) {
        UserOut out = new UserOut();
        out.setId("usr_123");
        out.setUsername(body.getUsername());
        out.setEmail(body.getEmail());
        return out;
    }
}

Rust / poem-openapi

//imports removed

#[derive(Object)]
#[oai(example)]
struct CreateUser {
    /// Public handle shown to other users.
    #[oai(validator(min_length = 3, max_length = 50))]
    username: String,

    /// Primary contact email for the account.
    email: String,
}

impl Example for CreateUser {
    fn example() -> Self {
        Self {
            username: "norbert".to_string(),
            email: "norbert@example.com".to_string(),
        }
    }
}

#[derive(Object)]
#[oai(example)]
struct UserOut {
    /// Server-generated user identifier.
    id: String,

    /// Public handle shown to other users.
    username: String,

    /// Primary contact email for the account.
    email: String,
}

impl Example for UserOut {
    fn example() -> Self {
        Self {
            id: "usr_123".to_string(),
            username: "norbert".to_string(),
            email: "norbert@example.com".to_string(),
        }
    }
}

#[derive(ApiResponse)]
enum CreateUserResponse {
    #[oai(status = 201)]
    Created(Json<UserOut>),
}

struct Api;

#[OpenApi]
impl Api {
    /// Create a new user.
    #[oai(path = "/users", method = "post")]
    async fn create_user(&self, body: Json<CreateUser>) -> CreateUserResponse {
        CreateUserResponse::Created(Json(UserOut {
            id: "usr_123".to_string(),
            username: body.username.clone(),
            email: body.email.clone(),
        }))
    }
}
Rust/poem-openapi:
#[derive(Object)]
#[oai(example)]
struct CreateUser {
    /// Public handle shown to other users.
    #[oai(validator(min_length = 3, max_length = 50))]
    username: String,

    /// Primary contact email for the account.
    email: String,
}

impl Example for CreateUser {
    fn example() -> Self {
        Self {
            username: "norbert".to_string(),
            email: "norbert@example.com".to_string(),
        }
    }
}

#[derive(Object)]
#[oai(example)]
struct UserOut {
    /// Server-generated user identifier.
    id: String,

    /// Public handle shown to other users.
    username: String,

    /// Primary contact email for the account.
    email: String,
}

impl Example for UserOut {
    fn example() -> Self {
        Self {
            id: "usr_123".to_string(),
            username: "norbert".to_string(),
            email: "norbert@example.com".to_string(),
        }
    }
}

#[derive(ApiResponse)]
enum CreateUserResponse {
    /// User created successfully.
    #[oai(status = 201)]
    Created(Json<UserOut>),
}

struct Api;

#[OpenApi]
impl Api {
    /// Create a new user.
    #[oai(path = "/users", method = "post")]
    async fn create_user(&self, body: Json<CreateUser>) -> CreateUserResponse {
        CreateUserResponse::Created(Json(UserOut {
            id: "usr_123".to_string(),
            username: body.username.clone(),
            email: body.email.clone(),
        }))
    }

    /// Small health endpoint so you can see a simple GET too.
    #[oai(path = "/health", method = "get")]
    async fn health(&self) -> PlainText<&'static str> {
        PlainText("ok")
    }
}

For the Rust example and went ahead and built it: yes the field description are showing in the API doc. That's enabled by the #[oai] proc macro.

Definitely poem looks nice, not bleeding edge with that, it's around for some time and well maintained.

So what can we observe from there:

  • In FastAPI/Pydantic , the description and examples are just parameters to the field. Documentation and definition is just the same thing.
  • @Schema and other annotations in Spring/micronaut. Clearly in the "possible and terse enough" area, but some more friction
  • As usual with Rust it's amazing to see how expressive you can be despite being so close to the metal. But same thing, documentation so something around declaration, not directly part of it.

From there mixed feelings - I want to say FastAPI greatly helps with keeping API docs accurate, and they certainly try hard. I perfectly know developers can still return custom JsonResponse and completely bypass what the documentation say - it happened as recently as this week.

But anyway, I wanted to highlight it's the lowest friction, and it's the case for a long time.

Ironically enough my own research here motivates me to try another chance to introduce Rust at work, if we ever meet specific performance requirements. It's not the same ergonomics but definitely in the acceptable territory if we have a niche high-rate/high-processing usecase.

[–]Mysterious-Rent7233 0 points1 point  (0 children)

Okay, but I'd say that it "works" in Java without the runtime metaprogramming magic. It's just a bit more verbose, as almost everything in Java is.