Mert Özen Her Satırda Daha İleri
Test ve Kalite Integration ve E2E Test — #05

Angular ile API Arasındaki Contract: OpenAPI Dokümanı, Breaking Change Tespiti ve Ne Zaman Pact?

Mert Özen 14 Kas 2026 11 dk 1 görüntülenme
Angular ile API Arasındaki Contract: OpenAPI Dokümanı, Breaking Change Tespiti ve Ne Zaman Pact?

Angular ile API arasındaki sözleşmeyi build'de üretilip commit'lenen OpenAPI 3.1 dokümanı taşır; CI'da oasdiff breaking change'i, dokümandan üretilen TypeScript tipleri ve tsc de kullanılan alanların kırılmasını yakalar. Dokümanın sayıları neden 'integer ya da string' gösterdiğini, enum, nullable ve required tuzaklarını, breaking change sınıflandırmasını ve Pact'in ne zaman gerektiğini gerçek çıktılarla anlatıyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Microsoft.AspNetCore.OpenApi 10.0.15, Microsoft.Extensions.ApiDescription.Server 10.0.15, oasdiff 1.37, openapi-typescript 7.13, Angular 22.2 ve TypeScript 6.0 ile derlenip denenmiştir (Angular 22.2 TypeScript 6.0 istediği için Angular tarafında TypeScript 7 değil 6.0 kullanıldı).

Önce Tahmin Et

Bookstore'un Angular arayüzü kitap listesini API'den alıyor. Tipleri elle yazmak yerine API'nin ürettiği OpenAPI dokümanından üretmeye karar verdin. .NET 10'un ürettiği dokümanda BookDto şöyle görünüyor (kısalttım):

"BookDto": {
  "required": ["id", "isbn", "title", "price", "stock"],
  "type": "object",
  "properties": {
    "id": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": ["integer", "string"], "format": "int32" },
    "price": { "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", "type": ["number", "string"], "format": "double" },
    "stock": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": ["integer", "string"], "format": "int32" }
  }
}

openapi-typescript bundan stock: number | string üretti. Angular tarafındaki küçük bir yardımcı fonksiyon da şu:

export function stockLabel(book: BookDto): string {
  return book.stock > 0 ? `${book.stock} in stock` : 'Out of stock';
}

İki soru: C# tarafında int Stock olan bir alan dokümana neden "integer ya da string" diye geçti? Ve tsc bu fonksiyon için ne der?

Cevap: Doküman Neyi Söylüyorsa Client Ona İnanır

TypeScript derlemesi durdu:

src/app/catalog/labels.ts(4,10): error TS2365: Operator '>' cannot be applied to types 'string | number' and 'number'.
src/app/catalog/labels.ts(14,13): error TS2322: Type 'string' is not assignable to type 'never'.

Doküman yanlış değil, ama sorulan soruya cevap vermiyor. Minimal API'ler JSON için web varsayılanlarını kullanıyor ve bu varsayılanlarda sayılar string olarak da okunabiliyor; {"quantity": "2"} gönderen bir client'ın isteği kabul ediliyor. OpenAPI üreticisi bu serializer ayarını dokümana yansıtıyor, request ile response ayrımı yapmadan. Sonuç: API cevaplarda her zaman sayı döndürdüğü hâlde, üretilmiş tipler Angular kodunu her yerde number | string ile uğraşmaya zorluyor. İkinci hata da aynı aileden: sipariş durumu C#'ta bir enum, ama DTO'da (Data Transfer Object; API'nin dışarıya verdiği veri tipi) string olduğu için dokümanda sadece "type": "string" var ve Angular'daki switch bütün durumları ele aldığını kanıtlayamıyor.

İki düzeltme de API tarafında, dokümanı üreten kodda:

builder.Services.AddOpenApi();
builder.Services.ConfigureHttpJsonOptions(options =>
    options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict);
// Enum JSON'da isimle yazılır; OpenAPI dokümanına da değer listesi olarak geçer
[JsonConverter(typeof(JsonStringEnumConverter<OrderStatus>))]
public enum OrderStatus
{
    Placed,
    Cancelled
}

public sealed record OrderDto(int Id, string Reference, OrderStatus Status, decimal Total, string? TrackingNumber, DateTimeOffset CreatedAt);

