ASP.NET Core - 9. 고급 Web Service 기능
이번 Posting에서는 RESTful web service를 생성할 때 사용할 수 있는 고급 기능들에 대해서 알아보고자 합니다. 대략적으로 Entity Framework Core사용 시 관련 Data를 다루는 방법과 HTTP PATCH method 지원을 추가하는 방법, web service의 명세를 서술하기 위한 OpenAPI사용법 등에 관한 내용을 알아볼 것입니다.
1. 준비사항
해당 글의 예제는 이전글에서 사용하던 Project를 그대로 이어서 사용할 것입니다. 다만 추가적으로 Controllers folder에 SuppliersController.cs라는 file을 아래 내용으로 추가해 줍니다.
using Microsoft.AspNetCore.Mvc;
using MyWebApp.Models;
namespace MyWebApp.Controller
{
[ApiController]
[Route("api/[controller]")]
public class SuppliersController : ControllerBase
{
private NorthwindContext context;
public SuppliersController(NorthwindContext ctx)
{
context = ctx;
}
[HttpGet("{id}")]
public async Task<Suppliers?> GetSupplier(int id)
{
return await context.Suppliers.FindAsync(id);
}
}
}
예제는 ControllerBase class를 명시적으로 상속받도록 하고 있으며 NorthwindContext service의 의존성을 선언하고 있고 '/api/[controller]/{id}' URL에 대응되는 GetSupplier 이름의 Action을 정의하여 GET 요청을 처리하도록 하고 있습니다.
Project를 실행하여 /api/suppliers/1로 URL을 요청하여 아래와 같은 결과가 나오는지를 확인합니다.
결과는 URL의 마지막 segment값과 primary key와 일치하는 Supplier개체를 표시할 것입니다. 다만 이전 글에서 NULL값을 가진 속성은 무시하도록 Project를 설정하였으므로 응답은 Supplier data model class에 정의된 navigation속성은 포함되지 않을 것입니다.
2. 관계 Data 다루기
이번 Posting에서는 Entity Framework Core에 대해 상세하게 들어가지는 않을 것이지만 대부분의 Web Serivce에서 직면하게 될 Data질의에 관한 한가지 상황을 살펴보고자 합니다. 예제에서 언급한 Supplier data model class는 navigation속성을 포함하고 있는데 이 속성은 Include method를 사용할 때 Entity Framework Core가 database의 relationship에 따라 해당 속성에 값을 채울 수 있습니다.
[HttpGet("{id}")]
public async Task<Suppliers?> GetSupplier(int id)
{
return await context.Suppliers.Include(s => s.Products).FirstAsync(s => s.SupplierId == id);
}
Include method의 역활은 간단한데 Entity Framework Core가 Database의 relationship에 따라 관련된 Data를 읽어 들이도록 하는 것입니다. 따라서 이 경우 Include method는 Supplier class에서 정의된 Products라는 navigationt속성을 Select 하고 Supplier와 관련된 Product개체를 가져와 Products속성에 할당하게 됩니다.
Project를 실행하고 이전과 동일한 URL로 요청을 시도합니다. 하지만 해당 요청은 실패하게 될 것입니다.
결과를 보면 JSON serializer가 'object cycle'을 보고하고 있는데 이것은 response에 serialized가 될 수 있는 Data에서 순환참조가 존재한다는 것을 의미합니다.
예제의 Code를 보면 당장은 Include method의 사용이 왜 순환참조를 발생시켰는지 알아내기가 쉽지 않음을 느낄 수 있습니다. 이 것은 통상 Database로부터 읽은 Data의 양을 최소화하려고 시도하는 것이지만 ASP.NET Core application에서 사용되는 Entity Framework Core기능 자체에 의해 유발됩니다.
Entity Framework Core가 개체를 생성할때는 같은 database context에 의해 이미 생성된 개체를 사용하여 navigation속성을 채우게 됩니다. 이 것은 database context의 생명주기가 길고 시간이 지남에 따라 많은 요청을 수행하는 데 사용되는 desktop app과 같은 몇몇 Application에서는 유용하게 사용될 수 있는 기능이지만 각각의 요청에 대응해 새로운 context 개체가 생성되는 ASP.NET Core Application에서는 그 다지 유용한 방법이라고 할 수 없습니다.
Entity Framework Core는 Supplier와 관련 Product개체를 Database에 질의하고 Suppliers.Products속성에 할당하도록 하고 있는데 문제는 Products와 Suppliers개체가 서로를 참조하는 방식으로 구조가 이루어 졌다는데 있습니다. Controller Action method에서 Suppliers개체를 반환할 때 JSON serializer가 개체의 속성을 통해 작동하면서 자연스럽게 Products개체 참조를 따르게 되고 이때 각각의 Products에서도 역시 Supplier개체를 참조하게 되면서 결국 서로 간에 순환 참조를 이루게 되는 것입니다.
Entity Framework는 서로간에 순환 참조를 계속 따라가다 결국 제한된 최대치의 순환 깊이까지 도달하게 되고 마지막에 위와 같은 오류를 발생시키게 되는 것입니다.
(1) 관계 Data간 순환 참조 방지하기
Entity Framework Core가 Database로부터 읽어 들인 Data에서 순환 참조를 생성하는 것 자체는 막을 수 있는 방법이 없습니다. 이러한 상황에서 예외를 일으키지 않으려면 JSON serializer에게 순환 참조를 포함하지 않는 Data를 제공해야 하는데 이를 위한 가장 쉬운 방법은 Entity Framework Core에 의해 개체가 생성된 후, 그리고 개체를 serialize 하기 이전에 해당 개체를 변경히는 것입니다.
[HttpGet("{id}")]
public async Task<Suppliers?> GetSupplier(int id)
{
Suppliers suppliers = await context.Suppliers.Include(x => x.Products).FirstAsync(x => x.SupplierId== id);
if (suppliers.Products != null)
{
foreach(Products p in suppliers.Products)
p.Supplier = null;
}
return suppliers;
}
foreach문은 각 Products 개체의 suppliers속성을 null로 설정함으로서 순환 참조가 이루어지지 않도록 하고 있습니다. Project를 실행하여 /api/suppliers/1 URL을 다시 요청하게 되면 오류 없이 예상한 결과를 반환하게 될 것입니다.
3. HTTP PATCH Method 추가하기
Data type이 단순한 경우에는 PUT Method를 사용하여 기존에 존재하던 개체를 변경함으로서 개체의 편집 기능을 처리할 수 있습니다. 그런데 만약 예를 들어 Products에서 하나의 단일 속성만을 변경하고자 하는 경우에도 PUT Method를 사용하는 것은 다른 Products개체를 통해 모든 속성의 값을 포함해야 하므로 언뜻 무엇인가를 낭비하는 것처럼 느껴질 수 있습니다.
실제 작업을 처리하기에 너무 많은 속성이 정의되어 있거나 Client가 선택한 속성에 대한 값만을 전달받은 경우라면 전체 Data만으로 일부만을 변경하기 위한 작업을 수행하는 것은 그다지 쉬운 일이 아닙니다. 때문에 이러한 문제를 해결하는 방법은 PATCH method를 사용하여 개체 자체를 완전히 교체하기보다는 Web Service로 변경에 필요한 것만을 전달하는 것입니다.
(1) JSON Patch
ASP.NET Core는 기본적으로 JSON Patch 표준을 통한 작업을 지원함으로서 일정한 방식으로 변경사항을 특정할 수 있습니다. JSON Patch 표준을 사용하면 변경을 위한 복잡한 설정을 수행할 수 있지만 여기서는 단지 속성의 값을 바꾸는 것에만 초점을 맞추어 진행할 것입니다. JSON Patch standard에 관한 자세한 사항은 아래 link를 참고하시가 바랍니다.
RFC 6902: JavaScript Object Notation (JSON) Patch (rfc-editor.org)
JSON Patch standard에서 client는 HTTP Patch요청에서 Web Service JSON data를 다음과 같이 보낼 것입니다.
[
{ "op": "replace", "path": "/baz", "value": "boo" }
]
JSON Patch document는 필요한 동작과 값을 배열로서 표현하고 있는데 이때 동작은 op속성을 통해 동작의 유형을 특정하고 path속성을 통해 동작이 적용될 곳을 특정하고 있습니다.
대부분의 Application에서는 거의 모두 특정 속성의 값을 변경하기 위한 replace동작을 필요로 합니다. 예제에서의 JSON Patch document에서는 path속성을 위해 새로운 값을 설정하고 있는데 당연한 이야기지만 JSON Patch document에서 Supplier class에 정의된 속성이 언급되지 않으면 해당 속성은 변경되지 않을 것입니다.
(2) JSON Patch Package 설치와 설정
JSON Patch 지원기능은 Empty template을 통해 Project를 생성할때는 설치되지 않으므로 다음과 같이 Manage NuGet Packages를 통해 Microsoft.AspNetCore.Mvc.NewtonsoftJson을 설치해야 합니다.
Microsoft는 JSON Patch의 구현을 third-party Newtonsoft JSON.NET serializer에 의존해 구현합니다. Package설치가 완료되면 아래와 같이 Program.cs file을 수정하여 JSON.NET serializer를 사용할 수 있도록 설정합니다.
예제에서는 Microsoft.AspNetCore.Mvc.NewtonsoftJson version이 7.0으로 표시되어 있습니다. 글 작성 당시에는 .NET 7이 출시된 시기이며 Project가 .NET 7에 맞춰 생성된 것이라면 7.0을 설치해야 하지만 .NET 6.0이라면 Microsoft.AspNetCore.Mvc.NewtonsoftJson package도 6.0에 맞춰 설치해야 합니다.
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MyWebApp.Models;
using System.Text.Json;
//using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSqlServer<NorthwindContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
builder.Services.AddControllers().AddNewtonsoftJson();
builder.Services.AddControllers();
builder.Services.Configure<MvcNewtonsoftJsonOptions>(opts => {
opts.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore;
});
//builder.Services.Configure<JsonOptions>(opts => {
// opts.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
//});
var app = builder.Build();
AddNewtonsoftJson method는 기본 ASP. NET Core serializer를 대체하는.JSON.NET serializer를 사용하기 위한 것입니다. JSON.NET serializer는 options pattern을 통해 적용 가능한 MvcNewtonsoftJsonOptions이라는 자체 설정 class를 가지고 있는데 예제에서는 NullValueHandling값을 설정하였으며 이를 통해 serializer가 NULL속성을 가진 값은 무시할 수 있도록 하였습니다.
JSON.NET serializer에서 설정가능한 더 자세한 사항은 아래 Link를 참고하시기 바랍니다.
(3) Action Method 정의
위와 같이 필요한 Package를 설치하고 나면 SuppliersController.cs file에 PATCH method를 아래와 같이 추가합니다.
[HttpPatch("{id}")]
public async Task<Suppliers?> PatchSupplier(int id, JsonPatchDocument<Suppliers> patchDoc)
{
Suppliers? s = await context.Suppliers.FindAsync(id);
if (s != null)
{
patchDoc.ApplyTo(s);
await context.SaveChangesAsync();
}
return s;
}
action method를 HttpPatch로 Decorate함으로서 해당 method가 Patch HTTP 요청을 처리할 것임을 알려주고 있습니다. model binding기능은 JsonPatchDocument<T> method 매개변수를 통해 JSON Patch documen를 처리하는 데 사용되는데 JsonPatchDocument<T> class는 다시 ApplyTo Method를 정의하고 있으며 이를 통해 개체의 각 동작을 적용하게 됩니다. 예제에서 action method는 Database로부터 Suppliers개체를 전달받아 JSON PATCH를 적용하고 변경된 개체를 저장하고 있습니다.
Project를 실행한뒤 POSTMAN을 통해 /api/suppliers/29 URL을 지정하고 PATCH Method로 다음과 같이 JSON PATCH document를 전송합니다.
suppliers 개체와 함께 200OK응답이 돌아오면 다음과 같이 ID가 29인 suppliers개체의 city값이 바뀌어 있음을 확인할 수 있습니다.
4. Content Formatting
이제까지의 Web Service 예제는 JSON형태의 응답을 만들어 내지만 이것은 Action method가 생성할 수 있는 유일한 Data형식이 아닙니다. action결과로 만들어 낼 수 있는 Content형식으로는 4가지 요소에 의존하는데 첫 번째는 client가 수용할 수 있는 것, 두 번째 Application이 만들어 낼 수 있는 것, 세 번째 action method에서 특정된 content 정책, 네 번째 action method에서 반환된 type이 그것입니다. 어떻게 하면 모든 것을 알맞게 할 수 있는지를 알아내는 것은 어려울 수 있지만 좋은 소식은 기본적인 정책이 대부분의 Application에서 잘 작동하고 있다는 것이고 따라서 개발자는 변경이 필요하거나 예상한 형태로 결과를 얻지 못했을 때 그때 상황에서 돌아가는 방식을 이해하고 있으면 그것으로 충분한 것입니다.
(1) Default Content 정책
정확한 Content형식을 얻을 수 있는 가장 좋은 방법은 우선 client와 action method에 사용가능한 content의 형식에 어떠한 제한도 두지 않았을 때 무슨 일이 발생하고 있는지를 이해하는 것입니다. 이러한 상황에서 결과는 단순해지며 충분히 예측할 수 있게 됩니다.
- action method가 string을 반환하는 경우 string은 있는 그대로 client에 전송되고 응답의 Content-Type header는 text/plain으로 설정됩니다.
- 이외 int와 같은 type을 포함해 다른 모든 Data type에서 Data는 JSON으로 형식화 되며 응답의 Content-Type header는 application/json으로 설정됩니다.
문자열의 경우 특별한 대우를 받는데 그 이유는 단순 문자열을 JSON으로 encode하게 되면 예상치 못한 문제를 유발할 수 있기 때문입니다. 다른 단순한 Type, 예를 들어 C#의 int형 값 2와 같은 것을 encode 하는 경우 응답은 문자열로 인용되어 "2"와 같은 결과를 응답하게 됩니다. 그런데 문자열을 encode 하게 되면 이중으로 문자열이 인용되어 "hello"는 결국 ""hello""와 같이 될 수 있습니다. 모든 client가 이중으로 encode 된 응답에 잘 대처할 수 있는 것은 아니므로 text/plain형식을 사용하고 이러한 문제를 해결하기보다는 그냥 피하는 것이 Application을 더 신뢰할 수 있도록 만들 수 있습니다. 물론 소수의 application에서만 단순한 string값을 보냄으로써 이러한 문제를 맞닥뜨리는 것은 흔하지 않고 대부분의 많은 곳에서는 JSON형식의 개체를 전송하곤 합니다. 기본 정책을 확인해 보기 위해 ContentController.cs file을 Project의 Controller folder에 아래와 같이 추가해줍니다.
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MyWebApp.Models;
namespace MyWebApp.Controller
{
[ApiController]
[Route("/api/[controller]")]
public class ContentController : ControllerBase
{
private NorthwindContext context;
public ContentController(NorthwindContext dataContext)
{
context = dataContext;
}
[HttpGet("string")]
public string GetString() => "This is a string response";
[HttpGet("object")]
public async Task<Products> GetObject()
{
return await context.Products.FirstAsync();
}
}
}
예제의 Controller에서는 string과 개체 결과를 반환하는 Action method를 정의하고 있습니다. Project를 실행하고 POSTMAN을 통해 /api/content/string URL로 GET요청을 시도하여 GetString() method를 호출합니다.
위의 요청을 통해 응답으로 Content-Type header와 content의 결과를 확인할 수 있습니다. 이번에는 GetObject action method에서 처리될 /api/content/object URL을 요청합니다.
이번 응답은 JSON으로 encode된 것으로서 명확한 형식을 갖추고 있음을 알 수 있습니다.
(2) Content Negotiation
대부분 client는 요청에 Accept header를 포함하고 있습니다. 이를 통해 client는 자신이 응답으로 받을 일련의 형식을 특정하고 있으며 MIME type의 설정을 통해 그 형식을 표현하고 있습니다. 예를 들어 Microsoft Edge의 경우 요청 시 Accept header를 다음과 같이 설정될 수 있습니다.
text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 |
위 header의 값에 따라 Microsoft Edge는 HTML과 XHTML(HTML의 XML호환 문서형식), XML, WEBP 그리고 APNG등을 처리할 수 있다는 것을 나타내고 있습니다. Edge는 또한 application/signed-exchange를 지원하고 있음을 나타내고 있는데 이것은 서명 교환을 위해 사용되는 Data Type이며 Content가 전달된 방식과는 관계없이 원본의 유효성을 확인할 수 있도록 하는 것입니다.
header의 q는 상대적인 우선순위를 명시하는 것이며 지정하지 않을 경우 기본값은 1이 됩니다. 예제의 경우 application/xml의 q값은 0.9로 명시되어 있는데 이것은 Server에게 Edge가 XML Data를 받을 수는 있지만 HTML이나 XHTML의 처리를 더 선호한다고 말해주는 것입니다. 또한 예제에서 */*을 통해 Server에게 모든 형식의 Data를 받을 수 있음을 알려주고 있지만 그에 따른 q값은 지정된 유형의 값 중 가장 낮은 값으로 설정되어 있음을 알 수 있습니다. 여러 설정이 같이 존재하긴 하지만 Edge에 의해 제공된 Accept header는 Server에게 아래와 같은 정보를 보내는 것이라고 정리할 수 있습니다.
- Edge는 HTML 혹은 XHTML Data와 WEBP, APNG image를 받는 것을 선호하고 있습니다.
- 위의 형식을 제공해 줄 수 없다면 다음으로 가장 선호되는 형식은 XML 또는 서명교환입니다.
- 선호되는 형식 중 어떤 것도 가능하지 않다면 Edge는 그 외 다른 모든 형식을 수용할 것입니다.
이제까지의 설명을 통해 아마도 ASP.NET Core Application에서 만들어지는 형식을 Accept header의 설정을 변경함으로써 끌 수 있을 것이라고 생각할 수 있지만 사전에 필요한 몇몇 준비가 같이 되어 있어야 하므로 아직까지 이러한 방식은 잘 작동하지 않습니다.
Accept header가 바뀌면 어떠한 상황이 발생하는지를 확인하기 위해 Accept header를 POSTMAN을 통해 아래와 같이 고의적으로 변경하여 ASP.NET Core에게 client가 오로지 XML Data만 받을 수 있음을 명시합니다.
하지만 요청의 결과는 이전과 동일하게 JSON형식의 응답이 생성됨을 알 수 있습니다.
사실상 Accept header를 포함하는 것은 응답의 형식에 아무런 영향을 주지 않습니다. 심지어 ASP.NET Core application이 특정하지 않은 형식을 client에게 보내는 경우에도 마찬가지입니다. 문제는 기본적으로 MVC Framework가 오로지 JSON만을 사용하도록 설정된다는 것입니다. 오류를 반환하기보다는 MVC Framework는 비록 요청의 Accept header에 지정된 형식이 아니라 하더라도 client가 JSON Data를 처리할 수 있을 것이라는 것을 단정하고 JSON Data로의 처리를 진행하게 됩니다.
● XML 형식 사용하기
Application에서 응답할 수 있는 content의 형식에 대해서 협상이 가능하도록 하려면 Application을 구성하여 사용할 수 있는 형식을 선택할 수 있도록 해야 합니다. 비록 Web Application에서 JSON이 기본 형식이 되었지만 MVC Framework는 또한 Data를 XML로 encoding 하는 것을 지원하고 있습니다.
필요하다면 Microsoft.AspNetCore.Mvc.Formatters.OutputFormatter clsss로부터 상속하여 자체적인 content형식을 생성할 수도 있습니다. 하지만 Application에서 Data를 표현하기 위해 직접 형식 자체를 변경하는 것이 그다지 유용한 방법은 아니며 이것이 필요한 경우도 매우 드물다고 할 수 있습니다. 게다가 가장 일반적인 형식인 JSON과 XML은 기본적으로 이미 잘 구현되어 있습니다.
builder.Services.AddSqlServer<NorthwindContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
builder.Services.AddControllers().AddNewtonsoftJson().AddXmlDataContractSerializerFormatters();
builder.Services.Configure<MvcNewtonsoftJsonOptions>(opts => {
opts.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore;
});
XML Serializer에는 몇 가지 제한이 있는데 이 중에서 Entity Framework Core탐색 속성은 interface를 통해 정의되었기 때문에 이를 통한 처리가 불가능하다는 점도 포함됩니다. 예제는 serialize 가능한 개체를 생성하기 위해 ContentController.cs에서 GetObject method를 아래와 같이 변경하였습니다. 이때 이전에 project에 정의했던 ProductBinding을 재사용하였습니다.
[HttpGet("object")]
public async Task<ProductsBinding> GetObject()
{
Products p = await context.Products.FirstAsync();
return new ProductsBinding()
{
ProductName = p.ProductName,
UnitPrice = p.UnitPrice ?? 0
};
}
MVC Framework가 JSON형식만 가능했을 때에는 JSON형식의 응답을 encode 할 수밖에 없었습니다. 하지만 위와 같이 Application에 XML content형식을 적용함으로써 선택적으로 JSON과는 다른 형식의 응답을 가져올 수 있게 되었습니다. project를 실행하고 POSTMAN을 통해 이전과 동일하게 XML Data를 재요청하면 아래와 같은 결과를 볼 수 있게 됩니다.
● Accept Header 완벽히 준수하기
MVC Framework는 Accept Header가 모든 형식을 표현하는 */*을 포함하고 있다면 항상 JSON형식을 사용합니다. 심지어는 더 높은 우선권을 가지는 다른 지원되는 형식이 있다고 하더라도 여전히 JSON형식을 사용하는데 다소 혼란스러울 수 있지만 이것은 Browser로부터의 요청을 일관적으로 처리할 수 있도록 의도적으로 적용된 독특한 기능입니다. POSTMAN을 통해 다음과 같은 방법으로 요청을 시도합니다. 이 요청은 Accept header를 통해 XML을 요구하지만 XML이 불가능한 경우 그 외 다른 모든 형식이 적용될 수 있도록 하는 값을 포함하고 있습니다.
위 예제에서는 비록 Accept header를 통해 MVC Framework에게 client가 XML을 선호한다고 알려주고 있지만 '*/*'의 존재로 인해 JSON응답이 반환되는 현상을 볼 수 있습니다. 문제는 'img/png'처럼 사용자가 요청한 형식으로 MVC Framework가 생성하는데 필요한 설정이 되지 않은 경우에도 또한 JSON응답이 사용되다는 것입니다.
사실상 위 예제와 같은 모든 상황에서 MVC Framework는 JSON Data형식의 응답을 반환하고 있는데 client입장에서는 이러한 동작을 전혀 예상하지 못할 수 있습니다. 아래 예제의 2개 설정은 client에 의해 전송된 Accept 설정을 MVC Framework가 준수할 수 있도록 하기 위한 것으로 기본 설정에 의한 JSON Data를 반환하지 않도록 합니다.
builder.Services.Configure<MvcOptions>(opts => {
opts.RespectBrowserAcceptHeader = true;
opts.ReturnHttpNotAcceptable = true;
});
var app = builder.Build();
예제에서 option pattern은 MvcOptions개체를 설정하는 데 사용되었는데 여기서 RespectBrowserAcceptHeader를 true로 설정하는 것은 Accept header가 */*을 포함하고 있을 때 JSON형식의 응답으로 대체되지 않도록 하기 위한 것입니다. 또한 ReturnHttpNotAcceptable 속성의 true설정 역시 client가 지원되지 않는 Data형식을 요청할 때 JSON형식의 응답으로 대체되지 않도록 합니다.
Project를 다시 실행하고 POSTMAN에서는 Accept header에 이전에 전송했었던 'application/ xml,*/*;q=0.8'값을 다시 전송합니다. 그러면 JSON응답이 돌아오는 대신 XML응답이 생성됨을 알 수 있습니다.
이번에는 Accept header의 값으로 'img/png'를 다시 지정하여 요청을 시도합니다.
이번에는 HTTP 응답으로 '406 Not Acceptable'을 보게 됩니다. 406 상태 code는 client가 처리할 수 있는 형식과 MVC Framework가 생성할 수 있는 형식 사이에 일치되는 형식이 없음을 나타내는 것으로 결과적으로는 client가 자신이 처리할 수 없는 Data형식은 더 이상 수신받지 않게 됩니다.
(3) Action Result 형식 특정하기
MVC Framework가 Action method result에 사용할 수 있는 Data형식은 Produces attribute를 사용해 특정될 수 있습니다. 여기서 Produces는 attribute가 요청과 응답을 바꿀 수 있도록 하는 filter의 하나의 예가 될 수 있습니다.
[HttpGet("object")]
[Produces("application/json")]
public async Task<ProductsBinding> GetObject()
{
Products p = await context.Products.FirstAsync();
return new ProductsBinding()
{
ProductName = p.ProductName,
UnitPrice = p.UnitPrice ?? 0
};
}
하나이상의 유형을 지정할 수 있는 attribute의 매개변수는 action으로부터 응답에 사용될 수 있는 형식을 특정할 수 있는데 예제에서 Produces attribute는 MVC Framework가 Accept header를 처리할 때 고려할 수 있는 유형을 제한하고 있습니다. Produces attribute의 적용 효과를 확인해 보기 위해 POSTMAN에서 Accept header를 'application/ xml,application/json;q=0.8'로 지정하고 /api/content/object URL을 요청합니다.
예제에서 Accept header는 MVC Framework에게 client는 XML Data를 선호하긴 하지만 JSON 역시 받아들일 수 있음을 알려주고 있습니다. 하지만 설정한 Produces attribute는 GetObject action method의 Data형식으로는 가능하지 않으므로 JSON serializer가 응답을 생성될 수 있도록 처리됩니다.
(4) URL을 통한 Data유형 요청하기
경우에 따라 Accept header는 client 측 개발자의 제어 하에 있지 않을 수 있습니다. 이런 경우에는 URL을 통해 응답의 형식을 요청할 수 있도록 하는 것이 도움이 될 수 있습니다. 이 기능은 action method에서 FormatFilter attribute를 적용하고 action method의 route에 format segment 변수를 적용함으로써 사용할 수 있습니다.
[HttpGet("object/{format?}")]
[FormatFilter]
[Produces("application/json", "application/xml")]
public async Task<ProductsBinding> GetObject()
{
Products p = await context.Products.FirstAsync();
return new ProductsBinding()
{
ProductName = p.ProductName,
UnitPrice = p.UnitPrice ?? 0
};
}
FormatFilter attribute는 요청과 응답을 변경할 수 있는 filter의 한 예로서 예제에서 해당 filter는 format segment의 값을 요청과 일치하는 route로부터 가져와 client로 보내진 Accept header를 재정의는데 사용합니다. 또한 Produces attribute를 통해 Data유형의 범위를 더 넓게 지정(그래 봐야 2개밖에 안되지만)함으로써 action method는 JSON과 XML응답을 반환할 수 있게 되었습니다.
Application에서 지원하는 각 Data형식은 XML을 위한 xml과 JSON을 위한 json이라는 약어를 가지고 있습니다. 따라서 action method가 이들 약어 중 하나를 포함하는 URL을 통해 지정될 때 Accept header는 무시되고 지정된 형식이 사용될 것입니다. attribute의 적용 결과를 확인해 보기 위해 project를 실행하고 POSTMAN에서 /api/content/object/xml과 /api/content/object/json을 차례로 요청해 봅니다.
(5) Action Method에서 수신한 형식 제한하기
대부분의 content형식 결정은 ASP.NET Core Application이 client에게 보내는 Data형식에 초점을 맞춥니다. 하지만 결과를 처리하는 동일한 serializer는 client가 요청 body를 통해서 보내는 Data를 역직렬 화하는 데 사용됩니다. 역직렬화 처리는 자동으로 일어나며 대부분의 Application에서는 그 들이 전송하기로 설정된 모든 형식의 Data를 기꺼이 받아들입니다. 예제는 JSON과 XML을 보내도록 설정되었는데 이 것은 client가 JSON을 보내고 XML data를 요청할 수도 있다는 것을 의미하기도 합니다.
Consumes attribute는 action method에 적용되어 처리할 형식을 아래와 같이 제한할 수 있습니다.
[HttpPost]
[Consumes("application/json")]
public string SaveProductJson(ProductsBinding product)
{
return $"JSON: {product.ProductName}";
}
[HttpPost]
[Consumes("application/xml")]
public string SaveProductXml(ProductsBinding product)
{
return $"XML: {product.ProductName}";
}
새롭게 추가된 action method에는 Consumes attribute가 적용되어 있으며 이를 통해 각각 처리할 수 있는 Data형식을 제한하고 있습니다. 또한 예제에서는 Attribute의 결합을 통해 'application/json'값의 Content-Type header를 가진 HTTP POST 요청이 SaveProductJson action method에 의해 처리될 수 있도록 하고 있으며 Content-Type header가 'application/xml'인 HTTP POST요청이 SaveProductXml action method에 의해 처리될 수 있도록 하고 있습니다.
Project를 실행하고 POSTMAN에서 Content-Type을 'application/json'으로 설정하고 Method를 POST로 지정해 다음과 같이 api/content URL요청을 시도해 봅니다.
요청은 자동적으로 action method에 정확히 route 되어 설정한 형식으로 응답을 수행하게 됩니다. 이번에는 ContentType의 설정을 'application/xml'으로만 변경하고 POST method로 XML data를 아래와 같이 요청합니다.
요청은 SaveProductXml action method로 route되어 위와 같은 응답을 생성하게 됩니다. 하지만 만약 Content-Type header가 application에서 지원하지 않는 형식으로 설정되어 요청이 들어오게 되면 MVC Framework는 415 - Unsupported Media Type을 응답을 전송하게 됩니다.
5. Web Service 탐색과 문서화
Web Service와 client 측에 대한 개발을 모두 담당하는 경우 각 action의 목적과 결과를 분명히 정의하면서 거의 동시에 작업이 이루어질 것입니다. 그런데 만약 Web Service가 제삼자 개발자에 의해 사용될 수 있다면 그런 개발자들에게 Web Service를 어떻게 사용할 수 있을지를 설명하는 문서화를 제공해줄 필요가 있고 이때 Swagger라고 알려진 OpenAPI 명세를 통해 다른 개발자들로 하여금 해당 명세의 사용방법을 이해하기 위한 정보를 제공하고 Program적으로 사용될 수 있도록 지원해 줄 수 있습니다.
(1) Action 충돌 처리하기
OpenAPI discovery process는 HTTP Method의 URL Pattern의 고유한 결합을 필요로 합니다. process는 Consumes attribute는 지원하지 않으므로 ContentController에서 XML Data와 JSON을 전달받는 개별적인 action을 삭제해줄 필요가 있습니다.
[HttpPost]
[Consumes("application/json")]
public string SaveProductJson(ProductsBinding product)
{
return $"JSON: {product.ProductName}";
}
//[HttpPost]
//[Consumes("application/xml")]
//public string SaveProductXml(ProductsBinding product)
//{
// return $"XML: {product.ProductName}";
//}
위와 같이 action method를 주석 처리함으로써 결과적으로 남아있는 각 action method는 고유한 HTTP과 URL의 고유한 조합을 가질 수 있게 되었습니다.
(2) Swashbuckle 설치 및 설정
Swashbuckle은 OpenAPI specification 중 가장 널리 사용되는 ASP.NET Core 구현체이며 자동적으로 ASP.NET Core Application에 있는 Web Service의 상세를 생성합니다. Package는 또한 Web service를 직접 확인하고 TEST 할 수 있는 자체적인 도구를 포함하고 있습니다.
Package는 다음과 같이 NuGet Packages에서 'Swashbuckle.AspNetCore'을 검색해 설치할 수 있습니다.
Package를 설치하고 나면 Swashbuckle에서 제공하는 Service와 Middleware를 Program.cs에서 아래와 같이 추가합니다.
builder.Services.Configure<MvcOptions>(opts => {
opts.RespectBrowserAcceptHeader = true;
opts.ReturnHttpNotAcceptable = true;
});
builder.Services.AddSwaggerGen(c => {
c.SwaggerDoc("v1", new OpenApiInfo { Title = "myWebApp", Version = "v1" });
});
var app = builder.Build();
app.MapControllers();
app.UseMiddleware<MyWebApp.TestMiddleware>();
app.MapGet("/", () => "Hello World!");
app.UseSwagger();
app.UseSwaggerUI(options => {
options.SwaggerEndpoint("/swagger/v1/swagger.json", "myWebApp");
});
app.Run();
예제에서는 2개의 기능을 설정했는데 하나는 Application에 포함하고 있는 Web Service의 OPEN API 명세를 생성하는 것입니다. Project를 실행한 뒤 Web browser를 통해 /swagger/v1/swagger.json URL을 요청하면 실제 생성된 결과를 확인해 볼 수 있습니다.
결과는 장황해 보이지만 각각 수신하게 될 Data의 상세와 생성할 응답의 범위와 함께 Web Service Controller가 지원하는 해당 URL을 확인해 볼 수 있습니다.
두 번째 기능은 Web Service에서 OPEN API 명세를 직접 사용할 수 있는 UI와 이를 좀 더 쉽게 이해할 수 있는 정보, 그리고 각 action에 대해 TEST 할 수 있는 기능을 제공하는 것입니다. Project를 실행해 /swagger를 요청하면 다음과 같은 interface를 확인해 볼 수 있습니다.
위 화면에서 각각의 action을 확장해 보면 요청에 예상되는 Data와 client가 예상 가능한 다양한 응답에 대한 상세를 포함하여 좀 더 자세한 정보를 확인해 볼 수 있습니다.
(3) API 명세 조정하기
API discovery process에 대한 의존은 Web Service를 제대로 파악하지 못한 결과를 만들어 낼 수 있습니다. 이러한 예를 /api/products/{id} URL pattern의 GET 요청에 대해 서술하고 있는 Products section의 항목에서 확인해 볼 수 있습니다. 이 item을 확장하고 응답 section을 확인해 보면 명세서상으로는 단지 상태 code만 반환되고 있다는 것을 알 수 있습니다.
API discovery process는 action method에서 생성될 수 있는 응답을 가정하여 명세를 만들 뿐 실제 발생할 수 있는 상황을 그대로 반영하지 않습니다. 이 경우 ProductsController의 GetProduct action method는 discovery process가 감지하지 못한 다른 응답을 반환할 수 있습니다.
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id)
{
Products? p = await _context.Products.FindAsync(id);
if (p == null)
return NotFound();
return Ok(p);
}
이러한 문제는 다른 개발자가 OpenAPI data를 사용해 Web Service의 client를 구현하고자 하는 경우 action이 Database에서 해당 개체를 찾을 수 없을 때 404 - Not Found응답을 반환하게 된다는 것을 예상할 수 없게 합니다.
● API Analyzer 동작하기
ASP.NET Core는 Web Service Controller를 분석하고 위에서 설명한 것과 같은 문제를 보여주는 analyzer를 포함하고 있습니다. analyzer를 사용하기 위해서는 해당 Project의 project file(csproj)에 아래와 같은 구문을 추가해야 합니다.(Visual Studio의 경우 Project explorer에서 Project를 mouse 오른쪽 button으로 눌러 'Edit Project File'을 선택하면 해당 File을 바로 편집할 수 있습니다.)
<PropertyGroup>
<IncludeOpenAPIAnalyzers>true</IncludeOpenAPIAnalyzers>
</PropertyGroup>
Project file에 위 구문을 추가하고 저장하게 되면 해당 위의 GetProduct action method의 NotFound()에는 다음과 같은 경고를 표시하게 됩니다.
● Action Method 결과 유형 선언하기
analyzer에 의해 감지된 문제를 수정하기 위해서 ProducesResponseType attribute를 아래와 같이 action method가 생성할 수 있는 응답의 유형을 각각 선언하는 데 사용합니다.
[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProduct(int id)
{
Products? p = await _context.Products.FindAsync(id);
if (p == null)
return NotFound();
return Ok(p);
}
Project를 실행하고 /swagger URL을 다시 요청해 보면 404 응답이 반영된 action method의 명세를 볼 수 있습니다.