ASP.NET Web API 2에서 OData 쿼리 옵션 지원

작성자: Mike Wasson

코드 예제가 포함된 이 개요에서는 ASP.NET 4.x에 대한 ASP.NET Web API 2의 지원 OData 쿼리 옵션을 보여 줍니다.

OData는 OData 쿼리를 수정하는 데 사용할 수 있는 매개 변수를 정의합니다. 클라이언트는 요청 URI의 쿼리 문자열에서 이러한 매개 변수를 보냅니다. 예를 들어 결과를 정렬하기 위해 클라이언트는 $orderby 매개 변수를 사용합니다.

http://localhost/Products?$orderby=Name

OData 사양은 이러한 매개 변수 쿼리 옵션을 호출합니다. 프로젝트의 모든 Web API 컨트롤러에 대해 OData 쿼리 옵션을 사용하도록 설정할 수 있습니다. 컨트롤러는 OData 엔드포인트일 필요가 없습니다. 이렇게 하면 필터링 및 정렬과 같은 기능을 모든 Web API 애플리케이션에 추가할 수 있는 편리한 방법이 제공됩니다.

쿼리 옵션을 사용하도록 설정하기 전에 OData 보안 지침 항목을 읽어보세요.

OData 쿼리 옵션 사용

Web API는 다음 OData 쿼리 옵션을 지원합니다.

옵션 Description
$expand 관련 엔터티를 인라인으로 확장합니다.
$filter 부울 조건에 따라 결과를 필터링합니다.
$inlinecount 응답에 일치하는 엔터티의 총 수를 포함하도록 서버에 지시합니다. (서버 쪽 페이징에 유용합니다.)
$orderby 결과를 정렬합니다.
$select 응답에 포함할 속성을 선택합니다.
$skip 첫 번째 n개의 결과를 건너뜁니다.
$top 첫 번째 n 결과만 반환합니다.

OData 쿼리 옵션을 사용하려면 명시적으로 사용하도록 설정해야 합니다. 전체 애플리케이션에 대해 전역적으로 사용하도록 설정하거나 특정 컨트롤러 또는 특정 작업에 사용하도록 설정할 수 있습니다.

OData 쿼리 옵션을 전역적으로 사용하도록 설정하려면 시작 시 HttpConfiguration 클래스에서 EnableQuerySupport를 호출합니다.

public static void Register(HttpConfiguration config)
{
    // ...

    config.EnableQuerySupport();

    // ...
}

EnableQuerySupport 메서드를 사용하면 IQueryable 형식을 반환하는 모든 컨트롤러 작업에 대해 전역적으로 쿼리 옵션을 사용할 수 있습니다. 전체 애플리케이션에 대해 쿼리 옵션을 사용하도록 설정하지 않으려면 작업 메서드에 [Queryable] 특성을 추가하여 특정 컨트롤러 작업에 대해 쿼리 옵션을 사용하도록 설정할 수 있습니다.

public class ProductsController : ApiController
{
    [Queryable]
    IQueryable<Product> Get() {}
}

예제 쿼리

이 섹션에서는 OData 쿼리 옵션을 사용하여 가능한 쿼리 유형을 보여 줍니다. 쿼리 옵션에 대한 자세한 내용은 www.odata.org OData 설명서를 참조하세요.

$expand 및 $select 대한 자세한 내용은 ASP.NET Web API OData에서 $select, $expand 및 $value 사용을 참조하세요.

클라이언트 기반 페이징

큰 엔터티 집합의 경우 클라이언트는 결과 수를 제한할 수 있습니다. 예를 들어 클라이언트는 결과의 다음 페이지를 가져오기 위한 "다음" 링크를 사용하여 한 번에 10개의 항목을 표시할 수 있습니다. 이를 위해 클라이언트는 $top 및 $skip 옵션을 사용합니다.

http://localhost/Products?$top=10&$skip=20

$top 옵션은 반환할 최대 항목 수를 제공하고 $skip 옵션은 건너뛸 항목 수를 제공합니다. 이전 예제에서는 항목 21~30을 가져옵니다.

필터링

$filter 옵션을 사용하면 클라이언트가 부울 식을 적용하여 결과를 필터링할 수 있습니다. 필터 식은 매우 강력합니다. 논리 및 산술 연산자, 문자열 함수 및 날짜 함수가 포함됩니다.

범주가 "Toys"와 같은 모든 제품을 반환합니다. http://localhost/Products?$filter=Category eq 'Toys'
가격이 10 미만인 모든 제품을 반환합니다. http://localhost/Products?$filter=Price lt 10
논리 연산자: 가격 = 5 및 가격 ><= 15인 모든 제품을 반환합니다. http://localhost/Products?$filter=Price ge 5 and Price le 15
문자열 함수: 이름에 "zz"가 있는 모든 제품을 반환합니다. http://localhost/Products?$filter=substringof('zz',Name)
날짜 함수: 2005년 이후에 ReleaseDate를 사용하여 모든 제품을 반환합니다. http://localhost/Products?$filter=year(ReleaseDate) gt 2005

