Skip to content

Commit

Permalink
Added support for FormUrlEncoded
Browse files Browse the repository at this point in the history
  • Loading branch information
jezzsantos committed Nov 28, 2024
1 parent f1be55d commit 8cf5d4e
Show file tree
Hide file tree
Showing 33 changed files with 627 additions and 111 deletions.
2 changes: 1 addition & 1 deletion docs/decisions/0051-api-framework-binding.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ It is possible to provide our own customer binding, by providing a `BindAsync` m
`Custom Binding`

- We would prefer is developers did not have to use binding attributes like `[FromRoute]`, `[FromQuery]`, `[FromBody]` and `[FromForm]`.
- We want to support both `application/json`, or in `multipart/form-data`.
- We want to support both `application/json`, and `multipart/form-data` and `application/form-urlencoded`.
- We can make this easier for the developer

### Pros and Cons of the Options
Expand Down
12 changes: 11 additions & 1 deletion src/ApiHost1/Api/TestingOnly/TestingWebApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,17 @@ public async Task<ApiResult<string, StringMessageTestingOnlyResponse>> OpenApiGe
}

public async Task<ApiPostResult<string, StringMessageTestingOnlyResponse>> OpenApiMultiPartForm(
OpenApiPostMultiPartFormTestingOnlyRequest request, CancellationToken cancellationToken)
OpenApiPostMultiPartFormDataTestingOnlyRequest request, CancellationToken cancellationToken)
{
await Task.CompletedTask;
return () =>
new PostResult<StringMessageTestingOnlyResponse>(
new StringMessageTestingOnlyResponse { Message = $"amessage{request.RequiredField}" },
"alocation");
}

public async Task<ApiPostResult<string, StringMessageTestingOnlyResponse>> OpenApiFormUrlEncoded(
OpenApiPostFormUrlEncodedTestingOnlyRequest request, CancellationToken cancellationToken)
{
await Task.CompletedTask;
return () =>
Expand Down
128 changes: 121 additions & 7 deletions src/Infrastructure.Web.Api.Common.UnitTests/WebRequestSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public GivenAJsonRequest()
_serviceProvider.Setup(sp => sp.GetService(typeof(JsonSerializerOptions)))
.Returns(JsonSerializerOptions.Default);
}

[Fact]
public async Task WhenBindAsyncAndEmptyHttpRequest_ThenReturnsInstance()
{
Expand Down Expand Up @@ -101,7 +102,7 @@ public async Task WhenBindAsyncAndPropertiesInRouteValues_ThenReturnsInstance()
}

[Trait("Category", "Unit")]
public class GivenAMultiPartFormRequest
public class GivenAMultiPartFormDataRequest
{
[Fact]
public async Task WhenBindAsyncAndEmptyHttpRequest_ThenReturnsInstance()
Expand All @@ -114,7 +115,7 @@ public async Task WhenBindAsyncAndEmptyHttpRequest_ThenReturnsInstance()
}
};

var result = await TestMultiPartRequest.BindAsync(context, null!);
var result = await TestMultiPartFormDataDataRequest.BindAsync(context, null!);

result.Should().NotBeNull();
result!.Id.Should().BeNull();
Expand All @@ -140,7 +141,7 @@ public async Task WhenBindAsyncAndPropertiesInQueryString_ThenReturnsInstance()
}
};

var result = await TestMultiPartRequest.BindAsync(context, null!);
var result = await TestMultiPartFormDataDataRequest.BindAsync(context, null!);

result.Should().NotBeNull();
result!.Id.Should().Be("anid");
Expand All @@ -166,7 +167,7 @@ public async Task WhenBindAsyncAndPropertiesInRouteValues_ThenReturnsInstance()
}
};

var result = await TestMultiPartRequest.BindAsync(context, null!);
var result = await TestMultiPartFormDataDataRequest.BindAsync(context, null!);

result.Should().NotBeNull();
result!.Id.Should().Be("anid");
Expand All @@ -191,7 +192,107 @@ public async Task WhenBindAsyncAndPropertiesInForm_ThenReturnsInstance()
}
};

var result = await TestMultiPartRequest.BindAsync(context, null!);
var result = await TestMultiPartFormDataDataRequest.BindAsync(context, null!);

