OData (Open Data Protocol)
OData (Open Data Protocol) は、RESTful API を標準化し、データのクエリと操作を統一的に行うためのオープンスタンダードプロトコルです。
OData とは
概要
OData は OASIS (Organization for the Advancement of Structured Information Standards) によって標準化された、Web 経由でデータを公開・消費するためのプロトコルです。
主な特徴:
- HTTP プロトコルをベースとした RESTful API
- 標準化されたクエリ構文による柔軟なデータ取得
- メタデータによるスキーマの自己記述
- JSON および XML フォーマットのサポート
- CRUD 操作の統一的なインターフェース
OData のバージョン
- OData v2: 広く採用された初期バージョン(現在はレガシー)
- OData v4: 現在の標準バージョン(2014年に OASIS 標準として承認)
- OData v4.01: 最新の仕様(2020年承認)
OData の主要な機能
1. 統一的なリソースアドレス指定
OData では、リソースを URL で表現します。
# コレクション全体
GET https://api.example.com/odata/Products
# 特定のエンティティ
GET https://api.example.com/odata/Products(1)
# ナビゲーションプロパティ
GET https://api.example.com/odata/Products(1)/Category
# プロパティ値の直接取得
GET https://api.example.com/odata/Products(1)/Name/$value
2. 標準化されたクエリオプション
OData の最大の特徴は、URL クエリパラメータを使用した強力なデータフィルタリングとクエリ機能です。
$filter (フィルタリング)
データをフィルタリングして取得します。
# 価格が100以上の製品
GET /odata/Products?$filter=Price ge 100
# 名前に"Pro"を含む製品
GET /odata/Products?$filter=contains(Name, 'Pro')
# 複数条件(AND/OR)
GET /odata/Products?$filter=Price lt 50 and Category/Name eq 'Electronics'
# 日付フィルタ
GET /odata/Orders?$filter=OrderDate ge 2024-01-01T00:00:00Z
比較演算子:
| 演算子 | 意味 | 例 |
|---|---|---|
eq | 等しい | Price eq 100 |
ne | 等しくない | Status ne 'Cancelled' |
gt | より大きい | Quantity gt 10 |
ge | 以上 | Price ge 50 |
lt | より小さい | Stock lt 5 |
le | 以下 | Discount le 0.2 |
論理演算子:
| 演算子 | 意味 | 例 |
|---|---|---|
and | AND条件 | Price gt 10 and Price lt 100 |
or | OR条件 | Category eq 'Books' or Category eq 'Music' |
not | NOT条件 | not (Status eq 'Inactive') |
文字列関数:
| 関数 | 説明 | 例 |
|---|---|---|
contains(field, 'value') | 部分一致 | contains(Name, 'Phone') |
startswith(field, 'value') | 前方一致 | startswith(Email, 'admin') |
endswith(field, 'value') | 後方一致 | endswith(Email, '@example.com') |
length(field) | 文字列長 | length(Name) gt 10 |
tolower(field) | 小文字変換 | tolower(Name) eq 'product' |
toupper(field) | 大文字変換 | toupper(Code) eq 'ABC' |
算術関数:
# 割引後の価格が100以下
GET /odata/Products?$filter=(Price mul (1 sub Discount)) le 100
# 在庫が閾値の50%以下
GET /odata/Products?$filter=Stock le (Threshold div 2)
日付・時刻関数:
| 関数 | 説明 | 例 |
|---|---|---|
year(field) | 年を取得 | year(CreatedDate) eq 2024 |
month(field) | 月を取得 | month(CreatedDate) eq 12 |
day(field) | 日を取得 | day(CreatedDate) eq 25 |
hour(field) | 時を取得 | hour(CreatedTime) ge 9 |
minute(field) | 分を取得 | minute(CreatedTime) eq 30 |
now() | 現在時刻 | CreatedDate lt now() |
$select (プロパティ選択)
取得するプロパティを指定します(パフォーマンス向上)。
# 特定のフィールドのみ取得
GET /odata/Products?$select=Id,Name,Price
# ネストしたプロパティの選択
GET /odata/Products?$select=Name,Category/Name
$expand (関連エンティティの展開)
ナビゲーションプロパティを展開して、関連データを一度に取得します。
# カテゴリ情報を含めて取得
GET /odata/Products?$expand=Category
# 複数の関連エンティティを展開
GET /odata/Orders?$expand=Customer,OrderItems
# ネストした展開
GET /odata/Orders?$expand=OrderItems($expand=Product)
# 展開したエンティティをフィルタリング
GET /odata/Orders?$expand=OrderItems($filter=Quantity gt 5)
# 展開と選択の組み合わせ
GET /odata/Products?$expand=Category($select=Name)&$select=Name,Price
$orderby (並び替え)
結果を並び替えます。
# 昇順(デフォルト)
GET /odata/Products?$orderby=Price
# 降順
GET /odata/Products?$orderby=Price desc
# 複数フィールドで並び替え
GET /odata/Products?$orderby=Category/Name,Price desc
# 関数を使った並び替え
GET /odata/Products?$orderby=length(Name) desc
$top と $skip (ページネーション)
結果のページネーションを実装します。
# 最初の10件を取得
GET /odata/Products?$top=10
# 最初の10件をスキップして次の10件を取得
GET /odata/Products?$skip=10&$top=10
# ページネーション(3ページ目、1ページ20件)
GET /odata/Products?$skip=40&$top=20
$count (カウント)
結果の総数を取得します。
# カウントのみ取得
GET /odata/Products/$count
# データと一緒にカウントを取得
GET /odata/Products?$count=true&$top=10
# フィルタ後のカウント
GET /odata/Products/$count?$filter=Price gt 100
$search (全文検索)
全文検索を実行します(OData v4 以降)。
# 単純な検索
GET /odata/Products?$search=laptop
# AND検索
GET /odata/Products?$search=laptop AND gaming
# OR検索
GET /odata/Products?$search=laptop OR desktop
# フレーズ検索
GET /odata/Products?$search="high performance"
$apply (集計・グループ化)
データの集計とグループ化を行います(OData v4 以降の拡張機能)。
# カテゴリごとの製品数
GET /odata/Products?$apply=groupby((Category/Name), aggregate($count as Total))
# カテゴリごとの平均価格
GET /odata/Products?$apply=groupby((Category/Name), aggregate(Price with average as AvgPrice))
# 複数の集計
GET /odata/Orders?$apply=groupby((Customer/Name), aggregate(TotalAmount with sum as TotalSales, $count as OrderCount))
3. メタデータとサービスドキュメント
OData サービスは、スキーマ情報をメタデータとして公開します。
サービスドキュメント
GET https://api.example.com/odata/
レスポンス例(JSON):
{
"@odata.context": "https://api.example.com/odata/$metadata",
"value": [
{
"name": "Products",
"kind": "EntitySet",
"url": "Products"
},
{
"name": "Categories",
"kind": "EntitySet",
"url": "Categories"
},
{
"name": "Orders",
"kind": "EntitySet",
"url": "Orders"
}
]
}
メタデータドキュメント
GET https://api.example.com/odata/$metadata
レスポンス例(XML - CSDL形式):
<?xml version="1.0" encoding="UTF-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
<edmx:DataServices>
<Schema Namespace="ProductService" xmlns="http://docs.oasis-open.org/odata/ns/edm">
<!-- エンティティ型定義 -->
<EntityType Name="Product">
<Key>
<PropertyRef Name="Id"/>
</Key>
<Property Name="Id" Type="Edm.Int32" Nullable="false"/>
<Property Name="Name" Type="Edm.String" MaxLength="100"/>
<Property Name="Price" Type="Edm.Decimal" Precision="18" Scale="2"/>
<Property Name="Stock" Type="Edm.Int32"/>
<Property Name="CategoryId" Type="Edm.Int32"/>
<NavigationProperty Name="Category" Type="ProductService.Category"/>
</EntityType>
<EntityType Name="Category">
<Key>
<PropertyRef Name="Id"/>
</Key>
<Property Name="Id" Type="Edm.Int32" Nullable="false"/>
<Property Name="Name" Type="Edm.String" MaxLength="50"/>
<NavigationProperty Name="Products" Type="Collection(ProductService.Product)"/>
</EntityType>
<!-- エンティティコンテナ -->
<EntityContainer Name="Container">
<EntitySet Name="Products" EntityType="ProductService.Product">
<NavigationPropertyBinding Path="Category" Target="Categories"/>
</EntitySet>
<EntitySet Name="Categories" EntityType="ProductService.Category">
<NavigationPropertyBinding Path="Products" Target="Products"/>
</EntitySet>
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>
4. CRUD 操作
Create (作成) - POST
POST https://api.example.com/odata/Products
Content-Type: application/json
{
"Name": "New Product",
"Price": 99.99,
"Stock": 50,
"CategoryId": 1
}
レスポンス(201 Created):
{
"@odata.context": "https://api.example.com/odata/$metadata#Products/$entity",
"Id": 123,
"Name": "New Product",
"Price": 99.99,
"Stock": 50,
"CategoryId": 1
}
Read (読取) - GET
GET https://api.example.com/odata/Products(123)
レスポンス(200 OK):
{
"@odata.context": "https://api.example.com/odata/$metadata#Products/$entity",
"Id": 123,
"Name": "New Product",
"Price": 99.99,
"Stock": 50,
"CategoryId": 1
}
Update (更新) - PUT / PATCH
PUT - 完全更新(すべてのプロパティを指定):
PUT https://api.example.com/odata/Products(123)
Content-Type: application/json
{
"Name": "Updated Product",
"Price": 89.99,
"Stock": 45,
"CategoryId": 1
}
PATCH - 部分更新(変更するプロパティのみ指定):
PATCH https://api.example.com/odata/Products(123)
Content-Type: application/json
{
"Price": 79.99,
"Stock": 40
}
Delete (削除) - DELETE
DELETE https://api.example.com/odata/Products(123)
レスポンス(204 No Content)
5. バッチリクエスト
複数の操作を一度に実行できます。
POST https://api.example.com/odata/$batch
Content-Type: multipart/mixed; boundary=batch_36522ad7-fc75-4b56-8c71-56071383e77b
--batch_36522ad7-fc75-4b56-8c71-56071383e77b
Content-Type: application/http
Content-Transfer-Encoding: binary
GET Products(1) HTTP/1.1
Host: api.example.com
Accept: application/json
--batch_36522ad7-fc75-4b56-8c71-56071383e77b
Content-Type: application/http
Content-Transfer-Encoding: binary
GET Products(2) HTTP/1.1
Host: api.example.com
Accept: application/json
--batch_36522ad7-fc75-4b56-8c71-56071383e77b--
OData のデータ型
基本データ型(Primitive Types)
| OData型 | 説明 | 例 |
|---|---|---|
Edm.String | 文字列 | "Hello" |
Edm.Int32 | 32ビット整数 | 123 |
Edm.Int64 | 64ビット整数 | 9223372036854775807 |
Edm.Decimal | 10進数 | 99.99 |
Edm.Double | 倍精度浮動小数点 | 3.14159 |
Edm.Boolean | 真偽値 | true / false |
Edm.DateTime | 日時(v2) | 2024-12-23T10:30:00 |
Edm.DateTimeOffset | タイムゾーン付き日時(v4) | 2024-12-23T10:30:00+09:00 |
Edm.Date | 日付のみ(v4) | 2024-12-23 |
Edm.TimeOfDay | 時刻のみ(v4) | 10:30:00 |
Edm.Duration | 期間 | PT12H30M |
Edm.Guid | GUID | 12345678-1234-1234-1234-123456789012 |
Edm.Binary | バイナリデータ | T0RhdGE= (Base64) |
複合型(Complex Types)
<ComplexType Name="Address">
<Property Name="Street" Type="Edm.String"/>
<Property Name="City" Type="Edm.String"/>
<Property Name="PostalCode" Type="Edm.String"/>
<Property Name="Country" Type="Edm.String"/>
</ComplexType>
<EntityType Name="Customer">
<Key>
<PropertyRef Name="Id"/>
</Key>
<Property Name="Id" Type="Edm.Int32" Nullable="false"/>
<Property Name="Name" Type="Edm.String"/>
<Property Name="BillingAddress" Type="ProductService.Address"/>
<Property Name="ShippingAddress" Type="ProductService.Address"/>
</EntityType>
コレクション型(Collection Types)
<EntityType Name="Product">
<Property Name="Tags" Type="Collection(Edm.String)"/>
<Property Name="Dimensions" Type="Collection(Edm.Double)"/>
</EntityType>
OData のベストプラクティス
1. OData を使用すべき場面
✅ 適している場面
-
複雑なクエリ要件がある
- クライアントが動的にフィルタ、ソート、ページネーションを行う必要がある
- 様々な条件での検索が必要なダッシュボードやレポート画面
-
データ探索とアドホッククエリ
- データアナリストやパワーユーザーが自由にデータを探索する
- BI ツールとの統合
-
標準化されたインターフェースが必要
- 複数のクライアント(Web、モバイル、デスクトップ)で共通のAPIを使用
- サードパーティ統合
-
Microsoft エコシステム
- SharePoint、Dynamics 365、Power Platform との統合
- .NET アプリケーション(ASP.NET Core OData ライブラリ)
-
メタデータ駆動型アプリケーション
- 動的にUIを生成する
- スキーマ情報から自動的にクライアントコードを生成
❌ 適していない場面
-
単純なCRUD操作のみ
- OData のオーバーヘッドが不要
- シンプルな REST API で十分
-
パフォーマンスが最重要
- OData のクエリ解析とメタデータのオーバーヘッド
- GraphQL や専用エンドポイントの方が効率的な場合も
-
非常に特殊なクエリロジック
- OData の標準クエリでは表現できない複雑なビジネスロジック
- カスタムエンドポイントの方が適切
-
リアルタイム通信
- WebSocket や SignalR の方が適している
- OData は HTTP ベースのリクエスト/レスポンス
-
バイナリデータの大量転送
- ファイルアップロード/ダウンロードには専用エンドポイントを使用
2. クエリのパフォーマンス最適化
$select を使用して必要なフィールドのみ取得
# ❌ 悪い例: すべてのフィールドを取得
GET /odata/Products
# ✅ 良い例: 必要なフィールドのみ
GET /odata/Products?$select=Id,Name,Price
$top でページサイズを制限
# ❌ 悪い例: 無制限に取得
GET /odata/Products
# ✅ 良い例: ページサイズを制限
GET /odata/Products?$top=20
深いネストの $expand を避ける
# ❌ 悪い例: 深くネストした展開
GET /odata/Orders?$expand=Customer($expand=Orders($expand=OrderItems($expand=Product)))
# ✅ 良い例: 必要な階層のみ展開
GET /odata/Orders?$expand=Customer,OrderItems($expand=Product;$select=Name,Price)
インデックスされたフィールドでフィルタリング
# ✅ データベースインデックスを活用
GET /odata/Products?$filter=CategoryId eq 5 and Price gt 100
3. セキュリティのベストプラクティス
クエリの複雑さを制限
サーバー側で以下の制限を設定:
- 最大 $top 値: 例えば 100 や 1000
- 最大 $expand の深さ: 例えば 2階層まで
- $filter の複雑さ: ネストした条件の最大数
ASP.NET Core での実装例:
services.AddControllers()
.AddOData(opt => opt
.SetMaxTop(100)
.Expand().Select().Filter().OrderBy().Count()
.SetMaxExpansionDepth(2));
認証・認可の実装
// エンティティレベルのフィルタリング
[Authorize]
[EnableQuery]
public IQueryable<Order> GetOrders()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
// ログインユーザーの注文のみ返す
return _context.Orders.Where(o => o.UserId == userId);
}
// 詳細な認可チェック
[Authorize]
public async Task<IActionResult> Get([FromODataUri] int key)
{
var order = await _context.Orders.FindAsync(key);
if (order == null)
return NotFound();
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (order.UserId != userId && !User.IsInRole("Admin"))
return Forbid();
return Ok(order);
}
機密データの保護
// メタデータから機密プロパティを除外
public class UserDto
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
// パスワードハッシュは公開しない
}
// または属性で制御
public class User
{
public int Id { get; set; }
public string Name { get; set; }
[Ignore] // OData メタデータから除外
public string PasswordHash { get; set; }
}
SQL インジェクション対策
- OData ライブラリは通常、パラメータ化されたクエリを使用するため安全
- カスタムフィルタ関数を実装する際は、必ずパラメータ化を使用
// ✅ 安全: パラメータ化されたクエリ
_context.Products.Where(p => p.CategoryId == categoryId);
// ❌ 危険: 文字列連結(使用しない)
_context.Products.FromSqlRaw($"SELECT * FROM Products WHERE CategoryId = {categoryId}");
// ✅ 安全: パラメータを使用
_context.Products.FromSqlRaw("SELECT * FROM Products WHERE CategoryId = {0}", categoryId);
4. エラーハンドリング
OData は標準的なエラー形式を定義しています。
{
"error": {
"code": "InvalidQuery",
"message": "The query specified in the URI is not valid.",
"target": "$filter",
"details": [
{
"code": "UnknownProperty",
"message": "Property 'InvalidField' does not exist.",
"target": "InvalidField"
}
],
"innererror": {
"trace": "...",
"context": "..."
}
}
}
サーバー側での実装例:
public class ODataErrorHandler
{
public IActionResult HandleODataException(ODataException ex)
{
var error = new ODataError
{
ErrorCode = "BadRequest",
Message = ex.Message,
Target = ex.Target,
Details = ex.Details?.Select(d => new ODataErrorDetail
{
ErrorCode = d.ErrorCode,
Message = d.Message,
Target = d.Target
}).ToList()
};
return new BadRequestObjectResult(new { error });
}
}
5. バージョニング戦略
URL パスによるバージョニング
GET https://api.example.com/odata/v1/Products
GET https://api.example.com/odata/v2/Products
カスタムヘッダーによるバージョニング
GET https://api.example.com/odata/Products
API-Version: 2.0
クエリパラメータによるバージョニング
GET https://api.example.com/odata/Products?api-version=2.0
実装例
ASP.NET Core での実装
1. パッケージのインストール
dotnet add package Microsoft.AspNetCore.OData
2. エンティティモデルの定義
// Models/Product.cs
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; }
}
// Models/Category.cs
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
public ICollection<Product> Products { get; set; }
}
3. DbContext の設定
// Data/ApplicationDbContext.cs
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<Product> Products { get; set; }
public DbSet<Category> Categories { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId);
}
}
4. OData の設定(Program.cs)
using Microsoft.AspNetCore.OData;
using Microsoft.OData.ModelBuilder;
var builder = WebApplication.CreateBuilder(args);
// EDM モデルの構築
var modelBuilder = new ODataConventionModelBuilder();
modelBuilder.EntitySet<Product>("Products");
modelBuilder.EntitySet<Category>("Categories");
// OData サービスの登録
builder.Services.AddControllers()
.AddOData(options => options
.Select() // $select を有効化
.Filter() // $filter を有効化
.OrderBy() // $orderby を有効化
.Expand() // $expand を有効化
.Count() // $count を有効化
.SetMaxTop(100) // $top の最大値を設定
.AddRouteComponents("odata", modelBuilder.GetEdmModel()));
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
var app = builder.Build();
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
app.Run();
5. コントローラーの実装
// Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OData.Query;
using Microsoft.AspNetCore.OData.Routing.Controllers;
[Route("odata/[controller]")]
public class ProductsController : ODataController
{
private readonly ApplicationDbContext _context;
public ProductsController(ApplicationDbContext context)
{
_context = context;
}
// GET: odata/Products
[EnableQuery] // OData クエリを有効化
public IQueryable<Product> Get()
{
return _context.Products;
}
// GET: odata/Products(5)
[EnableQuery]
public SingleResult<Product> Get([FromRoute] int key)
{
return SingleResult.Create(_context.Products.Where(p => p.Id == key));
}
// POST: odata/Products
public async Task<IActionResult> Post([FromBody] Product product)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
_context.Products.Add(product);
await _context.SaveChangesAsync();
return Created(product);
}
// PATCH: odata/Products(5)
public async Task<IActionResult> Patch([FromRoute] int key, [FromBody] Delta<Product> delta)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var product = await _context.Products.FindAsync(key);
if (product == null)
{
return NotFound();
}
delta.Patch(product);
await _context.SaveChangesAsync();
return Updated(product);
}
// DELETE: odata/Products(5)
public async Task<IActionResult> Delete([FromRoute] int key)
{
var product = await _context.Products.FindAsync(key);
if (product == null)
{
return NotFound();
}
_context.Products.Remove(product);
await _context.SaveChangesAsync();
return NoContent();
}
}
6. カスタムクエリオプションの実装
// カスタムフィルタの追加
public class CustomProductsController : ODataController
{
private readonly ApplicationDbContext _context;
public CustomProductsController(ApplicationDbContext context)
{
_context = context;
}
[EnableQuery]
[HttpGet("odata/Products/InStock")]
public IQueryable<Product> GetInStockProducts()
{
// 在庫があるものだけ返す
return _context.Products.Where(p => p.Stock > 0);
}
[EnableQuery]
[HttpGet("odata/Products/LowStock")]
public IQueryable<Product> GetLowStockProducts([FromQuery] int threshold = 10)
{
// 在庫が少ないものを返す
return _context.Products.Where(p => p.Stock <= threshold);
}
}
クライアント側の実装(TypeScript)
// OData クライアントクラス
class ODataClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
// クエリビルダー
async query<T>(
entitySet: string,
options?: {
filter?: string;
select?: string[];
expand?: string[];
orderby?: string;
top?: number;
skip?: number;
count?: boolean;
}
): Promise<{ value: T[]; '@odata.count'?: number }> {
const params = new URLSearchParams();
if (options?.filter) params.append('$filter', options.filter);
if (options?.select) params.append('$select', options.select.join(','));
if (options?.expand) params.append('$expand', options.expand.join(','));
if (options?.orderby) params.append('$orderby', options.orderby);
if (options?.top) params.append('$top', options.top.toString());
if (options?.skip) params.append('$skip', options.skip.toString());
if (options?.count) params.append('$count', 'true');
const url = `${this.baseUrl}/${entitySet}?${params.toString()}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`OData query failed: ${response.statusText}`);
}
return await response.json();
}
// 単一エンティティの取得
async getById<T>(entitySet: string, id: number | string): Promise<T> {
const url = `${this.baseUrl}/${entitySet}(${id})`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`OData get failed: ${response.statusText}`);
}
return await response.json();
}
// 作成
async create<T>(entitySet: string, entity: Partial<T>): Promise<T> {
const url = `${this.baseUrl}/${entitySet}`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(entity),
});
if (!response.ok) {
throw new Error(`OData create failed: ${response.statusText}`);
}
return await response.json();
}
// 更新
async update<T>(
entitySet: string,
id: number | string,
entity: Partial<T>
): Promise<void> {
const url = `${this.baseUrl}/${entitySet}(${id})`;
const response = await fetch(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(entity),
});
if (!response.ok) {
throw new Error(`OData update failed: ${response.statusText}`);
}
}
// 削除
async delete(entitySet: string, id: number | string): Promise<void> {
const url = `${this.baseUrl}/${entitySet}(${id})`;
const response = await fetch(url, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`OData delete failed: ${response.statusText}`);
}
}
}
// 使用例
interface Product {
Id: number;
Name: string;
Price: number;
Stock: number;
CategoryId: number;
Category?: Category;
}
interface Category {
Id: number;
Name: string;
Products?: Product[];
}
const client = new ODataClient('https://api.example.com/odata');
// 複雑なクエリの実行
async function fetchProducts() {
const result = await client.query<Product>('Products', {
filter: "Price gt 50 and contains(Name, 'Pro')",
select: ['Id', 'Name', 'Price'],
expand: ['Category($select=Name)'],
orderby: 'Price desc',
top: 20,
skip: 0,
count: true,
});
console.log(`Total count: ${result['@odata.count']}`);
console.log('Products:', result.value);
}
// ページネーション
async function fetchProductsPage(page: number, pageSize: number = 20) {
const skip = (page - 1) * pageSize;
const result = await client.query<Product>('Products', {
orderby: 'Id',
top: pageSize,
skip: skip,
count: true,
});
return {
items: result.value,
total: result['@odata.count'],
page: page,
pageSize: pageSize,
totalPages: Math.ceil((result['@odata.count'] || 0) / pageSize),
};
}
// CRUD 操作
async function crudOperations() {
// 作成
const newProduct = await client.create<Product>('Products', {
Name: 'New Product',
Price: 99.99,
Stock: 50,
CategoryId: 1,
});
console.log('Created:', newProduct);
// 読取
const product = await client.getById<Product>('Products', newProduct.Id);
console.log('Retrieved:', product);
// 更新
await client.update<Product>('Products', newProduct.Id, {
Price: 89.99,
Stock: 45,
});
console.log('Updated');
// 削除
await client.delete('Products', newProduct.Id);
console.log('Deleted');
}
OData と他のAPIスタイルの比較
OData vs REST
| 側面 | OData | 標準的なREST |
|---|---|---|
| 標準化 | 厳密に標準化されている | 柔軟、慣例ベース |
| クエリ機能 | 強力な組み込みクエリ | カスタム実装が必要 |
| メタデータ | 自動的に提供される | 通常は提供されない |
| 学習曲線 | やや複雑 | シンプル |
| 柔軟性 | 標準に従う必要がある | 自由度が高い |
| 適用シーン | 複雑なクエリ、企業アプリ | 一般的なWebアプリ |
OData vs GraphQL
| 側面 | OData | GraphQL |
|---|---|---|
| クエリ言語 | URL クエリパラメータ | 専用のクエリ言語 |
| 型システム | EDM(Entity Data Model) | GraphQLスキーマ |
| 過剰取得/不足取得 | $select/$expand で制御 | クエリで正確に指定 |
| 変更操作 | 標準的なHTTP動詞 | Mutation |
| リアルタイム | なし | Subscription |
| ツールエコシステム | 成熟(Microsoft系) | 非常に豊富 |
セキュリティの考慮事項
1. 認証と認可
// JWT ベアラートークン認証
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
};
});
// エンティティレベルの認可
[Authorize(Roles = "Admin,User")]
[EnableQuery]
public IQueryable<Product> Get()
{
return _context.Products;
}
2. レート制限
// ASP.NET Core のレート制限ミドルウェア
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.User.Identity?.Name ?? httpContext.Request.Headers.Host.ToString(),
factory: partition => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = 100,
QueueLimit = 0,
Window = TimeSpan.FromMinutes(1)
}));
});
app.UseRateLimiter();
3. CORS 設定
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowSpecificOrigin",
builder => builder
.WithOrigins("https://yourdomain.com")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials());
});
app.UseCors("AllowSpecificOrigin");
4. 入力検証
public class ProductValidator : AbstractValidator<Product>
{
public ProductValidator()
{
RuleFor(p => p.Name)
.NotEmpty().WithMessage("Product name is required")
.MaximumLength(100).WithMessage("Name must not exceed 100 characters");
RuleFor(p => p.Price)
.GreaterThan(0).WithMessage("Price must be greater than 0");
RuleFor(p => p.Stock)
.GreaterThanOrEqualTo(0).WithMessage("Stock cannot be negative");
}
}
// コントローラーで使用
public async Task<IActionResult> Post([FromBody] Product product)
{
var validator = new ProductValidator();
var validationResult = await validator.ValidateAsync(product);
if (!validationResult.IsValid)
{
return BadRequest(validationResult.Errors);
}
_context.Products.Add(product);
await _context.SaveChangesAsync();
return Created(product);
}
まとめ
OData の主な利点
- ✅ 標準化: 一貫性のある API 設計
- ✅ 強力なクエリ: 柔軟なフィルタリング、ソート、ページネーション
- ✅ メタデータ: 自己記述型のスキーマ
- ✅ ツールサポート: 豊富なライブラリとツール
- ✅ 生産性: 定型的なコードの削減
OData の課題
- ❌ 複雑性: 学習曲線がやや急
- ❌ オーバーヘッド: メタデータとクエリ処理のコスト
- ❌ 柔軟性の制約: 標準に従う必要がある
- ❌ パフォーマンス: 不適切なクエリによる問題
推奨される使用シナリオ
- 企業向けアプリケーション: 複雑なデータクエリとレポート
- データ探索ツール: ダッシュボード、BI統合
- Microsoft エコシステム: SharePoint、Dynamics、Power Platform
- API の標準化: 組織全体で一貫したAPI設計
- 自動コード生成: メタデータからのクライアント生成