Sonrasında BookDto'daki sayılar "type": "integer" ve "type": "number" oldu, OrderStatus da {"enum": ["Placed", "Cancelled"]} olarak dokümana girdi. Üretilmiş TypeScript tipi stock: number ve OrderStatus: "Placed" | "Cancelled" hâline geldi, tsc hatasız geçti. JSON'daki durum değeri zaten "Placed" ve "Cancelled" diye yazılıyordu; kablodaki format değişmedi, yalnız doküman dürüstleşti. Test projesindeki iptal testinin assertion'ı da artık string yerine OrderStatus.Cancelled ile karşılaştırıyor.

Strict ise kablodaki davranışı değiştiriyor. Aynı sipariş isteğini "quantity": "1" ile gönderdiğimde API artık 400 döndü; öncesinde doküman bunu kabul ettiğini söylüyordu. Bu, dokümanı düzeltmenin bir client'ı kırabileceği anlamına geliyor. Angular client'ı sayıları sayı olarak gönderiyorsa sorun yok; ama karar bilinçli verilmeli ve diğer client'lara sorulmalı.

OpenAPI Dokümanını Build'de Üretmek

.NET 10 varsayılan olarak OpenAPI 3.1 dokümanı üretiyor; ürettiğim dosyanın ilk satırı "openapi": "3.1.1". Dokümanı yalnız çalışan uygulamanın /openapi/v1.json endpoint'inden değil, build sırasında bir dosya olarak da almak için Microsoft.Extensions.ApiDescription.Server paketi yetiyor:

<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <!-- Doküman repo'daki contracts klasörüne yazılır ve commit'lenir -->
  <OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../../contracts</OpenApiDocumentsDirectory>
</PropertyGroup>

<ItemGroup>
  <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.15" />
  <PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.15">
    <PrivateAssets>all</PrivateAssets>
    <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
  </PackageReference>
</ItemGroup>

Her dotnet build, contracts/Bookstore.Api.json dosyasını yeniden yazıyor. Learn'e göre build zamanı üretim, uygulamanın entry point'ini sahte bir sunucuyla çalıştırarak yapılıyor; yani Program.cs build sırasında da çalışıyor. Startup'ta migration uygulayan, bir secret store'a bağlanan ya da dış servise istek atan kod varsa, Learn'deki öneri bu yolları entry assembly adı GetDocument.Insider olduğunda atlamak. Bookstore'un Program.cs'i startup'ta dışarıya dokunmadığı için ek bir koşul gerekmedi.

Dosyayı repo'ya koymanın iki faydası var. Birincisi, API'deki her sözleşme değişikliği pull request'te bir JSON diff'i olarak görünüyor; review eden kişi C# kodunun neyi değiştirdiğini kablodaki karşılığıyla okuyor. İkincisi, Angular tarafı tipleri çalışan bir sunucuya ihtiyaç duymadan bu dosyadan üretebiliyor.

Angular Tarafı: Dokümandan Üretilen Tipler

Tipleri openapi-typescript ile üretiyorum. Paketi Angular projesine devDependency olarak eklemeye çalıştığımda npm durdu:

Could not resolve dependency:
peer typescript@"^5.x" from openapi-typescript@7.13.0
node_modules/openapi-typescript
  dev openapi-typescript@"7.13.0" from the root project

openapi-typescript 7.13 peer dependency olarak TypeScript 5 istiyor, Angular 22.2 ise TypeScript 6.0. --legacy-peer-deps ile zorlamak yerine aracı projenin bağımlılık ağacına sokmadan, sabit sürümle npx üzerinden çalıştırdım:

{
  "scripts": {
    "api:types": "npx --yes openapi-typescript@7.13.0 ../contracts/Bookstore.Api.json -o src/app/api/bookstore-api.d.ts",
    "typecheck": "tsc -p tsconfig.json --noEmit"
  },
  "dependencies": {
    "@angular/common": "22.2.6",
    "@angular/core": "22.2.6",
    "rxjs": "7.8.2"
  },
  "devDependencies": {
    "typescript": "6.0.3"
  }
}

Üretilen dosya bir components tipi içeriyor; Angular service'i ondan takma adlar alıp HttpClient'a veriyor:

import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import type { components } from './bookstore-api';

