|
| 1 | +## Dependencies |
| 2 | + |
| 3 | +Hummingbird won't enforce any dependencies, or provide special treatment for dependencies on a framework level. However, we'll provide some guidelines and tips for _common_ dependencies and how to use them. |
| 4 | + |
| 5 | +## Project Structure |
| 6 | + |
| 7 | +The project structure is a recommended project layout, defining what modules are recommended to create and how these modules are structured in terms of folders and files. |
| 8 | + |
| 9 | +### Module Structure |
| 10 | + |
| 11 | +Every Hummingbird projects neds a primary executable target. This is the **MyApp** target in the example below. |
| 12 | + |
| 13 | +If a user has an OpenAPI specification for their API, a separate OpenAPI module is recommended based on [swift-openapi-generator](https://github.com/apple/swift-openapi-generator). This module will be used to generate the API server code. |
| 14 | + |
| 15 | +``` |
| 16 | +├── Sources |
| 17 | +│ ├── MyApp |
| 18 | +│ └── MyAppOpenAPI # Optional |
| 19 | +│ |
| 20 | +├── Tests |
| 21 | +│ └── MyAppTests |
| 22 | +│ |
| 23 | +└── Package.swift |
| 24 | +``` |
| 25 | + |
| 26 | +## App Module Structure |
| 27 | + |
| 28 | +The **MyApp** module is the primary module for the project. It contains the entry point for the application in **App.swift**, and has folders for **routes**, **database**, **repositories**, **services**, **middleware** and **config**. |
| 29 | + |
| 30 | + |
| 31 | +``` |
| 32 | +├── Routes |
| 33 | +│ ├── ...Routes.swift |
| 34 | +│ └── BuildRouter.swift |
| 35 | +│ |
| 36 | +├── Database |
| 37 | +│ ├── Models |
| 38 | +│ └── Migrations |
| 39 | +│ |
| 40 | +├── Repositories |
| 41 | +│ └── UserRepository.swift |
| 42 | +│ |
| 43 | +├── Services |
| 44 | +│ └── OTelService.swift |
| 45 | +│ └── DatabaseService.swift |
| 46 | +│ |
| 47 | +├── Middleware |
| 48 | +│ └── AuthMiddleware.swift |
| 49 | +│ |
| 50 | +├── Application+build.swift |
| 51 | +└── App.swift |
| 52 | +``` |
| 53 | + |
| 54 | +### App.swift |
| 55 | + |
| 56 | +The **App.swift** file is the entry point for the application. It's a command line tool based on [swift-argument-parser](https://github.com/apple/swift-argument-parser), allowing deployments to customize the application's configuration through command line arguments. |
| 57 | + |
| 58 | +The `MyApp` type also confirms to `MyAppArguments`, which is a user-defined protocol that specifies the command line arguments for the application. This enables the Hummingbird Application to be configured with different settings when running in a unit test, or other environments. |
| 59 | + |
| 60 | +```swift |
| 61 | +import ArgumentParser |
| 62 | +import Hummingbird |
| 63 | + |
| 64 | +@main |
| 65 | +struct MyApp: AsyncParsableCommand, MyAppArguments { |
| 66 | + @Option(name: .shortAndLong) |
| 67 | + var httpHostname: String = "127.0.0.1" |
| 68 | + |
| 69 | + @Option(name: .shortAndLong) |
| 70 | + var httpPort: Int = 8080 |
| 71 | + |
| 72 | + /// The connection string for the PostgreSQL database |
| 73 | + @Option(name: .shortAndLong) |
| 74 | + var postgres: String = "postgres://localhost:5432/myapp" |
| 75 | + |
| 76 | + func run() async throws { |
| 77 | + let app = try await buildApplication(self) |
| 78 | + try await app.runService() |
| 79 | + } |
| 80 | +} |
| 81 | +``` |
| 82 | + |
| 83 | +### Application+Build.swift |
| 84 | + |
| 85 | +The **Application+build.swift** file is a recommended file that contains the `buildApplication` function. This function is used to build the application, and is used in the `App.swift` file. |
| 86 | + |
| 87 | +The file also contains a global default `RequestContext` type, which is used to customize the context carried through the request lifecycle for the application. The default `typealias` enables users to easily customize the type without having to change the entire application. |
| 88 | + |
| 89 | +```swift |
| 90 | +// Customize the RequestContext type to your needs |
| 91 | +typealias AppRequestContext = BasicRequestContext |
| 92 | + |
| 93 | +protocol MyAppArguments { |
| 94 | + var httpHostname: String { get } |
| 95 | + var httpPort: Int { get } |
| 96 | + var postgres: String { get } |
| 97 | +} |
| 98 | + |
| 99 | +func buildApplication(_ arguments: some MyAppArguments) async throws -> Application { |
| 100 | + let router = Router(context: AppRequestContext.self) |
| 101 | + router.add("users", routes: UserController().routes) |
| 102 | + let logger = Logger(label: "MyApp") |
| 103 | + |
| 104 | + let app = Application( |
| 105 | + router: router, |
| 106 | + configuration: .init( |
| 107 | + address: .hostname(arguments.httpHostname, port: arguments.httpPort), |
| 108 | + serverName: "MyApp" |
| 109 | + ), |
| 110 | + logger: logger |
| 111 | + ) |
| 112 | + app.addServices(PostgresService(connectionString: arguments.postgres)) |
| 113 | + return app |
| 114 | +} |
| 115 | +``` |
| 116 | + |
| 117 | +## Routes and Controllers |
| 118 | + |
| 119 | +The **routes** folder contains functions that apply routes to the router through "controller" types. Each Controller is generically contrained over the ``RequestContext`` type, so that it can be used with any Router, RouterGroup or other implementation. |
| 120 | + |
| 121 | +Let's take **UserController.swift** as an example: |
| 122 | + |
| 123 | +```swift |
| 124 | +struct UserController<Context: RequestContext> { |
| 125 | + var routes: RouteCollection<Context> { |
| 126 | + return RouteCollection(context: Context.self) |
| 127 | + .get { request, context in |
| 128 | + ... |
| 129 | + } |
| 130 | + } |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +Controllers can specify dependencies on services they need, like a database connection. To do so, add these properties on the controller: |
| 135 | + |
| 136 | +```swift |
| 137 | +struct UserController<Context: RequestContext> { |
| 138 | + // Dependency on MongoDB |
| 139 | + let database: MongoDatabase |
| 140 | + |
| 141 | + var routes: RouteCollection<Context> { |
| 142 | + return RouteCollection(context: Context.self) |
| 143 | + .get { request, context in |
| 144 | + ... |
| 145 | + } |
| 146 | + } |
| 147 | +} |
| 148 | +``` |
| 149 | + |
| 150 | +If you need the ability to inject dependencies, you can use a `protocol` to abstract the implementation of the dependency. |
| 151 | + |
| 152 | +```swift |
| 153 | +protocol SomeInjectableService { |
| 154 | + func doSomething() |
| 155 | +} |
| 156 | + |
| 157 | +struct UserController<Context: RequestContext> { |
| 158 | + let service: any SomeInjectableService |
| 159 | + |
| 160 | + var routes: RouteCollection<Context> { |
| 161 | + return RouteCollection(context: Context.self) |
| 162 | + .get { request, context in |
| 163 | + self.service.doSomething() |
| 164 | + ... |
| 165 | + } |
| 166 | + } |
| 167 | +} |
| 168 | +``` |
| 169 | + |
| 170 | +All Controllers' routes are combined into a single router in **BuildRouter.swift**: |
| 171 | + |
| 172 | +```swift |
| 173 | +let router = Router(context: MyContext.self) |
| 174 | +router.add("users", routes: UserController().routes) |
| 175 | +``` |
| 176 | + |
| 177 | +## Request Context |
| 178 | + |
| 179 | +It's also recommended to users that they refine the AppContext requirements for only as much as strictly required by the routes. For example: |
| 180 | + |
| 181 | +```swift |
| 182 | +// ✅ This is recommended, because it allows a different RequestContext to be used for login routes |
| 183 | +struct UserController<Context: RequestContext> { ... } |
| 184 | +``` |
| 185 | + |
| 186 | +If a route has requirements for the request, like being authenticated, it's recommended to leverage _protocol composition_ to refine the RequestContext. |
| 187 | + |
| 188 | +```swift |
| 189 | +// ✅ This is recommended, because it leverages protocol composition to refine the RequestContext |
| 190 | +// while still being flexible enough to allow different concrete types to fulfil the requirements |
| 191 | +struct UserProfileController< |
| 192 | + Context: RequestContext & AuthenticatedRequestContext |
| 193 | +> { ... } |
| 194 | +``` |
| 195 | + |
| 196 | +Users should define various traits related to a request's state (context) in separate protocols. |
| 197 | + |
| 198 | +```swift |
| 199 | +protocol RateLimitedRequestContext { |
| 200 | + var rateLimitStatus: RateLimitStatus { get } |
| 201 | + var ipAddress: String { get } |
| 202 | +} |
| 203 | + |
| 204 | +protocol AuthenticatedRequestContext { |
| 205 | + var user: User { get } |
| 206 | +} |
| 207 | +``` |
| 208 | + |
| 209 | +## Middleware |
| 210 | + |
| 211 | +Middleware follow the same RequestContext conventions and recommendations as Routes. There's one distinction between Routes in that middleware can modify the request context. |
| 212 | + |
| 213 | +```swift |
| 214 | +public struct AuthenticationGuardMiddleware<Context: AuthRequestContext>: RouterMiddleware { |
| 215 | + public init() {} |
| 216 | + |
| 217 | + public func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response { |
| 218 | + guard context.identity != nil else { |
| 219 | + throw HTTPError(.unauthorized) |
| 220 | + } |
| 221 | + return try await next(request, context) |
| 222 | + } |
| 223 | +} |
| 224 | +``` |
| 225 | + |
| 226 | +### Child Request Contexts |
| 227 | + |
| 228 | +```swift |
| 229 | +struct MyAppRequestContext: RequestContext { |
| 230 | + public var coreContext: CoreRequestContextStorage |
| 231 | + // User is optional, because it's not required for all routes |
| 232 | + public var user: User? |
| 233 | + |
| 234 | + public init(source: Source) { |
| 235 | + self.coreContext = .init(source: source) |
| 236 | + self.routerContext = .init() |
| 237 | + self.user = nil |
| 238 | + } |
| 239 | +} |
| 240 | +``` |
| 241 | + |
| 242 | +```swift |
| 243 | +struct AuthenticatedRequestContext: ChildRequestContext { |
| 244 | + public var coreContext: CoreRequestContextStorage |
| 245 | + // User is required in all routes, and this is statically known |
| 246 | + public var user: User |
| 247 | + |
| 248 | + init(context: MyAppRequestContext) throws { |
| 249 | + guard let user = context.user else { |
| 250 | + throw Abort(.unauthorized) |
| 251 | + } |
| 252 | + |
| 253 | + self.coreContext = context.coreContext |
| 254 | + self.user = user |
| 255 | + } |
| 256 | +} |
| 257 | +``` |
| 258 | + |
| 259 | +## Services |
| 260 | + |
| 261 | +Users should define (external) resources in `Services`. For example: |
| 262 | + |
| 263 | +- PostgreSQL |
| 264 | +- S3 Bucket Management |
| 265 | +- Job Queues |
| 266 | +- Caching |
| 267 | +- OpenTelemetry |
| 268 | +- File System Access |
| 269 | + |
| 270 | +Each service manages it's lifecycle through [swift-service-lifecycle](https://github.com/swift-server/swift-service-lifecycle). The order of services is important, as services are initialized in the order they are added to the application, and teared down in reverse order. |
| 271 | + |
| 272 | +```swift |
| 273 | +let app = Application() |
| 274 | +let postgres = PostgresService(connectionString: arguments.postgres) |
| 275 | +app.addServices( |
| 276 | + OpenTelemetryService(), |
| 277 | + // Postgres reports to OTel through the global logger, so initialized later |
| 278 | + postgres, |
| 279 | + // Depends on postgres, so initialized later |
| 280 | + PostgresJobQueueService(postgres: postgres) |
| 281 | +) |
| 282 | +``` |
| 283 | + |
| 284 | +## Observability |
| 285 | + |
| 286 | +Swift's Observability APIs offer a powerful and flexible way to instrument and observe the application. Hummingbird already reports to [swift-log](https://github.com/apple/swift-log) and provides middleware for [swift-metrics](https://github.com/apple/swift-metrics) and [swift-distributed-tracing](https://github.com/apple/swift-distributed-tracing). These will report to the globally set up observability backend(s) automatically, if they're set up. |
| 287 | + |
| 288 | +By default, logs are emitted to `stdout`, and metrics + traces are discarded. If you want to trace signals in a single place, we recommend using [swift-otel](https://github.com/swift-otel/swift-otel/issues) as an observability backend. |
| 289 | + |
| 290 | +See the [OTel Example Project](https://github.com/hummingbird-project/hummingbird-examples/tree/main/open-telemetry) for a complete example. |
| 291 | + |
| 292 | +### Logging Recommendations |
| 293 | + |
| 294 | +Use the [Logging Guidelines](https://www.swift.org/documentation/server/guides/libraries/log-levels.html) set forth by the Swift Server Workgroup. It's strongly recommended to leverage **structured logging** to make logs more readable and searchable. |
| 295 | + |
| 296 | +✅ Good: |
| 297 | + |
| 298 | +```swift |
| 299 | +logger.info("User logged in", metadata: ["user_id": .string(user.id)]) |
| 300 | +``` |
| 301 | + |
| 302 | +❌ Bad: |
| 303 | + |
| 304 | +```swift |
| 305 | +logger.info("User \(user.id) logged in") |
| 306 | +``` |
| 307 | + |
| 308 | +## Scalability |
| 309 | + |
| 310 | +Hummingbird is explicitly designed for resource efficiency. A good example is how HTTP bodies are always represented as an `AsyncSequence` of bytes. This means that the server can stream data to the client without having to buffer the entire response in memory. Likewise, any uploads from a user through Hummingbird can be efficiently handled through the `AsyncSequence` APIs. |
| 311 | + |
| 312 | +It's highly recommended that you avoid `collect`ing responses into memory if possible. Instead, attempt to leverage the `AsyncSequence` APIs to stream data. |
| 313 | + |
| 314 | +Any data that needs to be stored in contiguous memory, like a JSON blob, should be strictly limited in the maximum size as to avoid (memory) resource exhaustion and performance issues. |
| 315 | + |
| 316 | +### Handling Bodies |
| 317 | + |
| 318 | +If you're using standard libraries like Foundation's `JSONDecoder`, limit the body size to "reasonable" sizes. What is considered reasonable depends per application, but lower is generally better. |
| 319 | + |
| 320 | +[MultipartKit 5](https://github.com/vapor/multipart-kit) will support streaming multipart parsing, in addition to [IkigaJSON](https://github.com/orlandos-nl/ikigajson) for streaming JSON parsing. |
| 321 | + |
| 322 | +You can handle the request/response bodies as an `AsyncSequence`, so you can iterate over the body. This will provide backpressure to the source if any part of the system cannot keep up. |
| 323 | + |
| 324 | +```swift |
| 325 | +router.get { req, context in |
| 326 | + for try await chunk in req.body { |
| 327 | + // Do something with the chunk, like writing to the filesystem |
| 328 | + } |
| 329 | + |
| 330 | + return Response(status: .ok) |
| 331 | +} |
| 332 | +``` |
| 333 | + |
| 334 | +In the above example, if the disk cannot keep up, the client's upload speed will be throttled to match the disk's speed. This prevents excessive memory build up in the server. |
| 335 | + |
| 336 | +### Persistence |
| 337 | + |
| 338 | +Leverage [swift-jobs](https://github.com/hummingbird-project/swift-jobs) or other job queue implementations to offload long running tasks, or tasks that can be parallelized to a background job. This will enable you to scale your application horizontally by adding more instances of your Hummingbird app. |
| 339 | + |
| 340 | +Avoid local database systems such as [SQLite](https://sqlite.org), including wrappers like [GRDB](https://github.com/groue/GRDB.swift), as these only reside on the local machine. |
| 341 | + |
| 342 | +Database such as [Postgres](https://www.postgresql.org) or [Valkey](https://valkey.io/)/[Redis](https://redis.io) are highly scalable and mature solutions for persistence and/or caching. These databases have mature libraries for Swift, and are well supported by the community. |
0 commit comments