정렬

결과를 정렬하려면 $orderby 필터를 사용합니다.

가격별로 정렬합니다. http://localhost/Products?$orderby=Price
내림차순으로 가격별로 정렬합니다(가장 높은 순서에서 가장 낮은 순서로). http://localhost/Products?$orderby=Price desc
범주별로 정렬한 다음, 범주 내에서 내림차순으로 가격을 기준으로 정렬합니다. http://localhost/odata/Products?$orderby=Category,Price desc

Server-Driven 페이징

데이터베이스에 수백만 개의 레코드가 포함된 경우 모든 레코드를 하나의 페이로드로 보내지 않으려는 것입니다. 이를 방지하기 위해 서버는 단일 응답에서 보내는 항목 수를 제한할 수 있습니다. 서버 페이징을 사용하도록 설정하려면 Queryable 특성에서 PageSize 속성을 설정합니다. 값은 반환할 최대 항목 수입니다.

[Queryable(PageSize=10)]
public IQueryable<Product> Get() 
{
    return products.AsQueryable();
}

컨트롤러가 OData 형식을 반환하는 경우 응답 본문에는 다음 데이터 페이지에 대한 링크가 포함됩니다.

{
  "odata.metadata":"http://localhost/$metadata#Products",
  "value":[
    { "ID":1,"Name":"Hat","Price":"15","Category":"Apparel" },
    { "ID":2,"Name":"Socks","Price":"5","Category":"Apparel" },
    // Others not shown
  ],
  "odata.nextLink":"http://localhost/Products?$skip=10"
}

클라이언트는 이 링크를 사용하여 다음 페이지를 가져올 수 있습니다. 결과 집합의 총 항목 수를 알아보기 위해 클라이언트는 "allpages" 값으로 $inlinecount 쿼리 옵션을 설정할 수 있습니다.

http://localhost/Products?$inlinecount=allpages

"allpages" 값은 응답에 총 개수를 포함하도록 서버에 지시합니다.

{
  "odata.metadata":"http://localhost/$metadata#Products",
  "odata.count":"50",
  "value":[
    { "ID":1,"Name":"Hat","Price":"15","Category":"Apparel" },
    { "ID":2,"Name":"Socks","Price":"5","Category":"Apparel" },
    // Others not shown
  ]
}

참고

다음 페이지 링크와 인라인 개수 모두 OData 형식이 필요합니다. 그 이유는 OData가 링크와 개수를 보유할 응답 본문에 특수 필드를 정의하기 때문입니다.

OData가 아닌 형식의 경우 쿼리 결과를 PageResult T> 개체로 래핑하여 다음 페이지 링크 및 인라인 수를 지원할 수< 있습니다. 그러나 좀 더 많은 코드가 필요합니다. 예를 들면 다음과 같습니다.

public PageResult<Product> Get(ODataQueryOptions<Product> options)
{
    ODataQuerySettings settings = new ODataQuerySettings()
    {
        PageSize = 5
    };

    IQueryable results = options.ApplyTo(_products.AsQueryable(), settings);

    return new PageResult<Product>(
        results as IEnumerable<Product>, 
        Request.GetNextPageLink(), 
        Request.GetInlineCount());
}

다음은 JSON 응답 예제입니다.

{
  "Items": [
    { "ID":1,"Name":"Hat","Price":"15","Category":"Apparel" },
    { "ID":2,"Name":"Socks","Price":"5","Category":"Apparel" },

    // Others not shown
    
  ],
  "NextPageLink": "http://localhost/api/values?$inlinecount=allpages&$skip=10",
  "Count": 50
}

쿼리 옵션 제한

쿼리 옵션을 사용하면 클라이언트가 서버에서 실행되는 쿼리를 많이 제어할 수 있습니다. 경우에 따라 보안 또는 성능상의 이유로 사용 가능한 옵션을 제한할 수 있습니다. [Queryable] 특성에는 이에 대한 몇 가지 기본 제공 속성이 있습니다. 다음은 몇 가지 예제입니다.

페이징을 지원하기 위해 $skip 및 $top 허용합니다.

[Queryable(AllowedQueryOptions=
    AllowedQueryOptions.Skip | AllowedQueryOptions.Top)]

데이터베이스에 인덱싱되지 않은 속성에 대한 정렬을 방지하기 위해 특정 속성에 의한 순서만 허용합니다.

