From 980c8e15f6aaa4e04aa062d22b9a7503c27ce238 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 8 Mar 2025 13:52:20 +0100 Subject: [PATCH] Improved and documented the DocCModilleware middleware in the library target. --- .../Internal/Extensions/String+Formats.swift | 8 + .../Internal/Middlewares/DocCMiddleware.swift | 72 ++++-- .../Middlewares/DoccMiddlewareTests.swift | 206 ++++++++++++++++++ .../Helpers/Extensions/Tag+Definitions.swift | 1 + .../Helpers/Mocks/FileProviderMock.swift | 55 +++++ 5 files changed, 318 insertions(+), 24 deletions(-) create mode 100644 Test/Sources/Cases/Internal/Middlewares/DoccMiddlewareTests.swift create mode 100644 Test/Sources/Helpers/Mocks/FileProviderMock.swift diff --git a/Library/Sources/Internal/Extensions/String+Formats.swift b/Library/Sources/Internal/Extensions/String+Formats.swift index fddf08a..b4a2610 100644 --- a/Library/Sources/Internal/Extensions/String+Formats.swift +++ b/Library/Sources/Internal/Extensions/String+Formats.swift @@ -9,8 +9,16 @@ extension String { static let data = "/data/%@" /// A format pattern used to generate relative paths that starts with the `/docs` string. static let docs = "/docs/%@" + /// A format pattern used to generate relative paths that finishes with the `/documentation` string. + static let documentation = "%@documentation" + /// A format pattern used to generate relative paths for JSON documentation files. + static let documentationJSON = "/data/documentation/%@.json" /// A format pattern used to generate relative paths that starts and finishes with the `/` string. static let folder = "/%@/" + ///A format pattern used to generate relative paths that finishes with the `/` string. + static let forwardSlash = "%@/" + /// A format pattern used to generate relative paths for index files. + static let index = "%@/%@/index.html" /// A format pattern used to generate relative paths that starts with the `/` string. static let root = "/%@" } diff --git a/Library/Sources/Internal/Middlewares/DocCMiddleware.swift b/Library/Sources/Internal/Middlewares/DocCMiddleware.swift index 1f5366d..8400241 100644 --- a/Library/Sources/Internal/Middlewares/DocCMiddleware.swift +++ b/Library/Sources/Internal/Middlewares/DocCMiddleware.swift @@ -2,6 +2,15 @@ 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 @@ -13,6 +22,11 @@ struct DocCMiddleware< // 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 Logger that outputs information about the root folder requests. init( _ rootFolder: String, threadPool: NIOThreadPool = .singleton, @@ -24,7 +38,9 @@ struct DocCMiddleware< logger: logger ) } - + + /// Initialises this middleware with an asset provider, that conforms to the `FileProvider` protocol. + /// - Parameter assetProvider: An asset provider to use with the middleware. init(assetProvider: AssetProvider) { self.assetProvider = assetProvider } @@ -43,39 +59,33 @@ struct DocCMiddleware< else { throw HTTPError(.badRequest) } - - /* - Send all requests starting with /documentation/ or /tutorials/ to the index.html file - Send all requests starting with /css/, /data/, /downloads/, /images/, /img/, /index/, /js/, or /videos/ to their respective folders - Send all requests to favicon.ico, favicon.svg, and theme-settings.json to their respective files - 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 .json - Redirect requests to / and /documentation to /documentation/ - Redirect requests to /tutorials to /tutorials/ - */ guard uriPath.starts(with: /^\/archives\/\w+/) else { return try await next(input, context) } - let pathArchive = Path.archivePath(from: uriPath) - let nameArchive = Path.archiveName(from: uriPath) - let uriResource = Path.resourcePath(from: uriPath) + 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 { - return .redirect(to: uriPath + "/documentation") + return .redirect(to: String(format: .Format.Path.documentation, uriPath)) } for staticFile in StaticFile.allCases { if uriResource.contains(staticFile.path) { if staticFile == .documentation { - // 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 .json + // 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( - "/data/documentation/\(nameArchive).json", + String(format: .Format.Path.documentationJSON, nameArchive), at: pathArchive, context: context ) } else { - // Send all requests to favicon.ico, favicon.svg, and theme-settings.json to their respective files + // 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, @@ -87,6 +97,8 @@ struct DocCMiddleware< 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, @@ -97,19 +109,22 @@ struct DocCMiddleware< 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( - "\(indexPrefix.path)/\(nameArchive)/index.html", + String(format: .Format.Path.index, indexPrefix.path, nameArchive), at: pathArchive, context: context ) } else { - return .redirect(to: uriPath + "/") + // 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)) } } } - throw HTTPError(.notFound) + throw HTTPError(.notImplemented) } } @@ -120,20 +135,29 @@ private extension DocCMiddleware { // MARK: Functions + /// 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. + /// - 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, context: Context ) async throws -> Response { - let filePath = folder + path - - guard let fileIdentifier = assetProvider.getFileIdentifier(filePath) else { + guard let fileIdentifier = assetProvider.getFileIdentifier(folder + path) else { throw HTTPError(.notFound) } let body = try await assetProvider.loadFile(id: fileIdentifier, context: context) - return .init(status: .ok, headers: [:], body: body) + return .init( + status: .ok, + headers: [:], + body: body + ) } } diff --git a/Test/Sources/Cases/Internal/Middlewares/DoccMiddlewareTests.swift b/Test/Sources/Cases/Internal/Middlewares/DoccMiddlewareTests.swift new file mode 100644 index 0000000..931abd9 --- /dev/null +++ b/Test/Sources/Cases/Internal/Middlewares/DoccMiddlewareTests.swift @@ -0,0 +1,206 @@ +// +// Test.swift +// DocCRepo +// +// Created by Javier Cicchelli on 08/03/2025. +// + +import Hummingbird +import HummingbirdTesting +import Testing + +@testable import AppLibrary + +@Suite("DocCMiddleware", .tags(.middleware)) +struct DoccMiddlewareTests { + + @Test(arguments: zip([String].urisRedirect, [String].pathsRedirect)) + func redirects( + from uri: String, + to path: String + ) async throws { + // GIVEN + let router = Router.test() + let app = Application(router: router) + + // WHEN + // THEN + try await app.test(.router) { client in + try await client.execute(uri: uri, method: .get) { response in + #expect(response.status == .seeOther) + #expect(response.headers[.location] == path) + } + } + } + + @Test(arguments: zip([String].urisServes, [String].identifiersServes)) + func servesFile( + for uri: String, + with identifier: String + ) async throws { + // GIVEN + var provider = FileProviderMock() + + provider.setFile(identifier: identifier) + + let router = Router.test(assetProvider: provider) + let app = Application(router: router) + + // WHEN + // THEN + try await app.test(.router) { client in + try await client.execute(uri: uri, method: .get) { response in + #expect(response.status == .ok) + #expect(response.body == ByteBuffer(string: identifier)) + } + } + } + + @Test(arguments: zip([String].urisInvalid, [HTTPResponse.Status].statusesInvalid)) + func throwError( + for uri: String, + with status: HTTPResponse.Status + ) async throws { + // GIVEN + let router = Router.test() + let app = Application(router: router) + + // WHEN + // THEN + try await app.test(.router) { client in + try await client.execute(uri: uri, method: .get) { response in + #expect(response.status == status) + } + } + } + +} + +// MARK: - Router+Constants + +private extension Router { + + // MARK: Functions + + static func test(assetProvider: some FileProvider = FileProviderMock()) -> Router { + let router = Router() + + router.addMiddleware { + DocCMiddleware(assetProvider: assetProvider) + } + + return router + } + +} + +// MARK: - Collection+String + +private extension Collection where Element == String { + + // MARK: Computed + + static var identifiersServes: [String] {[ + "/SomeArchive.doccarchive/documentation/somearchive/index.html", + "/SomeArchive.doccarchive/tutorials/somearchive/index.html", + "/SomeArchive.doccarchive/favicon.ico", + "/SomeArchive.doccarchive/favicon.svg", + "/SomeArchive.doccarchive/theme-settings.json", + "/SomeArchive.doccarchive/data/documentation/somearchive.json", + "/SomeArchive.doccarchive/css/some-css-file.css", + "/SomeArchive.doccarchive/data/some-data-file.bin", + "/SomeArchive.doccarchive/downloads/some-download-file", + "/SomeArchive.doccarchive/images/some-image-file.jpg", + "/SomeArchive.doccarchive/img/some-image-file.png", + "/SomeArchive.doccarchive/index/some-index-file", + "/SomeArchive.doccarchive/js/some-js-file.js", + "/SomeArchive.doccarchive/videos/some-video-file.mp4", + ]} + + + static var pathsRedirect: [String] {[ + "/archives/SomeArchive/documentation", + "/archives/SomeArchive/documentation/", + "/archives/SomeArchive/tutorials/", + ]} + + static var urisInvalid: [String] {[ + "", + "some-path", + "some/uri/path", + "../", + "/../", + "/archives", + "/archives/SomeArchive/favicon.ico", + "/archives/SomeArchive/favicon.svg", + "/archives/SomeArchive/theme-settings.json", + "/archives/SomeArchive/data/documentation.json", + "/archives/SomeArchive/css/some-css-file.css", + "/archives/SomeArchive/data/some-data-file.bin", + "/archives/SomeArchive/downloads/some-download-file", + "/archives/SomeArchive/images/some-image-file.jpg", + "/archives/SomeArchive/img/some-image-file.png", + "/archives/SomeArchive/index/some-index-file", + "/archives/SomeArchive/js/some-js-file.js", + "/archives/SomeArchive/videos/some-video-file.mp4", + "/archives/SomeArchive/index.html", + "/archives/SomeArchive/xxx", + "/archives/SomeArchive/xxx/index.html" + ]} + + static var urisRedirect: [String] {[ + "/archives/SomeArchive/", + "/archives/SomeArchive/documentation", + "/archives/SomeArchive/tutorials", + ]} + + static var urisServes: [String] {[ + "/archives/SomeArchive/documentation/", + "/archives/SomeArchive/tutorials/", + "/archives/SomeArchive/favicon.ico", + "/archives/SomeArchive/favicon.svg", + "/archives/SomeArchive/theme-settings.json", + "/archives/SomeArchive/data/documentation.json", + "/archives/SomeArchive/css/some-css-file.css", + "/archives/SomeArchive/data/some-data-file.bin", + "/archives/SomeArchive/downloads/some-download-file", + "/archives/SomeArchive/images/some-image-file.jpg", + "/archives/SomeArchive/img/some-image-file.png", + "/archives/SomeArchive/index/some-index-file", + "/archives/SomeArchive/js/some-js-file.js", + "/archives/SomeArchive/videos/some-video-file.mp4", + ]} + +} + +// MARK: - Collection+HTTPResponse.Status + +private extension Collection where Element == HTTPResponse.Status { + + // MARK: Computed + + static var statusesInvalid: [HTTPResponse.Status] {[ + .notFound, + .badRequest, + .badRequest, + .badRequest, + .badRequest, + .notFound, + .notFound, + .notFound, + .notFound, + .notFound, + .notFound, + .notFound, + .notFound, + .notFound, + .notFound, + .notFound, + .notFound, + .notFound, + .notImplemented, + .notImplemented, + .notImplemented + ]} + +} diff --git a/Test/Sources/Helpers/Extensions/Tag+Definitions.swift b/Test/Sources/Helpers/Extensions/Tag+Definitions.swift index c1e68e3..ec4e135 100644 --- a/Test/Sources/Helpers/Extensions/Tag+Definitions.swift +++ b/Test/Sources/Helpers/Extensions/Tag+Definitions.swift @@ -5,6 +5,7 @@ extension Tag { // MARK: Constants @Tag static var enumeration: Tag + @Tag static var middleware: Tag @Tag static var provider: Tag } diff --git a/Test/Sources/Helpers/Mocks/FileProviderMock.swift b/Test/Sources/Helpers/Mocks/FileProviderMock.swift new file mode 100644 index 0000000..885e0bc --- /dev/null +++ b/Test/Sources/Helpers/Mocks/FileProviderMock.swift @@ -0,0 +1,55 @@ +import Foundation +import Hummingbird + +struct FileProviderMock { + + // MARK: Properties + + private var attributes: [String: Data] = [:] + private var identifiers: [String] = [] + + // MARK: Functions + + mutating func setFile(identifier: String) { + identifiers += [identifier] + attributes[identifier] = identifier.data(using: .utf8) + } + +} + +// MARK: - FileProvider + +extension FileProviderMock: FileProvider { + + // MARK: Type aliases + + typealias FileAttributes = Data + typealias FileIdentifier = String + + // MARK: Functions + + func getFileIdentifier(_ path: String) -> String? { + identifiers.first { $0 == path } + } + + func getAttributes(id: String) async throws -> Data? { + attributes[id] + } + + func loadFile(id: String, context: some RequestContext) async throws -> ResponseBody { + guard let fileData = attributes[id] else { + throw HTTPError(.notFound) + } + + return .init(byteBuffer: .init(data: fileData)) + } + + func loadFile( + id: String, + range: ClosedRange, + context: some RequestContext + ) async throws -> ResponseBody { + try await loadFile(id: id, context: context) + } + +}