Skip to content

Replace Model trait with Format trait (applied to Response instead) #2559

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

Open
wants to merge 11 commits into
base: main
Choose a base branch
from

Conversation

analogrelay
Copy link
Member

Early draft of #1831. It should have the necessary changes in typespec_client_core, but I haven't gone through other crates and updated them. I'll continue doing that to see if other surprising issues appear, but wanted to give folks (particularly @heaths) a chance to take a look at how Response<T, F> looks and how I implemented it.

I did things a little different than I originally planned because I wanted to retain the ability to take a default "bare" Response type (using no explicit type parameters, and thus just the defaults) and treat it like a raw response with no deserialization. So, here's how it works:

The Response<T, F> type expects two type parameters: T is the return type for into_body() calls, the type of the model, and F is a marker type, implementing a marker trait Format, that specifies how T is "formatted" (i.e. JSON, XML, etc.).

There are two formats in typespec_client_core: DefaultFormat is the "default" format. It deserializes T using some default rules that prefer JSON formatting (see below), XmlFormat is available when feature xml is enabled, it deserializes T using XML.

The Format trait used by F doesn't actually provide any methods. Instead, there is a separate trait, DeserializeWith<F: Format> that is applied to T. This allows us to provide implementations that vary based on BOTH the target type T and the desired format F. We provide the following implementations in typespec_client_core:

  • impl<F> DeserializeWith<F> for ResponseBody which "deserializes" a ResponseBody from a ResponseBody by just returning it. This allows Response<ResponseBody, F>::into_body() return the raw ResponseBody. This happens no matter what format is specified, so Response<ResponseBody, XmlFormat>::into_body() still returns a raw unparsed XML buffer.
  • impl<T: DeserializeOwned> DeserializeWith<DefaultFormat> for T which deserializes a T: DeserializeOwned using serde_json, IF the response was marked with DefaultFormat. This impl is only present if feature = "json". The DefaultFormat type exists unconditionally though, because it is the default.
  • impl<T: DeserializeOwned> DeserializeWith<XmlFormat> for T which deserializes a T: DeserializeOwned using our XML parsing library. This impl, like XmlFormat itself, is only present if feature = "xml".

Future serialization formats can be implemented with new marker types and new blanket DeserializeWith<SomeOtherFormat> impls.

In order to "set" these marker type parameters (T, F) we have a few other changes:

  1. Response<ResponseBody, F> (for any F) has an into_typed::<T, F2>() method that transforms the response from a raw "untyped" response into a fully typed Response<T, F>, using the type and format specified (usually via inference).
  2. Transport/Policy send methods are parameterized with T: DeserializeWith<F>, F: Format and call into_typed automatically. Calling Response<ResponseBody, DefaultFormat>::into_typed::<ResponseBody, DefaultFormat>() (which would happen when a service client intends to return a bare response) is essentially a no-op. I'll do some investigation to see how well the compiler optimizes this out. Since the only place we use T and F are in PhantomData, my hope is it optimizes quite well.

These changes may mean that the emitter doesn't have to change, for service client methods that are satisfied with the default format. I'm not quite sure about that yet.

I expect this PR to fail CI because I haven't updated anything outside typespec_client_core. The main purpose here is to give folks a chance to review the APIs above and make comments. Please feel free to do so!

@github-actions github-actions bot added the Azure.Core The azure_core crate label May 3, 2025
@analogrelay analogrelay changed the title Ashleyst/1831 model format trait Replace Model trait with Format trait (applied to Response instead) May 5, 2025
@analogrelay
Copy link
Member Author

I've now updated the hand-written crates in this repo, but need to consider the best way to update generated crates. We'll need to update the emitter, but I could also update the generated code here right away. I'm going to do that in a separate commit so that I can confirm it works and then we can decide whether to keep it or not. That commit will also serve as a clear guide for how we need to update the emitter.

@analogrelay
Copy link
Member Author

Calling Response<ResponseBody, DefaultFormat>::into_typed::<ResponseBody, DefaultFormat>() (which would happen when a service client intends to return a bare response) is essentially a no-op

Some confirmation here: I ran a simple sample through the Playground that checked the assembly of a function that consumed its argument, copied all the non-zero-size fields to a new version of the struct, and changed zero-sized values (PhantomData). If you force that function to be non-inlined, it's a complete no-op function consisting only of the prologue/epilogue. If you allow it to be inlined, it disappears completely.

.map(|r| r.with_model_type())
}