export type BookDto = components['schemas']['BookDto'];
export type OrderDto = components['schemas']['OrderDto'];

@Injectable({ providedIn: 'root' })
export class BookApi {
  private readonly http = inject(HttpClient);

  search(term: string): Observable<BookDto[]> {
    return this.http.get<BookDto[]>('/api/books', { params: { search: term } });
  }

  order(id: number): Observable<OrderDto> {
    return this.http.get<OrderDto>(`/api/orders/${id}`);
  }
}
export function statusLabel(order: OrderDto): string {
  switch (order.status) {
    case 'Placed':
      return 'Order placed';
    case 'Cancelled':
      return 'Order cancelled';
    default: {
      // Yeni bir durum eklenirse derleme burada durur
      const unhandled: never = order.status;
      return unhandled;
    }
  }
}

Elle yazılmış tipler de aynı işi görebilir; fark, sapmayı kimin yakaladığı. Elle yazılmış bir interface BookDto, API'den bir alan kaldırıldığında derlemeye devam eder ve hatayı tarayıcıda undefined olarak gösterir. Dokümandan üretilmiş tip ise bir sonraki npm run api:types ile değişir ve tsc o alanı kullanan her satırda durur. Component ve service testleri Frontend Testleri serisinin konusu; burada ilgilendiğimiz, sözleşmenin derleme anında kontrol edilmesi.

Breaking Change'leri Sınıflandırmak

Hangi değişikliğin client'ı kıracağını görmek için API'de beş değişikliği birlikte yaptım: BookDto'dan Stock'u kaldırdım, OrderStatus'a Shipped ekledim, OrderDto.Total'ı decimal'dan string'e çevirdim, PlaceOrderRequest'e string? CouponCode ekledim ve kitap listesine opsiyonel bir inStock query parametresi koydum. Eski ve yeni dokümanı oasdiff ile karşılaştırdım (çıktıyı tekrar eden endpoint'lerden temizledim):

$ oasdiff breaking contract-base.json contracts/Bookstore.Api.json
10 changes: 10 error, 0 warning, 0 info
error	[response-required-property-removed] at contracts/Bookstore.Api.json
	in API GET /api/books
		removed the required property `items/stock` from the response with the `200` status

error	[new-required-request-property] at contracts/Bookstore.Api.json
	in API POST /api/orders
		added the new required request property `couponCode`

error	[response-property-enum-value-added] at contracts/Bookstore.Api.json
	in API POST /api/orders
		added the new `Shipped` enum value to the `status` response property for the response status `201`
		The server may now return a value the previous contract excluded, so a client written against it may not handle the response. If the value set is meant to grow, declare it with x-extensible-enum.

error	[response-property-type-changed] at contracts/Bookstore.Api.json
	in API POST /api/orders
		the `total` response's property `type/format` changed from `number/double` to `string` for status `201`

oasdiff changelog aynı karşılaştırmada iki bilgi satırı daha gösterdi: inStock parametresi "new-optional-request-parameter" olarak geçti, bir de breaking change olduğu hâlde info.version'ın hâlâ 1.0.0 olduğu hatırlatıldı. Sınıflandırmanın mantığı, verinin hangi yöne aktığı:

DeğişiklikYönSonuçNeden
Alan kaldırmakresponsebreakingclient o alanı okuyor olabilir
Tip değiştirmekresponsebreakingclient'ın parse ettiği tip artık gelmiyor
Enum değeri eklemekresponsebreakingclient bilmediği bir değer alır
Zorunlu alan eklemekrequestbreakingeski client o alanı göndermiyor
Opsiyonel parametre eklemekrequestgüvenlieski client'ın isteği aynen geçerli
Alan eklemekresponsegenelde güvenliclient'ın bilinmeyen alanları yok sayması şartıyla

Yeni tipler üretilip tsc çalıştırıldığında Angular tarafındaki karşılığı da göründü:

src/app/catalog/labels.ts(4,15): error TS2339: Property 'stock' does not exist on type '{ id: number; isbn: string; title: string; price: number; }'.
src/app/catalog/labels.ts(4,35): error TS2339: Property 'stock' does not exist on type '{ id: number; isbn: string; title: string; price: number; }'.
src/app/catalog/labels.ts(14,13): error TS2322: Type '"Shipped"' is not assignable to type 'never'.

