Files
idoklad/Program.cs
T
JiriUhlirandClaude Opus 5 73f01d0228 oprava datumu: prijem data bez zony jako UTC
SDK validuje Post/Patch modely pred odeslanim a u kazde polozky DateTime
vyzaduje Kind == Utc. Datum bez zony, napriklad "2026-08-25", nacetl
Newtonsoft jako Unspecified, takze POST /issued-invoices koncil chybou
"DateTime must be in UTC format" jeste pred volanim iDokladu.

- DateTimeZoneHandling.Utc: hodnota bez zony dostane Kind Utc bez posunu,
  hodnota s offsetem se prepocita do UTC
- ExceptionHandlingMiddleware zachytava IdokladValidationException a vraci
  400 se seznamem vadnych vlastnosti misto prazdne 500

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 10:51:45 +02:00

145 lines
6.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.OpenApi.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using Idoklad.Client;
using Idoklad.Configuration;
using Idoklad.Credentials;
using Idoklad.Infrastructure;
using Idoklad.Services;
var builder = WebApplication.CreateBuilder(args);
// Configuration resolved from environment variables (single shared instance).
var settings = new IdokladSettings();
builder.Services.AddSingleton(settings);
// HttpClient for the iDoklad SDK, managed by IHttpClientFactory (recommended SDK usage).
builder.Services.AddHttpClient(DokladApiFactory.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(settings.RequestTimeoutSeconds);
});
builder.Services.AddHttpContextAccessor();
// Credential resolution + SDK client wiring.
builder.Services.AddScoped<RequestCredentialsProvider>();
builder.Services.AddScoped<DokladApiFactory>();
builder.Services.AddScoped<IdokladApiAccessor>();
// Agenda services.
builder.Services.AddScoped<ContactsService>();
builder.Services.AddScoped<IssuedInvoicesService>();
builder.Services.AddScoped<ReceivedInvoicesService>();
builder.Services.AddScoped<RegistersService>();
builder.Services.AddScoped<AccountService>();
builder.Services.AddScoped<CodeListsService>();
builder.Services.AddScoped<SalesDocumentsService>();
builder.Services.AddScoped<PurchaseCashService>();
builder.Services.AddScoped<PaymentsService>();
builder.Services.AddScoped<CatalogService>();
builder.Services.AddScoped<IntegrationService>();
builder.Services.AddScoped<StatisticsService>();
builder.Services.AddScoped<ReportsService>();
builder.Services.AddScoped<MailService>();
// Use Newtonsoft.Json so request/response binding matches the iDoklad SDK model attributes.
builder.Services
.AddControllers()
.AddNewtonsoftJson(options =>
{
options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
// The SDK validates every DateTime member of a Post/Patch model with a UTC check and
// rejects the model when Kind is not Utc. Newtonsoft's default (RoundtripKind) leaves a
// date without a zone, e.g. "2026-08-25", as Unspecified, so such a request failed
// validation before it ever reached iDoklad. Utc marks the value as UTC without shifting
// it and converts values that do carry an offset.
options.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Utc;
// Some IdokladSdk converters attached to model members only support reading (their
// WriteJson throws NotImplementedException), which makes responses carrying such models
// fail. The resolver bypasses them on the write path; the existing naming strategy is
// preserved so property names in responses do not change.
var contractResolver = new SdkContractResolver();
if (options.SerializerSettings.ContractResolver is DefaultContractResolver defaultResolver)
{
contractResolver.NamingStrategy = defaultResolver.NamingStrategy;
}
options.SerializerSettings.ContractResolver = contractResolver;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = settings.AppName,
Version = settings.AppVersion,
Description =
"REST integration with iDoklad built on the official IdokladSdk (.NET) 5.3.0, " +
"using the OAuth2 client credentials flow.\n\n" +
"**Credentials.** Every agenda endpoint needs an iDoklad ClientId, ClientSecret and ApplicationId. " +
"Sensitive values are required in request headers and are never accepted in the query string or body:\n\n" +
"- `X-ClientId` — iDoklad OAuth2 ClientId (required)\n" +
"- `X-ClientSecret` — iDoklad OAuth2 ClientSecret (required; sensitive; TLS only)\n" +
"- `X-ApplicationId` — OPTIONAL ApplicationId (GUID) for partner apps only; leave empty for a standard app\n" +
"- `X-Idoklad-Language` — optional response language (Cz, Sk, En)\n\n" +
"If a header is omitted, the matching environment default " +
"(`IDOKLAD_CLIENT_ID`, `IDOKLAD_CLIENT_SECRET`, `IDOKLAD_APPLICATION_ID`) is used. " +
"Only ClientId and ClientSecret are required; if either is missing the request is rejected with 401. " +
"When no ApplicationId is provided, authentication uses client_id + client_secret only.",
});
options.OperationFilter<CredentialHeadersOperationFilter>();
var xmlPath = Path.Combine(AppContext.BaseDirectory, $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml");
if (File.Exists(xmlPath))
{
options.IncludeXmlComments(xmlPath, includeControllerXmlComments: true);
}
});
var app = builder.Build();
// ROOT_PATH carries the public reverse-proxy prefix (e.g. /apps/idoklad). AppFactory's Caddy uses
// handle_path, which strips that prefix before the request reaches the container, so PathBase ends
// up empty and the OpenAPI server URL must come from ROOT_PATH directly (see the filter below).
var publicPrefix = string.IsNullOrWhiteSpace(settings.RootPath) ? null : "/" + settings.RootPath.Trim('/');
// UsePathBase is a no-op under handle_path (the request no longer carries the prefix) but keeps the
// app correct behind a proxy that forwards the prefix intact, so routes still resolve in that case.
if (publicPrefix is not null)
{
app.UsePathBase(publicPrefix);
}
app.UseMiddleware<ExceptionHandlingMiddleware>();
// Serve the OpenAPI document under the same /docs prefix as the UI so a relative endpoint
// resolves correctly both locally and behind a reverse-proxy prefix (ROOT_PATH).
app.UseSwagger(options =>
{
options.RouteTemplate = "docs/{documentName}/swagger.json";
// Advertise the public prefix (e.g. /apps/idoklad behind the portal proxy) as the OpenAPI
// server so Swagger UI "Try it out" targets {prefix}/contacts, not the host root. The prefix
// comes from ROOT_PATH because handle_path has already stripped it from the request, leaving
// httpReq.PathBase empty. Fall back to PathBase (a proxy that keeps the prefix) and finally "/".
options.PreSerializeFilters.Add((swaggerDoc, httpReq) =>
{
var serverUrl = publicPrefix ?? (httpReq.PathBase.HasValue ? httpReq.PathBase.Value : "/");
swaggerDoc.Servers = new List<OpenApiServer> { new() { Url = serverUrl } };
});
});
app.UseSwaggerUI(options =>
{
// Interactive docs at /docs (matching the sibling microsoft-365-service).
options.RoutePrefix = "docs";
// Relative endpoint — resolves to {pathBase}/docs/v1/swagger.json in the browser.
options.SwaggerEndpoint("v1/swagger.json", $"{settings.AppName} v1");
options.DocumentTitle = $"{settings.AppName} – API docs";
});
app.MapControllers();
app.Run();