result.Should().NotBeNull();
result!.Id.Should().Be("anid");
result.ANumberProperty.Should().Be(999);
result.AStringProperty.Should().Be("avalue");
}
}

[Trait("Category", "Unit")]
public class GivenAMultiPartFormUrlEncodedRequest
{
[Fact]
public async Task WhenBindAsyncAndEmptyHttpRequest_ThenReturnsInstance()
{
var context = new DefaultHttpContext
{
Request =
{
Form = new FormCollection(new Dictionary<string, StringValues>())
}
};

var result = await TestMultiPartUlEncodedRequest.BindAsync(context, null!);

result.Should().NotBeNull();
result!.Id.Should().BeNull();
result.ANumberProperty.Should().Be(0);
result.AStringProperty.Should().BeNull();
}

[Fact]
public async Task WhenBindAsyncAndPropertiesInQueryString_ThenReturnsInstance()
{
var context = new DefaultHttpContext
{
Request =
{
ContentType = HttpConstants.ContentTypes.FormUrlEncoded,
Form = new FormCollection(new Dictionary<string, StringValues>()),
Query = new QueryCollection(new Dictionary<string, StringValues>
{
{ nameof(TestRequest.Id), "anid" },
{ nameof(TestRequest.ANumberProperty), "999" },
{ nameof(TestRequest.AStringProperty), "avalue" }
})
}
};

var result = await TestMultiPartUlEncodedRequest.BindAsync(context, null!);

result.Should().NotBeNull();
result!.Id.Should().Be("anid");
result.ANumberProperty.Should().Be(999);
result.AStringProperty.Should().Be("avalue");
}

[Fact]
public async Task WhenBindAsyncAndPropertiesInRouteValues_ThenReturnsInstance()
{
var context = new DefaultHttpContext
{
Request =
{
ContentType = HttpConstants.ContentTypes.FormUrlEncoded,
Form = new FormCollection(new Dictionary<string, StringValues>()),
RouteValues = new RouteValueDictionary
{
{ nameof(TestRequest.Id), "anid" },
{ nameof(TestRequest.ANumberProperty), "999" },
{ nameof(TestRequest.AStringProperty), "avalue" }
}
}
};

var result = await TestMultiPartUlEncodedRequest.BindAsync(context, null!);

result.Should().NotBeNull();
result!.Id.Should().Be("anid");
result.ANumberProperty.Should().Be(999);
result.AStringProperty.Should().Be("avalue");
}

[Fact]
public async Task WhenBindAsyncAndPropertiesInForm_ThenReturnsInstance()
{
var context = new DefaultHttpContext
{
Request =
{
ContentType = HttpConstants.ContentTypes.MultiPartFormData,
Form = new FormCollection(new Dictionary<string, StringValues>
{
{ nameof(TestRequest.Id), "anid" },
{ nameof(TestRequest.ANumberProperty), "999" },
{ nameof(TestRequest.AStringProperty), "avalue" }
})
}
};

var result = await TestMultiPartUlEncodedRequest.BindAsync(context, null!);

result.Should().NotBeNull();
result!.Id.Should().Be("anid");
Expand All @@ -201,9 +302,22 @@ public async Task WhenBindAsyncAndPropertiesInForm_ThenReturnsInstance()
}
}

[Route("/aroute", OperationMethod.Get)]
[Route("/aroute", OperationMethod.Post)]
[UsedImplicitly]
public class TestMultiPartFormDataDataRequest : WebRequest<TestMultiPartFormDataDataRequest, TestResponse>,
IHasMultipartFormData
{
public int ANumberProperty { get; set; }

public string? AStringProperty { get; set; }

public string? Id { get; set; }
}