total'ın string'e dönüşmesi için hata çıkmadı, çünkü bu küçük Angular kodunda total'ı kullanan bir satır yok. Derleyici yalnız kullanılan alanları korur; oasdiff ise sözleşmenin tamamını.

CI'da Yakalamak

Üç kontrolü tek bir script'te topladım; CI bunu pull request'lerde, base branch'teki sözleşmeyle çalıştırıyor (git show origin/main:contracts/Bookstore.Api.json ile çıkarılan dosya):

#!/usr/bin/env bash
# Kullanım: scripts/check-contract.sh <base-contract.json>
set -euo pipefail
base="$1"

# 1. Build sözleşmeyi yeniden üretir; değişiklik commit'lenmiş olmalı
dotnet build src/Bookstore.Api -c Release --nologo -v quiet
git diff --exit-code -- contracts/Bookstore.Api.json

# 2. Base branch'e göre breaking change yok
oasdiff breaking "$base" contracts/Bookstore.Api.json --fail-on ERR

# 3. Angular tipleri sözleşmeden yeniden üretilir ve kod hâlâ derlenir
(cd web && npm run --silent api:types && npm run --silent typecheck)
echo "contract OK"

Değişiklik yokken script contract OK ile bitti. BookDto'dan Stock'u kaldırıp sözleşmeyi commit'lemeden çalıştırdığımda ilk adımda durdu ve diff'i gösterdi:

@@ -229,8 +229,7 @@
           "id",
           "isbn",
           "title",
-          "price",
-          "stock"
+          "price"
         ],

Sözleşmeyi commit'leyince ikinci adım devreye girdi; oasdiff üç endpoint için response-required-property-removed raporladı ve --fail-on ERR sayesinde script 1 çıkış koduyla bitti. Bu bayrak önemli: aynı karşılaştırmayı bayraksız çalıştırdığımda oasdiff on hatayı listeledi ama çıkış kodu 0'dı; pipeline yeşil kalırdı. GitHub Actions'ta Angular adımının kurulumu GitHub Actions serisinin "GitHub Actions ile Angular CI: setup-node, npm ci, Lint, Vitest, Build Budget'ları ve Playwright" yazısında.

Ne Zaman Pact?

Bu yazıdaki yaklaşım provider-driven: sözleşmeyi API yazıyor, client ona uyuyor, araçlar da sözleşmenin geriye uyumlu kalıp kalmadığına bakıyor. oasdiff bir alanın kaldırıldığını söyler ama o alanı gerçekten kimin kullandığını bilmez; stock'u hiçbir ekran kullanmıyorsa bu "breaking change" kimseyi kırmaz.

Consumer-driven contract testing (Pact) bu soruyu tersine çeviriyor: her consumer, provider'dan beklediği istek ve cevapları bir sözleşme dosyasına yazıyor, provider da bu dosyaları kendi testlerinde doğruluyor. Mekanizması ve service'ler arası kullanımı Microservices serisinin "Service Contract'ları ve Versioning: Expand-Contract, Tolerant Reader ve Consumer-Driven Contract Test" yazısında anlatıldı; burada tekrar etmiyorum. Angular ile API arasında Pact'e ancak şu durumlarda ihtiyaç duyuyorum: API'yi birden çok ekip ya da birden çok client (web, mobil, partner entegrasyonu) kullanıyorsa ve hangi alanın kim tarafından kullanıldığını bilmeden değişiklik yapılamıyorsa; ya da client ile API'nin deploy takvimleri birbirinden ayrıysa. Tek bir Angular uygulaması ve aynı repo'da yaşayan bir API için build'de üretilen doküman, oasdiff ve tsc üçlüsü daha az bakım isteyip aynı hataları yakalıyor.

Üretimde Dikkat

Breaking change'i yasaklamak yerine yönet. Bir alanı kaldırmak gerekiyorsa önce dokümanda deprecated olarak işaretle, Angular tarafı ondan vazgeçsin, sonraki sürümde kaldır; expand-contract adımları Microservices serisindeki yazıda. oasdiff'in "api-version-not-bumped" bilgisi de burada işe yarıyor: kırıcı bir değişiklik bilinçli yapılıyorsa API sürümü de değişmeli.