[Queryable(AllowedOrderByProperties="Id")] // comma-separated list of properties

"eq" 논리 함수를 허용하지만 다른 논리 함수는 허용하지 않습니다.

[Queryable(AllowedLogicalOperators=AllowedLogicalOperators.Equal)]

산술 연산자는 허용하지 않습니다.

[Queryable(AllowedArithmeticOperators=AllowedArithmeticOperators.None)]

QueryableAttribute instance 생성하고 EnableQuerySupport 함수에 전달하여 옵션을 전역적으로 제한할 수 있습니다.

var queryAttribute = new QueryableAttribute()
{
    AllowedQueryOptions = AllowedQueryOptions.Top | AllowedQueryOptions.Skip,
    MaxTop = 100
};
                
config.EnableQuerySupport(queryAttribute);

쿼리 옵션 직접 호출

[쿼리 가능] 특성을 사용하는 대신 컨트롤러에서 직접 쿼리 옵션을 호출할 수 있습니다. 이렇게 하려면 컨트롤러 메서드에 ODataQueryOptions 매개 변수를 추가합니다. 이 경우 [Queryable] 특성이 필요하지 않습니다.

public IQueryable<Product> Get(ODataQueryOptions opts)
{
    var settings = new ODataValidationSettings()
    {
        // Initialize settings as needed.
        AllowedFunctions = AllowedFunctions.AllMathFunctions
    };

    opts.Validate(settings);

    IQueryable results = opts.ApplyTo(products.AsQueryable());
    return results as IQueryable<Product>;
}

Web API는 URI 쿼리 문자열에서 ODataQueryOptions 를 채웁니다. 쿼리를 적용하려면 IQueryableApplyTo 메서드에 전달합니다. 메서드는 다른 IQueryable을 반환합니다.

고급 시나리오의 경우 IQueryable 쿼리 공급자가 없는 경우 ODataQueryOptions 를 검사하고 쿼리 옵션을 다른 형식으로 변환할 수 있습니다. (예를 들어, RaghuRam Nadiminti의 블로그 게시물 OData 쿼리를 HQL로 번역 참조)

쿼리 유효성 검사

[Queryable] 특성은 쿼리를 실행하기 전에 쿼리의 유효성을 검사합니다. 유효성 검사 단계는 QueryableAttribute.ValidateQuery 메서드에서 수행됩니다. 유효성 검사 프로세스를 사용자 지정할 수도 있습니다.

OData 보안 지침도 참조하세요.

먼저 Web.Http.OData.Query.Validators 네임스페이스에 정의된 유효성 검사기 클래스 중 하나를 재정의합니다. 예를 들어 다음 유효성 검사기 클래스는 $orderby 옵션에 대해 'desc' 옵션을 사용하지 않도록 설정합니다.

public class MyOrderByValidator : OrderByQueryValidator
{
    // Disallow the 'desc' parameter for $orderby option.
    public override void Validate(OrderByQueryOption orderByOption,
                                    ODataValidationSettings validationSettings)
    {
        if (orderByOption.OrderByNodes.Any(
                node => node.Direction == OrderByDirection.Descending))
        {
            throw new ODataException("The 'desc' option is not supported.");
        }
        base.Validate(orderByOption, validationSettings);
    }
}

[Queryable] 특성을 서브클래스하여 ValidateQuery 메서드를 재정의합니다.

public class MyQueryableAttribute : QueryableAttribute
{
    public override void ValidateQuery(HttpRequestMessage request, 
        ODataQueryOptions queryOptions)
    {
        if (queryOptions.OrderBy != null)
        {
            queryOptions.OrderBy.Validator = new MyOrderByValidator();
        }
        base.ValidateQuery(request, queryOptions);
    }
}

그런 다음, 전역적으로 또는 컨트롤러별로 사용자 지정 특성을 설정합니다.

// Globally:
config.EnableQuerySupport(new MyQueryableAttribute());

// Per controller:
public class ValuesController : ApiController
{
    [MyQueryable]
    public IQueryable<Product> Get()
    {
        return products.AsQueryable();
    }
}

ODataQueryOptions를 직접 사용하는 경우 옵션에서 유효성 검사기를 설정합니다.

public IQueryable<Product> Get(ODataQueryOptions opts)
{
    if (opts.OrderBy != null)
    {
        opts.OrderBy.Validator = new MyOrderByValidator();
    }

    var settings = new ODataValidationSettings()
    {
        // Initialize settings as needed.
        AllowedFunctions = AllowedFunctions.AllMathFunctions
    };

    // Validate
    opts.Validate(settings);

    IQueryable results = opts.ApplyTo(products.AsQueryable());
    return results as IQueryable<Product>;
}