[Route("/aroute", OperationMethod.Post)]
[UsedImplicitly]
public class TestMultiPartRequest : WebRequest<TestMultiPartRequest, TestResponse>, IHasMultipartForm
public class TestMultiPartUlEncodedRequest : WebRequest<TestMultiPartUlEncodedRequest, TestResponse>,
IHasFormUrlEncoded
{
public int ANumberProperty { get; set; }

Expand Down
72 changes: 64 additions & 8 deletions src/Infrastructure.Web.Api.IntegrationTests/ApiDocsSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using HtmlAgilityPack;
using Infrastructure.Hosting.Common;
using Infrastructure.Web.Api.Interfaces;
using Infrastructure.Web.Api.Operations.Shared.TestingOnly;
using Infrastructure.Web.Common;
using Infrastructure.Web.Hosting.Common.Auth;
using Infrastructure.Web.Hosting.Common.Documentation;
Expand Down Expand Up @@ -345,16 +346,17 @@ public GivenAPostMultiPartFormRequest(WebApiSetup<ApiHost1.Program> setup) : bas
}

[Fact]
public async Task WhenFetchOpenApi_ThenFieldsHaveDescriptions()
public async Task WhenFetchOpenApiForMultiPartForm_ThenFieldsHaveDescriptions()
{
var result = await HttpApi.GetAsync(WebConstants.SwaggerEndpointFormat.Format("v1"));

var openApi = new OpenApiStreamReader()
.Read(await result.Content.ReadAsStreamAsync(), out _);

var operation = openApi!.Paths["/testingonly/openapi/{Id}/binary"].Operations[OperationType.Post];
operation.Description.Should().Be("(request type: OpenApiPostMultiPartFormTestingOnlyRequest)");
operation.OperationId.Should().Be("OpenApiPostMultiPartFormTestingOnly");
var operation = openApi!.Paths["/testingonly/openapi/{Id}/form-data"].Operations[OperationType.Post];
operation.Description.Should()
.Be($"(request type: {nameof(OpenApiPostMultiPartFormDataTestingOnlyRequest)})");
operation.OperationId.Should().Be("OpenApiPostMultiPartFormDataTestingOnly");
operation.Parameters.Count.Should().Be(1);
operation.Parameters[0].Name.Should().Be("Id");
operation.Parameters[0].In.Should().Be(ParameterLocation.Path);
Expand All @@ -370,14 +372,14 @@ public async Task WhenFetchOpenApi_ThenFieldsHaveDescriptions()
}

[Fact]
public async Task WhenFetchOpenApi_ThenResponseHas201Response()
public async Task WhenFetchOpenApiForMultiPartForm_ThenResponseHas201Response()
{
var result = await HttpApi.GetAsync(WebConstants.SwaggerEndpointFormat.Format("v1"));

var openApi = new OpenApiStreamReader()
.Read(await result.Content.ReadAsStreamAsync(), out _);

var operation = openApi!.Paths["/testingonly/openapi/{Id}/binary"].Operations[OperationType.Post];
var operation = openApi!.Paths["/testingonly/openapi/{Id}/form-data"].Operations[OperationType.Post];
operation.Responses["201"].Description.Should().Be("Created");
operation.Responses["201"].Content.Count.Should().Be(2);
operation.Responses["201"].Content[HttpConstants.ContentTypes.Json].Schema.Reference.ReferenceV3.Should()
Expand All @@ -387,14 +389,68 @@ public async Task WhenFetchOpenApi_ThenResponseHas201Response()
}

[Fact]
public async Task WhenFetchOpenApi_ThenResponseHasGeneralErrorResponses()
public async Task WhenFetchOpenApiForMultiPartForm_ThenResponseHasGeneralErrorResponses()
{
var result = await HttpApi.GetAsync(WebConstants.SwaggerEndpointFormat.Format("v1"));

var openApi = new OpenApiStreamReader()
.Read(await result.Content.ReadAsStreamAsync(), out _);

var operation = openApi!.Paths["/testingonly/openapi/{Id}/form-data"].Operations[OperationType.Post];
operation.Responses.Count.Should().Be(10);
VerifyGeneralErrorResponses(operation.Responses);
}

