.NET'ten SOAP Servislerine Bağlanmak: dotnet-svcutil, WCF Client, WS-Security, Sertifikalar ve Resilience
.NET'ten bir SOAP servisine dotnet-svcutil ile WSDL'den üretilen interface, kendi kurduğun binding ve ChannelFactory ile bağlanırsın; resilience HTTP seviyesinde değil operasyon seviyesinde kurulur, çünkü SOAP fault'ları HTTP 500 ile gelir ve WCF çağrıları CancellationToken almaz. Bookstore'da kurumsal faturaları UsernameToken ve mTLS ile sahte bir e-Fatura entegratörüne gönderiyor, ETTN ile retry'ı güvenli hâle getiriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, System.ServiceModel.Http 10.1, dotnet-svcutil 8.0, CoreWCF 1.9 ve Polly 8.9 ile derlenip denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) kurumsal siparişlerin faturasını bir e-Fatura entegratörüne gönderiyor ve entegratörün sunduğu tek arayüz bir SOAP (XML zarfları içinde mesaj taşıyan eski bir web servis protokolü) servisi. Üç soru:
Birincisi: entegratör, vergi numarası hatalı bir faturayı reddettiğinde HTTP yanıtının status code'u kaç olur? 03. yazıdaki standard resilience handler bu yanıtı ne yapar?
İkincisi: WCF (Windows Communication Foundation; .NET'in SOAP client ve servis altyapısı) client'ına bir message inspector ekleyip giden zarfı logluyorsun. Log'da UsernameToken'ın parolası görünür mü?
Üçüncüsü: retry'ı catch (CommunicationException) mantığıyla kurdun. Sertifikasına güvenmediğin bir sunucuya kaç istek gider?
Cevaplar: 500 ve standard handler onu geçici hata sayıp tekrar dener; parola görünmez, çünkü security header inspector'dan sonra ekleniyor; ve üç istek, çünkü WCF'in neredeyse bütün exception'ları CommunicationException'dan türüyor. Üçünü de aşağıda gerçek çıktılarla görüyoruz.
WSDL'den Proxy Üretmek: dotnet-svcutil
SOAP servisinin sözleşmesi WSDL (Web Services Description Language; servisin operasyonlarını ve mesaj şemalarını tanımlayan XML belgesi) dosyasında durur. 01. yazıda OpenAPI'den client ürettiğimiz gibi, burada da client'ı WSDL'den üretiyoruz. Visual Studio'da bunun arayüzü "Connected Services" altındaki WCF Web Service Reference; komut satırındaki karşılığı dotnet-svcutil. Gerçek entegratör yerine repo'daki Bookstore.FakeEInvoice projesi var: CoreWCF ile yazılmış, WSDL yayımlayan küçük bir SOAP servisi.
Aracı global değil, repo'nun tool manifest'ine kuruyoruz; böylece herkes aynı sürümü kullanıyor. Fake'i bir terminalde başlatıp aracı ikincisinde, solution kökünden çalıştırıyoruz:
dotnet tool install dotnet-svcutil --version 8.0.0
dotnet run --project src/Bookstore.FakeEInvoice
dotnet dotnet-svcutil http://127.0.0.1:52410/EInvoiceService.svc?wsdl --outputDir src/Bookstore.Api/Invoicing/Integrator --namespace "*,Bookstore.Invoicing.Integrator" --outputFile EInvoiceIntegrator.cs --noLogo
Attempting to download metadata from 'http://127.0.0.1:52410/EInvoiceService.svc?wsdl' using WS-Metadata Exchange and HttpGet.
Generating files...
İlk denemede üç sürprizle karşılaştım. dotnet-svcutil 8.0.0 paketinin içinde yalnız net8.0 ve net9.0 derlemeleri var; sadece .NET 10 kurulu bir makinede "You must install or update .NET" hatası veriyor. Manifest'te bu tool için "rollForward": true yazınca .NET 10 üzerinde çalıştı. İkincisi, komutu proje klasöründe çalıştırdığımda araç projeyi referans olarak alıp net8.0 hedefli geçici bir bootstrapper projesi derlemeye çalıştı ve ASP.NET Core'un OpenAPI source generator'ı yüzünden CS0234 ile durdu. Solution kökünden çalıştırınca bu adım hiç olmadı. Üçüncüsü, --projectFile ile çalıştırınca csproj'a kendiliğinden bir satır ekledi:
<PackageReference Include="System.ServiceModel.NetTcp" Version="8.*" />
Bookstore Central Package Management kullanıyor; sürümler Directory.Packages.props'ta duruyor. Bu satırla build NU1008 hatası verdi. Çözüm basit: araç csproj'a dokunmasın, paketleri (System.ServiceModel.Http, System.ServiceModel.Primitives) biz ekleyelim.
Üretilen dosyada en çok işine yarayacak parça interface:
public interface IEInvoiceService
{
System.Threading.Tasks.Task<Bookstore.Invoicing.Integrator.InvoiceReceipt> SendInvoiceAsync(Bookstore.Invoicing.Integrator.InvoiceDocument request);
System.Threading.Tasks.Task<Bookstore.Invoicing.Integrator.InvoiceStatusInfo> GetInvoiceStatusAsync(string uuid);
}
Dikkat: method'lar CancellationToken almıyor. Bunun sonucunu aşağıda resilience kurarken göreceğiz. Klasörde ayrıca dotnet-svcutil.params.json var; WSDL değiştiğinde dotnet dotnet-svcutil --update Invoicing/Integrator --projectFile ... bu dosyayı okuyup kodu yeniden üretiyor (yine csproj'a satır ekledi, onu geri aldım). Üretilen kodu repoda tutuyoruz, tıpkı 01. yazıdaki Kiota client'ı gibi: WSDL'deki bir değişiklik pull request'te diff olarak görünsün.
Üretilen Koda Dokunmamak: Port, Adapter ve Kendi Binding'in
Üretilen client'ın GetBindingForEndpoint method'u WSDL'deki her endpoint için bir binding kuruyor:
System.ServiceModel.BasicHttpBinding result = new System.ServiceModel.BasicHttpBinding();
result.MaxBufferSize = int.MaxValue;
result.ReaderQuotas = System.Xml.XmlDictionaryReaderQuotas.Max;
result.MaxReceivedMessageSize = int.MaxValue;
result.AllowCookies = true;
return result;
Endpoint adresi de koda gömülü (http://127.0.0.1:52410/...) ve mesaj boyutu limitleri tamamen açık. Bu dosyayı elle düzeltmek cazip, ama bir sonraki üretimde düzeltmelerin hepsi kayboluyor. Onun yerine üretilen client sınıfını hiç kullanmıyoruz; yalnız interface ve data contract'ları alıp channel'ı kendimiz açıyoruz. Domain-Driven Design serisinin Context Mapping yazısındaki ve bu serinin 01. yazısındaki ACL (Anti-Corruption Layer; dış sistemin modelini kendi modelimize çeviren katman) burada da aynı: önce Bookstore'un kendi dili.
namespace Bookstore.Invoicing;
// Port: Bookstore'un e-fatura kesmek için kendi dili
public interface IInvoiceIntegrator
{
Task<InvoiceSubmission> SubmitAsync(Invoice invoice, CancellationToken cancellationToken);
}
Binding ve endpoint artık appsettings'teki Invoicing:EInvoice bölümünden geliyor:
public static Binding CreateBinding(EInvoiceIntegratorOptions settings)
{
var binding = settings.Security switch
{
EInvoiceSecurity.None => new BasicHttpBinding(BasicHttpSecurityMode.None),
EInvoiceSecurity.UserName => new BasicHttpBinding(BasicHttpSecurityMode.TransportWithMessageCredential)
{
Security = { Message = { ClientCredentialType = BasicHttpMessageCredentialType.UserName } }
},
EInvoiceSecurity.Certificate => new BasicHttpBinding(BasicHttpSecurityMode.Transport)
{
Security = { Transport = { ClientCredentialType = HttpClientCredentialType.Certificate } }
},
_ => throw new ArgumentOutOfRangeException(nameof(settings))
};
binding.OpenTimeout = TimeSpan.FromSeconds(5);
binding.SendTimeout = settings.SendTimeout;
// Üretilen kod int.MaxValue veriyor; bir makbuz için 1 MB fazlasıyla yeter
binding.MaxReceivedMessageSize = 1024 * 1024;
return binding;
}
ChannelFactory<IEInvoiceServiceChannel> bu binding'le uygulama başına bir kez oluşturuluyor (EInvoiceChannelFactory, singleton). Factory'yi kurmak pahalı, ondan channel açmak ucuz; her çağrı yeni bir channel kullanıyor.
Gerçek entegratörler genelde faturayı UBL (Universal Business Language; uluslararası e-fatura XML standardı) üzerine kurulu UBL-TR formatında, GİB'in (Gelir İdaresi Başkanlığı) tanımladığı bir XML belgesi olarak, çoğu zaman sıkıştırılıp base64'e çevrilmiş hâlde ister. Fake entegratörümüz bu ayrıntıyı atlıyor ve alanları doğrudan data contract'ta taşıyor. Ama bir kuralı aynen koruyor: her faturanın gönderen tarafın ürettiği bir ETTN'si (Evrensel Tekil Tanımlama Numarası; faturanın kimliği) var, biçimi bir UUID (Universally Unique Identifier; pratikte çakışmayan 128 bitlik kimlik) ve aynı ETTN ikinci kez gelirse reddediliyor. Resilience bölümünde bu kural işimize yarayacak.
Kimlik Doğrulama: UsernameToken, Client Sertifikası ve Desteklenmeyenler
Entegratörlerde en sık iki yöntem görürsün: TLS (Transport Layer Security; HTTPS bağlantısını şifreleyen protokol) ile korunan bir bağlantıda WS-Security UsernameToken (kullanıcı adı ve parolanın SOAP header'ında taşınması) ya da client sertifikasıyla mTLS (mutual TLS; iki tarafın da sertifikayla kimliğini kanıtladığı TLS bağlantısı). Bookstore varsayılan olarak ilkini kullanıyor:
case EInvoiceSecurity.UserName:
_factory.Credentials.UserName.UserName = settings.UserName;
_factory.Credentials.UserName.Password = settings.Password;
break;
case EInvoiceSecurity.Certificate:
_factory.Credentials.ClientCertificate.Certificate =
X509CertificateLoader.LoadPkcs12FromFile(settings.ClientCertificatePath!, password: null);
break;
Fake entegratörü --show-wire ile başlatırsan gelen isteğin header'ını olduğu gibi yazıyor (parolayı biz maskeledik):
<s:Header><o:Security s:mustUnderstand="1" xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"><u:Timestamp u:Id="_0"><u:Created>2026-11-15T08:20:52.308Z</u:Created><u:Expires>2026-11-15T08:25:52.308Z</u:Expires></u:Timestamp><o:UsernameToken u:Id="uuid-f1925873-5960-49db-94fd-120ffb430fd4-1"><o:Username>bookstore-test</o:Username><o:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">***</o:Password></o:UsernameToken></o:Security></s:Header>
PasswordText: parola düz metin olarak gidiyor, onu koruyan tek şey TLS. Bu yüzden TransportWithMessageCredential HTTPS'siz çalışmıyor. Parolanın kendisi appsettings.json'da değil, production'da bir secret store'da durmalı; bunu Configuration ve Secret Management serisinde anlattık. Yanlış parolayla gelen yanıt MessageSecurityException oldu, içinde de entegratörün fault mesajı: "Unknown user name or bad password."
Client sertifikası tarafında Certificate modunu ve fake'in /mtls endpoint'ini seçince aynı fatura ENV-4001 zarf numarasıyla kabul edildi. Sertifika yükleme ve chain doğrulamanın ayrıntıları Geliştirici için Kriptografi serisinin X.509 ve PKI (Public Key Infrastructure; sertifikaları üreten ve doğrulayan altyapı) yazısında.
.NET'teki WCF client, .NET Framework'teki WCF'in tamamı değil. Hangi binding'lerin kabul edildiğini samples/Bookstore.SoapLab ile denedim; channel açmak yetiyor, ağa hiçbir şey gitmiyor:
WSHttpBinding(Transport): channel created
WSHttpBinding(TransportWithMessageCredential): channel created
WSHttpBinding(Message): PlatformNotSupportedException: Operation is not supported on this platform.
BasicHttpBinding(Message): PlatformNotSupportedException: The value 'Message' is not supported in this context for the binding security property 'securityMode'.
Yani mesajı imzalayıp şifreleyen message security modu yok; taşıma katmanında TLS ve onun üstünde UsernameToken var. Entegratörün WSDL'i message security istiyorsa bunu sözleşme aşamasında konuşman gerekir; kodla çözemezsin.
Mesajı Loglamak ve Fault'u Kendi Hata Modeline Çevirmek
Entegratörle bir sorun yaşadığında destek ekibi ilk olarak giden ve gelen zarfı ister. WCF'te bunun yeri bir IClientMessageInspector. Vergi numaraları kişisel veri, onları maskeliyoruz:
public object? BeforeSendRequest(ref Message request, IClientChannel channel)
{
// Bir Message yalnız bir kez okunabilir: kopyasını logla, yenisini gönder
var buffer = request.CreateBufferedCopy(int.MaxValue);
request = buffer.CreateMessage();
logger.LogInformation("SOAP request {Action}: {Envelope}", request.Headers.Action, Mask(Write(buffer)));
return null;
}
İlk denemede log'da gövde yerine ... stream ... yazıyordu: Message.ToString() stream olarak okunan gövdeyi yazdırmıyor. Zarfı WriteMessage ile bir XmlWriter'a yazınca tamamı göründü:
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Header>
<Action s:mustUnderstand="1" xmlns="http://schemas.microsoft.com/ws/2005/05/addressing/none">http://einvoice.example.com/integrator/v1/IEInvoiceService/SendInvoice</Action>
</s:Header>
<s:Body>
<SendInvoice xmlns="http://einvoice.example.com/integrator/v1">
<request xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
<Uuid>ef3d1794-8a47-4be3-9f44-81fcbc2ec9ed</Uuid>
<InvoiceNumber>BKS2026000000001</InvoiceNumber>
<IssueDate>2026-11-15T00:00:00Z</IssueDate>
<SenderTaxNumber>***</SenderTaxNumber>
<ReceiverTaxNumber>***</ReceiverTaxNumber>
<ReceiverName>School 0042</ReceiverName>
<PayableAmount>4131.00</PayableAmount>
Tahmin sorusunun ikinci cevabı burada: bu istek HTTPS ve UsernameToken ile gitti, ama log'da o:Security header'ı yok. Inspector channel stack'in üstünde çalışıyor; security header'ı daha aşağıdaki security katmanı ekliyor. Parola zaten inspector'ın log'una düşmüyor, ama bunu ayarlara güvenerek değil, maskeyle garanti altına almak daha doğru: binding bir gün değişebilir.
Hatalı vergi numaralı kurumsal siparişe (1004) entegratörün yanıtı:
<s:Fault>
<faultcode>s:INVALID_TAX_NUMBER</faultcode>
<faultstring>Receiver tax number must be 10 or 11 digits.</faultstring>
<detail>
<IntegratorFault xmlns="http://einvoice.example.com/integrator/v1" xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
<Code>INVALID_TAX_NUMBER</Code>
<Message>Receiver tax number must be 10 or 11 digits.</Message>
</IntegratorFault>
</detail>
</s:Fault>
Bu zarf HTTP 500 ile geliyor; SOAP 1.1'de fault'un status code'u her zaman 500. Tahmin sorusunun birinci cevabı bu: HTTP status'a bakan bir retry, iş kuralı reddini sunucu arızası sanır. Adapter fault'u InvoiceIntegratorException'a çeviriyor, API de 422 dönüyor:
{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"The e-invoice was rejected by the integrator.","status":422,"integratorCode":"INVALID_TAX_NUMBER","traceId":"00-5f6cc1b947e8adb0edd23b3049f2ea27-19f8a898bd22fb0c-02"}
Entegratörün kodu (integratorCode) destek için yanıtta kalıyor; SOAP ayrıntıları ve stack trace yalnız bizim log'umuzda.
Channel Ömrü: Close, Abort ve İptal
WCF'te en eski tuzak using ile channel kapatmak: faulted bir channel'da Close exception fırlatır ve asıl hatanın üstüne yazar. Kural şu: başarıda Close, hatada Abort, faulted channel'ı asla tekrar kullanma.
private async ValueTask<T> CallAsync<T>(Func<IEInvoiceServiceChannel, Task<T>> operation, CancellationToken token)
{
var channel = channels.CreateChannel();
// WCF operasyonları CancellationToken almaz: bir çağrıyı durdurmanın tek yolu channel'ı abort etmek
using var registration = token.Register(channel.Abort);
try
{
var result = await operation(channel);
await Task.Factory.FromAsync(channel.BeginClose, channel.EndClose, null);
return result;
}
catch (CommunicationException) when (token.IsCancellationRequested)
{
channel.Abort();
throw new OperationCanceledException(token);
}
catch
{
// Faulted bir channel kapatılamaz ve tekrar kullanılamaz, yalnız abort edilir
channel.Abort();
throw;
}
}
token.Register(channel.Abort) satırı önemli. 04. yazıda Polly'nin timeout'unun iyimser olduğunu, token'ı dinlemeyen kodu kesemediğini görmüştük. WCF çağrısı token almadığı için Polly'nin timeout'u tek başına hiçbir şeyi durdurmaz; token iptal edildiğinde channel'ı abort etmek, devam eden HTTP isteğini gerçekten kapatıyor. Tek bir çağrının süresini ise binding'in SendTimeout'u belirliyor.
Bunu fake entegratörü --mode Slow ile (yanıt 5 saniye sonra), API'yi SendTimeout 6 saniyeyle başlatıp denedim. curl --max-time 1 isteği 1 saniyede bırakınca HttpContext.RequestAborted token'ı iptal oldu, channel abort edildi ve çağrı 5 saniyelik yanıtı beklemeden bitti:
Execution attempt. Source: 'einvoice/(null)/Retry', Operation Key: '', Result: 'A task was canceled.', Handled: 'False', Attempt: '0', Execution Time: 922.5451ms
Resilience pipeline executed. Source: 'einvoice/(null)', Operation Key: '', Result: 'A task was canceled.', Execution Time: 937.2477ms
Resilience: Retry'ı Operasyon Seviyesinde Kurmak
WCF client kendi HTTP katmanını yönetiyor; IHttpClientFactory'ye ve AddResilienceHandler'a bağlanmıyor. Bağlanabilseydi de istemezdik: HTTP seviyesindeki retry 500 gelen her fault'u tekrar gönderirdi. Pipeline'ı 04. yazıdaki gibi AddResiliencePipeline ile kurup operasyonun etrafına koyuyoruz:
builder.Services.AddResiliencePipeline(SoapInvoiceIntegrator.PipelineName, pipeline => pipeline
.AddTimeout(TimeSpan.FromSeconds(30))
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder().Handle<Exception>(SoapInvoiceIntegrator.IsTransient),
MaxRetryAttempts = 2,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(500)
}));
Neyin geçici olduğunu söyleyen predicate'in sırası önemli. FaultException, MessageSecurityException, SecurityNegotiationException, ServerTooBusyException: hepsi CommunicationException'dan türüyor. İlk sürümümde son satır en üstteydi ve sertifikasına güvenmediğim sunucuya üç istek gitti; log'daki son deneme Attempt: '2' ve UntrustedRoot hatasıyla bitti. Tahmin sorusunun üçüncü cevabı buydu. Düzeltilmiş hâli:
// Yalnız biraz sonra başarılı olabilecek olanı tekrar dene. FaultException bir CommunicationException'dır, sıra önemli
public static bool IsTransient(Exception exception) => exception switch
{
FaultException => false,
// Yanlış parola, güvenilmeyen ya da süresi dolmuş sertifika: sonraki deneme de aynı şekilde başarısız olur
MessageSecurityException or SecurityAccessDeniedException or SecurityNegotiationException => false,
ServerTooBusyException => false,
TimeoutException or CommunicationException => true,
_ => false
};
ServerTooBusyException WCF'in 503 için fırlattığı exception. Fake entegratörü --mode Maintenance ile başlatınca (503 + Retry-After: 600) tek istek gitti ve API 503 döndü; on dakikalık bakım penceresine saniyeler içinde tekrar gitmenin anlamı yok.
Peki fatura göndermek idempotent mi, retry güvenli mi? 06. yazıdaki Idempotency-Key'in buradaki karşılığı ETTN. InvoiceStore UUID'yi ve fatura numarasını ilk denemeden önce sabitliyor; her retry, hatta kullanıcının API'yi tekrar çağırması da aynı UUID'yi gönderiyor. Entegratör aynı UUID'yi ikinci kez görünce DUPLICATE_UUID fault'u dönüyor; adapter bunu hata saymıyor, durumu soruyor. Fake'i --mode Slow ile başlattım: faturayı hemen kaydediyor ama yanıtı 5 saniye sonra veriyor. SendTimeout 2 saniye:
Execution attempt. Source: 'einvoice/(null)/Retry', Operation Key: '', Result: 'The request channel timed out attempting to send after 00:00:01.9965394. Increase the timeout value passed to the call to Request or increase the SendTimeout value on the Binding. The time allotted to this operation may have been a portion of a longer timeout.', Handled: 'True', Attempt: '0', Execution Time: 2066.674ms
Execution attempt. Source: 'einvoice/(null)/Retry', Operation Key: '', Result: 'Invoice 1618f4e0-a58c-4824-95c1-97f1b7082c18 was already received.', Handled: 'False', Attempt: '1', Execution Time: 41.1307ms
Execution attempt. Source: 'einvoice/(null)/Retry', Operation Key: '', Result: 'Bookstore.Invoicing.Integrator.InvoiceStatusInfo', Handled: 'False', Attempt: '0', Execution Time: 8.4471ms
Invoice 1618f4e0-a58c-4824-95c1-97f1b7082c18 was already received as ENV-4001
API'nin yanıtı ve curl'ün ölçtüğü süre:
{"orderId":1003,"uuid":"1618f4e0-a58c-4824-95c1-97f1b7082c18","number":"BKS2026000000001","envelopeId":"ENV-4001","status":"QUEUED"}
2.847307s
İlk deneme timeout oldu, ama fatura entegratöre ulaşmıştı. İkinci deneme DUPLICATE_UUID aldı (Handled: 'False', yani retry yapılmadı), durum sorgusu zarf numarasını verdi ve istek 2,8 saniyede tamamlandı. Entegratörde tek fatura var. Gerçek bir entegratörde "aynı UUID" yanıtının adı ve kodu farklı olacak; dokümanında bunu bul ve adapter'da karşıla. Faturayı istek içinde kesmek yerine bu işi bir background job'a almak ve tekrar denemeyi oraya bırakmak da mümkün; mantığı Background Job'lar serisinin retry ve idempotency yazısında.
WireMock.Net ile SOAP Test Etmek
05. yazıdaki testlerin aynısını SOAP için de yazabiliriz. WireMock.Net için SOAP sadece bir POST: path'e ve SOAPAction header'ına göre eşleştirip hazır zarf dönüyoruz. Zarfları fake entegratörün gerçek yanıtlarından kopyaladım:
// SOAP 1.1: fault her zaman HTTP 500 ile gelir
private static IResponseBuilder Fault(string code, string message) => Soap(
$"""<s:Fault><faultcode>s:{code}</faultcode><faultstring>{message}</faultstring><detail><IntegratorFault xmlns="{Ns}"><Code>{code}</Code><Message>{message}</Message></IntegratorFault></detail></s:Fault>""",
500);
[Fact]
public async Task Timeout_then_duplicate_uuid_is_treated_as_success()
{
// İlk yanıt 1 sn'lik send timeout'tan geç geliyor; entegratör faturayı çoktan kaydetmiş
_integrator.Given(Operation("SendInvoice")).InScenario("send").WillSetStateTo("stored")
.RespondWith(Soap(Receipt).WithDelay(TimeSpan.FromSeconds(3)));
_integrator.Given(Operation("SendInvoice")).InScenario("send").WhenStateIs("stored")
.RespondWith(Fault("DUPLICATE_UUID", "Invoice was already received."));
_integrator.Given(Operation("GetInvoiceStatus")).RespondWith(Soap(
$"""<GetInvoiceStatusResponse xmlns="{Ns}"><GetInvoiceStatusResult><Uuid>u</Uuid><EnvelopeId>ENV-9001</EnvelopeId><Status>QUEUED</Status></GetInvoiceStatusResult></GetInvoiceStatusResponse>"""));
await using var api = CreateApi();
using var response = await api.CreateClient().PostAsync("/api/orders/1003/invoice", null, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var invoice = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Equal("ENV-9001", invoice.GetProperty("envelopeId").GetString());
// WireMock geciken isteği ancak geç yanıtı yazıldıktan sonra log'a ekliyor
await Task.Delay(TimeSpan.FromSeconds(2.5), TestContext.Current.CancellationToken);
Assert.Equal(2, Requests("SendInvoice"));
Assert.Equal(1, Requests("GetInvoiceStatus"));
}
Son Task.Delay bir çalıştırmanın sonucu: ilk koşuda test Expected: 2, Actual: 1 ile kırmızıya döndü. WireMock, gecikmeli yanıtı veren isteği log'una ancak yanıt yazıldıktan sonra ekliyor. Aynı dosyada iş kuralı fault'unun 422'ye çevrildiğini ve tek istek gittiğini, 503'ün de tekrar gönderilmediğini doğrulayan iki test daha var:
Test run summary: Passed!
total: 17
failed: 0
succeeded: 17
skipped: 0
duration: 17s 095ms
Sunucu tarafında, yani eski bir WCF servisini .NET'e taşıman gerekiyorsa adres CoreWCF: WCF'in servis tarafının .NET'e taşınmış hâli. Fake entegratörümüz de onunla yazıldı; WSDL'i ve fault'ları gerçek bir WCF servisininkiyle aynı biçimde üretiyor.
Üretimde Dikkat
Her çağrının bir zaman bütçesi olsun ve iki katmanda tanımlansın: binding'in SendTimeout'u tek bir denemeyi, Polly'nin timeout'u toplam süreyi sınırlıyor. İkincisinin işe yaraması için channel'ın token'a bağlanıp abort edilmesi gerekiyor.
Mesaj loglamayı varsayılan olarak açma. Zarflar büyük ve kişisel veri taşıyor; LogMessages ayarını yalnız destek gerektiğinde, kısa süreliğine aç. Maskelenecek alanların listesini entegratörün şemasına göre gözden geçir.
Sertifika doğrulamasını kapatma. Fake'in kendinden imzalı sertifikası için Bookstore development ortamında tek bir sertifikayı sabitliyor (PinnedCertificateValidator); production'da makinenin güven deposu ve gerçek CA (Certificate Authority; sertifikayı imzalayan kuruluş) zinciri geçerli. Client sertifikanın ve entegratörün sunucu sertifikasının bitiş tarihini izle; 01. yazıdaki tabloda dediğimiz gibi, süresi dolan sertifikaya retry işe yaramaz.
Sık Düşülen Tuzaklar
Üretilen proxy'yi elle düzenlemek. Adres, timeout ve limitler bir sonraki --update'te geri gelir. Ayarları kendi binding'inde tut.
using ile channel ya da client kapatmak. Faulted channel'da Close exception fırlatır, asıl hatayı gizler. Başarıda Close, hatada Abort.
Faulted bir channel'ı ya da client'ı alan olarak saklayıp tekrar kullanmak. Bir hata sonrası o nesne işe yaramaz; uygulama başına tutulacak olan channel değil, ChannelFactory.
catch (CommunicationException) ile retry. Fault'u, kimlik hatasını ve sertifika hatasını da tekrar dener.
SOAP client'a HTTP seviyesinde retry koymak. Fault'lar 500 ile geldiği için iş kuralı reddi de tekrar gönderilir.
Küçük Bir Deneme
★☆☆ samples/Bookstore.SoapLab'e NetHttpBinding ve BasicHttpsBinding ekle. Hangileri channel açabiliyor?
★★☆ Fake entegratörü --mode Slow ile başlat ve Invoicing:EInvoice:SendTimeout'u 6 saniyeye çıkar. Kaç istek gidiyor ve yanıt ne kadar sürüyor? Sonra Polly'deki toplam timeout'u 3 saniyeye indir; channel abort edilince API ne dönüyor?
★★★ IssueAsync'i bir background job'a taşı: endpoint faturayı Pending olarak kaydedip 202 dönsün, job Transient hatalarda aynı UUID ile daha sonra tekrar denesin, Rejected hatalarında back office'e bir görev açsın.
Bu yazıda SOAP'ın HTTP'den farklı davranan yerlerini gördük: hatalar 500 ile geliyor, client'ın kendi HTTP katmanı var ve iptal token'la değil abort ile oluyor. Bir sonraki ve serinin son yazısında, hiç HTTP olmayan bir entegrasyona bakıyoruz: SFTP (SSH File Transfer Protocol; şifreli SSH bağlantısı üzerinden dosya aktarımı) ve FTP (File Transfer Protocol; klasik dosya aktarım protokolü) ile dosya alışverişi, klasör izleme, aynı dosyayı iki kez işlememek ve banka ekstresiyle mutabakat.