refit
refit copied to clipboard
[FEATURE Request] Deserialize `ActionResult<object>` impossible as it does not contain a constructor
Describe the bug
The benefit of using Refit is the ability to share API interface across applications. But due to limitations in both Text.Json and Newtonsoft.Json it is impossible to deserialize a json coming from an interface using ActionResult<object>
as return type because ActionResult<object>
does not have a parameterless constructor. IActionResult
works fine.
I'm not sure if this is possible to solve in Refit as it is due to Text.Json and Newtonsoft.Json limitations. But there should be some guidelines as to what to do as ActionResult<object>
is a very common return type of API endpoints.
- I realize that ActionResult is not meant for the client side, but to truly share interface across server and client it is necessary to include it in the interface file.
Steps To Reproduce
Unit test file
using Microsoft.AspNetCore.Mvc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using Refit;
using System;
using System.Threading.Tasks;
using FluentAssertions;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using Newtonsoft.Json.Serialization;
namespace RefitActionResult
{
[TestClass]
public class RefitActionResultTest
{
private WireMockServer _server = null!;
[TestInitialize]
public void SetUp()
{
_server = WireMockServer.Start();
}
[TestCleanup]
public void TearDown()
{
try
{
_server.Stop();
}
catch (OperationCanceledException)
{
// OK, we're done anyway.
}
}
[TestMethod]
public async Task DeserializeActionResult_Refit_Ok()
{
var scenarioName = "Deserialize minimal SiteConfigDto";
_server.Given(Request.Create().WithPath("/").UsingGet())
.InScenario(scenarioName)
.WillSetStateTo("Sync 1")
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithBody(JsonConvert.SerializeObject(new object() { }))
);
var simpleApi = RestService.For<IDemoApi>($"http://localhost:{_server.Ports[0]}",
new RefitSettings()
{
ContentSerializer = new NewtonsoftJsonContentSerializer(
new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }
)
}
);
var objectFromApi = await simpleApi.Get();
objectFromApi.Should().BeEquivalentTo(new Object());
}
}
public interface IDemoApi
{
[Get("/")]
Task<ActionResult<object>> Get();
}
}
*.csproj file. The same error occurs in net6.0 as well
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.7" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.7" />
<PackageReference Include="coverlet.collector" Version="3.1.0" />
<PackageReference Include="Refit" Version="6.3.2" />
<PackageReference Include="Refit.Newtonsoft.Json" Version="6.2.16" />
<PackageReference Include="WireMock.Net" Version="1.4.35" />
</ItemGroup>
</Project>
Expected behavior
When using an interface with ActionResult
as return type, Refit needs to handle this somehow
Environment
- Version: Refit: 6.3.2 (probably all versions)
- DotNet: Net5.0 and Net6.0 (probably all dotnet)
Please close or move to elsewhere as you see fit
After fiddling around we first tried out hiding the interface method using the new
keyword:
public interface IDemoApiForClient : IDemoApi
{
[Get("/")]
new Task<object> Get();
}
- This works, but we had a large interface of types including lots of
IActionResults
which still failed, and this will result in lots of definition duplication
So then we ended up creating a Bash script in the nuget package (for the shared interface) which copied the server interface and removed all ActionResult<T>
and IActionResult
.
The bash script looks something like this:
#!/bin/bash
## Generate Cloud API Client Interface in order for clients to utilize the same interface as server
INTERFACE_NAME="ISharedConfigServerApi"
OUTPUT_NAME="ISharedConfigClientApi"
OUTPUT_FILE_NAME="${OUTPUT_NAME}.cs"
echo "/* AUTO GENERATED FILE. DO NOT EDIT */
$(cat "${INTERFACE_NAME}.cs")" > $OUTPUT_FILE_NAME
# Rename interface
sed -i "s/${INTERFACE_NAME}/${OUTPUT_NAME}/g" $OUTPUT_FILE_NAME
# Update interface <summary>x</summary> comment
sed -i "s/Only to be used on server side/Only to be used on client side/g" $OUTPUT_FILE_NAME
# Rewrite Task<IActionResult> -> Task
sed -i "s/Task<IActionResult>/Task/g" $OUTPUT_FILE_NAME
# Rewrite Task<ActionResult<T>> -> Task<T>
sed -i "s/Task<ActionResult<\([^>]*\)>>/Task<\1>/g" $OUTPUT_FILE_NAME
- This script is runned in Azure Devops before we call
dotnet restore
:
steps:
- task: Bash@3
displayName: "Generate client interface for CloudApi"
inputs:
workingDirectory: "$(Build.SourcesDirectory)/InRange.Config.Dto/CloudAPI/"
filePath: "$(Build.SourcesDirectory)/InRange.Config.Dto/CloudAPI/GenerateCloudApiClientInterface.sh"
failOnStderr: true
- task: DotNetCoreCLI@2
displayName: "Restore"
condition: succeeded()
inputs:
command: restore
- This is working quite well and removes all need for code duplication. Refit could probably implement something like this but using Source Generators
"...it is impossible to deserialize a json coming from an interface using ActionResult
"I realize that ActionResult is not meant for the client side, but to truly share interface across server and client it is necessary to include it in the interface file."
You're running into a problem here because it appears you're attempting to do something that you're really not supposed to. ActionResult
is a concern only for MVC and it doesn't really actually form part of the interface between the client and server despite being in the return type on the controller actions. If you return ActionResult<Foo>
then Foo
serialized as json is what goes over the wire and that's effectively the interface. So the corresponding Refit interface on the client side simply needs a return type of Task<ApiResponse<Foo>>
and everything works perfectly. Attempting to leak specific MVC implementation details across that boundary isn't appropriate and that's why you're running into problems attempting to do it.
Modern .NET APIs have great integration with swagger / open-api and can automatically generate the API schema based from the controller actions if you annotate them correctly as well. You can have the return type as IActionResult
and use the [ProducesResponseType]
attribute to tell swagger/open-api what the API interface actually is. See the docs: https://docs.microsoft.com/en-us/aspnet/core/web-api/action-return-types?view=aspnetcore-6.0#synchronous-action
ActionResult vs IActionResult