diff --git a/docs/plans/2026-07-27 - arr-instance-config-ui.md b/docs/plans/2026-07-27 - arr-instance-config-ui.md index 99aaac5..c5d1f75 100644 --- a/docs/plans/2026-07-27 - arr-instance-config-ui.md +++ b/docs/plans/2026-07-27 - arr-instance-config-ui.md @@ -72,8 +72,8 @@ value; only the save path accepts a plaintext key. Editing shows an empty field This matters more than usual because **`Presentation/Web` currently has no authentication at all** (#48). Until that lands, anyone who can reach the UI can reach this page. Masking limits it to *writing* keys rather than *harvesting* existing ones — but it does not remove the exposure, and this -page raises the cost of leaving #48 undone. Storage is plaintext in Postgres; encryption at rest is -out of scope and belongs with #48's decisions. +page raises the cost of leaving #48 undone. Storage is plaintext in Postgres — acceptable for a POC, and now tracked for replacement by **#60** +(encryption at rest, where the real question is key management rather than the cipher). ### Test Connection is an Action that runs in a UI host diff --git a/src/Application/ApplicationServiceExtensions.cs b/src/Application/ApplicationServiceExtensions.cs index bc0d642..f8742d1 100644 --- a/src/Application/ApplicationServiceExtensions.cs +++ b/src/Application/ApplicationServiceExtensions.cs @@ -35,6 +35,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services // Settings services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Auth (#48) — SetupToken is a singleton so it survives for the process lifetime and is // logged once at startup; the handlers are scoped like every other use-case. diff --git a/src/Application/Settings/TestArrConnection.cs b/src/Application/Settings/TestArrConnection.cs new file mode 100644 index 0000000..8c5b55c --- /dev/null +++ b/src/Application/Settings/TestArrConnection.cs @@ -0,0 +1,64 @@ +using Krautwatch.Domain.Entities; +using Krautwatch.Domain.Interfaces; + +namespace Krautwatch.Application.Settings; + +// ══════════════════════════════════════════════════════════════ +// Messages +// ══════════════════════════════════════════════════════════════ + +/// Result of a connectivity test, shaped for display. +public record ArrConnectionTestResult(bool Ok, string Message, string? Failure) +{ + public static ArrConnectionTestResult From(ArrConnectionResult result) => new( + result.Ok, + result.Message, + result.Ok ? null : result.Failure.ToString()); + + public static ArrConnectionTestResult NotFound() => + new(false, "That instance no longer exists.", nameof(ArrConnectionFailure.Unexpected)); +} + +// ══════════════════════════════════════════════════════════════ +// Action (IO-driven, DR-009) +// ══════════════════════════════════════════════════════════════ + +/// +/// Tests connectivity to a configured Sonarr/Radarr instance and caches the outcome on the record, so the +/// settings page can show state without re-probing every instance on each page load. +/// +/// +/// This is an **Action** — it touches the outside world — but unlike the crawl Actions it runs in a UI +/// host rather than an agent. That is a deliberate, narrow deviation from DR-009: the operator clicks a +/// button and expects an answer, and round-tripping "test this instance" through the durable bus to an +/// agent and polling for a result is far more machinery than a button warrants. Recorded in +/// docs/plans/2026-07-27 - arr-instance-config-ui.md. +/// +public class TestArrConnectionHandler(IArrInstanceRepository repository, IArrClient client) +{ + /// Tests a stored instance and persists the outcome. + public async Task HandleAsync(Guid instanceId, CancellationToken ct = default) + { + var instance = await repository.GetByIdAsync(instanceId, ct); + if (instance is null) + return ArrConnectionTestResult.NotFound(); + + var result = await client.TestConnectionAsync(instance, ct); + + instance.LastTestedAt = DateTimeOffset.UtcNow; + instance.LastTestOk = result.Ok; + instance.LastTestMessage = result.Message; + await repository.UpdateAsync(instance, ct); + + return ArrConnectionTestResult.From(result); + } + + /// + /// Tests unsaved details, so the operator can verify before committing a record — and so a wrong key + /// never has to be persisted just to discover it is wrong. Nothing is written. + /// + public async Task HandleAsync( + ArrInstance unsaved, + CancellationToken ct = default) => + ArrConnectionTestResult.From(await client.TestConnectionAsync(unsaved, ct)); +} diff --git a/src/Infrastructure/Arr/ArrHttpClient.cs b/src/Infrastructure/Arr/ArrHttpClient.cs new file mode 100644 index 0000000..b58d256 --- /dev/null +++ b/src/Infrastructure/Arr/ArrHttpClient.cs @@ -0,0 +1,153 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Text.Json; +using Krautwatch.Domain.Entities; +using Krautwatch.Domain.Interfaces; +using Microsoft.Extensions.Logging; + +namespace Krautwatch.Infrastructure.Arr; + +/// +/// Talks to a Sonarr/Radarr instance over its v3 API. Both expose the same +/// GET /api/v3/system/status shape, authenticated with an X-Api-Key header. +/// +/// +/// The point of this class is that failures are **classified**, not just reported. "It doesn't work" is +/// the most common self-hosting complaint and the operator can only act on a specific cause: a wrong port +/// looks nothing like a wrong API key, and a reverse-proxy subpath left off the base URL looks nothing +/// like either. See . +/// +public class ArrHttpClient(HttpClient http, ILogger logger) : IArrClient +{ + private const string StatusPath = "api/v3/system/status"; + + public async Task TestConnectionAsync( + ArrInstance instance, + CancellationToken ct = default) + { + if (!TryBuildStatusUri(instance.BaseUrl, out var uri)) + return ArrConnectionResult.Fail( + ArrConnectionFailure.Unexpected, + $"'{instance.BaseUrl}' is not a valid absolute URL."); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + // The *arr apps accept the key as a header or a query parameter. Header, so it never lands + // in a proxy access log. + request.Headers.TryAddWithoutValidation("X-Api-Key", instance.ApiKey); + + using var response = await http.SendAsync(request, ct); + + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + return ArrConnectionResult.Fail( + ArrConnectionFailure.Unauthorized, + "Reached the instance, but the API key was rejected."); + + if (response.StatusCode == HttpStatusCode.NotFound) + return ArrConnectionResult.Fail( + ArrConnectionFailure.ApiNotFound, + $"No API at {uri}. If it sits behind a reverse proxy on a subpath, include that " + + "path in the base URL."); + + if (!response.IsSuccessStatusCode) + return ArrConnectionResult.Fail( + ArrConnectionFailure.Unexpected, + $"Unexpected response {(int)response.StatusCode} {response.ReasonPhrase}."); + + return await ReadStatusAsync(response, uri, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; // the caller gave up; not an instance failure + } + catch (OperationCanceledException) + { + // HttpClient surfaces its own timeout as a cancellation, not a TimeoutException. + return ArrConnectionResult.Fail( + ArrConnectionFailure.Unreachable, + "Timed out. Check the host and port, and that the instance is running."); + } + catch (HttpRequestException ex) + { + return Classify(ex, uri); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Unexpected failure testing {BaseUrl}.", instance.BaseUrl); + return ArrConnectionResult.Fail(ArrConnectionFailure.Unexpected, ex.Message); + } + } + + private static async Task ReadStatusAsync( + HttpResponseMessage response, + Uri uri, + CancellationToken ct) + { + try + { + using var doc = await JsonDocument.ParseAsync( + await response.Content.ReadAsStreamAsync(ct), cancellationToken: ct); + + // A 200 from something that isn't an *arr — typically the wrong port entirely, landing on some + // other web app that happily returns a page. + if (doc.RootElement.ValueKind != JsonValueKind.Object + || !doc.RootElement.TryGetProperty("appName", out var appName) + || !doc.RootElement.TryGetProperty("version", out var version)) + { + return ArrConnectionResult.Fail( + ArrConnectionFailure.NotAnArrInstance, + $"{uri} answered, but not with a Sonarr/Radarr status. Is that the right port?"); + } + + return ArrConnectionResult.Success( + appName.GetString() ?? "unknown", + version.GetString() ?? "unknown"); + } + catch (JsonException) + { + return ArrConnectionResult.Fail( + ArrConnectionFailure.NotAnArrInstance, + $"{uri} answered with something that isn't JSON. Is that the right port?"); + } + } + + /// Maps transport-level failures onto causes an operator can act on. + private static ArrConnectionResult Classify(HttpRequestException ex, Uri uri) + { + // TLS problems hide inside the exception chain, and are common with self-signed certificates. + for (Exception? inner = ex; inner is not null; inner = inner.InnerException) + { + if (inner is AuthenticationException) + return ArrConnectionResult.Fail( + ArrConnectionFailure.TlsFailure, + $"TLS handshake with {uri.Host} failed — often a self-signed or expired certificate."); + + if (inner is SocketException socket) + return ArrConnectionResult.Fail( + ArrConnectionFailure.Unreachable, + $"Could not reach {uri.Host}:{uri.Port} ({socket.SocketErrorCode})."); + } + + return ArrConnectionResult.Fail(ArrConnectionFailure.Unreachable, ex.Message); + } + + /// + /// Builds the status URL, preserving any subpath in the configured base URL — instances behind a + /// reverse proxy are commonly served from something like https://host/sonarr. + /// + internal static bool TryBuildStatusUri(string baseUrl, out Uri uri) + { + uri = null!; + if (string.IsNullOrWhiteSpace(baseUrl) + || !Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var parsed) + || (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)) + return false; + + // A trailing slash matters to Uri's relative resolution: without it the last path segment would be + // replaced rather than appended, silently dropping the subpath. + var basePath = parsed.AbsoluteUri.EndsWith('/') ? parsed.AbsoluteUri : parsed.AbsoluteUri + "/"; + return Uri.TryCreate(new Uri(basePath), StatusPath, out uri!); + } +} diff --git a/src/Infrastructure/InfrastructureServiceExtensions.cs b/src/Infrastructure/InfrastructureServiceExtensions.cs index af8d9bd..f37925b 100644 --- a/src/Infrastructure/InfrastructureServiceExtensions.cs +++ b/src/Infrastructure/InfrastructureServiceExtensions.cs @@ -1,5 +1,6 @@ using Krautwatch.Domain.Interfaces; using Krautwatch.Domain.Options; +using Krautwatch.Infrastructure.Arr; using Krautwatch.Infrastructure.Auth; using Krautwatch.Infrastructure.Catalog; using Krautwatch.Infrastructure.Catalog.MediathekView; @@ -134,6 +135,22 @@ public static IServiceCollection AddZdfCrawler(this IServiceCollection services) return services; } + /// + /// Registers the outbound Sonarr/Radarr client (#4). Needed by any host that tests instance + /// connectivity or (per #6) pre-warms the crawl list from a monitored-series list. + /// + public static IServiceCollection AddArrClient(this IServiceCollection services) + { + services.AddHttpClient(http => + { + // Short and explicit: this sits behind an operator clicking "Test", so a hung connection has + // to fail fast rather than leave the button spinning. HttpClient's 100s default is far too + // long for that. + http.Timeout = TimeSpan.FromSeconds(10); + }); + return services; + } + /// /// Registers the Wolverine-backed (DR-009 §5). Call only from a /// host that has configured Wolverine (UseWolverine) — i.e. the crawl agents. diff --git a/tests/Application.Tests/TestArrConnectionHandlerTests.cs b/tests/Application.Tests/TestArrConnectionHandlerTests.cs new file mode 100644 index 0000000..062bdcf --- /dev/null +++ b/tests/Application.Tests/TestArrConnectionHandlerTests.cs @@ -0,0 +1,95 @@ +using Krautwatch.Application.Settings; +using Krautwatch.Domain.Entities; +using Krautwatch.Domain.Enums; +using Krautwatch.Domain.Interfaces; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Krautwatch.Application.Tests; + +public class TestArrConnectionHandlerTests +{ + private static ArrInstance Instance() => new() + { + Id = Guid.NewGuid(), Name = "Sonarr", Kind = ArrKind.Sonarr, + BaseUrl = "http://sonarr:8989", ApiKey = "key-abc", + }; + + [Fact] + public async Task Caches_a_successful_outcome_on_the_instance() + { + var repo = Substitute.For(); + var client = Substitute.For(); + var instance = Instance(); + repo.GetByIdAsync(instance.Id, Arg.Any()).Returns(instance); + client.TestConnectionAsync(instance, Arg.Any()) + .Returns(ArrConnectionResult.Success("Sonarr", "4.0.10")); + + var result = await new TestArrConnectionHandler(repo, client) + .HandleAsync(instance.Id, TestContext.Current.CancellationToken); + + result.Ok.ShouldBeTrue(); + result.Message.ShouldBe("Sonarr 4.0.10"); + result.Failure.ShouldBeNull(); + + // Cached so the settings page can render state without re-probing every instance. + instance.LastTestOk.ShouldBe(true); + instance.LastTestMessage.ShouldBe("Sonarr 4.0.10"); + instance.LastTestedAt.ShouldNotBeNull(); + await repo.Received(1).UpdateAsync(instance, Arg.Any()); + } + + [Fact] + public async Task Caches_a_failure_with_its_specific_cause() + { + var repo = Substitute.For(); + var client = Substitute.For(); + var instance = Instance(); + repo.GetByIdAsync(instance.Id, Arg.Any()).Returns(instance); + client.TestConnectionAsync(instance, Arg.Any()).Returns( + ArrConnectionResult.Fail(ArrConnectionFailure.Unauthorized, "API key was rejected.")); + + var result = await new TestArrConnectionHandler(repo, client) + .HandleAsync(instance.Id, TestContext.Current.CancellationToken); + + result.Ok.ShouldBeFalse(); + result.Failure.ShouldBe("Unauthorized"); // specific, so the UI can say something useful + instance.LastTestOk.ShouldBe(false); + await repo.Received(1).UpdateAsync(instance, Arg.Any()); + } + + [Fact] + public async Task Reports_a_missing_instance_without_calling_out() + { + var repo = Substitute.For(); + var client = Substitute.For(); + repo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns((ArrInstance?)null); + + var result = await new TestArrConnectionHandler(repo, client) + .HandleAsync(Guid.NewGuid(), TestContext.Current.CancellationToken); + + result.Ok.ShouldBeFalse(); + await client.DidNotReceive().TestConnectionAsync(Arg.Any(), Arg.Any()); + await repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Testing_unsaved_details_writes_nothing() + { + // So a wrong key never has to be persisted just to find out it is wrong. + var repo = Substitute.For(); + var client = Substitute.For(); + var unsaved = Instance(); + client.TestConnectionAsync(unsaved, Arg.Any()) + .Returns(ArrConnectionResult.Success("Radarr", "5.2")); + + var result = await new TestArrConnectionHandler(repo, client) + .HandleAsync(unsaved, TestContext.Current.CancellationToken); + + result.Ok.ShouldBeTrue(); + result.Message.ShouldBe("Radarr 5.2"); + await repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + await repo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/tests/Infrastructure.Tests/ArrHttpClientTests.cs b/tests/Infrastructure.Tests/ArrHttpClientTests.cs new file mode 100644 index 0000000..d704946 --- /dev/null +++ b/tests/Infrastructure.Tests/ArrHttpClientTests.cs @@ -0,0 +1,236 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Text; +using Krautwatch.Domain.Entities; +using Krautwatch.Domain.Enums; +using Krautwatch.Domain.Interfaces; +using Krautwatch.Infrastructure.Arr; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; + +namespace Krautwatch.Infrastructure.Tests; + +/// +/// Covers the failure classification, since a specific cause is the whole point of this client — and +/// against a stub rather than a live test, because there is no real Sonarr to point at. +/// +public class ArrHttpClientTests +{ + private static ArrInstance Instance(string baseUrl = "http://sonarr:8989") => new() + { + Name = "Sonarr", Kind = ArrKind.Sonarr, BaseUrl = baseUrl, ApiKey = "key-abc", + }; + + private static ArrHttpClient Client(StubHandler handler) => + new(new HttpClient(handler), NullLogger.Instance); + + // ── success ─────────────────────────────────────────────── + + [Fact] + public async Task Reports_the_app_name_and_version_on_success() + { + var handler = StubHandler.Json("""{"appName":"Sonarr","version":"4.0.10.2544"}"""); + + var result = await Client(handler).TestConnectionAsync(Instance(), TestContext.Current.CancellationToken); + + result.Ok.ShouldBeTrue(); + result.Failure.ShouldBe(ArrConnectionFailure.None); + result.Message.ShouldBe("Sonarr 4.0.10.2544"); + } + + [Fact] + public async Task Sends_the_api_key_as_a_header() + { + // Header rather than a query parameter so the key never lands in a proxy access log. + var handler = StubHandler.Json("""{"appName":"Sonarr","version":"4.0"}"""); + + await Client(handler).TestConnectionAsync(Instance(), TestContext.Current.CancellationToken); + + handler.LastRequest!.Headers.GetValues("X-Api-Key").ShouldHaveSingleItem().ShouldBe("key-abc"); + handler.LastRequest.RequestUri!.Query.ShouldNotContain("key-abc"); + } + + [Fact] + public async Task Preserves_a_reverse_proxy_subpath() + { + var handler = StubHandler.Json("""{"appName":"Sonarr","version":"4.0"}"""); + + await Client(handler).TestConnectionAsync( + Instance("https://media.example.com/sonarr"), TestContext.Current.CancellationToken); + + handler.LastRequest!.RequestUri!.AbsoluteUri + .ShouldBe("https://media.example.com/sonarr/api/v3/system/status"); + } + + [Theory] + [InlineData("http://sonarr:8989")] + [InlineData("http://sonarr:8989/")] + public async Task Handles_a_base_url_with_or_without_a_trailing_slash(string baseUrl) + { + var handler = StubHandler.Json("""{"appName":"Sonarr","version":"4.0"}"""); + + await Client(handler).TestConnectionAsync(Instance(baseUrl), TestContext.Current.CancellationToken); + + handler.LastRequest!.RequestUri!.AbsoluteUri + .ShouldBe("http://sonarr:8989/api/v3/system/status"); + } + + // ── classified failures ─────────────────────────────────── + + [Theory] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + public async Task A_rejected_key_is_reported_as_unauthorized(HttpStatusCode status) + { + var result = await Client(StubHandler.Status(status)) + .TestConnectionAsync(Instance(), TestContext.Current.CancellationToken); + + result.Ok.ShouldBeFalse(); + result.Failure.ShouldBe(ArrConnectionFailure.Unauthorized); + result.Message.ShouldContain("API key"); + } + + [Fact] + public async Task A_404_points_at_the_base_path_rather_than_the_key() + { + // The usual cause is a reverse-proxy subpath left out of the base URL — very different fix to a + // wrong API key, so it must not be conflated with one. + var result = await Client(StubHandler.Status(HttpStatusCode.NotFound)) + .TestConnectionAsync(Instance(), TestContext.Current.CancellationToken); + + result.Failure.ShouldBe(ArrConnectionFailure.ApiNotFound); + result.Message.ShouldContain("subpath"); + } + + [Fact] + public async Task A_200_from_something_that_is_not_an_arr_is_detected() + { + // Typically the wrong port entirely, landing on some other web app that answers happily. + var result = await Client(StubHandler.Json("""{"hello":"world"}""")) + .TestConnectionAsync(Instance(), TestContext.Current.CancellationToken); + + result.Failure.ShouldBe(ArrConnectionFailure.NotAnArrInstance); + result.Message.ShouldContain("right port"); + } + + [Fact] + public async Task Non_json_on_a_200_is_detected() + { + var result = await Client(StubHandler.Text("hi")) + .TestConnectionAsync(Instance(), TestContext.Current.CancellationToken); + + result.Failure.ShouldBe(ArrConnectionFailure.NotAnArrInstance); + } + + [Fact] + public async Task A_socket_failure_is_reported_as_unreachable() + { + var handler = StubHandler.Throws(new HttpRequestException( + "connect failed", new SocketException((int)SocketError.ConnectionRefused))); + + var result = await Client(handler).TestConnectionAsync(Instance(), TestContext.Current.CancellationToken); + + result.Failure.ShouldBe(ArrConnectionFailure.Unreachable); + result.Message.ShouldContain("sonarr"); + } + + [Fact] + public async Task A_tls_failure_is_distinguished_from_unreachable() + { + // Buried in the exception chain, and common with self-signed certificates. + var handler = StubHandler.Throws(new HttpRequestException( + "ssl", new AuthenticationException("cert rejected"))); + + var result = await Client(handler).TestConnectionAsync( + Instance("https://sonarr:8989"), TestContext.Current.CancellationToken); + + result.Failure.ShouldBe(ArrConnectionFailure.TlsFailure); + result.Message.ShouldContain("certificate"); + } + + [Fact] + public async Task A_timeout_is_reported_as_unreachable() + { + // HttpClient surfaces its own timeout as a cancellation, not a TimeoutException. + var handler = StubHandler.Throws(new TaskCanceledException("timed out")); + + var result = await Client(handler).TestConnectionAsync(Instance(), TestContext.Current.CancellationToken); + + result.Failure.ShouldBe(ArrConnectionFailure.Unreachable); + result.Message.ShouldContain("Timed out"); + } + + [Fact] + public async Task An_unexpected_status_is_not_silently_treated_as_success() + { + var result = await Client(StubHandler.Status(HttpStatusCode.BadGateway)) + .TestConnectionAsync(Instance(), TestContext.Current.CancellationToken); + + result.Ok.ShouldBeFalse(); + result.Failure.ShouldBe(ArrConnectionFailure.Unexpected); + result.Message.ShouldContain("502"); + } + + [Theory] + [InlineData("")] + [InlineData("not a url")] + [InlineData("sonarr:8989")] // no scheme + [InlineData("ftp://sonarr:8989")] // wrong scheme + public async Task A_malformed_base_url_fails_without_a_request(string baseUrl) + { + var handler = StubHandler.Json("""{"appName":"Sonarr","version":"4.0"}"""); + + var result = await Client(handler).TestConnectionAsync( + Instance(baseUrl), TestContext.Current.CancellationToken); + + result.Ok.ShouldBeFalse(); + handler.LastRequest.ShouldBeNull(); // never left the process + } + + [Fact] + public async Task Caller_cancellation_propagates_rather_than_being_reported_as_a_failure() + { + // A cancelled page load is not an instance problem, and must not be cached as one. + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + var handler = StubHandler.Throws(new TaskCanceledException("cancelled")); + + await Should.ThrowAsync(async () => + await Client(handler).TestConnectionAsync(Instance(), cts.Token)); + } + + // ── stub ────────────────────────────────────────────────── + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _respond; + public HttpRequestMessage? LastRequest { get; private set; } + + private StubHandler(Func respond) => _respond = respond; + + public static StubHandler Json(string body) => new(() => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + + public static StubHandler Text(string body) => new(() => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "text/html"), + }); + + public static StubHandler Status(HttpStatusCode status) => + new(() => new HttpResponseMessage(status)); + + public static StubHandler Throws(Exception ex) => new(() => throw ex); + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(_respond()); + } + } +}