diff --git a/Package.swift b/Package.swift index cf22bee5..803bb133 100644 --- a/Package.swift +++ b/Package.swift @@ -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: [ @@ -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"), ], ), ] diff --git a/Sources/authorizer/main.swift b/Sources/authorizer/main.swift index 6be2e2ff..011c61e1 100644 --- a/Sources/authorizer/main.swift +++ b/Sources/authorizer/main.swift @@ -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? @@ -41,10 +47,7 @@ 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"] @@ -52,19 +55,17 @@ let runtime = LambdaRuntime { 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) } diff --git a/Sources/lambda/Models/Spotify.swift b/Sources/lambda/Models/Spotify.swift index 311541b0..0eb345eb 100644 --- a/Sources/lambda/Models/Spotify.swift +++ b/Sources/lambda/Models/Spotify.swift @@ -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] +} diff --git a/Sources/lambda/Services/NowPlayingService.swift b/Sources/lambda/Services/NowPlayingService.swift index ec251aad..c9986194 100644 --- a/Sources/lambda/Services/NowPlayingService.swift +++ b/Sources/lambda/Services/NowPlayingService.swift @@ -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: "" diff --git a/Sources/lambda/Services/TopTracksService.swift b/Sources/lambda/Services/TopTracksService.swift new file mode 100644 index 00000000..e8a860f2 --- /dev/null +++ b/Sources/lambda/Services/TopTracksService.swift @@ -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 + } + } +} diff --git a/Sources/lambda/Services/VersionService.swift b/Sources/lambda/Services/VersionService.swift index a54a5af8..7f5674bf 100644 --- a/Sources/lambda/Services/VersionService.swift +++ b/Sources/lambda/Services/VersionService.swift @@ -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) } } diff --git a/Sources/lambda/Utils/Cache.swift b/Sources/lambda/Utils/Cache.swift index fac5e35f..fc0e1dff 100644 --- a/Sources/lambda/Utils/Cache.swift +++ b/Sources/lambda/Utils/Cache.swift @@ -1,6 +1,5 @@ import Foundation - actor MemoryCache { private var cache: [String: CacheEntry] = [:] diff --git a/Sources/lambda/Utils/Environment.swift b/Sources/lambda/Utils/Environment.swift new file mode 100644 index 00000000..ff0e3fa9 --- /dev/null +++ b/Sources/lambda/Utils/Environment.swift @@ -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) + } +} diff --git a/Sources/lambda/Utils/ResponseBuilder.swift b/Sources/lambda/Utils/ResponseBuilder.swift index 206e997b..c9e99678 100644 --- a/Sources/lambda/Utils/ResponseBuilder.swift +++ b/Sources/lambda/Utils/ResponseBuilder.swift @@ -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( 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"] = @@ -22,8 +24,8 @@ 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, @@ -31,4 +33,13 @@ enum ResponseBuilder { 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 + ) + } } diff --git a/Sources/lambda/Utils/Router.swift b/Sources/lambda/Utils/Router.swift index 6024a76e..bb710ca3 100644 --- a/Sources/lambda/Utils/Router.swift +++ b/Sources/lambda/Utils/Router.swift @@ -1,6 +1,7 @@ import AWSLambdaEvents import AWSLambdaRuntime import Foundation +import HTTPTypes final class Router: @unchecked Sendable { typealias Handler = (APIGatewayV2Request, LambdaContext) throws -> APIGatewayV2Response @@ -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 @@ -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 { @@ -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") } } @@ -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 +} diff --git a/Sources/lambda/http/SpotifyApi.swift b/Sources/lambda/http/SpotifyApi.swift index c54e2e80..d54da0d6 100644 --- a/Sources/lambda/http/SpotifyApi.swift +++ b/Sources/lambda/http/SpotifyApi.swift @@ -4,7 +4,7 @@ import Foundation import FoundationNetworking #endif -enum SpotifyServiceError: Error, CustomStringConvertible { +enum SpotifyServiceError: Error, CustomStringConvertible, Sendable { case missingAccessToken case missingRefreshToken case missingClientCredentials @@ -49,7 +49,9 @@ struct TokenResponse: Codable { } actor SpotifyApi { + private static let requestTimeout: TimeInterval = 10 private let baseURL: String + private let session: URLSession private let accessToken: String? private let clientId: String? private let clientSecret: String? @@ -57,13 +59,19 @@ actor SpotifyApi { private var cachedAccessToken: String? private var tokenExpiresAt: Date? - init(baseURL: String? = nil, accessToken: String? = nil) { + init(baseURL: String? = nil, accessToken: String? = nil, session: URLSession? = nil) { self.baseURL = baseURL ?? "https://api.spotify.com/v1" - let env = ProcessInfo.processInfo.environment - self.accessToken = accessToken ?? env["SPOTIFY_ACCESS_TOKEN"] - self.clientId = env["SPOTIFY_CLIENT_ID"] - self.clientSecret = env["SPOTIFY_CLIENT_SECRET"] - self.refreshToken = env["SPOTIFY_REFRESH_TOKEN"] + let config: URLSessionConfiguration = { + let c = URLSessionConfiguration.default + c.timeoutIntervalForRequest = Self.requestTimeout + c.timeoutIntervalForResource = Self.requestTimeout + return c + }() + self.session = session ?? URLSession(configuration: config) + self.accessToken = accessToken ?? Environment.Spotify.accessToken + self.clientId = Environment.Spotify.clientId + self.clientSecret = Environment.Spotify.clientSecret + self.refreshToken = Environment.Spotify.refreshToken } private func getAccessToken() async throws -> String { @@ -73,8 +81,7 @@ actor SpotifyApi { if let cachedToken = cachedAccessToken, let expiresAt = tokenExpiresAt, - expiresAt > Date() - { + expiresAt > Date() { return cachedToken } @@ -91,7 +98,6 @@ actor SpotifyApi { throw SpotifyServiceError.invalidURL(urlString: tokenURL) } - // Create basic auth header let credentials = "\(clientId):\(clientSecret)" guard let credentialsData = credentials.data(using: .utf8) else { throw SpotifyServiceError.invalidResponse @@ -109,14 +115,14 @@ actor SpotifyApi { components.path = "/api/token" components.queryItems = [ URLQueryItem(name: "grant_type", value: "refresh_token"), - URLQueryItem(name: "refresh_token", value: refreshToken), + URLQueryItem(name: "refresh_token", value: refreshToken) ] guard let queryString = components.url?.query else { throw SpotifyServiceError.invalidResponse } request.httpBody = queryString.data(using: .utf8) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw SpotifyServiceError.invalidResponse @@ -137,8 +143,7 @@ actor SpotifyApi { let lowercasedResponse = responseString.lowercased() if lowercasedResponse.contains("\"error\"") - || lowercasedResponse.contains("error_description") - { + || lowercasedResponse.contains("error_description") { throw SpotifyServiceError.httpError( statusCode: httpResponse.statusCode, message: responseString) } @@ -199,7 +204,7 @@ actor SpotifyApi { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw SpotifyServiceError.invalidResponse @@ -220,10 +225,55 @@ actor SpotifyApi { throw SpotifyServiceError.decodingError("Empty response from Spotify API") } + return try decodeSpotifyResponse(SpotifyResponse.self, from: data) + } + + func getTopTracks(timeRange: String = "medium_term", limit: Int = 10) async throws + -> SpotifyTopTracksResponse { + let accessToken = try await getAccessToken() + + var components = URLComponents(string: "\(baseURL)/me/top/tracks")! + components.queryItems = [ + URLQueryItem(name: "time_range", value: timeRange), + URLQueryItem(name: "limit", value: String(min(50, max(1, limit)))) + ] + + guard let url = components.url else { + throw SpotifyServiceError.invalidURL( + urlString: components.string ?? "\(baseURL)/me/top/tracks") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw SpotifyServiceError.invalidResponse + } + + guard (200...299).contains(httpResponse.statusCode) else { + let errorMessage = + String(data: data, encoding: .utf8) ?? "Unable to read error response" + throw SpotifyServiceError.httpError( + statusCode: httpResponse.statusCode, message: errorMessage) + } + + guard !data.isEmpty else { + throw SpotifyServiceError.decodingError("Empty response from Spotify API") + } + + return try decodeSpotifyResponse(SpotifyTopTracksResponse.self, from: data) + } + + private func decodeSpotifyResponse(_ type: T.Type, from data: Data) throws + -> T { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase do { - return try decoder.decode(SpotifyResponse.self, from: data) + return try decoder.decode(type, from: data) } catch { let errorDetails = "\(error)" if let decodingError = error as? DecodingError { @@ -251,6 +301,5 @@ actor SpotifyApi { throw SpotifyServiceError.decodingError( "Failed to decode Spotify response: \(errorDetails)") } - } } diff --git a/Sources/lambda/main.swift b/Sources/lambda/main.swift index 2188d3e4..119feeb3 100644 --- a/Sources/lambda/main.swift +++ b/Sources/lambda/main.swift @@ -6,7 +6,6 @@ let router = createRouter() let runtime = LambdaRuntime { (event: APIGatewayV2Request, context: LambdaContext) -> APIGatewayV2Response in - return try await router.handle(event: event, context: context) } diff --git a/package.json b/package.json index dc161fe6..3c8b87ef 100644 --- a/package.json +++ b/package.json @@ -1 +1,40 @@ -{"name":"root","version":"1.4.2","private":true,"engineStrict":true,"engines":{"node":">=22","bun":"1.3.5"},"scripts":{"commit":"cz","fmt:tf":"terraform fmt terraform","changeset":"changeset","changeset:version":"changeset version","changeset:status":"changeset status"},"devDependencies":{"@changesets/changelog-github":"^0.5.0","@changesets/cli":"^2.29.8","@commitlint/cli":"^19.8.1","@commitlint/config-conventional":"^19.8.1","commitizen":"4.3.1","conventional-changelog-cli":"^3.0.0","git-cz":"^4.9.0","lint-staged":"16.1.6"},"config":{"commitizen":{"path":"cz-conventional-changelog"}},"husky":{"hooks":{"prepare-commit-msg":"exec < /dev/tty && npx cz --hook || true"}}} \ No newline at end of file +{ + "name": "root", + "version": "1.4.2", + "private": true, + "engineStrict": true, + "engines": { + "node": ">=22", + "bun": "1.3.5" + }, + "scripts": { + "commit": "cz", + "fmt:tf": "terraform fmt terraform", + "changeset": "changeset", + "changeset:version": "changeset version", + "changeset:status": "changeset status" + }, + "devDependencies": { + "@changesets/changelog-github": "^0.5.0", + "@changesets/cli": "^2.29.8", + "@commitlint/cli": "^19.8.1", + "@commitlint/config-conventional": "^19.8.1", + "commitizen": "4.3.1", + "conventional-changelog-cli": "^3.0.0", + "git-cz": "^4.9.0", + "lint-staged": "16.1.6" + }, + "config": { + "commitizen": { + "path": "cz-conventional-changelog" + } + }, + "lint-staged": { + "*.swift": "swiftlint lint" + }, + "husky": { + "hooks": { + "prepare-commit-msg": "exec < /dev/tty && npx cz --hook || true" + } + } +} diff --git a/terraform/gateway.tf b/terraform/gateway.tf index c7595c1e..62209c61 100644 --- a/terraform/gateway.tf +++ b/terraform/gateway.tf @@ -123,6 +123,15 @@ resource "aws_apigatewayv2_route" "lambda_route_now_playing" { authorizer_id = aws_apigatewayv2_authorizer.api_key.id authorization_type = "CUSTOM" } + +resource "aws_apigatewayv2_route" "lambda_route_top_tracks" { + api_id = aws_apigatewayv2_api.lambda.id + target = "integrations/${aws_apigatewayv2_integration.lambda.id}" + route_key = "GET /api/top-tracks" + operation_name = "top-tracks" + authorizer_id = aws_apigatewayv2_authorizer.api_key.id + authorization_type = "CUSTOM" +} ##############################################################################