diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..3201dfa
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,37 @@
+# Context
+
+## Domain Terms
+
+### Now Playing
+
+The public music status exposed by this Lambda. The live route shape is `/api/now-playing`.
+
+### Last.fm Now-Playing Source
+
+Last.fm is the source of truth for Now Playing. Spotify may still be used when explicitly requested, but callers should not need to treat Last.fm and Spotify as equal sources of truth.
+
+### Spotify Now-Playing Path
+
+`/api/now-playing?provider=spotify` may remain as an explicit path for checking Spotify behaviour. It is not the main Now Playing source and should not shape the main Now Playing interface.
+
+`/api/now-playing?provider=lastfm` is a compatibility alias for the main Now Playing path. It should behave the same as omitting the provider query parameter.
+
+### Empty Now-Playing View Model
+
+When the Last.fm lookup fails, `/api/now-playing` should still return an empty Now Playing view model with `status: 500` in the response body. This preserves the client-facing shape while making the failure visible to callers that inspect the model.
+
+### Now-Playing Cache Policy
+
+Both Last.fm Now Playing and the explicit Spotify path should be cached. Cache behaviour belongs inside the Now Playing module so callers do not need to know provider-specific cache keys or freshness rules.
+
+### Runtime Configuration
+
+Runtime Configuration is the environment-backed settings required by the Lambda functions at execution time. It includes music provider credentials, deployment metadata, observability settings, and feature flags.
+
+Runtime Configuration should be one shared module used by the API Lambda, authorizer, and observability code. Observability should not maintain a separate environment reader.
+
+Runtime Configuration should validate lazily by section. A missing provider secret should fail only the path that needs that provider. Missing Sentry or Pushgateway settings should disable that sink rather than fail requests. Missing authorizer API key should continue to deny authorization instead of throwing.
+
+Runtime Configuration should expose typed section objects instead of raw environment-variable lookups. Sections should own their defaults, optional sink behaviour, and provider-specific credential rules.
+
+Runtime Configuration should use typed `*Options` classes with `Section` constants, init properties/defaults, and targeted `IsValid(out errorMessage)` validation where a section has required values.
diff --git a/Lho.Lambda.slnx b/Lho.Lambda.slnx
index a119ad5..b07b30e 100644
--- a/Lho.Lambda.slnx
+++ b/Lho.Lambda.slnx
@@ -3,6 +3,7 @@
+
diff --git a/src/Lho.Lambda.Authorizer/Functions/AuthorizerFunction.cs b/src/Lho.Lambda.Authorizer/Functions/AuthorizerFunction.cs
index 2c76b41..0796940 100644
--- a/src/Lho.Lambda.Authorizer/Functions/AuthorizerFunction.cs
+++ b/src/Lho.Lambda.Authorizer/Functions/AuthorizerFunction.cs
@@ -4,6 +4,8 @@
using Amazon.Lambda.Core;
using Lho.Lambda.Authorizer.Models;
using Lho.Lambda.Observability;
+using Lho.Lambda.RuntimeConfiguration;
+using Lho.Lambda.RuntimeConfiguration.Options;
namespace Lho.Lambda.Authorizer.Functions;
@@ -11,6 +13,19 @@ public class AuthorizerFunction
{
private static readonly HashSet ValidConsumers = ["lhowsam-dev", "lhowsam-prod", "lhowsam-local"];
+ private readonly AuthorizerOptions _authorizerOptions;
+
+ public AuthorizerFunction()
+ : this(RuntimeConfig.Current.Authorizer)
+ {
+
+ }
+
+ public AuthorizerFunction(AuthorizerOptions authorizerOptions)
+ {
+ _authorizerOptions = authorizerOptions;
+ }
+
public async Task FunctionHandler(AuthorizerRequest request, ILambdaContext context)
{
var stopwatch = Stopwatch.StartNew();
@@ -34,7 +49,7 @@ public async Task FunctionHandler(AuthorizerRequest re
try
{
var apiKey = GetHeaderValue(request.Headers, "x-api-key");
- var validKey = Environment.GetEnvironmentVariable("API_KEY");
+ var validKey = _authorizerOptions.ApiKey;
if (!SecureCompare(apiKey, validKey))
{
diff --git a/src/Lho.Lambda.Authorizer/Lho.Lambda.Authorizer.csproj b/src/Lho.Lambda.Authorizer/Lho.Lambda.Authorizer.csproj
index d5c33ff..6695c29 100644
--- a/src/Lho.Lambda.Authorizer/Lho.Lambda.Authorizer.csproj
+++ b/src/Lho.Lambda.Authorizer/Lho.Lambda.Authorizer.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/Lho.Lambda.Observability/Lho.Lambda.Observability.csproj b/src/Lho.Lambda.Observability/Lho.Lambda.Observability.csproj
index 5cbb573..35db08e 100644
--- a/src/Lho.Lambda.Observability/Lho.Lambda.Observability.csproj
+++ b/src/Lho.Lambda.Observability/Lho.Lambda.Observability.csproj
@@ -8,4 +8,8 @@
+
+
+
+
diff --git a/src/Lho.Lambda.Observability/ObservabilityConfig.cs b/src/Lho.Lambda.Observability/ObservabilityConfig.cs
index 35504c7..664b192 100644
--- a/src/Lho.Lambda.Observability/ObservabilityConfig.cs
+++ b/src/Lho.Lambda.Observability/ObservabilityConfig.cs
@@ -1,51 +1,33 @@
+using Lho.Lambda.RuntimeConfiguration;
+using Lho.Lambda.RuntimeConfiguration.Options;
+
namespace Lho.Lambda.Observability;
public static class ObservabilityConfig
{
- public static string ServiceName => String("SERVICE_NAME", "now-playing");
-
- public static string EnvironmentName => String("ENVIRONMENT", "local");
-
- public static string Version => String("VERSION", "unknown");
-
- public static string GitSha => String("GIT_SHA", "unknown");
+ private static ObservabilityOptions Config => RuntimeConfig.Current.Observability;
- public static string SentryEnvironment => String("SENTRY_ENVIRONMENT", EnvironmentName);
+ public static string ServiceName => Config.ServiceName;
- public static string SentryRelease => String("SENTRY_RELEASE", Version);
+ public static string EnvironmentName => Config.EnvironmentName;
- public static string? SentryDsn => OptionalString("SENTRY_DSN");
+ public static string Version => Config.Version;
- public static string? PushgatewayUrl => OptionalString("PUSHGATEWAY_URL");
+ public static string GitSha => Config.GitSha;
- public static string? PushgatewayAuthHeader => OptionalString("PUSHGATEWAY_AUTH_HEADER");
+ public static string SentryEnvironment => Config.SentryEnvironment;
- public static string PushgatewayJob => String("PROMETHEUS_JOB", ServiceName);
+ public static string SentryRelease => Config.SentryRelease;
- public static bool MetricsEnabled => Bool("METRICS_ENABLED", defaultValue: true) && !string.IsNullOrEmpty(PushgatewayUrl);
+ public static string? SentryDsn => Config.SentryDsn;
- public static double SentryTracesSampleRate => Double("SENTRY_TRACES_SAMPLE_RATE", defaultValue: 0.5);
+ public static string? PushgatewayUrl => Config.PushgatewayUrl;
- private static string String(string key, string defaultValue)
- {
- return Environment.GetEnvironmentVariable(key) ?? defaultValue;
- }
+ public static string? PushgatewayAuthHeader => Config.PushgatewayAuthHeader;
- private static string? OptionalString(string key)
- {
- var value = Environment.GetEnvironmentVariable(key);
- return string.IsNullOrWhiteSpace(value) ? null : value;
- }
+ public static string PushgatewayJob => Config.PushgatewayJob;
- private static bool Bool(string key, bool defaultValue)
- {
- var value = Environment.GetEnvironmentVariable(key);
- return value is null ? defaultValue : string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || value == "1";
- }
+ public static bool MetricsEnabled => Config.MetricsEnabled;
- private static double Double(string key, double defaultValue)
- {
- var value = Environment.GetEnvironmentVariable(key);
- return double.TryParse(value, out var parsed) ? Math.Clamp(parsed, 0, 1) : defaultValue;
- }
+ public static double SentryTracesSampleRate => Config.SentryTracesSampleRate;
}
diff --git a/src/Lho.Lambda.RuntimeConfiguration/Lho.Lambda.RuntimeConfiguration.csproj b/src/Lho.Lambda.RuntimeConfiguration/Lho.Lambda.RuntimeConfiguration.csproj
new file mode 100644
index 0000000..b606fa2
--- /dev/null
+++ b/src/Lho.Lambda.RuntimeConfiguration/Lho.Lambda.RuntimeConfiguration.csproj
@@ -0,0 +1,6 @@
+
+
+ net8.0
+ true
+
+
diff --git a/src/Lho.Lambda.RuntimeConfiguration/Options/AuthorizerOptions.cs b/src/Lho.Lambda.RuntimeConfiguration/Options/AuthorizerOptions.cs
new file mode 100644
index 0000000..c0afeff
--- /dev/null
+++ b/src/Lho.Lambda.RuntimeConfiguration/Options/AuthorizerOptions.cs
@@ -0,0 +1,8 @@
+namespace Lho.Lambda.RuntimeConfiguration.Options;
+
+public class AuthorizerOptions
+{
+ public const string Section = "Authorizer";
+
+ public string? ApiKey { get; init; }
+}
diff --git a/src/Lho.Lambda.RuntimeConfiguration/Options/DeploymentOptions.cs b/src/Lho.Lambda.RuntimeConfiguration/Options/DeploymentOptions.cs
new file mode 100644
index 0000000..36f3c70
--- /dev/null
+++ b/src/Lho.Lambda.RuntimeConfiguration/Options/DeploymentOptions.cs
@@ -0,0 +1,14 @@
+namespace Lho.Lambda.RuntimeConfiguration.Options;
+
+public class DeploymentOptions
+{
+ public const string Section = "Deployment";
+
+ public string Version { get; init; } = "unknown";
+
+ public string DeployedAt { get; init; } = "unknown";
+
+ public string DeployedBy { get; init; } = "unknown";
+
+ public string GitSha { get; init; } = "unknown";
+}
diff --git a/src/Lho.Lambda.RuntimeConfiguration/Options/FeatureFlagsOptions.cs b/src/Lho.Lambda.RuntimeConfiguration/Options/FeatureFlagsOptions.cs
new file mode 100644
index 0000000..d730f68
--- /dev/null
+++ b/src/Lho.Lambda.RuntimeConfiguration/Options/FeatureFlagsOptions.cs
@@ -0,0 +1,8 @@
+namespace Lho.Lambda.RuntimeConfiguration.Options;
+
+public class FeatureFlagsOptions
+{
+ public const string Section = "FeatureFlags";
+
+ public bool ShouldCallSpotify { get; init; } = true;
+}
diff --git a/src/Lho.Lambda.RuntimeConfiguration/Options/LastFmOptions.cs b/src/Lho.Lambda.RuntimeConfiguration/Options/LastFmOptions.cs
new file mode 100644
index 0000000..fbfc889
--- /dev/null
+++ b/src/Lho.Lambda.RuntimeConfiguration/Options/LastFmOptions.cs
@@ -0,0 +1,42 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace Lho.Lambda.RuntimeConfiguration.Options;
+
+public class LastFmOptions
+{
+ public const string Section = "LastFm";
+
+ public string? ApiKey { get; init; }
+
+ public string? Username { get; init; }
+
+ public LastFmCredentials RequireCredentials()
+ {
+ if (!IsValid(out var errorMessage))
+ {
+ throw new RuntimeConfigurationException(errorMessage);
+ }
+
+ return new LastFmCredentials(ApiKey!, Username!);
+ }
+
+ public bool IsValid([NotNullWhen(false)] out string? errorMessage)
+ {
+ var errors = new List();
+
+ if (string.IsNullOrWhiteSpace(ApiKey))
+ {
+ errors.Add($"Last.fm API key is not configured. Ensure {Section}:{nameof(ApiKey)} or LASTFM_API_KEY is set.");
+ }
+
+ if (string.IsNullOrWhiteSpace(Username))
+ {
+ errors.Add($"Last.fm username is not configured. Ensure {Section}:{nameof(Username)} or LASTFM_USERNAME is set.");
+ }
+
+ errorMessage = errors.Count == 0 ? null : string.Join(" ", errors);
+ return errorMessage is null;
+ }
+}
+
+public sealed record LastFmCredentials(string ApiKey, string Username);
diff --git a/src/Lho.Lambda.RuntimeConfiguration/Options/ObservabilityOptions.cs b/src/Lho.Lambda.RuntimeConfiguration/Options/ObservabilityOptions.cs
new file mode 100644
index 0000000..b68e08d
--- /dev/null
+++ b/src/Lho.Lambda.RuntimeConfiguration/Options/ObservabilityOptions.cs
@@ -0,0 +1,32 @@
+namespace Lho.Lambda.RuntimeConfiguration.Options;
+
+public class ObservabilityOptions
+{
+ public const string Section = "Observability";
+
+ public string ServiceName { get; init; } = "now-playing";
+
+ public string EnvironmentName { get; init; } = "local";
+
+ public string Version { get; init; } = "unknown";
+
+ public string GitSha { get; init; } = "unknown";
+
+ public string SentryEnvironment { get; init; } = "local";
+
+ public string SentryRelease { get; init; } = "unknown";
+
+ public string? SentryDsn { get; init; }
+
+ public string? PushgatewayUrl { get; init; }
+
+ public string? PushgatewayAuthHeader { get; init; }
+
+ public string PushgatewayJob { get; init; } = "now-playing";
+
+ public bool MetricsEnabledFlag { get; init; } = true;
+
+ public double SentryTracesSampleRate { get; init; } = 0.5;
+
+ public bool MetricsEnabled => MetricsEnabledFlag && !string.IsNullOrEmpty(PushgatewayUrl);
+}
diff --git a/src/Lho.Lambda.RuntimeConfiguration/Options/SpotifyOptions.cs b/src/Lho.Lambda.RuntimeConfiguration/Options/SpotifyOptions.cs
new file mode 100644
index 0000000..fb00840
--- /dev/null
+++ b/src/Lho.Lambda.RuntimeConfiguration/Options/SpotifyOptions.cs
@@ -0,0 +1,51 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace Lho.Lambda.RuntimeConfiguration.Options;
+
+public class SpotifyOptions
+{
+ public const string Section = "Spotify";
+
+ public string? ClientId { get; init; }
+
+ public string? ClientSecret { get; init; }
+
+ public string? RefreshToken { get; init; }
+
+ public string? AccessToken { get; init; }
+
+ public SpotifyRefreshCredentials RequireRefreshCredentials()
+ {
+ if (!IsRefreshTokenFlowValid(out var errorMessage))
+ {
+ throw new RuntimeConfigurationException(errorMessage);
+ }
+
+ return new SpotifyRefreshCredentials(ClientId!, ClientSecret!, RefreshToken!);
+ }
+
+ public bool IsRefreshTokenFlowValid([NotNullWhen(false)] out string? errorMessage)
+ {
+ var errors = new List();
+
+ if (string.IsNullOrWhiteSpace(ClientId))
+ {
+ errors.Add($"Spotify client ID is not configured. Ensure {Section}:{nameof(ClientId)} or SPOTIFY_CLIENT_ID is set.");
+ }
+
+ if (string.IsNullOrWhiteSpace(ClientSecret))
+ {
+ errors.Add($"Spotify client secret is not configured. Ensure {Section}:{nameof(ClientSecret)} or SPOTIFY_CLIENT_SECRET is set.");
+ }
+
+ if (string.IsNullOrWhiteSpace(RefreshToken))
+ {
+ errors.Add($"Spotify refresh token is not configured. Ensure {Section}:{nameof(RefreshToken)} or SPOTIFY_REFRESH_TOKEN is set.");
+ }
+
+ errorMessage = errors.Count == 0 ? null : string.Join(" ", errors);
+ return errorMessage is null;
+ }
+}
+
+public sealed record SpotifyRefreshCredentials(string ClientId, string ClientSecret, string RefreshToken);
diff --git a/src/Lho.Lambda.RuntimeConfiguration/RuntimeConfig.cs b/src/Lho.Lambda.RuntimeConfiguration/RuntimeConfig.cs
new file mode 100644
index 0000000..087f240
--- /dev/null
+++ b/src/Lho.Lambda.RuntimeConfiguration/RuntimeConfig.cs
@@ -0,0 +1,101 @@
+using Lho.Lambda.RuntimeConfiguration.Options;
+
+namespace Lho.Lambda.RuntimeConfiguration;
+
+public class RuntimeConfig
+{
+ public LastFmOptions LastFm { get; init; } = new();
+
+ public SpotifyOptions Spotify { get; init; } = new();
+
+ public DeploymentOptions Deployment { get; init; } = new();
+
+ public ObservabilityOptions Observability { get; init; } = new();
+
+ public AuthorizerOptions Authorizer { get; init; } = new();
+
+ public FeatureFlagsOptions Features { get; init; } = new();
+
+ public static RuntimeConfig Current => FromEnvironment(Environment.GetEnvironmentVariable);
+
+ public static RuntimeConfig FromEnvironment(IReadOnlyDictionary environment)
+ {
+ return FromEnvironment(key => environment.TryGetValue(key, out var value) ? value : null);
+ }
+
+ public static RuntimeConfig FromEnvironment(Func getEnvironmentVariable)
+ {
+ var environmentName = String(getEnvironmentVariable, "ENVIRONMENT", "local");
+ var serviceName = String(getEnvironmentVariable, "SERVICE_NAME", "now-playing");
+ var version = String(getEnvironmentVariable, "VERSION", "unknown");
+
+ return new RuntimeConfig
+ {
+ LastFm = new LastFmOptions
+ {
+ ApiKey = OptionalString(getEnvironmentVariable, "LASTFM_API_KEY"),
+ Username = OptionalString(getEnvironmentVariable, "LASTFM_USERNAME")
+ },
+ Spotify = new SpotifyOptions
+ {
+ ClientId = OptionalString(getEnvironmentVariable, "SPOTIFY_CLIENT_ID"),
+ ClientSecret = OptionalString(getEnvironmentVariable, "SPOTIFY_CLIENT_SECRET"),
+ RefreshToken = OptionalString(getEnvironmentVariable, "SPOTIFY_REFRESH_TOKEN"),
+ AccessToken = OptionalString(getEnvironmentVariable, "SPOTIFY_ACCESS_TOKEN")
+ },
+ Deployment = new DeploymentOptions
+ {
+ Version = version,
+ DeployedAt = String(getEnvironmentVariable, "DEPLOYED_AT", "unknown"),
+ DeployedBy = String(getEnvironmentVariable, "DEPLOYED_BY", "unknown"),
+ GitSha = String(getEnvironmentVariable, "GIT_SHA", "unknown")
+ },
+ Observability = new ObservabilityOptions
+ {
+ ServiceName = serviceName,
+ EnvironmentName = environmentName,
+ Version = version,
+ GitSha = String(getEnvironmentVariable, "GIT_SHA", "unknown"),
+ SentryEnvironment = String(getEnvironmentVariable, "SENTRY_ENVIRONMENT", environmentName),
+ SentryRelease = String(getEnvironmentVariable, "SENTRY_RELEASE", version),
+ SentryDsn = OptionalString(getEnvironmentVariable, "SENTRY_DSN"),
+ PushgatewayUrl = OptionalString(getEnvironmentVariable, "PUSHGATEWAY_URL"),
+ PushgatewayAuthHeader = OptionalString(getEnvironmentVariable, "PUSHGATEWAY_AUTH_HEADER"),
+ PushgatewayJob = String(getEnvironmentVariable, "PROMETHEUS_JOB", serviceName),
+ MetricsEnabledFlag = Bool(getEnvironmentVariable, "METRICS_ENABLED", defaultValue: true),
+ SentryTracesSampleRate = Double(getEnvironmentVariable, "SENTRY_TRACES_SAMPLE_RATE", defaultValue: 0.5)
+ },
+ Authorizer = new AuthorizerOptions
+ {
+ ApiKey = OptionalString(getEnvironmentVariable, "API_KEY")
+ },
+ Features = new FeatureFlagsOptions
+ {
+ ShouldCallSpotify = Bool(getEnvironmentVariable, "SHOULD_CALL_SPOTIFY", defaultValue: true)
+ }
+ };
+ }
+
+ private static string String(Func getEnvironmentVariable, string key, string defaultValue)
+ {
+ return getEnvironmentVariable(key) ?? defaultValue;
+ }
+
+ private static string? OptionalString(Func getEnvironmentVariable, string key)
+ {
+ var value = getEnvironmentVariable(key);
+ return string.IsNullOrWhiteSpace(value) ? null : value;
+ }
+
+ private static bool Bool(Func getEnvironmentVariable, string key, bool defaultValue)
+ {
+ var value = getEnvironmentVariable(key);
+ return value is null ? defaultValue : string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || value == "1";
+ }
+
+ private static double Double(Func getEnvironmentVariable, string key, double defaultValue)
+ {
+ var value = getEnvironmentVariable(key);
+ return double.TryParse(value, out var parsed) ? Math.Clamp(parsed, 0, 1) : defaultValue;
+ }
+}
diff --git a/src/Lho.Lambda.RuntimeConfiguration/RuntimeConfigurationException.cs b/src/Lho.Lambda.RuntimeConfiguration/RuntimeConfigurationException.cs
new file mode 100644
index 0000000..08fa4b0
--- /dev/null
+++ b/src/Lho.Lambda.RuntimeConfiguration/RuntimeConfigurationException.cs
@@ -0,0 +1,3 @@
+namespace Lho.Lambda.RuntimeConfiguration;
+
+public sealed class RuntimeConfigurationException(string message) : Exception(message);
diff --git a/src/Lho.Lambda.Tests/ApiFunctionTests.cs b/src/Lho.Lambda.Tests/ApiFunctionTests.cs
index 99416fa..97b78df 100644
--- a/src/Lho.Lambda.Tests/ApiFunctionTests.cs
+++ b/src/Lho.Lambda.Tests/ApiFunctionTests.cs
@@ -1,8 +1,14 @@
using System.Net;
using System.Net.Sockets;
+using System.Text;
using System.Text.Json;
using Amazon.Lambda.APIGatewayEvents;
+using Lho.Lambda.Clients.LastFm;
+using Lho.Lambda.Clients.Spotify;
using Lho.Lambda.Functions;
+using Lho.Lambda.RuntimeConfiguration;
+using Lho.Lambda.RuntimeConfiguration.Options;
+using Lho.Lambda.Utils;
using Xunit;
namespace Lho.Lambda.Tests;
@@ -10,47 +16,144 @@ namespace Lho.Lambda.Tests;
public class ApiFunctionTests
{
[Fact]
- public async Task HealthEndpointReturnsOkWithoutCacheHeader()
+ public async Task MissingRouteReturnsNotFound()
{
var function = new ApiFunction();
- var response = await function.FunctionHandler(CreateRequest("/test/api/health"), new TestLambdaContext());
+ var response = await function.FunctionHandler(CreateRequest("/api/missing"), new TestLambdaContext());
- Assert.Equal((int)HttpStatusCode.OK, response.StatusCode);
- Assert.Equal("no-cache", response.Headers["Cache-Control"]);
- Assert.Equal("application/json", response.Headers["content-type"]);
- Assert.Equal("OK", JsonDocument.Parse(response.Body).RootElement.GetProperty("status").GetString());
+ Assert.Equal((int)HttpStatusCode.NotFound, response.StatusCode);
+ Assert.Equal("Not Found", JsonDocument.Parse(response.Body).RootElement.GetProperty("error").GetString());
}
[Fact]
- public async Task MissingRouteReturnsNotFound()
+ public async Task NowPlayingLastFmProviderUsesMainLastFmPath()
{
- var function = new ApiFunction();
+ var function = CreateFunction(
+ spotifyHandler: new StaticJsonHandler("""
+ {
+ "is_playing": true,
+ "item": {
+ "name": "Spotify song",
+ "artists": [{ "name": "Spotify artist" }],
+ "album": {
+ "name": "Spotify album",
+ "images": [{ "url": "https://example.com/spotify.jpg" }]
+ },
+ "external_urls": { "spotify": "https://open.spotify.com/track/spotify" }
+ }
+ }
+ """),
+ lastFmHandler: new StaticJsonHandler("""
+ {
+ "recenttracks": {
+ "track": [{
+ "name": "Last.fm song",
+ "artist": { "#text": "Last.fm artist" },
+ "album": { "#text": "Last.fm album" },
+ "url": "https://last.fm/track/current",
+ "image": [{ "#text": "https://example.com/lastfm.jpg", "size": "large" }],
+ "@attr": { "nowplaying": "true" }
+ }]
+ }
+ }
+ """));
- var response = await function.FunctionHandler(CreateRequest("/api/missing"), new TestLambdaContext());
+ var response = await function.FunctionHandler(
+ CreateRequest("/api/now-playing", rawQueryString: "provider=lastfm"),
+ new TestLambdaContext());
+ var body = JsonDocument.Parse(response.Body).RootElement;
- Assert.Equal((int)HttpStatusCode.NotFound, response.StatusCode);
- Assert.Equal("Not Found", JsonDocument.Parse(response.Body).RootElement.GetProperty("error").GetString());
+ Assert.Equal((int)HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("Last.fm song", body.GetProperty("title").GetString());
+ Assert.Equal("Last.fm artist", body.GetProperty("artist").GetString());
}
[Fact]
- public async Task VersionEndpointUsesDeploymentEnvironmentValues()
+ public async Task NowPlayingSpotifyProviderUsesSpotifyPath()
{
- SetEnvironment("VERSION", "1.2.3");
- SetEnvironment("DEPLOYED_AT", "2026-05-10T09:00:00Z");
- SetEnvironment("DEPLOYED_BY", "tests");
- SetEnvironment("GIT_SHA", "abc123");
+ Environment.SetEnvironmentVariable("SHOULD_CALL_SPOTIFY", "true");
+ var function = CreateFunction(
+ spotifyHandler: new StaticJsonHandler("""
+ {
+ "is_playing": true,
+ "item": {
+ "name": "Spotify song",
+ "artists": [{ "name": "Spotify artist" }],
+ "album": {
+ "name": "Spotify album",
+ "images": [{ "url": "https://example.com/spotify.jpg" }]
+ },
+ "external_urls": { "spotify": "https://open.spotify.com/track/spotify" }
+ }
+ }
+ """),
+ lastFmHandler: new StaticJsonHandler("""
+ {
+ "recenttracks": {
+ "track": [{
+ "name": "Last.fm song",
+ "artist": { "#text": "Last.fm artist" },
+ "album": { "#text": "Last.fm album" },
+ "url": "https://last.fm/track/current",
+ "image": [{ "#text": "https://example.com/lastfm.jpg", "size": "large" }],
+ "@attr": { "nowplaying": "true" }
+ }]
+ }
+ }
+ """));
- var function = new ApiFunction();
+ var response = await function.FunctionHandler(
+ CreateRequest("/api/now-playing", rawQueryString: "provider=spotify"),
+ new TestLambdaContext());
+ var body = JsonDocument.Parse(response.Body).RootElement;
+
+ Assert.Equal((int)HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("Spotify song", body.GetProperty("title").GetString());
+ Assert.Equal("Spotify artist", body.GetProperty("artist").GetString());
+ }
- var response = await function.FunctionHandler(CreateRequest("/invoke/api/version"), new TestLambdaContext());
+ [Fact]
+ public async Task NowPlayingUsesInjectedFeatureFlags()
+ {
+ Environment.SetEnvironmentVariable("SHOULD_CALL_SPOTIFY", "true");
+ var spotifyHandler = new StaticJsonHandler("""
+ {
+ "is_playing": true,
+ "item": {
+ "name": "Spotify song",
+ "artists": [{ "name": "Spotify artist" }],
+ "album": {
+ "name": "Spotify album",
+ "images": [{ "url": "https://example.com/spotify.jpg" }]
+ },
+ "external_urls": { "spotify": "https://open.spotify.com/track/spotify" }
+ }
+ }
+ """);
+ var spotifyApi = new SpotifyApi(
+ CreateHttpClient(spotifyHandler),
+ new SpotifyOptions { AccessToken = "test-token" });
+ var function = new ApiFunction(
+ new RuntimeConfig
+ {
+ Features = new FeatureFlagsOptions { ShouldCallSpotify = false }
+ },
+ new MemoryCache(),
+ spotifyApi,
+ new LastFmApi(
+ CreateHttpClient(new StaticJsonHandler("{}"), "https://lastfm.test/"),
+ new LastFmOptions { ApiKey = "api-key", Username = "user" }));
+
+ var response = await function.FunctionHandler(
+ CreateRequest("/api/now-playing", rawQueryString: "provider=spotify"),
+ new TestLambdaContext());
var body = JsonDocument.Parse(response.Body).RootElement;
Assert.Equal((int)HttpStatusCode.OK, response.StatusCode);
- Assert.Equal("1.2.3", body.GetProperty("version").GetString());
- Assert.Equal("2026-05-10T09:00:00Z", body.GetProperty("deployedAt").GetString());
- Assert.Equal("tests", body.GetProperty("deployedBy").GetString());
- Assert.Equal("abc123", body.GetProperty("gitSha").GetString());
+ Assert.False(body.GetProperty("isPlaying").GetBoolean());
+ Assert.True(body.GetProperty("maintenance").GetBoolean());
+ Assert.Equal(0, spotifyHandler.RequestCount);
}
[Fact]
@@ -90,12 +193,15 @@ public async Task ApiHealthAndVersionRoutesPushInvocationMetrics()
}
}
- private static APIGatewayHttpApiV2ProxyRequest CreateRequest(string path, string method = "GET")
+ private static APIGatewayHttpApiV2ProxyRequest CreateRequest(
+ string path,
+ string method = "GET",
+ string rawQueryString = "")
{
return new APIGatewayHttpApiV2ProxyRequest
{
RawPath = path,
- RawQueryString = "",
+ RawQueryString = rawQueryString,
RequestContext = new APIGatewayHttpApiV2ProxyRequest.ProxyRequestContext
{
Http = new APIGatewayHttpApiV2ProxyRequest.HttpDescription
@@ -107,11 +213,6 @@ private static APIGatewayHttpApiV2ProxyRequest CreateRequest(string path, string
};
}
- private static void SetEnvironment(string key, string value)
- {
- Environment.SetEnvironmentVariable(key, value);
- }
-
private static void ConfigureMetrics(int port)
{
Environment.SetEnvironmentVariable("METRICS_ENABLED", "true");
@@ -121,6 +222,33 @@ private static void ConfigureMetrics(int port)
Environment.SetEnvironmentVariable("ENVIRONMENT", "test");
}
+ private static ApiFunction CreateFunction(StaticJsonHandler spotifyHandler, StaticJsonHandler lastFmHandler)
+ {
+ var spotifyApi = new SpotifyApi(
+ CreateHttpClient(spotifyHandler),
+ new SpotifyOptions { AccessToken = "test-token" });
+ var lastFmApi = new LastFmApi(
+ CreateHttpClient(lastFmHandler, "https://lastfm.test/"),
+ new LastFmOptions { ApiKey = "api-key", Username = "user" });
+
+ return new ApiFunction(
+ new RuntimeConfig
+ {
+ Features = new FeatureFlagsOptions { ShouldCallSpotify = true }
+ },
+ new MemoryCache(),
+ spotifyApi,
+ lastFmApi);
+ }
+
+ private static HttpClient CreateHttpClient(HttpMessageHandler handler, string baseAddress = "https://api.spotify.test/v1/")
+ {
+ return new HttpClient(handler)
+ {
+ BaseAddress = new Uri(baseAddress)
+ };
+ }
+
private static async Task InvokeAndReadMetric(
HttpListener listener,
ApiFunction function,
@@ -147,4 +275,20 @@ private static int GetFreePort()
listener.Start();
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
+
+ private sealed class StaticJsonHandler(string json) : HttpMessageHandler
+ {
+ public int RequestCount { get; private set; }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ RequestCount++;
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json")
+ };
+
+ return Task.FromResult(response);
+ }
+ }
}
diff --git a/src/Lho.Lambda.Tests/AuthorizerFunctionTests.cs b/src/Lho.Lambda.Tests/AuthorizerFunctionTests.cs
index 708715e..90b6ad5 100644
--- a/src/Lho.Lambda.Tests/AuthorizerFunctionTests.cs
+++ b/src/Lho.Lambda.Tests/AuthorizerFunctionTests.cs
@@ -1,5 +1,6 @@
using Lho.Lambda.Authorizer.Functions;
using Lho.Lambda.Authorizer.Models;
+using Lho.Lambda.RuntimeConfiguration.Options;
using Xunit;
namespace Lho.Lambda.Tests;
@@ -69,6 +70,22 @@ public async Task DeniesMissingApiKeyConfiguration()
Assert.False(response.IsAuthorized);
}
+ [Fact]
+ public async Task UsesInjectedAuthorizerOptions()
+ {
+ Environment.SetEnvironmentVariable("API_KEY", "environment-secret");
+ var function = new AuthorizerFunction(new AuthorizerOptions { ApiKey = "injected-secret" });
+
+ var response = await function.FunctionHandler(
+ CreateRequest(new Dictionary
+ {
+ ["x-api-key"] = "injected-secret"
+ }),
+ new TestLambdaContext());
+
+ Assert.True(response.IsAuthorized);
+ }
+
private static AuthorizerRequest CreateRequest(Dictionary headers)
{
return new AuthorizerRequest
diff --git a/src/Lho.Lambda.Tests/Lho.Lambda.Tests.csproj b/src/Lho.Lambda.Tests/Lho.Lambda.Tests.csproj
index f44720b..286e64a 100644
--- a/src/Lho.Lambda.Tests/Lho.Lambda.Tests.csproj
+++ b/src/Lho.Lambda.Tests/Lho.Lambda.Tests.csproj
@@ -19,6 +19,7 @@
+
diff --git a/src/Lho.Lambda.Tests/NowPlayingServiceTests.cs b/src/Lho.Lambda.Tests/NowPlayingServiceTests.cs
index feb958a..0716af2 100644
--- a/src/Lho.Lambda.Tests/NowPlayingServiceTests.cs
+++ b/src/Lho.Lambda.Tests/NowPlayingServiceTests.cs
@@ -2,6 +2,7 @@
using System.Text;
using Lho.Lambda.Clients.LastFm;
using Lho.Lambda.Clients.Spotify;
+using Lho.Lambda.RuntimeConfiguration.Options;
using Lho.Lambda.Services;
using Lho.Lambda.Utils;
using Xunit;
@@ -10,12 +11,101 @@ namespace Lho.Lambda.Tests;
public class NowPlayingServiceTests
{
+ [Fact]
+ public async Task NowPlayingUsesLastFmAndCachesResult()
+ {
+ var lastFmHandler = new StaticJsonHandler("""
+ {
+ "recenttracks": {
+ "track": [{
+ "name": "Last.fm song",
+ "artist": { "#text": "Last.fm artist" },
+ "album": { "#text": "Last.fm album" },
+ "url": "https://last.fm/track/current",
+ "image": [
+ { "#text": "https://example.com/small.jpg", "size": "small" },
+ { "#text": "https://example.com/large.jpg", "size": "large" }
+ ],
+ "@attr": { "nowplaying": "true" }
+ }]
+ }
+ }
+ """);
+ var service = CreateService(
+ spotifyHandler: new StaticJsonHandler("{}"),
+ lastFmHandler: lastFmHandler);
+
+ var first = await service.GetNowPlaying();
+ var second = await service.GetNowPlaying();
+
+ Assert.True(first.IsPlaying);
+ Assert.Equal("Last.fm song", first.Title);
+ Assert.Equal("Last.fm artist", first.Artist);
+ Assert.Equal("Last.fm album", first.Album);
+ Assert.Equal("https://example.com/large.jpg", first.AlbumImageUrl);
+ Assert.Same(first, second);
+ Assert.Equal(1, lastFmHandler.RequestCount);
+ }
+
+ [Fact]
+ public async Task NowPlayingFailureReturnsEmptyStatus500ViewModel()
+ {
+ var service = CreateService(
+ spotifyHandler: new StaticJsonHandler("{}"),
+ lastFmHandler: new StaticJsonHandler("lastfm failed", HttpStatusCode.InternalServerError));
+
+ var response = await service.GetNowPlaying();
+
+ Assert.False(response.IsPlaying);
+ Assert.False(response.Maintenance);
+ Assert.Equal(500, response.Status);
+ Assert.Equal("", response.Album);
+ Assert.Equal("", response.AlbumImageUrl);
+ Assert.Equal("", response.Artist);
+ Assert.Equal("", response.SongUrl);
+ Assert.Equal("", response.Title);
+ }
+
+ [Fact]
+ public async Task SpotifyNowPlayingUsesSpotifyAndCachesResult()
+ {
+ Environment.SetEnvironmentVariable("SHOULD_CALL_SPOTIFY", "true");
+ var spotifyHandler = new StaticJsonHandler("""
+ {
+ "is_playing": true,
+ "item": {
+ "name": "Spotify song",
+ "artists": [{ "name": "Spotify artist" }],
+ "album": {
+ "name": "Spotify album",
+ "images": [{ "url": "https://example.com/spotify.jpg" }]
+ },
+ "external_urls": { "spotify": "https://open.spotify.com/track/spotify" }
+ }
+ }
+ """);
+ var service = CreateService(
+ spotifyHandler: spotifyHandler,
+ lastFmHandler: new StaticJsonHandler("{}"));
+
+ var first = await service.GetSpotifyNowPlaying();
+ var second = await service.GetSpotifyNowPlaying();
+
+ Assert.True(first.IsPlaying);
+ Assert.Equal("Spotify song", first.Title);
+ Assert.Equal("Spotify artist", first.Artist);
+ Assert.Equal("Spotify album", first.Album);
+ Assert.Equal("https://example.com/spotify.jpg", first.AlbumImageUrl);
+ Assert.Same(first, second);
+ Assert.Equal(1, spotifyHandler.RequestCount);
+ }
+
[Fact]
public async Task SpotifyReturnsEmptyResponseWhenItemIsNotPlaying()
{
Environment.SetEnvironmentVariable("SHOULD_CALL_SPOTIFY", "true");
var spotifyApi = new SpotifyApi(
- new HttpClient(new StaticJsonHandler("""
+ CreateHttpClient(new StaticJsonHandler("""
{
"is_playing": false,
"item": {
@@ -29,15 +119,21 @@ public async Task SpotifyReturnsEmptyResponseWhenItemIsNotPlaying()
}
}
""")),
- "https://api.spotify.test/v1",
- accessToken: "test-token");
- var lastFmApi = new LastFmApi(new HttpClient(new StaticJsonHandler("{}")), "https://lastfm.test/", "api-key", "user");
- var service = new NowPlayingService(new MemoryCache(), spotifyApi, lastFmApi, new TestLambdaLogger());
+ new SpotifyOptions { AccessToken = "test-token" });
+ var lastFmApi = new LastFmApi(
+ CreateHttpClient(new StaticJsonHandler("{}"), "https://lastfm.test/"),
+ new LastFmOptions { ApiKey = "api-key", Username = "user" });
+ var service = new NowPlayingService(
+ new MemoryCache(),
+ spotifyApi,
+ lastFmApi,
+ new TestLambdaLogger(),
+ new FeatureFlagsOptions { ShouldCallSpotify = true });
var response = await service.HandleNowPlaying("spotify");
Assert.False(response.IsPlaying);
- Assert.Null(response.Maintenance);
+ Assert.False(response.Maintenance);
Assert.Equal(200, response.Status);
Assert.Equal("", response.Album);
Assert.Equal("", response.AlbumImageUrl);
@@ -46,14 +142,45 @@ public async Task SpotifyReturnsEmptyResponseWhenItemIsNotPlaying()
Assert.Equal("", response.Title);
}
- private sealed class StaticJsonHandler(string json) : HttpMessageHandler
+ private static NowPlayingService CreateService(StaticJsonHandler spotifyHandler, StaticJsonHandler lastFmHandler)
+ {
+ var spotifyApi = new SpotifyApi(
+ CreateHttpClient(spotifyHandler),
+ new SpotifyOptions { AccessToken = "test-token" });
+ var lastFmApi = new LastFmApi(
+ CreateHttpClient(lastFmHandler, "https://lastfm.test/"),
+ new LastFmOptions { ApiKey = "api-key", Username = "user" });
+
+ return new NowPlayingService(
+ new MemoryCache(),
+ spotifyApi,
+ lastFmApi,
+ new TestLambdaLogger(),
+ new FeatureFlagsOptions { ShouldCallSpotify = true });
+ }
+
+ private static HttpClient CreateHttpClient(HttpMessageHandler handler, string baseAddress = "https://api.spotify.test/v1/")
{
+ return new HttpClient(handler)
+ {
+ BaseAddress = new Uri(baseAddress)
+ };
+ }
+
+ private sealed class StaticJsonHandler(
+ string json,
+ HttpStatusCode statusCode = HttpStatusCode.OK) : HttpMessageHandler
+ {
+ public int RequestCount { get; private set; }
+
protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
+ RequestCount++;
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
+ response.StatusCode = statusCode;
return Task.FromResult(response);
}
diff --git a/src/Lho.Lambda.Tests/RuntimeConfigurationTests.cs b/src/Lho.Lambda.Tests/RuntimeConfigurationTests.cs
new file mode 100644
index 0000000..e04fa9e
--- /dev/null
+++ b/src/Lho.Lambda.Tests/RuntimeConfigurationTests.cs
@@ -0,0 +1,87 @@
+using Lho.Lambda.RuntimeConfiguration;
+using Xunit;
+
+namespace Lho.Lambda.Tests;
+
+public class RuntimeConfigurationTests
+{
+ [Fact]
+ public void FromEnvironmentBuildsTypedSections()
+ {
+ var config = RuntimeConfig.FromEnvironment(new Dictionary
+ {
+ ["LASTFM_API_KEY"] = "lastfm-key",
+ ["LASTFM_USERNAME"] = "lastfm-user",
+ ["SPOTIFY_ACCESS_TOKEN"] = "spotify-access",
+ ["VERSION"] = "1.2.3",
+ ["DEPLOYED_AT"] = "2026-05-10T09:00:00Z",
+ ["DEPLOYED_BY"] = "tests",
+ ["GIT_SHA"] = "abc123",
+ ["SENTRY_DSN"] = "https://sentry.example",
+ ["PUSHGATEWAY_URL"] = "https://pushgateway.example",
+ ["PUSHGATEWAY_AUTH_HEADER"] = "Authorization=Basic abc",
+ ["API_KEY"] = "authorizer-key",
+ ["SHOULD_CALL_SPOTIFY"] = "false"
+ });
+
+ Assert.Equal("lastfm-key", config.LastFm.RequireCredentials().ApiKey);
+ Assert.Equal("lastfm-user", config.LastFm.RequireCredentials().Username);
+ Assert.Equal("spotify-access", config.Spotify.AccessToken);
+ Assert.Equal("1.2.3", config.Deployment.Version);
+ Assert.Equal("2026-05-10T09:00:00Z", config.Deployment.DeployedAt);
+ Assert.Equal("tests", config.Deployment.DeployedBy);
+ Assert.Equal("abc123", config.Deployment.GitSha);
+ Assert.Equal("https://sentry.example", config.Observability.SentryDsn);
+ Assert.Equal("https://pushgateway.example", config.Observability.PushgatewayUrl);
+ Assert.Equal("Authorization=Basic abc", config.Observability.PushgatewayAuthHeader);
+ Assert.Equal("authorizer-key", config.Authorizer.ApiKey);
+ Assert.False(config.Features.ShouldCallSpotify);
+ }
+
+ [Fact]
+ public void MissingLastFmCredentialsFailOnlyWhenLastFmSectionIsRequired()
+ {
+ var config = RuntimeConfig.FromEnvironment(new Dictionary());
+
+ var exception = Assert.Throws(() => config.LastFm.RequireCredentials());
+
+ Assert.Contains("LASTFM_API_KEY", exception.Message, StringComparison.Ordinal);
+ Assert.Contains("LASTFM_USERNAME", exception.Message, StringComparison.Ordinal);
+ Assert.Equal("unknown", config.Deployment.Version);
+ }
+
+ [Fact]
+ public void MissingSpotifyRefreshCredentialsFailOnlyWhenRefreshCredentialsAreRequired()
+ {
+ var config = RuntimeConfig.FromEnvironment(new Dictionary
+ {
+ ["SPOTIFY_ACCESS_TOKEN"] = "access-token"
+ });
+
+ Assert.Equal("access-token", config.Spotify.AccessToken);
+
+ var exception = Assert.Throws(() => config.Spotify.RequireRefreshCredentials());
+
+ Assert.Contains("SPOTIFY_CLIENT_ID", exception.Message, StringComparison.Ordinal);
+ Assert.Contains("SPOTIFY_CLIENT_SECRET", exception.Message, StringComparison.Ordinal);
+ Assert.Contains("SPOTIFY_REFRESH_TOKEN", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void MissingObservabilitySinksDisableThem()
+ {
+ var config = RuntimeConfig.FromEnvironment(new Dictionary());
+
+ Assert.Null(config.Observability.SentryDsn);
+ Assert.Null(config.Observability.PushgatewayUrl);
+ Assert.False(config.Observability.MetricsEnabled);
+ }
+
+ [Fact]
+ public void MissingAuthorizerApiKeyIsOptionalConfiguration()
+ {
+ var config = RuntimeConfig.FromEnvironment(new Dictionary());
+
+ Assert.Null(config.Authorizer.ApiKey);
+ }
+}
diff --git a/src/Lho.Lambda/Clients/LastFm/LastFmApi.cs b/src/Lho.Lambda/Clients/LastFm/LastFmApi.cs
index 168bdb5..63f444e 100644
--- a/src/Lho.Lambda/Clients/LastFm/LastFmApi.cs
+++ b/src/Lho.Lambda/Clients/LastFm/LastFmApi.cs
@@ -1,6 +1,6 @@
using System.Text.Json;
using Lho.Lambda.Models;
-using Lho.Lambda.Utils;
+using Lho.Lambda.RuntimeConfiguration.Options;
namespace Lho.Lambda.Clients.LastFm;
@@ -12,37 +12,19 @@ public class LastFmApi
};
private readonly HttpClient _httpClient;
- private readonly string _baseUrl;
- private readonly string? _apiKey;
- private readonly string? _username;
+ private readonly LastFmOptions _lastFmOptions;
-
- public LastFmApi()
- : this(new HttpClient { Timeout = TimeSpan.FromSeconds(10) }, "https://ws.audioscrobbler.com/2.0/")
- {
- }
-
- public LastFmApi(HttpClient httpClient, string baseUrl, string? apiKey = null, string? username = null)
+ public LastFmApi(HttpClient httpClient, LastFmOptions lastFmOptions)
{
_httpClient = httpClient;
- _baseUrl = baseUrl;
- _apiKey = apiKey ?? EnvironmentConfig.LastFm.ApiKey;
- _username = username ?? EnvironmentConfig.LastFm.Username;
+ _lastFmOptions = lastFmOptions;
}
public async Task GetRecentTracks()
{
- if (string.IsNullOrEmpty(_apiKey))
- {
- throw new LastFmServiceException("Missing Last.fm API key");
- }
-
- if (string.IsNullOrEmpty(_username))
- {
- throw new LastFmServiceException("Missing Last.fm username");
- }
+ var credentials = _lastFmOptions.RequireCredentials();
- var requestUri = $"{_baseUrl}?method=user.getrecenttracks&user={Uri.EscapeDataString(_username)}&api_key={Uri.EscapeDataString(_apiKey)}&format=json&limit=1";
+ var requestUri = $"?method=user.getrecenttracks&user={Uri.EscapeDataString(credentials.Username)}&api_key={Uri.EscapeDataString(credentials.ApiKey)}&format=json&limit=1";
var response = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, requestUri));
await EnsureSuccess(response);
diff --git a/src/Lho.Lambda/Clients/Spotify/SpotifyApi.cs b/src/Lho.Lambda/Clients/Spotify/SpotifyApi.cs
index c820d44..b0925b4 100644
--- a/src/Lho.Lambda/Clients/Spotify/SpotifyApi.cs
+++ b/src/Lho.Lambda/Clients/Spotify/SpotifyApi.cs
@@ -3,7 +3,7 @@
using System.Text;
using System.Text.Json;
using Lho.Lambda.Models;
-using Lho.Lambda.Utils;
+using Lho.Lambda.RuntimeConfiguration.Options;
namespace Lho.Lambda.Clients.Spotify;
@@ -15,32 +15,19 @@ public class SpotifyApi
};
private readonly HttpClient _httpClient;
- private readonly string _baseUrl;
- private readonly string? _accessToken;
- private readonly string? _clientId;
- private readonly string? _clientSecret;
- private readonly string? _refreshToken;
+ private readonly SpotifyOptions _spotifyOptions;
private string? _cachedAccessToken;
private DateTimeOffset? _tokenExpiresAt;
- public SpotifyApi()
- : this(new HttpClient { Timeout = TimeSpan.FromSeconds(10) }, "https://api.spotify.com/v1")
- {
- }
-
- public SpotifyApi(HttpClient httpClient, string baseUrl, string? accessToken = null)
+ public SpotifyApi(HttpClient httpClient, SpotifyOptions spotifyOptions)
{
_httpClient = httpClient;
- _baseUrl = baseUrl;
- _accessToken = accessToken ?? EnvironmentConfig.Spotify.AccessToken;
- _clientId = EnvironmentConfig.Spotify.ClientId;
- _clientSecret = EnvironmentConfig.Spotify.ClientSecret;
- _refreshToken = EnvironmentConfig.Spotify.RefreshToken;
+ _spotifyOptions = spotifyOptions;
}
public async Task GetNowPlaying()
{
- var request = new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}/me/player/currently-playing");
+ var request = new HttpRequestMessage(HttpMethod.Get, "me/player/currently-playing");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetAccessToken());
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -57,7 +44,7 @@ public SpotifyApi(HttpClient httpClient, string baseUrl, string? accessToken = n
public async Task GetTopTracks(string timeRange = "medium_term", int limit = 10)
{
var boundedLimit = Math.Clamp(limit, 1, 50);
- var requestUri = $"{_baseUrl}/me/top/tracks?time_range={Uri.EscapeDataString(timeRange)}&limit={boundedLimit}";
+ var requestUri = $"me/top/tracks?time_range={Uri.EscapeDataString(timeRange)}&limit={boundedLimit}";
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetAccessToken());
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -71,9 +58,9 @@ public async Task GetTopTracks(string timeRange = "med
private async Task GetAccessToken()
{
- if (!string.IsNullOrEmpty(_accessToken))
+ if (!string.IsNullOrEmpty(_spotifyOptions.AccessToken))
{
- return _accessToken;
+ return _spotifyOptions.AccessToken;
}
if (!string.IsNullOrEmpty(_cachedAccessToken) && _tokenExpiresAt > DateTimeOffset.UtcNow)
@@ -81,23 +68,15 @@ private async Task GetAccessToken()
return _cachedAccessToken;
}
- if (string.IsNullOrEmpty(_refreshToken))
- {
- throw new SpotifyServiceException("Missing Spotify refresh token");
- }
-
- if (string.IsNullOrEmpty(_clientId) || string.IsNullOrEmpty(_clientSecret))
- {
- throw new SpotifyServiceException("Missing Spotify client ID or client secret");
- }
+ var credentials = _spotifyOptions.RequireRefreshCredentials();
using var request = new HttpRequestMessage(HttpMethod.Post, "https://accounts.spotify.com/api/token");
- var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_clientId}:{_clientSecret}"));
- request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
+ var encodedCredentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{credentials.ClientId}:{credentials.ClientSecret}"));
+ request.Headers.Authorization = new AuthenticationHeaderValue("Basic", encodedCredentials);
request.Content = new FormUrlEncodedContent(new Dictionary
{
["grant_type"] = "refresh_token",
- ["refresh_token"] = _refreshToken
+ ["refresh_token"] = credentials.RefreshToken
});
var response = await _httpClient.SendAsync(request);
diff --git a/src/Lho.Lambda/Functions/ApiFunction.cs b/src/Lho.Lambda/Functions/ApiFunction.cs
index 807eb45..42c2f14 100644
--- a/src/Lho.Lambda/Functions/ApiFunction.cs
+++ b/src/Lho.Lambda/Functions/ApiFunction.cs
@@ -4,6 +4,7 @@
using Lho.Lambda.Clients.LastFm;
using Lho.Lambda.Clients.Spotify;
using Lho.Lambda.Observability;
+using Lho.Lambda.RuntimeConfiguration;
using Lho.Lambda.Services;
using Lho.Lambda.Utils;
@@ -12,22 +13,30 @@ namespace Lho.Lambda.Functions;
public class ApiFunction
{
- private static readonly MemoryCache Cache = new();
- private static readonly SpotifyApi SpotifyApi = new();
- private static readonly LastFmApi LastFmApi = new();
-
+ private readonly RuntimeConfig _runtimeConfig;
private readonly MemoryCache _cache;
private readonly SpotifyApi _spotifyApi;
private readonly LastFmApi _lastFmApi;
public ApiFunction()
- : this(Cache, SpotifyApi, LastFmApi)
+ : this(RuntimeConfig.Current)
+ {
+
+ }
+
+ public ApiFunction(RuntimeConfig runtimeConfig)
+ : this(
+ runtimeConfig,
+ new MemoryCache(),
+ new SpotifyApi(CreateHttpClient("https://api.spotify.com/v1/"), runtimeConfig.Spotify),
+ new LastFmApi(CreateHttpClient("https://ws.audioscrobbler.com/2.0/"), runtimeConfig.LastFm))
{
}
- public ApiFunction(MemoryCache cache, SpotifyApi spotifyApi, LastFmApi lastFmApi)
+ public ApiFunction(RuntimeConfig runtimeConfig, MemoryCache cache, SpotifyApi spotifyApi, LastFmApi lastFmApi)
{
+ _runtimeConfig = runtimeConfig;
_cache = cache;
_spotifyApi = spotifyApi;
_lastFmApi = lastFmApi;
@@ -69,8 +78,8 @@ ILambdaContext ctx
response = path switch
{
"/api/health" => ResponseBuilder.CreateResponse(new { status = "OK" }, includeCacheControl: false),
- "/api/version" => ResponseBuilder.CreateResponse(VersionService.GetVersion(), includeCacheControl: false),
- "/api/now-playing" => await HandleNowPlaying(request, ctx, provider!),
+ "/api/version" => ResponseBuilder.CreateResponse(new VersionService(_runtimeConfig.Deployment).GetVersion(), includeCacheControl: false),
+ "/api/now-playing" => await HandleNowPlaying(ctx, provider!),
"/api/top-tracks" => await HandleTopTracks(request, ctx),
_ => ResponseBuilder.ErrorResponse(404, "Not Found")
};
@@ -107,11 +116,13 @@ ILambdaContext ctx
private async Task HandleNowPlaying(
- APIGatewayHttpApiV2ProxyRequest request,
ILambdaContext context,
string provider)
{
- var response = await new NowPlayingService(_cache, _spotifyApi, _lastFmApi, context.Logger).HandleNowPlaying(provider);
+ var service = new NowPlayingService(_cache, _spotifyApi, _lastFmApi, context.Logger, _runtimeConfig.Features);
+ var response = string.Equals(provider, "spotify", StringComparison.OrdinalIgnoreCase)
+ ? await service.GetSpotifyNowPlaying()
+ : await service.GetNowPlaying();
return ResponseBuilder.CreateResponse(response, revalidateSeconds: 3);
}
@@ -241,4 +252,13 @@ private static bool IsSupportedMethod(string method, string path)
return string.Equals(method, "HEAD", StringComparison.OrdinalIgnoreCase) && path == "/api/health";
}
+
+ private static HttpClient CreateHttpClient(string baseAddress)
+ {
+ return new HttpClient
+ {
+ BaseAddress = new Uri(baseAddress),
+ Timeout = TimeSpan.FromSeconds(10)
+ };
+ }
}
diff --git a/src/Lho.Lambda/Lho.Lambda.csproj b/src/Lho.Lambda/Lho.Lambda.csproj
index 15f0a9f..cd066c0 100644
--- a/src/Lho.Lambda/Lho.Lambda.csproj
+++ b/src/Lho.Lambda/Lho.Lambda.csproj
@@ -12,6 +12,7 @@
+
diff --git a/src/Lho.Lambda/Models/Spotify.cs b/src/Lho.Lambda/Models/Spotify.cs
index 14ce654..5009b58 100644
--- a/src/Lho.Lambda/Models/Spotify.cs
+++ b/src/Lho.Lambda/Models/Spotify.cs
@@ -4,7 +4,7 @@ namespace Lho.Lambda.Models;
public record NowPlayingResponse(
bool IsPlaying,
- bool? Maintenance,
+ bool Maintenance,
int Status,
string Album,
string AlbumImageUrl,
diff --git a/src/Lho.Lambda/Services/NowPlayingService.cs b/src/Lho.Lambda/Services/NowPlayingService.cs
index dac0742..dc9569e 100644
--- a/src/Lho.Lambda/Services/NowPlayingService.cs
+++ b/src/Lho.Lambda/Services/NowPlayingService.cs
@@ -3,20 +3,47 @@
using Lho.Lambda.Clients.Spotify;
using Lho.Lambda.Models;
using Lho.Lambda.Observability;
+using Lho.Lambda.RuntimeConfiguration.Options;
using Lho.Lambda.Utils;
namespace Lho.Lambda.Services;
-public class NowPlayingService(MemoryCache cache, SpotifyApi spotifyApi, LastFmApi lastFmApi, ILambdaLogger logger)
+public class NowPlayingService(
+ MemoryCache cache,
+ SpotifyApi spotifyApi,
+ LastFmApi lastFmApi,
+ ILambdaLogger logger,
+ FeatureFlagsOptions featureFlags)
{
private const string LastFmProvider = "lastfm";
private const string SpotifyProvider = "spotify";
+ private const string LastFmCacheKey = "NowPlaying:lastfm";
+ private const string SpotifyCacheKey = "NowPlaying:spotify";
public async Task HandleNowPlaying(string provider = LastFmProvider)
+ {
+ return string.Equals(provider, SpotifyProvider, StringComparison.OrdinalIgnoreCase)
+ ? await GetSpotifyNowPlaying()
+ : await GetNowPlaying();
+ }
+
+ public async Task GetNowPlaying()
+ {
+ return await GetCachedNowPlaying(LastFmCacheKey, LastFmProvider, HandleLastFmNowPlaying);
+ }
+
+ public async Task GetSpotifyNowPlaying()
+ {
+ return await GetCachedNowPlaying(SpotifyCacheKey, SpotifyProvider, HandleSpotifyNowPlaying);
+ }
+
+ private async Task GetCachedNowPlaying(
+ string cacheKey,
+ string provider,
+ Func> fetch)
{
try
{
- var cacheKey = $"NowPlaying:{provider}";
var cachedResponse = cache.Get(cacheKey);
if (cachedResponse is not null)
{
@@ -24,11 +51,7 @@ public async Task HandleNowPlaying(string provider = LastFmP
return cachedResponse;
}
- var response = provider switch
- {
- SpotifyProvider => await HandleSpotifyNowPlaying(),
- _ => await HandleLastFmNowPlaying()
- };
+ var response = await fetch();
if (response.Status == 200 && !string.IsNullOrEmpty(response.Title))
{
@@ -45,7 +68,7 @@ public async Task HandleNowPlaying(string provider = LastFmP
["operation"] = "now-playing",
["provider"] = provider
});
- return EmptyResponse(maintenance: null, status: 500);
+ return EmptyResponse(status: 500);
}
}
@@ -56,12 +79,12 @@ private async Task HandleLastFmNowPlaying()
if (track is null || !IsNowPlaying(track))
{
logger.LogLine("No song currently playing");
- return EmptyResponse(maintenance: null, status: 200);
+ return EmptyResponse(status: 200);
}
return new NowPlayingResponse(
IsPlaying: true,
- Maintenance: null,
+ Maintenance: false,
Status: 200,
Album: track.Album.Text,
AlbumImageUrl: track.Images.LastOrDefault(image => !string.IsNullOrEmpty(image.Url))?.Url ?? "",
@@ -72,7 +95,7 @@ private async Task HandleLastFmNowPlaying()
private async Task HandleSpotifyNowPlaying()
{
- if (!EnvironmentConfig.ShouldCallSpotify)
+ if (!featureFlags.ShouldCallSpotify)
{
return EmptyResponse(maintenance: true, status: 200);
}
@@ -81,13 +104,13 @@ private async Task HandleSpotifyNowPlaying()
if (nowPlayingResponse?.Item is null || !nowPlayingResponse.IsPlaying)
{
logger.LogLine("No song currently playing");
- return EmptyResponse(maintenance: null, status: 200);
+ return EmptyResponse(status: 200);
}
var item = nowPlayingResponse.Item;
return new NowPlayingResponse(
IsPlaying: nowPlayingResponse.IsPlaying,
- Maintenance: null,
+ Maintenance: false,
Status: 200,
Album: item.Album.Name,
AlbumImageUrl: item.Album.Images.FirstOrDefault()?.Url ?? "",
@@ -101,7 +124,7 @@ private static bool IsNowPlaying(LastFmTrack track)
return string.Equals(track.Attributes?.NowPlaying, "true", StringComparison.OrdinalIgnoreCase);
}
- private static NowPlayingResponse EmptyResponse(bool? maintenance, int status)
+ private static NowPlayingResponse EmptyResponse(int status, bool maintenance = false)
{
return new NowPlayingResponse(
IsPlaying: false,
diff --git a/src/Lho.Lambda/Services/VersionService.cs b/src/Lho.Lambda/Services/VersionService.cs
index ae4ca95..9fe22b4 100644
--- a/src/Lho.Lambda/Services/VersionService.cs
+++ b/src/Lho.Lambda/Services/VersionService.cs
@@ -1,16 +1,16 @@
using Lho.Lambda.Models;
-using Lho.Lambda.Utils;
+using Lho.Lambda.RuntimeConfiguration.Options;
namespace Lho.Lambda.Services;
-public static class VersionService
+public class VersionService(DeploymentOptions deployment)
{
- public static VersionResponse GetVersion()
+ public VersionResponse GetVersion()
{
return new VersionResponse(
- Version: EnvironmentConfig.Deploy.Version,
- DeployedAt: EnvironmentConfig.Deploy.DeployedAt,
- DeployedBy: EnvironmentConfig.Deploy.DeployedBy,
- GitSha: EnvironmentConfig.Deploy.GitSha);
+ Version: deployment.Version,
+ DeployedAt: deployment.DeployedAt,
+ DeployedBy: deployment.DeployedBy,
+ GitSha: deployment.GitSha);
}
}
diff --git a/src/Lho.Lambda/Utils/EnvironmentConfig.cs b/src/Lho.Lambda/Utils/EnvironmentConfig.cs
deleted file mode 100644
index dd2808b..0000000
--- a/src/Lho.Lambda/Utils/EnvironmentConfig.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-namespace Lho.Lambda.Utils;
-
-public static class EnvironmentConfig
-{
- public static string String(string key, string defaultValue = "")
- {
- return Environment.GetEnvironmentVariable(key) ?? defaultValue;
- }
-
- public static bool Bool(string key, bool defaultValue = false)
- {
- var value = Environment.GetEnvironmentVariable(key);
- return value is null ? defaultValue : string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || value == "1";
- }
-
- public static bool ShouldCallSpotify => Bool("SHOULD_CALL_SPOTIFY", defaultValue: true);
-
- public static class Spotify
- {
- public static string? ClientId => Environment.GetEnvironmentVariable("SPOTIFY_CLIENT_ID");
-
- public static string? ClientSecret => Environment.GetEnvironmentVariable("SPOTIFY_CLIENT_SECRET");
-
- public static string? RefreshToken => Environment.GetEnvironmentVariable("SPOTIFY_REFRESH_TOKEN");
-
- public static string? AccessToken => Environment.GetEnvironmentVariable("SPOTIFY_ACCESS_TOKEN");
- }
-
- public static class LastFm
- {
- public static string? ApiKey => Environment.GetEnvironmentVariable("LASTFM_API_KEY");
-
- public static string? Username => Environment.GetEnvironmentVariable("LASTFM_USERNAME");
- }
-
- public static class Deploy
- {
- public static string Version => String("VERSION", "unknown");
-
- public static string DeployedAt => String("DEPLOYED_AT", "unknown");
-
- public static string DeployedBy => String("DEPLOYED_BY", "unknown");
-
- public static string GitSha => String("GIT_SHA", "unknown");
- }
-}