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
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ let package = Package(
.executable(name: "authorizer", targets: ["authorizer"]),
],
dependencies: [
// for local dev
.package(
url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "2.6.0"),
.package(url: "https://github.com/awslabs/swift-aws-lambda-events.git", from: "1.5.0"),
.package(url: "https://github.com/apple/swift-http-types.git", from: "1.5.1"),
.package(url: "https://github.com/apple/swift-log.git", "1.0.0"..<"2.0.0"),
],
targets: [
Expand All @@ -31,6 +31,7 @@ let package = Package(
dependencies: [
.product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"),
.product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"),
.product(name: "HTTPTypes", package: "swift-http-types"),
],
),
]
Expand Down
21 changes: 11 additions & 10 deletions Sources/authorizer/main.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import AWSLambdaRuntime
import Foundation

private func secureCompare(_ a: String?, _ b: String?) -> Bool {
guard let a = a, let b = b else { return a == nil && b == nil }
guard a.count == b.count else { return false }
return zip(a.utf8, b.utf8).reduce(0 as UInt8) { $0 | ($1.0 ^ $1.1) } == 0
}

struct AuthorizerRequest: Codable {
let version: String?
let type: String?
Expand Down Expand Up @@ -41,30 +47,25 @@ func getHeaderValue(_ headers: [String: String]?, key: String) -> String? {

let runtime = LambdaRuntime {
(event: AuthorizerRequest, context: LambdaContext) -> AuthorizerSimpleResponse in

context.logger.info("Authorizer invoked")
context.logger.info("Headers: \(String(describing: event.headers))")
context.logger.info("RouteArn: \(String(describing: event.routeArn))")
context.logger.debug("Authorizer invoked")

let consumer = getHeaderValue(event.headers, key: "x-consumer")
let validConsumers = ["lhowsam-dev", "lhowsam-prod", "lhowsam-local"]

let apiKey = getHeaderValue(event.headers, key: "x-api-key")
let validKey = ProcessInfo.processInfo.environment["API_KEY"]

context.logger.info("API Key from header: '\(apiKey ?? "nil")'")

if apiKey != validKey {
context.logger.info("Deny - API key mismatch")
if !secureCompare(apiKey, validKey) {
context.logger.info("Deny - API key invalid")
return AuthorizerSimpleResponse(isAuthorized: false)
}

if let consumer = consumer, !validConsumers.contains(consumer) {
context.logger.info("Deny - Invalid consumer: \(consumer)")
context.logger.info("Deny - Invalid consumer")
return AuthorizerSimpleResponse(isAuthorized: false)
}

context.logger.info("Allow - Authorization successful")
context.logger.debug("Allow")
return AuthorizerSimpleResponse(isAuthorized: true)
}

Expand Down
16 changes: 16 additions & 0 deletions Sources/lambda/Models/Spotify.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,19 @@ struct SpotifyAlbum: Codable {
struct SpotifyExternalUrls: Codable {
let spotify: String
}

struct SpotifyTopTracksResponse: Codable {
let items: [SpotifyItem]
}

struct TopTrackResponseItem: Codable {
let title: String
let artist: String
let album: String
let albumImageUrl: String
let songUrl: String
}

struct TopTracksApiResponse: Codable {
let tracks: [TopTrackResponseItem]
}
7 changes: 1 addition & 6 deletions Sources/lambda/Services/NowPlayingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,7 @@ actor NowPlayingService {
return cachedResponse
}

let shouldCallSpotify =
ProcessInfo.processInfo.environment["SHOULD_CALL_SPOTIFY"] ?? "true"

if shouldCallSpotify.lowercased() == "false" {
logger.info("Now playing responses disabled")

if !Environment.shouldCallSpotify {
return NowPlayingResponse(
isPlaying: false, maintenance: true, status: 200, album: "",
albumImageUrl: "", artist: "", songUrl: "", title: ""
Expand Down
37 changes: 37 additions & 0 deletions Sources/lambda/Services/TopTracksService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation
import Logging

actor TopTracksService {
private let spotifyApi: SpotifyApi
private let logger: Logger

init(spotifyApi: SpotifyApi, logger: Logger) {
self.spotifyApi = spotifyApi
self.logger = logger
}

func handleTopTracks(timeRange: String, limit: Int) async throws -> TopTracksApiResponse {
do {
let response = try await spotifyApi.getTopTracks(timeRange: timeRange, limit: limit)

let tracks = response.items.map { item in
TopTrackResponseItem(
title: item.name,
artist: item.artists.map(\.name).joined(separator: ", "),
album: item.album.name,
albumImageUrl: item.album.images.first?.url ?? "",
songUrl: item.externalUrls.spotify
)
}

return TopTracksApiResponse(tracks: tracks)
} catch {
if let spotifyError = error as? SpotifyServiceError {
logger.error("Top tracks fetch failed: \(spotifyError.description)")
} else {
logger.error("Top tracks fetch failed: \(error.localizedDescription)")
}
throw error
}
}
}
13 changes: 5 additions & 8 deletions Sources/lambda/Services/VersionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,12 @@ struct VersionResponse: Codable {

actor VersionService {
func handleVersion() async throws -> APIGatewayV2Response {
let version = ProcessInfo.processInfo.environment["VERSION"] ?? "unknown"
let deployedAt = ProcessInfo.processInfo.environment["DEPLOYED_AT"] ?? "unknown"
let deployedBy = ProcessInfo.processInfo.environment["DEPLOYED_BY"] ?? "unknown"
let gitSha = ProcessInfo.processInfo.environment["GIT_SHA"] ?? "unknown"

let response = VersionResponse(
version: version, deployedAt: deployedAt, deployedBy: deployedBy, gitSha: gitSha
version: Environment.Deploy.version,
deployedAt: Environment.Deploy.deployedAt,
deployedBy: Environment.Deploy.deployedBy,
gitSha: Environment.Deploy.gitSha
)

return ResponseBuilder.createResponse(body: response, includeCacheControl: false)
return try ResponseBuilder.createResponse(body: response, includeCacheControl: false)
}
}
1 change: 0 additions & 1 deletion Sources/lambda/Utils/Cache.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import Foundation


actor MemoryCache {
private var cache: [String: CacheEntry] = [:]

Expand Down
32 changes: 32 additions & 0 deletions Sources/lambda/Utils/Environment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

enum Environment {
private static let env = ProcessInfo.processInfo.environment

static func string(_ key: String, default defaultValue: String = "") -> String {
env[key] ?? defaultValue
}

static func bool(_ key: String, default defaultValue: Bool = false) -> Bool {
guard let value = env[key] else { return defaultValue }
return value.lowercased() == "true" || value == "1"
}

enum Spotify {
static var clientId: String? { env["SPOTIFY_CLIENT_ID"] }
static var clientSecret: String? { env["SPOTIFY_CLIENT_SECRET"] }
static var refreshToken: String? { env["SPOTIFY_REFRESH_TOKEN"] }
static var accessToken: String? { env["SPOTIFY_ACCESS_TOKEN"] }
}

enum Deploy {
static var version: String { Environment.string("VERSION", default: "unknown") }
static var deployedAt: String { Environment.string("DEPLOYED_AT", default: "unknown") }
static var deployedBy: String { Environment.string("DEPLOYED_BY", default: "unknown") }
static var gitSha: String { Environment.string("GIT_SHA", default: "unknown") }
}

static var shouldCallSpotify: Bool {
Environment.bool("SHOULD_CALL_SPOTIFY", default: true)
}
}
31 changes: 21 additions & 10 deletions Sources/lambda/Utils/ResponseBuilder.swift
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import AWSLambdaEvents
import Foundation
import HTTPTypes

enum ResponseBuilder {
static let defaultCORSHeaders: [String: String] = [
"content-type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,OPTIONS,POST,PUT,DELETE"
]

static func createResponse<T: Encodable>(
body: T,
includeCacheControl: Bool,
includeCacheControl: Bool = true,
revalidateSeconds: Int = 3
) -> APIGatewayV2Response {
) throws -> APIGatewayV2Response {
let encoder = JSONEncoder()

var headers: [String: String] = [
"content-type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,OPTIONS,POST,PUT,DELETE",
]
var headers = defaultCORSHeaders

if includeCacheControl {
headers["Cache-Control"] =
Expand All @@ -22,13 +24,22 @@ enum ResponseBuilder {
headers["Cache-Control"] = "no-cache"
}

let bodyData = try? encoder.encode(body)
let bodyString = bodyData.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
let bodyData = try encoder.encode(body)
let bodyString = String(data: bodyData, encoding: .utf8) ?? "{}"

return APIGatewayV2Response(
statusCode: .ok,
headers: headers,
body: bodyString
)
}

static func errorResponse(statusCode: HTTPResponse.Status, message: String) -> APIGatewayV2Response {
let body = (try? JSONEncoder().encode(["error": message])).flatMap { String(data: $0, encoding: .utf8) } ?? #"{"error":"Unknown error"}"#
return APIGatewayV2Response(
statusCode: statusCode,
headers: defaultCORSHeaders,
body: body
)
}
}
87 changes: 54 additions & 33 deletions Sources/lambda/Utils/Router.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AWSLambdaEvents
import AWSLambdaRuntime
import Foundation
import HTTPTypes

final class Router: @unchecked Sendable {
typealias Handler = (APIGatewayV2Request, LambdaContext) throws -> APIGatewayV2Response
Expand All @@ -19,8 +20,7 @@ final class Router: @unchecked Sendable {
}

func handle(event: APIGatewayV2Request, context: LambdaContext) async throws
-> APIGatewayV2Response
{
-> APIGatewayV2Response {
let methodString = String(describing: event.context.http.method)
let requestMethod = HTTPMethod(rawValue: methodString) ?? .GET
var requestPath = event.rawPath
Expand All @@ -46,8 +46,8 @@ final class Router: @unchecked Sendable {
}
}

context.logger.info(
"Received request - Method: \(methodString), rawPath: '\(event.rawPath)', processed path: '\(requestPath)', available routes: \(routes.map { "\($0.method.rawValue) \($0.path)" }) + \(asyncRoutes.map { "\($0.method.rawValue) \($0.path)" })"
context.logger.debug(
"Request \(methodString) \(requestPath)"
)

for asyncRoute in asyncRoutes {
Expand All @@ -62,11 +62,7 @@ final class Router: @unchecked Sendable {
}
}

return APIGatewayV2Response(
statusCode: .notFound,
headers: ["content-type": "application/json"],
body: #"{"error": "Not Found"}"#
)
return ResponseBuilder.errorResponse(statusCode: HTTPResponse.Status.notFound, message: "Not Found")
}
}

Expand Down Expand Up @@ -104,47 +100,72 @@ func createRouter() -> Router {
let cache = MemoryCache()
let spotifyApi = SpotifyApi()

router.add(method: .GET, path: "/api/health") { event, context in
context.logger.info("Health check endpoint called")
return APIGatewayV2Response(
statusCode: .ok,
headers: ["content-type": "application/json"],
body: #"{"status": "OK"}"#
)
router.add(method: .GET, path: "/api/health") { _, _ in
(try? ResponseBuilder.createResponse(body: ["status": "OK"], includeCacheControl: false)) ?? ResponseBuilder.errorResponse(statusCode: HTTPResponse.Status.internalServerError, message: "Encoding error")
}

router.add(method: .GET, path: "/api/version") { event, context async throws in
router.add(method: .GET, path: "/api/version") { _, context async throws in
context.logger.info("Version endpoint called")
let versionService = VersionService()
return try await versionService.handleVersion()
}

router.add(method: .GET, path: "/api/now-playing") {
event, context async throws -> APIGatewayV2Response in
context.logger.info("GET /api/now-playing called")
_, context async throws -> APIGatewayV2Response in
let nowplayingService = NowPlayingService(
cache: cache,
spotifyApi: spotifyApi,
logger: context.logger
)
let response = try await nowplayingService.handleNowPlaying()
return try ResponseBuilder.createResponse(body: response, revalidateSeconds: 3)
}

let encoder = JSONEncoder()
let bodyData = try encoder.encode(response)
let bodyString = String(data: bodyData, encoding: .utf8) ?? "{}"

return APIGatewayV2Response(
statusCode: .ok,
headers: [
"content-type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,OPTIONS,POST,PUT,DELETE",
"Cache-Control":
"max-age=3, s-maxage=3, stale-while-revalidate=3, stale-if-error=3",
],
body: bodyString
router.add(method: .GET, path: "/api/top-tracks") {
event, context async throws -> APIGatewayV2Response in
let timeRange = parseQueryParam(
event: event, key: "time_range",
defaultValue: "medium_term",
allowedValues: ["short_term", "medium_term", "long_term"]
)
let limit = parseQueryParam(event: event, key: "limit", defaultValue: "20")
let limitInt = min(50, max(1, Int(limit) ?? 20))

let topTracksService = TopTracksService(
spotifyApi: spotifyApi,
logger: context.logger
)
do {
let response = try await topTracksService.handleTopTracks(
timeRange: timeRange,
limit: limitInt
)
return try ResponseBuilder.createResponse(body: response, revalidateSeconds: 300)
} catch {
context.logger.error("Top tracks failed: \(error)")
return ResponseBuilder.errorResponse(
statusCode: HTTPResponse.Status.internalServerError,
message: "Unable to fetch top tracks"
)
}
}

return router
}

private func parseQueryParam(
event: APIGatewayV2Request, key: String, defaultValue: String,
allowedValues: [String]? = nil
) -> String {
let raw = event.rawQueryString
guard let queryItems = URLComponents(string: "?\(raw)")?.queryItems else {
return defaultValue
}
guard let value = queryItems.first(where: { $0.name == key })?.value, !value.isEmpty else {
return defaultValue
}
if let allowed = allowedValues, !allowed.contains(value) {
return defaultValue
}
return value
}
Loading