[Fact]
public async Task WhenFetchOpenApiForFormUrlEncoded_ThenFieldsHaveDescriptions()
{
var result = await HttpApi.GetAsync(WebConstants.SwaggerEndpointFormat.Format("v1"));

var openApi = new OpenApiStreamReader()
.Read(await result.Content.ReadAsStreamAsync(), out _);

var operation = openApi!.Paths["/testingonly/openapi/{Id}/urlencoded"].Operations[OperationType.Post];
operation.Description.Should().Be($"(request type: {nameof(OpenApiPostFormUrlEncodedTestingOnlyRequest)})");
operation.OperationId.Should().Be("OpenApiPostFormUrlEncodedTestingOnly");
operation.Parameters.Count.Should().Be(1);
operation.Parameters[0].Name.Should().Be("Id");
operation.Parameters[0].In.Should().Be(ParameterLocation.Path);
var requestBody = operation.RequestBody;
requestBody.Content.Count.Should().Be(1);
requestBody.Content[HttpConstants.ContentTypes.FormUrlEncoded].Schema.Required.Should()
.BeEquivalentTo("requiredField");
var properties = requestBody.Content[HttpConstants.ContentTypes.FormUrlEncoded].Schema.Properties;
properties.Should().HaveCount(2);
properties["optionalField"].Type.Should().Be("string");
properties["requiredField"].Type.Should().Be("string");
}

[Fact]
public async Task WhenFetchOpenApiForFormUrlEncoded_ThenResponseHas201Response()
{
var result = await HttpApi.GetAsync(WebConstants.SwaggerEndpointFormat.Format("v1"));

var openApi = new OpenApiStreamReader()
.Read(await result.Content.ReadAsStreamAsync(), out _);

var operation = openApi!.Paths["/testingonly/openapi/{Id}/urlencoded"].Operations[OperationType.Post];
operation.Responses["201"].Description.Should().Be("Created");
operation.Responses["201"].Content.Count.Should().Be(2);
operation.Responses["201"].Content[HttpConstants.ContentTypes.Json].Schema.Reference.ReferenceV3.Should()
.Be("#/components/schemas/StringMessageTestingOnlyResponse");
operation.Responses["201"].Content[HttpConstants.ContentTypes.Xml].Schema.Reference.ReferenceV3.Should()
.Be("#/components/schemas/StringMessageTestingOnlyResponse");
}

[Fact]
public async Task WhenFetchOpenApiForFormUrlEncoded_ThenResponseHasGeneralErrorResponses()
{
var result = await HttpApi.GetAsync(WebConstants.SwaggerEndpointFormat.Format("v1"));

var openApi = new OpenApiStreamReader()
.Read(await result.Content.ReadAsStreamAsync(), out _);

var operation = openApi!.Paths["/testingonly/openapi/{Id}/binary"].Operations[OperationType.Post];
var operation = openApi!.Paths["/testingonly/openapi/{Id}/urlencoded"].Operations[OperationType.Post];
operation.Responses.Count.Should().Be(10);
VerifyGeneralErrorResponses(operation.Responses);
}
Expand Down
26 changes: 26 additions & 0 deletions src/Infrastructure.Web.Api.IntegrationTests/GeneralApiSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,32 @@ public async Task WhenPostWithEmptyBody_ThenReturns()
result.Content.Value.Message.Should().Be("amessage");
}

[Fact]
public async Task WhenPostWithFormData_ThenReturns()
{
var result = await Api.PostAsync(new OpenApiPostMultiPartFormDataTestingOnlyRequest
{
Id = "anid",
RequiredField = "avalue"
});

result.StatusCode.Should().Be(HttpStatusCode.Created);
result.Content.Value.Message.Should().Be("amessageavalue");
}

[Fact]
public async Task WhenPostWithUrlEncoded_ThenReturns()
{
var result = await Api.PostAsync(new OpenApiPostFormUrlEncodedTestingOnlyRequest
{
Id = "anid",
RequiredField = "avalue"
});

result.StatusCode.Should().Be(HttpStatusCode.Created);
result.Content.Value.Message.Should().Be("amessageavalue");
}

[Fact]
public async Task WhenPostWithRouteParamsAndEmptyBody_ThenReturns()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ namespace Infrastructure.Web.Api.Interfaces;
/// <summary>
/// A marker interface for requests that are expected to have a URL encoded form body
/// </summary>
public interface IHasUrlEncodedForm;
public interface IHasFormUrlEncoded;
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ namespace Infrastructure.Web.Api.Interfaces;
/// <summary>
/// A marker interface for requests that are expected to have a multipart form body
/// </summary>
public interface IHasMultipartForm;
public interface IHasMultipartFormData;
Loading

0 comments on commit 8cf5d4e

Please sign in to comment.