import HTTPTypes import Hummingbird /// `make bump` changes this text so the next build has to recompile this /// file, the one holding the `GET /` handler; `make unbump` puts it back. let bumpNote = "unbumped" /// The request header the page's JavaScript sends with each action. With /// it, an action answers 200 with the new `section.main` and /// `footer.footer` instead of 303. let partialHeader = HTTPField.Name("X-Todo-Partial")! /// Every route the contract lists. struct Routes: Sendable { let store: Store let visitors: Visitors let version: String func register(on router: Router) { router.get("/healthz") { _, _ in "ok\n" } router.get("/api/version") { _, _ in self.version } router.get("/static/base.css") { _, _ in Assets.response(Assets.baseCSS, type: "text/css; charset=utf-8") } router.get("/static/index.css") { _, _ in Assets.response(Assets.indexCSS, type: "text/css; charset=utf-8") } router.get("/static/app.css") { _, _ in Assets.response(Assets.appCSS, type: "text/css; charset=utf-8") } router.get("/static/app.js") { _, _ in Assets.response(Assets.appJS, type: "text/javascript; charset=utf-8") } for filter in Filter.allCases { router.get(RouterPath(filter.path)) { request, _ in try await self.page(request, filter: filter) } } router.post("/todos") { request, context in try await self.action(request, context) { list, form in let title = form.title?.trimmingWhitespace() ?? "" if !title.isEmpty { try await self.store.add(list: list, title: title) } return true } } router.post("/todos/toggle-all") { request, context in try await self.action(request, context) { list, _ in try await self.store.toggleAll(list: list) return true } } router.post("/todos/clear-completed") { request, context in try await self.action(request, context) { list, _ in try await self.store.clearCompleted(list: list) return true } } router.post("/todos/:id/toggle") { request, context in let id = try context.parameters.require("id", as: Int.self) return try await self.action(request, context) { list, _ in try await self.store.toggle(list: list, id: id) } } router.post("/todos/:id/edit") { request, context in let id = try context.parameters.require("id", as: Int.self) return try await self.action(request, context) { list, form in let title = form.title?.trimmingWhitespace() ?? "" return title.isEmpty ? try await self.store.destroy(list: list, id: id) : try await self.store.rename(list: list, id: id, title: title) } } router.post("/todos/:id/destroy") { request, context in let id = try context.parameters.require("id", as: Int.self) return try await self.action(request, context) { list, _ in try await self.store.destroy(list: list, id: id) } } } /// `GET /`, `/active` and `/completed`, with `?edit=` opening that /// item's editor. func page(_ request: Request, filter: Filter) async throws -> Response { let visitor = try await visitors.resolve(request) let todos = try await store.todos(list: visitor.list) let editing = request.uri.queryParameters["edit"].flatMap { Int(String($0)) } let html = Page.document(todos: todos, filter: filter, editing: editing, version: version) var response = Response.html(html) visitors.attach(visitor, to: &response) return response } struct Form: Decodable { var title: String? var filter: String? } /// Runs one form action for the caller's own list. `change` answers /// false when the item it names isn't in that list, which is a 404. func action( _ request: Request, _ context: BasicRequestContext, change: (Int, Form) async throws -> Bool ) async throws -> Response { var request = request let body = try await request.collectBody(upTo: context.maxUploadSize) guard let form = try? URLEncodedFormDecoder().decode(Form.self, from: String(buffer: body)) else { throw HTTPError(.badRequest, message: "Expected a form.") } let filter = Filter(field: form.filter) let visitor = try await visitors.resolve(request) var response: Response if try await change(visitor.list, form) { if request.headers[partialHeader] != nil { let todos = try await store.todos(list: visitor.list) response = .html(Page.body(todos: todos, filter: filter, editing: nil)) } else { response = Response(status: .seeOther, headers: [.location: filter.path]) } } else { response = Response(status: .notFound, headers: [.contentType: "text/plain; charset=utf-8"], body: .init(byteBuffer: ByteBuffer(string: "No such item in your list.\n"))) } visitors.attach(visitor, to: &response) return response } } extension Response { static func html(_ html: String) -> Response { Response( status: .ok, headers: [.contentType: "text/html; charset=utf-8", .cacheControl: "no-store"], body: .init(byteBuffer: ByteBuffer(string: html)) ) } } extension String { func trimmingWhitespace() -> String { String(self[...].trimmingWhitespace()) } }