diff --git a/Sources/Grodt/Application/routes/routes+Dependencies.swift b/Sources/Grodt/Application/routes/routes+Dependencies.swift index e4b19fc..c9e7edf 100644 --- a/Sources/Grodt/Application/routes/routes+Dependencies.swift +++ b/Sources/Grodt/Application/routes/routes+Dependencies.swift @@ -195,6 +195,7 @@ func installGlobalMiddleware(_ app: Application) { let globalRateLimiter = RateLimiterMiddleware(maxRequests: 100, perSeconds: 60) app.middleware.use(app.sessions.middleware) app.middleware.use(globalRateLimiter) + app.middleware.use(AccessLogMiddleware(app: app)) } func scheduleNightlyJobs(_ app: Application, _ container: AppContainer) throws { diff --git a/Sources/Grodt/Configuration/EnvironmentVariable.swift b/Sources/Grodt/Configuration/EnvironmentVariable.swift index 7b74c6f..36194de 100644 --- a/Sources/Grodt/Configuration/EnvironmentVariable.swift +++ b/Sources/Grodt/Configuration/EnvironmentVariable.swift @@ -16,6 +16,12 @@ extension Int: EnvironmentVariableConvertible { } } +extension Bool: EnvironmentVariableConvertible { + static func convert(from environmentString: String) -> Bool? { + return environmentString.lowercased() == "true" + } +} + extension Optional: EnvironmentVariableConvertible { static func convert(from environmentString: String) -> Optional? { return Int(environmentString) diff --git a/Sources/Grodt/Endpoints/Routes/ski/home/BrokerageInfoDTO.swift b/Sources/Grodt/Endpoints/DTOs/BrokerageInfoDTO.swift similarity index 100% rename from Sources/Grodt/Endpoints/Routes/ski/home/BrokerageInfoDTO.swift rename to Sources/Grodt/Endpoints/DTOs/BrokerageInfoDTO.swift diff --git a/Sources/Grodt/DTOs/CreatePortfolioRequestDTO.swift b/Sources/Grodt/Endpoints/DTOs/CreatePortfolioRequestDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/CreatePortfolioRequestDTO.swift rename to Sources/Grodt/Endpoints/DTOs/CreatePortfolioRequestDTO.swift diff --git a/Sources/Grodt/DTOs/CreateTransactionRequestDTO.swift b/Sources/Grodt/Endpoints/DTOs/CreateTransactionRequestDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/CreateTransactionRequestDTO.swift rename to Sources/Grodt/Endpoints/DTOs/CreateTransactionRequestDTO.swift diff --git a/Sources/Grodt/DTOs/CurrencyDTO.swift b/Sources/Grodt/Endpoints/DTOs/CurrencyDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/CurrencyDTO.swift rename to Sources/Grodt/Endpoints/DTOs/CurrencyDTO.swift diff --git a/Sources/Grodt/DTOs/DTOMappers/CurrencyDTOMapper.swift b/Sources/Grodt/Endpoints/DTOs/DTOMappers/CurrencyDTOMapper.swift similarity index 100% rename from Sources/Grodt/DTOs/DTOMappers/CurrencyDTOMapper.swift rename to Sources/Grodt/Endpoints/DTOs/DTOMappers/CurrencyDTOMapper.swift diff --git a/Sources/Grodt/DTOs/DTOMappers/DatedPerformanceDTOMapper.swift b/Sources/Grodt/Endpoints/DTOs/DTOMappers/DatedPerformanceDTOMapper.swift similarity index 100% rename from Sources/Grodt/DTOs/DTOMappers/DatedPerformanceDTOMapper.swift rename to Sources/Grodt/Endpoints/DTOs/DTOMappers/DatedPerformanceDTOMapper.swift diff --git a/Sources/Grodt/DTOs/DTOMappers/InvestmentDTOMapper.swift b/Sources/Grodt/Endpoints/DTOs/DTOMappers/InvestmentDTOMapper.swift similarity index 100% rename from Sources/Grodt/DTOs/DTOMappers/InvestmentDTOMapper.swift rename to Sources/Grodt/Endpoints/DTOs/DTOMappers/InvestmentDTOMapper.swift diff --git a/Sources/Grodt/DTOs/DTOMappers/LoginResponseDTOMapper.swift b/Sources/Grodt/Endpoints/DTOs/DTOMappers/LoginResponseDTOMapper.swift similarity index 100% rename from Sources/Grodt/DTOs/DTOMappers/LoginResponseDTOMapper.swift rename to Sources/Grodt/Endpoints/DTOs/DTOMappers/LoginResponseDTOMapper.swift diff --git a/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift b/Sources/Grodt/Endpoints/DTOs/DTOMappers/PortfolioDTOMapper.swift similarity index 100% rename from Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift rename to Sources/Grodt/Endpoints/DTOs/DTOMappers/PortfolioDTOMapper.swift diff --git a/Sources/Grodt/DTOs/DTOMappers/TickerDTOMapper.swift b/Sources/Grodt/Endpoints/DTOs/DTOMappers/TickerDTOMapper.swift similarity index 100% rename from Sources/Grodt/DTOs/DTOMappers/TickerDTOMapper.swift rename to Sources/Grodt/Endpoints/DTOs/DTOMappers/TickerDTOMapper.swift diff --git a/Sources/Grodt/DTOs/DTOMappers/UserDTOMapper.swift b/Sources/Grodt/Endpoints/DTOs/DTOMappers/UserDTOMapper.swift similarity index 100% rename from Sources/Grodt/DTOs/DTOMappers/UserDTOMapper.swift rename to Sources/Grodt/Endpoints/DTOs/DTOMappers/UserDTOMapper.swift diff --git a/Sources/Grodt/Endpoints/Routes/ski/home/HomeResponseDTO.swift b/Sources/Grodt/Endpoints/DTOs/HomeResponseDTO.swift similarity index 100% rename from Sources/Grodt/Endpoints/Routes/ski/home/HomeResponseDTO.swift rename to Sources/Grodt/Endpoints/DTOs/HomeResponseDTO.swift diff --git a/Sources/Grodt/DTOs/InvestmentDTO.swift b/Sources/Grodt/Endpoints/DTOs/InvestmentDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/InvestmentDTO.swift rename to Sources/Grodt/Endpoints/DTOs/InvestmentDTO.swift diff --git a/Sources/Grodt/DTOs/InvestmentDetailDTO.swift b/Sources/Grodt/Endpoints/DTOs/InvestmentDetailDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/InvestmentDetailDTO.swift rename to Sources/Grodt/Endpoints/DTOs/InvestmentDetailDTO.swift diff --git a/Sources/Grodt/DTOs/LoginResponseDTO.swift b/Sources/Grodt/Endpoints/DTOs/LoginResponseDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/LoginResponseDTO.swift rename to Sources/Grodt/Endpoints/DTOs/LoginResponseDTO.swift diff --git a/Sources/Grodt/DTOs/PerformanceDTO.swift b/Sources/Grodt/Endpoints/DTOs/PerformanceDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/PerformanceDTO.swift rename to Sources/Grodt/Endpoints/DTOs/PerformanceDTO.swift diff --git a/Sources/Grodt/DTOs/PerformanceTimeSeriesDTO.swift b/Sources/Grodt/Endpoints/DTOs/PerformanceTimeSeriesDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/PerformanceTimeSeriesDTO.swift rename to Sources/Grodt/Endpoints/DTOs/PerformanceTimeSeriesDTO.swift diff --git a/Sources/Grodt/DTOs/PortfolioDTO.swift b/Sources/Grodt/Endpoints/DTOs/PortfolioDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/PortfolioDTO.swift rename to Sources/Grodt/Endpoints/DTOs/PortfolioDTO.swift diff --git a/Sources/Grodt/DTOs/PortfolioInfoDTO.swift b/Sources/Grodt/Endpoints/DTOs/PortfolioInfoDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/PortfolioInfoDTO.swift rename to Sources/Grodt/Endpoints/DTOs/PortfolioInfoDTO.swift diff --git a/Sources/Grodt/DTOs/RenamePortfolioRequestDTO.swift b/Sources/Grodt/Endpoints/DTOs/RenamePortfolioRequestDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/RenamePortfolioRequestDTO.swift rename to Sources/Grodt/Endpoints/DTOs/RenamePortfolioRequestDTO.swift diff --git a/Sources/Grodt/DTOs/Response.swift b/Sources/Grodt/Endpoints/DTOs/Response.swift similarity index 100% rename from Sources/Grodt/DTOs/Response.swift rename to Sources/Grodt/Endpoints/DTOs/Response.swift diff --git a/Sources/Grodt/DTOs/TickerDTO.swift b/Sources/Grodt/Endpoints/DTOs/TickerDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/TickerDTO.swift rename to Sources/Grodt/Endpoints/DTOs/TickerDTO.swift diff --git a/Sources/Grodt/DTOs/UserInfoDTO.swift b/Sources/Grodt/Endpoints/DTOs/UserInfoDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/UserInfoDTO.swift rename to Sources/Grodt/Endpoints/DTOs/UserInfoDTO.swift diff --git a/Sources/Grodt/DTOs/brokerages/BrokerageAccountDTO.swift b/Sources/Grodt/Endpoints/DTOs/brokerages/BrokerageAccountDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/brokerages/BrokerageAccountDTO.swift rename to Sources/Grodt/Endpoints/DTOs/brokerages/BrokerageAccountDTO.swift diff --git a/Sources/Grodt/DTOs/brokerages/BrokerageAccountDTOMapper.swift b/Sources/Grodt/Endpoints/DTOs/brokerages/BrokerageAccountDTOMapper.swift similarity index 100% rename from Sources/Grodt/DTOs/brokerages/BrokerageAccountDTOMapper.swift rename to Sources/Grodt/Endpoints/DTOs/brokerages/BrokerageAccountDTOMapper.swift diff --git a/Sources/Grodt/DTOs/brokerages/BrokerageAccountInfoDTO.swift b/Sources/Grodt/Endpoints/DTOs/brokerages/BrokerageAccountInfoDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/brokerages/BrokerageAccountInfoDTO.swift rename to Sources/Grodt/Endpoints/DTOs/brokerages/BrokerageAccountInfoDTO.swift diff --git a/Sources/Grodt/DTOs/brokerages/BrokerageDTO.swift b/Sources/Grodt/Endpoints/DTOs/brokerages/BrokerageDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/brokerages/BrokerageDTO.swift rename to Sources/Grodt/Endpoints/DTOs/brokerages/BrokerageDTO.swift diff --git a/Sources/Grodt/DTOs/brokerages/BrokerageDTOMapper.swift b/Sources/Grodt/Endpoints/DTOs/brokerages/BrokerageDTOMapper.swift similarity index 100% rename from Sources/Grodt/DTOs/brokerages/BrokerageDTOMapper.swift rename to Sources/Grodt/Endpoints/DTOs/brokerages/BrokerageDTOMapper.swift diff --git a/Sources/Grodt/DTOs/brokerages/CreateBrokerageRequestDTO.swift b/Sources/Grodt/Endpoints/DTOs/brokerages/CreateBrokerageRequestDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/brokerages/CreateBrokerageRequestDTO.swift rename to Sources/Grodt/Endpoints/DTOs/brokerages/CreateBrokerageRequestDTO.swift diff --git a/Sources/Grodt/DTOs/transactions/TransactionDTO.swift b/Sources/Grodt/Endpoints/DTOs/transactions/TransactionDTO.swift similarity index 100% rename from Sources/Grodt/DTOs/transactions/TransactionDTO.swift rename to Sources/Grodt/Endpoints/DTOs/transactions/TransactionDTO.swift diff --git a/Sources/Grodt/DTOs/transactions/TransactionDTOMapper.swift b/Sources/Grodt/Endpoints/DTOs/transactions/TransactionDTOMapper.swift similarity index 100% rename from Sources/Grodt/DTOs/transactions/TransactionDTOMapper.swift rename to Sources/Grodt/Endpoints/DTOs/transactions/TransactionDTOMapper.swift diff --git a/Sources/Grodt/BusinessLogic/PriceService/CachedPriceService.swift b/Sources/Grodt/Endpoints/Services/PriceService/CachedPriceService.swift similarity index 100% rename from Sources/Grodt/BusinessLogic/PriceService/CachedPriceService.swift rename to Sources/Grodt/Endpoints/Services/PriceService/CachedPriceService.swift diff --git a/Sources/Grodt/BusinessLogic/PriceService/LivePriceService.swift b/Sources/Grodt/Endpoints/Services/PriceService/LivePriceService.swift similarity index 100% rename from Sources/Grodt/BusinessLogic/PriceService/LivePriceService.swift rename to Sources/Grodt/Endpoints/Services/PriceService/LivePriceService.swift diff --git a/Sources/Grodt/BusinessLogic/PriceService/PriceService.swift b/Sources/Grodt/Endpoints/Services/PriceService/PriceService.swift similarity index 100% rename from Sources/Grodt/BusinessLogic/PriceService/PriceService.swift rename to Sources/Grodt/Endpoints/Services/PriceService/PriceService.swift diff --git a/Sources/Grodt/BusinessLogic/PriceService/QuoteCache.swift b/Sources/Grodt/Endpoints/Services/PriceService/QuoteCache.swift similarity index 100% rename from Sources/Grodt/BusinessLogic/PriceService/QuoteCache.swift rename to Sources/Grodt/Endpoints/Services/PriceService/QuoteCache.swift diff --git a/Sources/Grodt/Middleware/AccessLogMiddleware.swift/AccessLogMiddleware.swift b/Sources/Grodt/Middleware/AccessLogMiddleware.swift/AccessLogMiddleware.swift new file mode 100644 index 0000000..47ef734 --- /dev/null +++ b/Sources/Grodt/Middleware/AccessLogMiddleware.swift/AccessLogMiddleware.swift @@ -0,0 +1,140 @@ +import Vapor +import NIOCore + +/// Verbose access/error logging with safe header redaction and request-id propagation. +/// Reads switches from `app.config.logging`. +struct AccessLogMiddleware: AsyncMiddleware { + + struct Config { + var logRequestHeaders: Bool + var logResponseHeaders: Bool + var logRequestBodyPreview: Bool + var maxBodyPreviewBytes: Int + var redactedHeaders: Set + + static func from(app: Application) -> Config { + let loggingEnv = app.config.logging + + return Config( + logRequestHeaders: loggingEnv.requestLogHeaders == true, + logResponseHeaders: loggingEnv.responseLogHeaders == true, + logRequestBodyPreview: loggingEnv.requestLogBodyPreview == true, + maxBodyPreviewBytes: max(loggingEnv.requestLogBodyPreviewMax ?? 1024, 1), + redactedHeaders: ["authorization", "cookie", "set-cookie", "proxy-authorization"] + ) + } + } + + private let config: Config + + /// Preferred initializer: build config from `AppConfiguration`. + init(app: Application) { + self.config = Config.from(app: app) + } + + /// Testing convenience initializer. + init(config: Config) { + self.config = config + } + + func respond(to req: Request, chainingTo next: AsyncResponder) async throws -> Response { + // Correlation id: honor incoming X-Request-ID or assign a new one. + let requestID = req.headers.first(name: .xRequestID) ?? UUID().uuidString + + // Attach to logger metadata so inner logs inherit it. + var requestScopedLogger = req.logger + requestScopedLogger[metadataKey: "request-id"] = .string(requestID) + + // Basic request context + let method = req.method.string + let scheme = req.url.scheme ?? "unknown" + let host = req.headers.first(name: .host) ?? req.application.http.server.configuration.hostname + let path = req.url.path.isEmpty ? "/" : req.url.path + let query = req.url.query ?? "" + let userAgent = req.headers.first(name: .userAgent) ?? "-" + let referer = req.headers.first(name: .referer) ?? "-" + let clientIP = req.remoteAddress?.ipAddress ?? req.remoteAddress?.description ?? "-" + let contentLength = req.headers.first(name: .contentLength) ?? "-" + let userID = req.auth.get(User.self)?.id?.uuidString ?? "-" + + // Optional header logging (redacted) + let requestHeadersSnapshot: String? = config.logRequestHeaders + ? redacted(headers: req.headers, redactedNames: config.redactedHeaders) + : nil + + // Optional body preview: only if resident (do not consume streaming bodies) + let requestBodyPreview: String? = { + guard config.logRequestBodyPreview, + let buffer = req.body.data, + buffer.readableBytes > 0 + else { return nil } + let previewLength = min(buffer.readableBytes, config.maxBodyPreviewBytes) + guard let slice = buffer.getSlice(at: 0, length: previewLength) else { return nil } + return String(buffer: slice) + }() + + // Request start log + requestScopedLogger.info("⇢ \(method) \(scheme)://\(host)\(path)\(query.isEmpty ? "" : "?\(query)") [ip:\(clientIP), user:\(userID), ua:\(userAgent), referer:\(referer), content-length:\(contentLength)]\(requestHeadersSnapshot.map { " headers:\($0)" } ?? "")\(requestBodyPreview.map { " body-preview:\($0)" } ?? "")") + + let startedAt = NIODeadline.now() + + do { + let response = try await next.respond(to: req) + let elapsedMs = millisecondsSince(startedAt) + + // Ensure response carries the request id for client correlation. + var responseHeaders = response.headers + if responseHeaders.first(name: .xRequestID) == nil { + responseHeaders.add(name: .xRequestID, value: requestID) + } + response.headers = responseHeaders + + // Optional response headers snapshot + let responseHeadersSnapshot: String? = config.logResponseHeaders + ? redacted(headers: response.headers, redactedNames: config.redactedHeaders) + : nil + + let statusCode = response.status.code + let responseLength = response.headers.first(name: .contentLength) ?? "-" + + if statusCode >= 500 { + requestScopedLogger.error("⇠ \(statusCode) \(method) \(path) (\(elapsedMs) ms) [resp-bytes:\(responseLength)]\(responseHeadersSnapshot.map { " headers:\($0)" } ?? "")") + } else if statusCode >= 400 { + requestScopedLogger.warning("⇠ \(statusCode) \(method) \(path) (\(elapsedMs) ms) [resp-bytes:\(responseLength)]\(responseHeadersSnapshot.map { " headers:\($0)" } ?? "")") + } else { + requestScopedLogger.info("⇠ \(statusCode) \(method) \(path) (\(elapsedMs) ms) [resp-bytes:\(responseLength)]\(responseHeadersSnapshot.map { " headers:\($0)" } ?? "")") + } + + return response + } catch { + let elapsedMs = millisecondsSince(startedAt) + // `reflecting:` usually contains lower-level details (e.g., SQL / decoding errors). + requestScopedLogger.error("✗ 500 \(method) \(path) (\(elapsedMs) ms) error: \(String(describing: error)) details: \(String(reflecting: error)) [ip:\(clientIP), user:\(userID), req-id:\(requestID)]") + throw error + } + } + + // MARK: - Helpers + + private func millisecondsSince(_ start: NIODeadline) -> Int { + let nanos = (NIODeadline.now().uptimeNanoseconds &- start.uptimeNanoseconds) + return Int(nanos / 1_000_000) + } + + private func redacted(headers: HTTPHeaders, redactedNames: Set) -> String { + var items: [String] = [] + for (headerName, headerValue) in headers { + if redactedNames.contains(headerName.lowercased()) { + items.append("\(headerName): ") + } else { + items.append("\(headerName): \(headerValue)") + } + } + return "{ " + items.joined(separator: ", ") + " }" + } +} + +// Small convenience for request-id propagation +extension HTTPHeaders.Name { + static let xRequestID = HTTPHeaders.Name("X-Request-ID") +} diff --git a/Sources/Grodt/Middleware/AccessLogMiddleware.swift/AppConfiguration+Logging.swift b/Sources/Grodt/Middleware/AccessLogMiddleware.swift/AppConfiguration+Logging.swift new file mode 100644 index 0000000..a598b8c --- /dev/null +++ b/Sources/Grodt/Middleware/AccessLogMiddleware.swift/AppConfiguration+Logging.swift @@ -0,0 +1,21 @@ +extension AppConfiguration { + struct Logging { + // Enable logging of request headers (with redaction) + @OptionalEnvironmentVariable(key: "REQUEST_LOG_HEADERS") + var requestLogHeaders: Bool? + + // Enable logging of response headers (with redaction) + @OptionalEnvironmentVariable(key: "RESPONSE_LOG_HEADERS") + var responseLogHeaders: Bool? + + // Enable logging of a safe preview of the request body (resident bodies only) + @OptionalEnvironmentVariable(key: "REQUEST_LOG_BODY_PREVIEW") + var requestLogBodyPreview: Bool? + + // Max bytes to preview from the request body if enabled (default 1024) + @OptionalEnvironmentVariable(key: "REQUEST_LOG_BODY_PREVIEW_MAX") + var requestLogBodyPreviewMax: Int? + } + + var logging: Logging { Logging() } +} diff --git a/Sources/Grodt/BusinessLogic/RateLimiterMiddleware/ClientRequestStore.swift b/Sources/Grodt/Middleware/RateLimiterMiddleware/ClientRequestStore.swift similarity index 100% rename from Sources/Grodt/BusinessLogic/RateLimiterMiddleware/ClientRequestStore.swift rename to Sources/Grodt/Middleware/RateLimiterMiddleware/ClientRequestStore.swift diff --git a/Sources/Grodt/BusinessLogic/RateLimiterMiddleware/RateLimiterMiddleware.swift b/Sources/Grodt/Middleware/RateLimiterMiddleware/RateLimiterMiddleware.swift similarity index 100% rename from Sources/Grodt/BusinessLogic/RateLimiterMiddleware/RateLimiterMiddleware.swift rename to Sources/Grodt/Middleware/RateLimiterMiddleware/RateLimiterMiddleware.swift diff --git a/Sources/Grodt/BusinessLogic/Request+requiredParameter.swift b/Sources/Grodt/Utilities/Request+requiredParameter.swift similarity index 100% rename from Sources/Grodt/BusinessLogic/Request+requiredParameter.swift rename to Sources/Grodt/Utilities/Request+requiredParameter.swift