Improved and documented the DocCModilleware middleware in the library target.
This commit is contained in:
parent
5b379fbc86
commit
980c8e15f6
@ -9,8 +9,16 @@ extension String {
|
|||||||
static let data = "/data/%@"
|
static let data = "/data/%@"
|
||||||
/// A format pattern used to generate relative paths that starts with the `/docs` string.
|
/// A format pattern used to generate relative paths that starts with the `/docs` string.
|
||||||
static let docs = "/docs/%@"
|
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.
|
/// A format pattern used to generate relative paths that starts and finishes with the `/` string.
|
||||||
static let folder = "/%@/"
|
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.
|
/// A format pattern used to generate relative paths that starts with the `/` string.
|
||||||
static let root = "/%@"
|
static let root = "/%@"
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,15 @@ import Hummingbird
|
|||||||
import Logging
|
import Logging
|
||||||
import NIOPosix
|
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<
|
struct DocCMiddleware<
|
||||||
Context: RequestContext,
|
Context: RequestContext,
|
||||||
AssetProvider: FileProvider
|
AssetProvider: FileProvider
|
||||||
@ -13,6 +22,11 @@ struct DocCMiddleware<
|
|||||||
|
|
||||||
// MARK: Initialisers
|
// 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(
|
init(
|
||||||
_ rootFolder: String,
|
_ rootFolder: String,
|
||||||
threadPool: NIOThreadPool = .singleton,
|
threadPool: NIOThreadPool = .singleton,
|
||||||
@ -24,7 +38,9 @@ struct DocCMiddleware<
|
|||||||
logger: logger
|
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) {
|
init(assetProvider: AssetProvider) {
|
||||||
self.assetProvider = assetProvider
|
self.assetProvider = assetProvider
|
||||||
}
|
}
|
||||||
@ -43,39 +59,33 @@ struct DocCMiddleware<
|
|||||||
else {
|
else {
|
||||||
throw HTTPError(.badRequest)
|
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 {
|
guard uriPath.starts(with: /^\/archives\/\w+/) else {
|
||||||
return try await next(input, context)
|
return try await next(input, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
let pathArchive = Path.archivePath(from: uriPath)
|
let pathArchive = PathProvider.archivePath(from: uriPath)
|
||||||
let nameArchive = Path.archiveName(from: uriPath)
|
let nameArchive = PathProvider.archiveName(from: uriPath)
|
||||||
let uriResource = Path.resourcePath(from: uriPath)
|
let uriResource = PathProvider.resourcePath(from: uriPath)
|
||||||
|
|
||||||
|
// rule #5: Redirects URI resources with `/` to `/documentation`.
|
||||||
if uriResource == .forwardSlash {
|
if uriResource == .forwardSlash {
|
||||||
return .redirect(to: uriPath + "/documentation")
|
return .redirect(to: String(format: .Format.Path.documentation, uriPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
for staticFile in StaticFile.allCases {
|
for staticFile in StaticFile.allCases {
|
||||||
if uriResource.contains(staticFile.path) {
|
if uriResource.contains(staticFile.path) {
|
||||||
if staticFile == .documentation {
|
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(
|
return try await serveFile(
|
||||||
"/data/documentation/\(nameArchive).json",
|
String(format: .Format.Path.documentationJSON, nameArchive),
|
||||||
at: pathArchive,
|
at: pathArchive,
|
||||||
context: context
|
context: context
|
||||||
)
|
)
|
||||||
} else {
|
} 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(
|
return try await serveFile(
|
||||||
uriResource,
|
uriResource,
|
||||||
at: pathArchive,
|
at: pathArchive,
|
||||||
@ -87,6 +97,8 @@ struct DocCMiddleware<
|
|||||||
|
|
||||||
for assetPrefix in AssetPrefix.allCases {
|
for assetPrefix in AssetPrefix.allCases {
|
||||||
if uriResource.contains(assetPrefix.path) {
|
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(
|
return try await serveFile(
|
||||||
uriResource,
|
uriResource,
|
||||||
at: pathArchive,
|
at: pathArchive,
|
||||||
@ -97,19 +109,22 @@ struct DocCMiddleware<
|
|||||||
|
|
||||||
for indexPrefix in IndexPrefix.allCases {
|
for indexPrefix in IndexPrefix.allCases {
|
||||||
if uriResource.contains(indexPrefix.path) {
|
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) {
|
if uriResource.hasSuffix(.forwardSlash) {
|
||||||
return try await serveFile(
|
return try await serveFile(
|
||||||
"\(indexPrefix.path)/\(nameArchive)/index.html",
|
String(format: .Format.Path.index, indexPrefix.path, nameArchive),
|
||||||
at: pathArchive,
|
at: pathArchive,
|
||||||
context: context
|
context: context
|
||||||
)
|
)
|
||||||
} else {
|
} 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
|
// 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(
|
func serveFile(
|
||||||
_ path: String,
|
_ path: String,
|
||||||
at folder: String,
|
at folder: String,
|
||||||
context: Context
|
context: Context
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
let filePath = folder + path
|
guard let fileIdentifier = assetProvider.getFileIdentifier(folder + path) else {
|
||||||
|
|
||||||
guard let fileIdentifier = assetProvider.getFileIdentifier(filePath) else {
|
|
||||||
throw HTTPError(.notFound)
|
throw HTTPError(.notFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = try await assetProvider.loadFile(id: fileIdentifier, context: context)
|
let body = try await assetProvider.loadFile(id: fileIdentifier, context: context)
|
||||||
|
|
||||||
return .init(status: .ok, headers: [:], body: body)
|
return .init(
|
||||||
|
status: .ok,
|
||||||
|
headers: [:],
|
||||||
|
body: body
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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<BasicRequestContext> {
|
||||||
|
|
||||||
|
// 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
|
||||||
|
]}
|
||||||
|
|
||||||
|
}
|
@ -5,6 +5,7 @@ extension Tag {
|
|||||||
// MARK: Constants
|
// MARK: Constants
|
||||||
|
|
||||||
@Tag static var enumeration: Tag
|
@Tag static var enumeration: Tag
|
||||||
|
@Tag static var middleware: Tag
|
||||||
@Tag static var provider: Tag
|
@Tag static var provider: Tag
|
||||||
|
|
||||||
}
|
}
|
||||||
|
55
Test/Sources/Helpers/Mocks/FileProviderMock.swift
Normal file
55
Test/Sources/Helpers/Mocks/FileProviderMock.swift
Normal file
@ -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<Int>,
|
||||||
|
context: some RequestContext
|
||||||
|
) async throws -> ResponseBody {
|
||||||
|
try await loadFile(id: id, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user