Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions Lho.Lambda.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<Project Path="src/Lho.Lambda.Authorizer/Lho.Lambda.Authorizer.csproj" />
<Project Path="src/Lho.Lambda.Local/Lho.Lambda.Local.csproj" />
<Project Path="src/Lho.Lambda.Observability/Lho.Lambda.Observability.csproj" />
<Project Path="src/Lho.Lambda.RuntimeConfiguration/Lho.Lambda.RuntimeConfiguration.csproj" />
<Project Path="src/Lho.Lambda.Tests/Lho.Lambda.Tests.csproj" />
<Project Path="src/Lho.Lambda/Lho.Lambda.csproj" />
</Folder>
Expand Down
17 changes: 16 additions & 1 deletion src/Lho.Lambda.Authorizer/Functions/AuthorizerFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,28 @@
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;

public class AuthorizerFunction
{
private static readonly HashSet<string> 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<AuthorizerSimpleResponse> FunctionHandler(AuthorizerRequest request, ILambdaContext context)
{
var stopwatch = Stopwatch.StartNew();
Expand All @@ -34,7 +49,7 @@ public async Task<AuthorizerSimpleResponse> 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))
{
Expand Down
1 change: 1 addition & 0 deletions src/Lho.Lambda.Authorizer/Lho.Lambda.Authorizer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Lho.Lambda.RuntimeConfiguration\Lho.Lambda.RuntimeConfiguration.csproj" />
<ProjectReference Include="..\Lho.Lambda.Observability\Lho.Lambda.Observability.csproj" />
</ItemGroup>
</Project>
4 changes: 4 additions & 0 deletions src/Lho.Lambda.Observability/Lho.Lambda.Observability.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@
<PackageReference Include="Amazon.Lambda.Core" Version="2.8.0" />
<PackageReference Include="Sentry" Version="6.5.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Lho.Lambda.RuntimeConfiguration\Lho.Lambda.RuntimeConfiguration.csproj" />
</ItemGroup>
</Project>
50 changes: 16 additions & 34 deletions src/Lho.Lambda.Observability/ObservabilityConfig.cs
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Lho.Lambda.RuntimeConfiguration.Options;

public class AuthorizerOptions
{
public const string Section = "Authorizer";

public string? ApiKey { get; init; }
}
14 changes: 14 additions & 0 deletions src/Lho.Lambda.RuntimeConfiguration/Options/DeploymentOptions.cs
Original file line number Diff line number Diff line change
@@ -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";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Lho.Lambda.RuntimeConfiguration.Options;

public class FeatureFlagsOptions
{
public const string Section = "FeatureFlags";

public bool ShouldCallSpotify { get; init; } = true;
}
42 changes: 42 additions & 0 deletions src/Lho.Lambda.RuntimeConfiguration/Options/LastFmOptions.cs
Original file line number Diff line number Diff line change
@@ -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<string>();

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);
Original file line number Diff line number Diff line change
@@ -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);
}
51 changes: 51 additions & 0 deletions src/Lho.Lambda.RuntimeConfiguration/Options/SpotifyOptions.cs
Original file line number Diff line number Diff line change
@@ -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<string>();

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);
Loading
Loading