Implemented phase 2 for the POC prototype in the Website service.
This commit is contained in:
1 parent
09c7b8a1a7
commit
43dcc03da6
28 files changed
+1468
-65
No files matched your search
@@ -21,21 +21,27 @@ public struct CatalogueController<Context: RequestContext> {
|
||||
/// The repository the shops and listings are read through.
|
||||
private let repository: CatalogueRepository
|
||||
|
||||
/// The store the signed-in viewer is read from, or `nil` to render every page signed out.
|
||||
private let sessions: SessionStore?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a catalogue controller.
|
||||
/// - Parameters:
|
||||
/// - repository: the repository the shops and listings are read through.
|
||||
/// - sessions: the store the signed-in viewer is read from, or `nil` (the default) to render every page signed out.
|
||||
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
|
||||
public init(
|
||||
repository: CatalogueRepository,
|
||||
sessions: SessionStore? = nil,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.repository = repository
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
}
|
||||
@@ -93,10 +99,13 @@ private extension CatalogueController {
|
||||
.filter { $0.slug != listing.slug }
|
||||
.prefix(Self.relatedCount)
|
||||
|
||||
return .init { [analytics, assetVersion] in
|
||||
let viewer = await sessions?.viewer(for: request)
|
||||
|
||||
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
ListingPage(
|
||||
listing: listing,
|
||||
related: Array(related),
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
@@ -113,11 +122,14 @@ private extension CatalogueController {
|
||||
let matches = try await repository.listings(matching: parameters.filter)
|
||||
let publishers = try await repository.publishers()
|
||||
|
||||
return .init { [analytics, assetVersion] in
|
||||
let viewer = await sessions?.viewer(for: request)
|
||||
|
||||
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
SearchPage(
|
||||
parameters: parameters,
|
||||
matches: matches,
|
||||
publishers: publishers,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
@@ -138,12 +150,15 @@ private extension CatalogueController {
|
||||
let inventory = try await repository.listings(matching: .init(shop: shop.slug))
|
||||
let matches = try await repository.listings(matching: parameters.filter(inShop: shop.slug))
|
||||
|
||||
return .init { [analytics, assetVersion] in
|
||||
let viewer = await sessions?.viewer(for: request)
|
||||
|
||||
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
ShopPage(
|
||||
shop: shop,
|
||||
inventory: inventory,
|
||||
matches: matches,
|
||||
parameters: parameters,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import HummingbirdElementary
|
||||
import Infrastructure
|
||||
import Localization
|
||||
|
||||
@@ -18,6 +19,12 @@ public struct RootController<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// What a page rendered per request is built from, the same as the cached ones.
|
||||
private let page: @Sendable (Locale, Viewer?) -> IndexPage
|
||||
|
||||
/// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page.
|
||||
private let sessions: SessionStore?
|
||||
|
||||
/// Negotiates the language the bare route answers in.
|
||||
private let negotiate: Negotiate
|
||||
|
||||
@@ -32,26 +39,35 @@ public struct RootController<Context: RequestContext> {
|
||||
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
|
||||
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
|
||||
/// - listings: the listings the landing page shows in its rows; none by default.
|
||||
/// - sessions: the store the signed-in viewer is read from, or `nil` (the default) to serve everyone the cached page.
|
||||
public init(
|
||||
assetVersion: String? = nil,
|
||||
siteOrigin: String? = nil,
|
||||
analytics: Analytics? = nil,
|
||||
listings: HomeListings = .init()
|
||||
listings: HomeListings = .init(),
|
||||
sessions: SessionStore? = nil
|
||||
) {
|
||||
let page: @Sendable (Locale, Viewer?) -> IndexPage = {
|
||||
.init(
|
||||
locale: $0,
|
||||
assetVersion: assetVersion,
|
||||
siteOrigin: siteOrigin,
|
||||
analytics: analytics,
|
||||
listings: listings,
|
||||
viewer: $1
|
||||
)
|
||||
}
|
||||
|
||||
self.negotiate = .init(bundle: .module)
|
||||
self.page = page
|
||||
self.sessions = sessions
|
||||
// The bare route negotiates, so the pages declare `Vary: Accept-Language`; the prefixed editions share the cache
|
||||
// and carry it too.
|
||||
self.responses = .init(
|
||||
bundle: .module,
|
||||
variesOnAcceptLanguage: true
|
||||
) {
|
||||
IndexPage(
|
||||
locale: $0,
|
||||
assetVersion: assetVersion,
|
||||
siteOrigin: siteOrigin,
|
||||
analytics: analytics,
|
||||
listings: listings
|
||||
)
|
||||
page($0, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,10 +119,11 @@ private extension RootController {
|
||||
func index(
|
||||
request: Request,
|
||||
context: Context
|
||||
) -> Response {
|
||||
responses.response(
|
||||
for: negotiate(for: request),
|
||||
request: request
|
||||
) async -> Response {
|
||||
await respond(
|
||||
in: negotiate(for: request),
|
||||
request: request,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,13 +135,37 @@ private extension RootController {
|
||||
/// - Returns: the handler answering requests for that edition of the page.
|
||||
func index(
|
||||
in language: Language
|
||||
) -> @Sendable (Request, Context) -> Response {
|
||||
{ request, _ in
|
||||
responses.response(
|
||||
for: language.identifier,
|
||||
) -> @Sendable (Request, Context) async -> Response {
|
||||
{ request, context in
|
||||
await respond(
|
||||
in: language.identifier,
|
||||
request: request,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers with the landing page in the given language: the cached page signed out, and one rendered around the viewer signed in.
|
||||
/// - Parameters:
|
||||
/// - language: the identifier of the language to answer in.
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
func respond(
|
||||
in language: String,
|
||||
request: Request,
|
||||
context: Context
|
||||
) async -> Response {
|
||||
guard let viewer = await sessions?.viewer(for: request) else {
|
||||
return responses.response(
|
||||
for: language,
|
||||
request: request
|
||||
)
|
||||
}
|
||||
|
||||
return HTMLResponse(additionalHeaders: .perViewer) { [page] in
|
||||
page(.init(identifier: language), viewer)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import Hummingbird
|
||||
import HummingbirdElementary
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
import Utility
|
||||
|
||||
/// Serves the gate: the sign-in screen, the sign-in itself, and the sign-out.
|
||||
///
|
||||
/// The passkey ceremony is **stubbed**: the prototype trusts whatever email it is given, and signs in the demo account when given none.
|
||||
/// Everything around the stub is real — the session, its cookie, and the return to whatever the visitor was about to do.
|
||||
///
|
||||
/// - 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 SessionController<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The repository accounts are found and created through.
|
||||
private let accounts: AccountRepository
|
||||
|
||||
/// The analytics tracker the page embeds, or `nil` to omit it.
|
||||
private let analytics: Analytics?
|
||||
|
||||
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||
private let assetVersion: String?
|
||||
|
||||
/// The repository the visitor's intent is described from.
|
||||
private let catalogue: CatalogueRepository
|
||||
|
||||
/// Whether the session cookie is limited to HTTPS.
|
||||
private let isSecure: Bool
|
||||
|
||||
/// The store sessions are started and ended in.
|
||||
private let sessions: SessionStore
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a session controller.
|
||||
/// - Parameters:
|
||||
/// - sessions: the store sessions are started and ended in.
|
||||
/// - accounts: the repository accounts are found and created through.
|
||||
/// - catalogue: the repository the visitor's intent is described from.
|
||||
/// - isSecure: whether the session cookie is limited to HTTPS; `false` by default, for plain-HTTP local development.
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit it.
|
||||
public init(
|
||||
sessions: SessionStore,
|
||||
accounts: AccountRepository,
|
||||
catalogue: CatalogueRepository,
|
||||
isSecure: Bool = false,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.accounts = accounts
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.catalogue = catalogue
|
||||
self.isSecure = isSecure
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterController
|
||||
|
||||
extension SessionController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.get(
|
||||
.Session.signIn,
|
||||
use: show
|
||||
)
|
||||
routes.post(
|
||||
.Session.signIn,
|
||||
use: signIn
|
||||
)
|
||||
routes.post(
|
||||
.Session.signOut,
|
||||
use: signOut
|
||||
)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension SessionController {
|
||||
|
||||
// MARK: Structs
|
||||
|
||||
/// The fields the sign-in form posts.
|
||||
struct SignInForm: Decodable {
|
||||
/// The email typed in, or `nil` when the saved passkey is used.
|
||||
let email: String?
|
||||
/// The path to return to.
|
||||
let `return`: String?
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// What the visitor was about to do, read off the path they return to, or `nil` when it names no listing or shop.
|
||||
func intent(
|
||||
returningTo path: String
|
||||
) async throws -> String? {
|
||||
let segments = path
|
||||
.prefix { $0 != "?" }
|
||||
.split(separator: "/")
|
||||
.map(String.init)
|
||||
|
||||
guard segments.count >= 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch segments[0] {
|
||||
case "listing":
|
||||
return try await catalogue
|
||||
.listing(slug: segments[1])
|
||||
.map { "To buy, offer on or ask about \($0.title)" }
|
||||
case "shop":
|
||||
return try await catalogue
|
||||
.shop(slug: segments[1])
|
||||
.map { "To message or follow \($0.name)" }
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the sign-in screen, never stored: it reflects the visitor's intent and their last attempt.
|
||||
func page(
|
||||
returnPath: String,
|
||||
error: String? = nil,
|
||||
status: HTTPResponse.Status = .ok,
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let intent = try await intent(returningTo: returnPath)
|
||||
|
||||
return HTMLResponse(
|
||||
status: status,
|
||||
additionalHeaders: [.cacheControl: "no-store"]
|
||||
) { [analytics, assetVersion] in
|
||||
SignInPage(
|
||||
returnPath: returnPath,
|
||||
intent: intent,
|
||||
error: error,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
/// Handles a request for the sign-in screen; a visitor already signed in is sent straight back.
|
||||
@Sendable
|
||||
func show(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let returnPath = String.Route.safeReturn(request.uri.queryParameters.get("return"))
|
||||
|
||||
guard await sessions.viewer(for: request) == nil else {
|
||||
return .redirect(to: returnPath, type: .found)
|
||||
}
|
||||
|
||||
return try await page(
|
||||
returnPath: returnPath,
|
||||
request: request,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
/// Handles a sign-in: starts a session for the account — the demo one when no email is given — and returns to the visitor's intent.
|
||||
@Sendable
|
||||
func signIn(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let form = try await URLEncodedFormDecoder().decode(SignInForm.self, from: request, context: context)
|
||||
let returnPath = String.Route.safeReturn(form.return)
|
||||
let typed = form.email?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
|
||||
guard let email = typed.isEmpty ? AccountRepository.demoEmail : NormalizeEmail()(typed) else {
|
||||
return try await page(
|
||||
returnPath: returnPath,
|
||||
error: "That doesn't look like an email address.",
|
||||
status: .unprocessableContent,
|
||||
request: request,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
let token = await sessions.start(for: try await accounts.account(email: email))
|
||||
var response = Response.redirect(to: returnPath, type: .normal)
|
||||
|
||||
response.setCookie(SessionStore.cookie(token, isSecure: isSecure))
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/// Handles a sign-out: ends the session, expires its cookie, and returns home.
|
||||
@Sendable
|
||||
func signOut(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
if let token = SessionStore.token(in: request) {
|
||||
await sessions.end(token)
|
||||
}
|
||||
|
||||
var response = Response.redirect(to: .Route.home, type: .normal)
|
||||
|
||||
response.setCookie(SessionStore.cookie("", isSecure: isSecure))
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private extension RouterPath {
|
||||
/// A namespace for the ``SessionController`` route paths.
|
||||
enum Session {
|
||||
/// The path of the sign-in screen, and of the sign-in posted from it.
|
||||
static let signIn: RouterPath = .init(.Route.signIn)
|
||||
/// The path a sign-out is posted to.
|
||||
static let signOut: RouterPath = .init(.Route.signOut)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user