Enum'lar büyüyecekse bunu sözleşmede söyle. oasdiff'in mesajı yeni enum değerini kırıcı sayıyor ve büyümesi beklenen değer kümeleri için x-extensible-enum uzantısını öneriyor. Angular tarafında bunun karşılığı, never kontrolü yerine bilinmeyen durumlar için bir varsayılan gösterim yazmak. Hangisini seçtiğin, yeni bir sipariş durumunun deploy sırasını belirliyor: önce client mı güncellenecek, önce API mi?

Para tutarları dokümanda "type": "number", "format": "double" olarak geçiyor; decimal'ın kesinliği sözleşmede görünmüyor. Angular'da tutarları göstermekten öte bir hesap yapılıyorsa (sepette toplam, indirim) bu fark önemli; hesabı API'de tut ya da tutarı sözleşmede string olarak taşımaya en baştan karar ver. Sonradan değiştirmek, yukarıdaki tablodaki tip değişikliği satırına düşüyor.

Sık Düşülen Tuzaklar

OpenAPI dokümanını gerçek davranışın garantisi sanmak. Sınıflandırma deneyindeki string? CouponCode, record'un constructor parametresi olduğu için dokümana required olarak girdi ve oasdiff onu "new-required-request-property" diye raporladı. Ama çalışan API, alanı hiç göndermeyen bir isteği 201 ile kabul etti; System.Text.Json constructor parametrelerinin zorunluluğunu varsayılan olarak uygulamıyor. Doküman bir şey, davranış başka bir şey söylüyordu. Parametreye varsayılan değer verdiğimde (string? CouponCode = null) alan required listesinden çıktı ve oasdiff'in hata sayısı ondan dokuza indi. Dokümanı tek kaynak kabul ediyorsan, integration testlerinden en az biri dokümandaki iddiayı gerçekten denemeli: zorunlu denen alan eksikken 400 mü geliyor, opsiyonel denen alan eksikken 200 mü?

Nullable ve required'ı karıştırmak. OrderDto.TrackingNumber dokümanda hem required hem ["null", "string"]: alan her zaman cevapta var ama değeri null olabilir. TypeScript'te bu trackingNumber: null | string demek (üretilen dosyada da böyle), trackingNumber?: string değil. Angular kodunda ?. ile ?? arasındaki farkı bu ayrım belirliyor; doküman yanlışsa tipler de yanlış olur.

Üretilmiş dosyayı elle düzeltmek. bookstore-api.d.ts her api:types çalışmasında baştan yazılıyor. Bir tip yanlış geliyorsa düzeltme yeri C# tarafı ya da OpenAPI transformer'ı; üretilmiş dosyadaki elle düzeltme bir sonraki build'de sessizce kaybolur.

Sözleşmeyi yalnız çalışan uygulamadan okumak. /openapi/v1.json'u CI'da almak için API'yi ayağa kaldırmak gerekir ve ortam ayarları (Development'ta açılan endpoint'ler gibi) dokümanı değiştirebilir. Build zamanı üretilen ve commit'lenen dosya, pull request'te herkesin aynı şeye baktığını garanti ediyor.

Küçük Bir Deneme

★☆☆ CreateBookRequest'teki Stock alanını int? Stock = null yap. Dokümandaki değişikliği diff'te bul ve oasdiff'in bunu hangi kategoriye koyduğunu gör.

★★☆ Bookstore'a bir integration testi ekle: çalışan API'nin /openapi/v1.json cevabını contracts/Bookstore.Api.json ile karşılaştırsın. İki doküman arasında fark çıkıyor mu? Çıkıyorsa neden?

★★★ OrderStatus'a x-extensible-enum uzantısını ekleyen bir OpenAPI schema transformer'ı yaz. Shipped değerini tekrar ekleyip oasdiff'in sonucunu karşılaştır, Angular tarafında never kontrolünü bilinmeyen durumlar için bir varsayılan gösterimle değiştir.

API'nin hem dışarıya hem Angular'a bakan sınırları artık test altında. Sıradaki yazıyla E2E (uçtan uca test) bloğuna geçiliyor: Playwright ile tarayıcıyı da işin içine katmak, locator'lar, auto-wait ve trace viewer.