-
Notifications
You must be signed in to change notification settings - Fork 283
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
base: main
Are you sure you want to change the base?
Replace Model
trait with Format
trait (applied to Response
instead)
#2559
Conversation
Model
trait with Format
trait (applied to Response
instead)
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. |
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>( |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>> { |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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::
- All service client methods can be deserialized using the same format - They create a
Pipeline<JsonFormat>
orPipeline<XmlFormat>
and go from there. - 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 useResponse::with_format
to change the format of their responses. - 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.
There was a problem hiding this comment.
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
andazure_core::http::Pipeline
as generic, but that's fine since users should still only interact with theazure_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 aspipeline: 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
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 F
s. The policies are held in an Arc
so they can be safely shared.
Opening this up for review. The build failure appears to be issues with APIView |
There was a problem hiding this 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>( |
There was a problem hiding this comment.
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.
97373fa
to
c29e3f7
Compare
There was a problem hiding this 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 .
There was a problem hiding this 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> { |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's discuss offline
There was a problem hiding this comment.
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.
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 howResponse<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 forinto_body()
calls, the type of the model, andF
is a marker type, implementing a marker traitFormat
, that specifies howT
is "formatted" (i.e. JSON, XML, etc.).There are two formats in
typespec_client_core
:DefaultFormat
is the "default" format. It deserializesT
using some default rules that prefer JSON formatting (see below),XmlFormat
is available when featurexml
is enabled, it deserializesT
using XML.The
Format
trait used byF
doesn't actually provide any methods. Instead, there is a separate trait,DeserializeWith<F: Format>
that is applied toT
. This allows us to provide implementations that vary based on BOTH the target typeT
and the desired formatF
. We provide the following implementations intypespec_client_core
:impl<F> DeserializeWith<F> for ResponseBody
which "deserializes" aResponseBody
from aResponseBody
by just returning it. This allowsResponse<ResponseBody, F>::into_body()
return the rawResponseBody
. This happens no matter what format is specified, soResponse<ResponseBody, XmlFormat>::into_body()
still returns a raw unparsed XML buffer.impl<T: DeserializeOwned> DeserializeWith<DefaultFormat> for T
which deserializes aT: DeserializeOwned
usingserde_json
, IF the response was marked withDefaultFormat
. This impl is only present iffeature = "json"
. TheDefaultFormat
type exists unconditionally though, because it is the default.impl<T: DeserializeOwned> DeserializeWith<XmlFormat> for T
which deserializes aT: DeserializeOwned
using our XML parsing library. This impl, likeXmlFormat
itself, is only present iffeature = "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:Response<ResponseBody, F>
(for anyF
) has aninto_typed::<T, F2>()
method that transforms the response from a raw "untyped" response into a fully typedResponse<T, F>
, using the type and format specified (usually via inference).send
methods are parameterized withT: DeserializeWith<F>, F: Format
and callinto_typed
automatically. CallingResponse<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 useT
andF
are inPhantomData
, 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!