From a045a235196f9f7d0a1f4c78482cbeca9c5784b2 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 19 Jul 2026 00:47:49 +0000 Subject: [PATCH] Web support for the Website service (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the work done to introduce a new Web Swift package that provides a reusable, declarative way to register route controllers on a Hummingbird router, then adopt it in the Website service. To provide further details about the work: * Web package * The `RouterController` protocol — a Sendable protocol to group controllers behind one `routes` property. * The `RouteCollectionBuilder` — a result builder that collects controllers' route collections into a stack, with full support for optionals, conditionals, and arrays. * The `addController(_:)` method — a `RouterMethods` extension letting controllers be listed declaratively and adding each one's routes at the router root. * Website service * Added Web as a dependency of the _WebsiteLibrary_ target. * Conformed the `RootController` and `HealthController`controllers to the `RouterController` protocol. * Updated the `App+Build` extension to to use the cleaner `router.addController { … }` instead. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/17 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../xcode/xcshareddata/xcschemes/Web.xcscheme | 77 +++++++++ Packages/Web/Package.swift | 47 ++++++ .../Builders/RouteCollectionBuilder.swift | 46 ++++++ .../RouterMethods+RouteCollections.swift | 34 ++++ .../Public/Protocols/RouterController.swift | 32 ++++ .../RouterMethods+RouteCollectionsTests.swift | 155 ++++++++++++++++++ .../Utils/Controllers/StubController.swift | 30 ++++ Services/Website/Package.swift | 6 + Services/Website/README.md | 18 +- .../Sources/App/Extensions/App+Build.swift | 8 +- .../Public/Controllers/HealthController.swift | 22 ++- .../Public/Controllers/RootController.swift | 23 ++- .../RouterMethods+RouteCollections.swift | 80 --------- Services/Website/Tests/Website.xctestplan | 7 + 14 files changed, 480 insertions(+), 105 deletions(-) create mode 100644 Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme create mode 100644 Packages/Web/Package.swift create mode 100644 Packages/Web/Sources/Public/Builders/RouteCollectionBuilder.swift create mode 100644 Packages/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift create mode 100644 Packages/Web/Sources/Public/Protocols/RouterController.swift create mode 100644 Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift create mode 100644 Packages/Web/Tests/Utils/Controllers/StubController.swift delete mode 100644 Services/Website/Sources/Library/Public/Extensions/RouterMethods+RouteCollections.swift diff --git a/Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme b/Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme new file mode 100644 index 0000000..5212084 --- /dev/null +++ b/Packages/Web/.swiftpm/xcode/xcshareddata/xcschemes/Web.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/Web/Package.swift b/Packages/Web/Package.swift new file mode 100644 index 0000000..c0c94e8 --- /dev/null +++ b/Packages/Web/Package.swift @@ -0,0 +1,47 @@ +// swift-tools-version: 6.3 + +import PackageDescription + +let package = Package( + name: "Web", + platforms: [ + .macOS(.v15), + ], + products: [ + .library( + name: "Web", + targets: [ + "Web" + ] + ), + ], + dependencies: [ + .package( + url: "https://github.com/hummingbird-project/hummingbird.git", + from: "2.25.0" + ), + ], + targets: [ + .target( + name: "Web", + dependencies: [ + .product( + name: "Hummingbird", + package: "hummingbird" + ), + ], + path: "Sources" + ), + .testTarget( + name: "WebTests", + dependencies: [ + .byName(name: "Web"), + .product( + name: "HummingbirdTesting", + package: "hummingbird" + ), + ], + path: "Tests" + ), + ] +) diff --git a/Packages/Web/Sources/Public/Builders/RouteCollectionBuilder.swift b/Packages/Web/Sources/Public/Builders/RouteCollectionBuilder.swift new file mode 100644 index 0000000..548bc46 --- /dev/null +++ b/Packages/Web/Sources/Public/Builders/RouteCollectionBuilder.swift @@ -0,0 +1,46 @@ +import Hummingbird + +/// A result builder that collects the route collections of ``RouterController`` values into a stack. +/// +/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting +/// controllers be listed declaratively rather than having their routes added one statement at a time. +@resultBuilder +public enum RouteCollectionBuilder { + + public static func buildExpression( + _ controller: some RouterController + ) -> [RouteCollection] { + [controller.routes] + } + + public static func buildBlock( + _ collections: [RouteCollection]... + ) -> [RouteCollection] { + collections.flatMap { $0 } + } + + public static func buildOptional( + _ collections: [RouteCollection]? + ) -> [RouteCollection] { + collections ?? [] + } + + public static func buildEither( + first collections: [RouteCollection] + ) -> [RouteCollection] { + collections + } + + public static func buildEither( + second collections: [RouteCollection] + ) -> [RouteCollection] { + collections + } + + public static func buildArray( + _ collections: [[RouteCollection]] + ) -> [RouteCollection] { + collections.flatMap { $0 } + } + +} diff --git a/Packages/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift b/Packages/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift new file mode 100644 index 0000000..d753351 --- /dev/null +++ b/Packages/Web/Sources/Public/Extensions/RouterMethods+RouteCollections.swift @@ -0,0 +1,34 @@ +import Hummingbird + +public extension RouterMethods { + + // MARK: Methods + + /// Adds the routes of ``RouterController`` values to the router using the + /// ``RouteCollectionBuilder`` result builder. + /// + /// Mirrors `addMiddleware`, letting controllers be listed declaratively: + /// + /// ```swift + /// router.addController { + /// RootController() + /// HealthController() + /// } + /// ``` + /// + /// Each controller's route collection is added at the router's root, exactly as a + /// sequence of `addRoutes(_:)` calls would. + /// - Parameter build: the controller stack result builder. + /// - Returns: the router, so calls can be chained. + @discardableResult + func addController( + @RouteCollectionBuilder _ build: () -> [RouteCollection] + ) -> Self { + for collection in build() { + addRoutes(collection) + } + + return self + } + +} diff --git a/Packages/Web/Sources/Public/Protocols/RouterController.swift b/Packages/Web/Sources/Public/Protocols/RouterController.swift new file mode 100644 index 0000000..819ec82 --- /dev/null +++ b/Packages/Web/Sources/Public/Protocols/RouterController.swift @@ -0,0 +1,32 @@ +import Hummingbird + +/// A type exposing its endpoints as a route collection ready to be added to a router. +/// +/// Conforming controllers group related endpoints behind a single ``routes`` property, +/// so the application composes them declaratively with ``Hummingbird/RouterMethods/addController(_:)``: +/// +/// ```swift +/// struct HealthController: RouterController { +/// var routes: RouteCollection { +/// RouteCollection(context: Context.self) +/// .get("health") { _, _ in HTTPResponse.Status.ok } +/// } +/// } +/// +/// router.addController { +/// HealthController() +/// } +/// ``` +public protocol RouterController: Sendable { + + // MARK: Associated types + + /// The request context the controller's routes operate on. + associatedtype Context: RequestContext + + // MARK: Properties + + /// The collection of routes the controller exposes. + var routes: RouteCollection { get } + +} diff --git a/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift b/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift new file mode 100644 index 0000000..1808834 --- /dev/null +++ b/Packages/Web/Tests/Cases/Public/Extensions/RouterMethods+RouteCollectionsTests.swift @@ -0,0 +1,155 @@ +import Hummingbird +import HummingbirdTesting +import Testing + +@testable import Web + +@Suite("addController method") +struct RouterMethodsTests { + + // MARK: Functional tests + + @Test + func `adds the routes of every listed controller`() async throws { + let router = Router() + + router.addController { + StubController(path: "first") + StubController(path: "second") + } + + try await Application(router: router).test(.router) { client in + try await client.execute( + uri: "/first", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(String(buffer: response.body) == "first") + } + + try await client.execute( + uri: "/second", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(String(buffer: response.body) == "second") + } + } + } + + @Test(arguments: [true, false]) + func `adds a controller behind a condition only when the condition holds`( + condition: Bool + ) async throws { + let router = Router() + + router.addController { + StubController(path: "always") + + if condition { + StubController(path: "conditional") + } + } + + try await Application(router: router).test(.router) { client in + try await client.execute( + uri: "/always", + method: .get + ) { response in + #expect(response.status == .ok) + } + + try await client.execute( + uri: "/conditional", + method: .get + ) { response in + #expect(response.status == (condition ? .ok : .notFound)) + } + } + } + + @Test(arguments: [true, false]) + func `adds only the taken branch of a condition`( + takesFirst: Bool + ) async throws { + let router = Router() + + router.addController { + if takesFirst { + StubController(path: "first") + } else { + StubController(path: "second") + } + } + + try await Application(router: router).test(.router) { client in + try await client.execute( + uri: "/first", + method: .get + ) { response in + #expect(response.status == (takesFirst ? .ok : .notFound)) + } + + try await client.execute( + uri: "/second", + method: .get + ) { response in + #expect(response.status == (takesFirst ? .notFound : .ok)) + } + } + } + + @Test + func `adds a controller for every iteration of a loop`() async throws { + let paths = ["one", "two", "three"] + let router = Router() + + router.addController { + for path in paths { + StubController(path: path) + } + } + + try await Application(router: router).test(.router) { client in + for path in paths { + try await client.execute( + uri: "/\(path)", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(String(buffer: response.body) == path) + } + } + } + } + + @Test + func `returns the router so calls can be chained`() async throws { + let router = Router() + + router + .addController { + StubController(path: "first") + } + .addController { + StubController(path: "second") + } + + try await Application(router: router).test(.router) { client in + try await client.execute( + uri: "/first", + method: .get + ) { response in + #expect(response.status == .ok) + } + + try await client.execute( + uri: "/second", + method: .get + ) { response in + #expect(response.status == .ok) + } + } + } + +} diff --git a/Packages/Web/Tests/Utils/Controllers/StubController.swift b/Packages/Web/Tests/Utils/Controllers/StubController.swift new file mode 100644 index 0000000..75b7586 --- /dev/null +++ b/Packages/Web/Tests/Utils/Controllers/StubController.swift @@ -0,0 +1,30 @@ +import Hummingbird +import Web + +/// A controller serving its path back as plain text, used to observe route registration. +struct StubController { + + // MARK: Properties + + /// The path the controller serves, also returned as the response body. + let path: String + +} + +// MARK: - RouterController + +extension StubController: RouterController { + + // MARK: Properties + + var routes: RouteCollection { + let routes = RouteCollection(context: BasicRequestContext.self) + + routes.get(.init(path)) { _, _ in + self.path + } + + return routes + } + +} diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index fdd8c5d..39c5aad 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -26,6 +26,9 @@ let package = Package( .package( path: "../../Packages/Persistence" ), + .package( + path: "../../Packages/Web" + ), .package( url: "https://github.com/elementary-swift/elementary.git", from: "0.6.0" @@ -77,6 +80,7 @@ let package = Package( dependencies: [ .byName(name: "Localization"), .byName(name: "Persistence"), + .byName(name: "Web"), .product( name: "Configuration", package: "swift-configuration" @@ -115,6 +119,8 @@ let package = Package( .testTarget( name: "WebsiteLibraryTests", dependencies: [ + .byName(name: "Persistence"), + .byName(name: "Web"), .byName(name: "WebsiteLibrary"), .product( name: "Elementary", diff --git a/Services/Website/README.md b/Services/Website/README.md index c705916..4293024 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -5,6 +5,7 @@ The **Loud** public website service — a [Hummingbird](https://github.com/hummi The service: - Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached). - Negotiates each request's language from its `Accept-Language` header against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers. +- Registers newsletter subscriptions at `POST /subscribe`: the landing page's form-encoded submission is validated and normalized, guarded by a hidden honeypot field against bots, and stored tagged with the request's negotiated language. - Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable). - Serves static files (CSS, JS, icons, manifest, `robots.txt`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`. - Returns a custom HTML 404 page, localized like the landing page, for any request that matches neither a route nor a static file. @@ -24,8 +25,9 @@ Two SwiftPM targets: | `Website` | executable | `Sources/App` | Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. | | `WebsiteLibrary` | library | `Sources/Library` | Controllers, middlewares, pages, cached responses, and configuration helpers. | -The `Website` executable depends on two local packages: +The `Website` executable depends on three local packages: - `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`). +- `Web` (`Packages/Web`) — the `RouterController` protocol the controllers conform to and the `addController` result-builder extension that registers their routes on the router declaratively. - `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver. The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination). @@ -39,6 +41,7 @@ LogRequestsMiddleware → NotFoundMiddleware (renders the localized 404 page on .notFound) → FileMiddleware (serves Resources/Static) RootController (GET / → landing page) +SubscriptionController (POST /subscribe → newsletter subscription) HealthController (GET /health → liveness, GET /health/ready → readiness) ``` @@ -47,8 +50,13 @@ Configuration is read through [swift-configuration](https://github.com/apple/swi the following sources, **highest precedence first**: 1. Command-line arguments (e.g. `--http-host 0.0.0.0`) 2. Process environment variables -3. A `.env` file in the working directory (optional) -4. Built-in defaults +3. A `.env.local` file in the working directory (optional) +4. A `.env` file in the working directory (optional) +5. Built-in defaults + +The two files play different roles: +- **`.env`** (git-ignored) holds your deployment values — it is the file the Makefile and Compose read for the `${VAR}` placeholders, and typically selects the MySQL/MariaDB backend. +- **`.env.local`** (tracked) holds the local development overrides — the in-memory database and `debug` logging. Because it sits *above* `.env`, a direct launch (`swift run` or a debugger) runs against the local values even when `.env` points at a deployment, the same way `docker-compose.override.yml` overrides the base Compose file. Compose itself never reads it, and the production image does not ship it — only the executable, its resources, and the static files are staged into the final stage. ### Environment variable naming A dotted config key maps to an environment variable by upper-casing, splitting camelCase, and replacing separators with `_`. For example `http.serverName` → `HTTP_SERVER_NAME`, @@ -119,6 +127,8 @@ swift run Website # binds to Hummingbird's default 127.0.0.1:8080 swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug ``` +A direct run picks up the local development overrides from `.env.local` (in-memory database, `debug` logging) over whatever `.env` configures. To run against another backend, override per launch — e.g. `DATABASE_DRIVER=mysql swift run Website` — since process environment variables outrank both files. + Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`): ```sh make pkg-build # swift build @@ -164,7 +174,7 @@ make pkg-test # = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel ``` -Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Website.xctestplan` covers two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests). +Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Website.xctestplan` covers the service's two targets — `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests) — plus the local packages' suites: `WebTests`, `PersistenceTests`, and `LocalizationTests`. The `Persistence` package has its own suite (run it from `Packages/Persistence`). Its tests run against the in-memory backend by default; the MySQL integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`/`NAME`/`USERNAME`/`PASSWORD`), so `swift test` stays runnable with no database: ```sh diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index dad5ec7..2bb0d52 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -157,9 +157,11 @@ private func router( ) } - router.addRoutes { - RootController().routes - HealthController(probe: probe).routes + router.addController { + RootController() + HealthController( + probe: probe + ) } return router diff --git a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift index ed37869..55f732e 100644 --- a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift @@ -1,13 +1,16 @@ import Hummingbird import NIOCore import Persistence +import Web /// Serves the website's health-check routes. /// -/// The controller exposes its routes as a `RouteCollection` so they can be added to a router (or a sub-group) by the application that composes it: +/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively: /// /// ```swift -/// router.addRoutes(HealthController(probe: probe).routes) +/// router.addController { +/// HealthController(probe: probe) +/// } /// ``` /// /// It always serves a liveness check at `/health`; when a `Probe` is supplied it also serves a readiness check at `/health/ready` that reports @@ -15,7 +18,7 @@ import Persistence /// readiness failure. /// /// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to. -public struct HealthController: Sendable { +public struct HealthController { // MARK: Properties @@ -33,13 +36,14 @@ public struct HealthController: Sendable { self.probe = probe } - // MARK: Computed +} + +// MARK: - RouterController + +extension HealthController: RouterController { + + // MARK: Properties - /// The routes served by the controller. - /// - /// Serves a `GET` request for the liveness path (`/health`) with a static JSON status payload, and — - /// when a `Probe` was supplied — a `GET` request for the readiness path (`/health/ready`) - /// that consults the probe. public var routes: RouteCollection { let routes = RouteCollection(context: Context.self) diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index d457c73..de32221 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -1,17 +1,20 @@ import Hummingbird +import Web /// Serves the website's root routes. /// -/// The controller exposes its routes as a `RouteCollection` so they can be added to a router -/// (or a sub-group) by the application that composes it: +/// The controller exposes its routes through its `RouterController` conformance, so the +/// application that composes it registers them declaratively: /// /// ```swift -/// router.addRoutes(RootController().routes) +/// router.addController { +/// RootController() +/// } /// ``` /// /// - Note: `Context` is the request context the routes are resolved against, and must match the /// context of the router the routes are added to. -public struct RootController: Sendable { +public struct RootController { // MARK: Properties @@ -25,12 +28,14 @@ public struct RootController: Sendable { self.responses = .init { IndexPage(locale: $0) } } - // MARK: Computed +} + +// MARK: - RouteController + +extension RootController: RouterController { + + // MARK: Properties - /// The routes served by the controller. - /// - /// Serves a `GET` request for the root path (`/`) by rendering the ``IndexPage`` in the - /// language negotiated for the request. public var routes: RouteCollection { let routes = RouteCollection(context: Context.self) diff --git a/Services/Website/Sources/Library/Public/Extensions/RouterMethods+RouteCollections.swift b/Services/Website/Sources/Library/Public/Extensions/RouterMethods+RouteCollections.swift deleted file mode 100644 index 038671d..0000000 --- a/Services/Website/Sources/Library/Public/Extensions/RouterMethods+RouteCollections.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Hummingbird - -/// A result builder that collects ``RouteCollection`` values into a stack. -/// -/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting route -/// collections be listed declaratively rather than added one statement at a time. -@resultBuilder -public enum RouteCollectionBuilder { - - public static func buildExpression( - _ collection: RouteCollection - ) -> [RouteCollection] { - [collection] - } - - public static func buildBlock( - _ collections: [RouteCollection]... - ) -> [RouteCollection] { - collections.flatMap { $0 } - } - - public static func buildOptional( - _ collections: [RouteCollection]? - ) -> [RouteCollection] { - collections ?? [] - } - - public static func buildEither( - first collections: [RouteCollection] - ) -> [RouteCollection] { - collections - } - - public static func buildEither( - second collections: [RouteCollection] - ) -> [RouteCollection] { - collections - } - - public static func buildArray( - _ collections: [[RouteCollection]] - ) -> [RouteCollection] { - collections.flatMap { $0 } - } - -} - -// MARK: - Helpers - -public extension RouterMethods { - - // MARK: Methods - - /// Adds route collections to the router using the ``RouteCollectionBuilder`` result builder. - /// - /// Mirrors `addMiddleware`, letting controllers be listed declaratively: - /// - /// ```swift - /// router.addRoutes { - /// RootController().routes - /// HealthController().routes - /// } - /// ``` - /// - /// Each collection is added at the router's root, exactly as a sequence of - /// `addRoutes(_:)` calls would. - /// - Parameter build: the route-collection stack result builder. - /// - Returns: the router, so calls can be chained. - @discardableResult - func addRoutes( - @RouteCollectionBuilder _ build: () -> [RouteCollection] - ) -> Self { - for collection in build() { - addRoutes(collection) - } - - return self - } - -} diff --git a/Services/Website/Tests/Website.xctestplan b/Services/Website/Tests/Website.xctestplan index 7b0061e..0a36976 100644 --- a/Services/Website/Tests/Website.xctestplan +++ b/Services/Website/Tests/Website.xctestplan @@ -45,6 +45,13 @@ "identifier" : "LocalizationTests", "name" : "LocalizationTests" } + }, + { + "target" : { + "containerPath" : "container:..\/..\/Packages\/Web", + "identifier" : "WebTests", + "name" : "WebTests" + } } ], "version" : 1