import Hummingbird import Logging import NIOPosix /// A middleware that proxy requests to content inside `.doccarchive` archive containers located in a hosting app. /// /// The routing logic this middleware implements are: /// 1. Send all requests starting with `/documentation/` or `/tutorials/` to the `index.html` file; /// 2. Send all requests starting with `/css/`, `/data/`, `/downloads/`, `/images/`, `/img/`, `/index/`, `/js/`, or `/videos/` to their respective folders; /// 3. Send all requests to `favicon.ico`, `favicon.svg`, and `theme-settings.json` to their respective files; /// 4. Send all requests to `/data/documentation.json` to the file in the `data/documentation/` folder that has the name of the module and ends with the `.json` extension; /// 5. Redirect requests to `/` and `/documentation` to the`/documentation/` folder; /// 6. Redirect requests to `/tutorials` to the`/tutorials/` folder. struct DocCMiddleware< Context: RequestContext, AssetProvider: FileProvider >: RouterMiddleware { // MARK: Properties private let assetProvider: AssetProvider private let logger: Logger // MARK: Initialisers /// Initialises this middleware with the local file system provider. /// - Parameters: /// - rootFolder: A root folder in the local file system where the *DocC* archive containers are located. /// - threadPool: A thread pool used when loading archives from the file system. /// - logger: A service that interacts with the logging system, init( _ rootFolder: String, threadPool: NIOThreadPool = .singleton, logger: Logger ) where AssetProvider == LocalFileSystem { self.assetProvider = LocalFileSystem( rootFolder: rootFolder, threadPool: threadPool, logger: logger ) self.logger = logger } /// Initialises this middleware with an asset provider, that conforms to the `FileProvider` protocol. /// - Parameters: /// - assetProvider: An asset provider to use with the middleware. /// - logger: A service that interacts with the logging system, init( assetProvider: AssetProvider, logger: Logger ) { self.assetProvider = assetProvider self.logger = logger } // MARK: Functions func handle( _ input: Request, context: Context, next: (Request, Context) async throws -> Response ) async throws -> Response { guard let uriPath = input.uri.path.removingPercentEncoding, !uriPath.contains(.previousFolder), uriPath.hasPrefix(.forwardSlash) else { defer { logger.error( "The request has issues.", metadata: .metadata( context: context, request: input, statusCode: .badRequest ), source: .source ) } throw HTTPError(.badRequest) } guard uriPath.starts(with: /^\/archives\/\w+/) else { return try await next(input, context) } let pathArchive = PathProvider.archivePath(from: uriPath) let nameArchive = PathProvider.archiveName(from: uriPath) let uriResource = PathProvider.resourcePath(from: uriPath) // rule #5: Redirects URI resources with `/` to `/documentation`. if uriResource == .forwardSlash { let pathRedirect = if uriPath.hasSuffix(.forwardSlash) { String(format: .Format.Path.documentation, uriPath) } else { String(format: .Format.Path.forwardSlash, uriPath) } return redirect( to: pathRedirect, input: input, context: context ) } for staticFile in StaticFile.allCases { if uriResource.contains(staticFile.path) { if staticFile == .documentation { // Rule #4: Redirects URI resources with `/data/documentation.json` to the file in the `data/documentation/` // folder that has the name of the module and ends with the `.json` extension in the *DocC* archive container. return try await serveFile( String(format: .Format.Path.documentationJSON, nameArchive), at: pathArchive, input: input, context: context ) } else { // Rule #3: Redirect URI resources for static files (`favicon.ico`, `favicon.svg`, and `theme-settings.json`) // to their respective files in the *DocC* archive container. return try await serveFile( uriResource, at: pathArchive, input: input, context: context ) } } } for assetPrefix in AssetPrefix.allCases { if uriResource.contains(assetPrefix.path) { // Rule #2: Redirect URI resources for asset files (`/css/`, `/data/`, `/downloads/`, `/images/`, `/img/`, // `/index/`, `/js/`, or `/videos/`)to their respective files in the *DocC* archive container. return try await serveFile( uriResource, at: pathArchive, input: input, context: context ) } } for indexPrefix in IndexPrefix.allCases { if uriResource.contains(indexPrefix.path) { // Rule #1: Redirect URI resources for `/documentation/` and `/tutorials/` folders to their respective `index.html` file. if uriResource.hasSuffix(.forwardSlash) { return try await serveFile( String(format: .Format.Path.index, indexPrefix.path, nameArchive), at: pathArchive, input: input, context: context ) } else { // rule #5: Redirects URI resources with `/documentation` to `/documentation/`. // rule #6: Redirects URI resources with `/tutorials` to `/tutorials/`. return redirect( to: String(format: .Format.Path.forwardSlash, uriPath), input: input, context: context ) } } } defer { logger.error( "The request has not been implemented yet.", metadata: .metadata( context: context, request: input, statusCode: .notImplemented ), source: .source ) } throw HTTPError(.notImplemented) } } // MARK: - Helpers private extension DocCMiddleware { // MARK: Functions /// Redirects a request to a new relative path. /// - Parameters: /// - path: A relative path to use in the redirection. /// - input: An input request. /// - context: A request context. /// - Returns: A HTTP response containing the redirection to another func redirect( to path: String, input: Request, context: Context ) -> Response { defer { logger.debug( "The path URI has been redirected to: \(path)", metadata: .metadata( context: context, request: input, statusCode: .permanentRedirect, redirect: path ), source: .source ) } return .redirect(to: path) } /// Serves a resource file from a provider as a HTTP response. /// - Parameters: /// - path: A relative path to a resource file. /// - folder: A folder accessible to the provider where to find resource files. /// - input: An input request. /// - context: A request context. /// - Returns: A HTTP response containing the content of a given resource file inside its body. /// - Throws:An error... func serveFile( _ path: String, at folder: String, input: Request, context: Context ) async throws -> Response { guard let fileIdentifier = assetProvider.getFileIdentifier(folder + path) else { defer { logger.error( "The resource has not been found.", metadata: .metadata( context: context, request: input, statusCode: .notFound ), source: .source ) } throw HTTPError(.notFound) } let body = try await assetProvider.loadFile( id: fileIdentifier, context: context ) defer { logger.debug( "The body of the response returned: \(body.contentLength ?? 0) bytes.", metadata: .metadata( context: context, request: input, statusCode: .ok ), source: .source ) } return .init( status: .ok, headers: [:], body: body ) } } // MARK: - String+Constants private extension String { static let source = "DocCMiddleware" }