pub async fn send_format<T, F>(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this because most cases will want to use the DefaultFormat. However, when a custom format is needed, the syntax gets clunky, because you'll have to call .with_format() on the result, but you can't do that until after you await. This results in having to write Ok(self.pipeline.send(...).await?.with_format()) which felt clunky, because with_format is infallible but send is fallible. It works just fine, but if I could bundle it up into a single send_format call, then methods that create XML-formatted responses would follow a similar pattern as those that create JSON-formatted ones

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep in mind, though, that with #2073, most clients will do this...which are generated. So that should be okay.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe send_with_format. Makes a little more sense, IMO, and follows with our "with" naming conventions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming is messy, and I might just drop the facade here and have a single send call. send_with_format was the first name I used but I felt it implied it was talking about the Request format. I don't like send_format either though...

@@ -82,11 +84,22 @@ impl Pipeline {
&self,
ctx: &Context<'_>,
request: &mut Request,
) -> crate::Result<Response<T>> {
) -> crate::Result<Response<T, DefaultFormat>> {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might hope we could write this method as pub async fn send<T, F = DefaultFormat>(...) -> Result<Response<T, F>>, which would allow the user to optionally specify a format. Alas, type parameters in functions cannot have defaults, because it messes with type inference (details in rust-lang/rust#36887)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason this "hard-codes" DefaultFormat is that if we don't specify a format, you can't write code like this:

pub async fn something(&self) -> azure_core::Result<SomeModelType> {
    //... prep the request
    self.pipeline.send(&ctx, &mut request).await?.into_body().await
}

Because you never name the Response type, the format goes un-inferred and you get an error. Instead, you'd have to write this:

pub async fn something(&self) -> azure_core::Result<SomeModelType> {
    //... prep the request
    self.pipeline.send::<SomeModelType, DefaultFormat>(&ctx, &mut request).await?.into_body().await
}

Now, if we're OK with that, I'm open to going back to one send method. I figured it was safe for send to default to the default format since the format of a response can be changed at zero cost using Response::with_format. I also included the below send_format short-cut method to do that in one call.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we move the format to the client, since enums and structs can have default typeparam values? The vast majority of clients will only deal with one format anyway - and almost always JSON. The worst case scenario is that if a caller is wanting JSON and XML both from a service, they have to use separate clients. Doesn't seem all that bad given we can share (via clone) options and all that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relatedly, rather than a "DefaultFormat", can we just be explicit? No guesswork. The "default" for 99% of our clients would be JsonFormat.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's an interesting idea. So, we'd make Pipeline take a type parameter F: Format? I think that would be OK. The way I see it, there are three scenarios::

  1. All service client methods can be deserialized using the same format - They create a Pipeline<JsonFormat> or Pipeline<XmlFormat> and go from there.
  2. Most service client methods can be deserialized using a single format, but some need a separate format - They can still create a Pipeline for their most common format and use Response::with_format to change the format of their responses.
  3. A service client has basically an even split of methods each using different formats - They can create several Pipeline instances. Clunky, but works fine.

Of course, scenario 1 is by far the most common IMO, especially since both formats will handle Response<ResponseBody> (raw bytes) and Response<()> (empty content) correctly.

I think I like it, I'll work that up.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It works, but has a few quirks that are worth pointing out (I'll push my commit too so you can take a look).

  • We have to define both typespec_client_core::http::Pipeline and azure_core::http::Pipeline as generic, but that's fine since users should still only interact with the azure_core one.
  • Setting a default does work: pub struct Pipeline<F: Format = Json>. However, it's a little quirky.
  • Default type parameters and constructors don't quite work well together. While a field labelled pipeline: Pipeline does get treated by the compiler as pipeline: Pipeline<JsonFormat> that is not true if the type is used in method call position: let pipeline = Pipeline::new(...) (with no specified type parameters) yields an error saying a type annotation is needed 1

Footnotes

  1. https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=59c5f013ad5856ba13de8dfcc102a1ff

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then the only other thing is that every Response said pipeline produces, even those with () or ResponseBody as the payload type, requires spelling out the whole thing, including the format type: Response<(), XmlFormat> or Response<ResponseBody, XmlFormat>

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that a client can support JSON or XML but not both?

Copy link
Member Author

@analogrelay analogrelay May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this approach, a Pipeline can only support one format, but nothing prevents a client from holding multiple Pipeline<F> instances with different Fs. The policies are held in an Arc so they can be safely shared.

@analogrelay
Copy link
Member Author

Opening this up for review. The build failure appears to be issues with APIView

Copy link
Member

@heaths heaths left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm liking this. There's a few discussion points, but I think it's really close.

.map(|r| r.with_model_type())
}

pub async fn send_format<T, F>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe send_with_format. Makes a little more sense, IMO, and follows with our "with" naming conventions.

@analogrelay analogrelay force-pushed the ashleyst/1831-model-format-trait branch from 97373fa to c29e3f7 Compare May 8, 2025 21:21
Copy link
Member

@Pilchie Pilchie left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally LGTM, but I'll defer to @heaths .

Copy link
Member

@heaths heaths left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Barring my one question, this LGTM but the "request changes" is more about putting this on a feature branch. You'll need to rebase anyway, so if you want to do it, that might be faster/easier. Just preface the branch name with "feature/" and make sure to push it to the upstream remote so you can retarget this PR and we keep all this great context!


impl Pipeline {
impl<F: Format> Pipeline<F> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. Was this a necessary viral change? Though, it's congruent with our discussion offline about requiring separate clients for JSON, XML, or whatever future format we might support. @JeffreyRichter, thoughts? I can catch you up offline. Basically, the whole point of this PR is to simplify callers because the client knows what the format is, so there's no reason to pass that responsibility off to callers as we have been.

I guess what I wasn't expecting is that we couldn't do this only in client methods' impl, but is that even a practical concern? Would we have any scenarios where - using the same client instance - we want to send e.g., both JSON and XML? Seems incredibly unlikely, though not impossible. (Unbranding might be more of a concern.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you referring specifically to the trait bound on F? The type parameter is certainly viral though, as only the client itself knows what format to use for a Pipeline.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's discuss offline

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put something on our calendars, but we can chat about it as well if people would rather.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Azure.Core The azure_core crate
Projects
None yet
Development

Successfully merging this pull request may close these issues.

azure_core::Model derive macro doesn't infer Deserialize bounds in impl
5 participants