diff --git a/.gitignore b/.gitignore index 00308ac..0276042 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,16 @@ node_modules/ *.log .env .env.* -dist/ + +# Gleam / Erlang build artifacts build/ +dist/ +*.beam +*.ez +erl_crash.dump + +# Generated web bundle staged into the server by build.sh +server/priv/static/at_record_web.js + +# Lexicon codecs generated by codegen (run ./gen.sh) +shared/src/at_record/gen/ diff --git a/.tangled/workflows/test.yml b/.tangled/workflows/test.yml new file mode 100644 index 0000000..19044ff --- /dev/null +++ b/.tangled/workflows/test.yml @@ -0,0 +1,37 @@ +when: + - event: ["push", "pull_request"] + branch: main + +engine: nixery + +dependencies: + nixpkgs: + - gleam + - erlang + - rebar3 + - nodejs + +steps: + - name: generate lexicon codecs + command: | + (cd codegen && gleam run) + (cd shared && gleam format src/at_record/gen) + + - name: check formatting + command: | + (cd codegen && gleam format --check src test) + (cd shared && gleam format --check src test) + (cd server && gleam format --check src test) + (cd web && gleam format --check src test) + + - name: shared tests + command: | + cd shared && gleam test + + - name: server tests + command: | + cd server && gleam test + + - name: web tests + command: | + cd web && gleam test diff --git a/README.md b/README.md index 0d4a61c..1c862f4 100644 --- a/README.md +++ b/README.md @@ -1 +1,88 @@ # at-record + +A Discogs-like, atproto-native record collection app, written in Gleam. Your +crate (the vinyl kind) is stored as records (the atproto kind) in your own PDS. + +This is the Gleam reincarnation of the `crate` design: same lexicons +(`dev.mokkenstorm.crate.*`), same PDS-as-storage idea, different stack. + +## Status: v1 vertical slice + +Log in, then add / list / delete entries in **your own** shelf. Each entry is a +`dev.mokkenstorm.crate.shelf.item` record written to your PDS. Metadata is typed +in by hand. No catalog authority, no Discogs seed, no cross-user search yet (see +"Roadmap"). + +## Layout + +A Gleam multi-target monorepo: `server` and `web` both depend on `shared` by path. + +| Package | Target | What it is | +| --------- | ---------- | -------------------------------------------------------------------- | +| `shared` | erlang+js | Generated lexicon codecs (`gen/`) + a hand-written `StoredItem`. | +| `server` | erlang | Wisp BFF: atproto XRPC client, identity resolution, session, routes. | +| `web` | javascript | Lustre SPA (login + shelf CRUD), talks only to the BFF. | +| `codegen` | erlang | Generates Gleam codecs from the lexicon JSON (see below). | + +Lexicons live under `lexicons/dev/mokkenstorm/crate/`. + +## Lexicon codegen + +`shared/src/at_record/gen/` is **generated** from the lexicon JSON by the +`codegen` package and is **gitignored** (regenerated, not committed). `codegen` +emits one Gleam module per lexicon file with the type + a `gleam/json` encoder + +a `gleam/dynamic/decode` decoder. It supports the constructs our lexicons use +(record/object defs; string/integer/boolean/array/ref; `required[]` -> non- +`Option`); `knownValues` map to plain `String` (an open set); a def using +anything else (e.g. `unknown`) is skipped with a warning. Regenerate with: + +```sh +./gen.sh # writes shared/src/at_record/gen/ (also run first by build.sh) +``` + +Because the output is gitignored, **run `./gen.sh` (or `./build.sh`) before +building or testing a fresh checkout.** + +## Run it locally + +Needs `gleam`, `erlang`, and `node`. + +```sh +./build.sh # build the Lustre bundle into server/priv/static +cd server && gleam run # serves http://localhost:8080 +``` + +Open http://localhost:8080 and sign in with your handle (e.g. `mokkenstorm.dev`) +and an **app password** created in your PDS settings. Add a record; it is written +to your PDS. Confirm independently: + +```sh +curl "https://eurosky.social/xrpc/com.atproto.repo.listRecords?repo=&collection=dev.mokkenstorm.crate.shelf.item" +``` + +## Tests + +```sh +./gen.sh # generate codecs first (gitignored output) +(cd shared && gleam test) +(cd server && gleam test) +(cd web && gleam test) +``` + +## Notable v1 shortcuts + +- **Auth is app-password session auth**, held server-side in a signed cookie + (the BFF, so the password never reaches the browser). The cookie is signed but + client-readable, so the access token is visible to the browser. Replace with + full atproto **OAuth (PAR/PKCE/DPoP)** and a server-side store before any + multi-user use. +- **`shelf.item.release` is optional here.** The lexicon normally requires a + strong ref to a promoted catalog record; with no catalog authority yet, a v1 + entry is carried by its `snapshot` alone. + +## Roadmap (from the `crate` design) + +Catalog authority + lazy promotion -> Discogs/MusicBrainz cold-seed -> +Jetstream indexer + cross-user search/appview -> social feed -> hybrid catalog +edits. Mobile, when wanted, is the same Lustre app wrapped in Capacitor or +Tauri 2 (Gleam has no native-widget path). diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..aa1f864 --- /dev/null +++ b/build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Build the Lustre web bundle and stage it for the server to serve. +set -euo pipefail +cd "$(dirname "$0")" + +./gen.sh + +echo "==> building web bundle" +# lustre/dev can report a non-zero exit even on success, so don't trust it; +# verify the artifact instead. +(cd web && gleam run -m lustre/dev build at_record_web --minify) || true + +if [ ! -f web/dist/at_record_web.js ]; then + echo "build failed: web/dist/at_record_web.js not found" >&2 + exit 1 +fi + +cp web/dist/at_record_web.js server/priv/static/at_record_web.js +echo "==> staged $(wc -c < server/priv/static/at_record_web.js | tr -d ' ') bytes into server/priv/static/" +echo "==> run: (cd server && gleam run) then open http://localhost:8080" diff --git a/codegen/.github/workflows/test.yml b/codegen/.github/workflows/test.yml new file mode 100644 index 0000000..eb8b0e4 --- /dev/null +++ b/codegen/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: erlef/setup-beam@v1 + with: + otp-version: "29" + gleam-version: "1.17.0" + rebar3-version: "3" + # elixir-version: "1" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/codegen/.gitignore b/codegen/.gitignore new file mode 100644 index 0000000..599be4e --- /dev/null +++ b/codegen/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/codegen/README.md b/codegen/README.md new file mode 100644 index 0000000..978b25d --- /dev/null +++ b/codegen/README.md @@ -0,0 +1,24 @@ +# codegen + +[![Package Version](https://img.shields.io/hexpm/v/codegen)](https://hex.pm/packages/codegen) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/codegen/) + +```sh +gleam add codegen@1 +``` +```gleam +import codegen + +pub fn main() -> Nil { + // TODO: An example of the project in use +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/codegen/gleam.toml b/codegen/gleam.toml new file mode 100644 index 0000000..f5b68fb --- /dev/null +++ b/codegen/gleam.toml @@ -0,0 +1,22 @@ +name = "codegen" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "", repo = "" } +# links = [{ title = "Website", href = "" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +gleam_json = ">= 3.1.0 and < 4.0.0" +simplifile = ">= 2.4.0 and < 3.0.0" +justin = ">= 1.1.0 and < 2.0.0" + +[dev_dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/codegen/manifest.toml b/codegen/manifest.toml new file mode 100644 index 0000000..f677195 --- /dev/null +++ b/codegen/manifest.toml @@ -0,0 +1,23 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, + { name = "justin", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "8B1C62269E8607D0A0ED698B7903984CE0BF7B6B3C0DEA3C6C8302B39402837B" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, +] + +[requirements] +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +justin = { version = ">= 1.1.0 and < 2.0.0" } +simplifile = { version = ">= 2.4.0 and < 3.0.0" } diff --git a/codegen/src/codegen.gleam b/codegen/src/codegen.gleam new file mode 100644 index 0000000..4849547 --- /dev/null +++ b/codegen/src/codegen.gleam @@ -0,0 +1,556 @@ +//// Lexicon -> Gleam codegen for at-record. +//// +//// Reads `../lexicons/**/*.json`, emits Gleam types + `gleam/json` encoders + +//// `gleam/dynamic/decode` decoders into `../shared/src/at_record/gen/`. Supports +//// the constructs our lexicons use (record/object defs; string/integer/boolean/ +//// array/ref fields; `required[]` -> non-Option). knownValues map to plain +//// `String` (the lexicon set is open). A def using anything else (e.g. +//// `unknown`) is skipped with a warning rather than emitted wrong. + +import gleam/dict.{type Dict} +import gleam/dynamic/decode +import gleam/io +import gleam/json +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string +import justin +import simplifile + +const lexicons_dir = "../lexicons" + +const out_dir = "../shared/src/at_record/gen" + +const out_module = "at_record/gen" + +const nsid_prefix = "dev.mokkenstorm.crate." + +// --- model --- + +type FieldType { + TString + TInt + TBool + TNumber + TRef(String) + TArray(FieldType) + TUnsupported(String) +} + +type Property { + Property(json_name: String, field_type: FieldType, required: Bool) +} + +type Def { + Record(nsid: String, properties: List(Property)) + Object(nsid: String, name: String, properties: List(Property)) + Unsupported(nsid: String, name: String, reason: String) + Ignored +} + +type Lexicon { + Lexicon(nsid: String, defs: List(Def)) +} + +// --- lexicon decoding --- + +fn field_type_decoder() -> decode.Decoder(FieldType) { + use kind <- decode.field("type", decode.string) + case kind { + "string" -> decode.success(TString) + "integer" -> decode.success(TInt) + "boolean" -> decode.success(TBool) + "number" -> decode.success(TNumber) + "ref" -> { + use ref <- decode.field("ref", decode.string) + decode.success(TRef(ref)) + } + "array" -> { + use items <- decode.field("items", field_type_decoder()) + decode.success(TArray(items)) + } + other -> decode.success(TUnsupported(other)) + } +} + +type Schema { + Schema(properties: Dict(String, FieldType), required: List(String)) +} + +fn schema_decoder() -> decode.Decoder(Schema) { + use properties <- decode.optional_field( + "properties", + dict.new(), + decode.dict(decode.string, field_type_decoder()), + ) + use required <- decode.optional_field( + "required", + [], + decode.list(decode.string), + ) + decode.success(Schema(properties:, required:)) +} + +type RawDef { + RawDef(kind: String, schema: Schema) +} + +fn raw_def_decoder() -> decode.Decoder(RawDef) { + use kind <- decode.field("type", decode.string) + case kind { + "object" -> { + use schema <- decode.then(schema_decoder()) + decode.success(RawDef("object", schema)) + } + "record" -> { + use schema <- decode.field("record", schema_decoder()) + decode.success(RawDef("record", schema)) + } + other -> decode.success(RawDef(other, Schema(dict.new(), []))) + } +} + +fn lexicon_decoder() -> decode.Decoder(Lexicon) { + use nsid <- decode.field("id", decode.string) + use raw <- decode.field("defs", decode.dict(decode.string, raw_def_decoder())) + let defs = + raw + |> dict.to_list + |> list.map(fn(pair) { build_def(nsid, pair.0, pair.1) }) + decode.success(Lexicon(nsid:, defs:)) +} + +fn build_def(nsid: String, name: String, raw: RawDef) -> Def { + let props = + raw.schema.properties + |> dict.to_list + |> list.map(fn(pair) { + Property( + json_name: pair.0, + field_type: pair.1, + required: list.contains(raw.schema.required, pair.0), + ) + }) + case unsupported_reason(props) { + Some(reason) -> + case raw.kind { + "object" | "record" -> Unsupported(nsid, name, reason) + _ -> Ignored + } + None -> + case raw.kind { + "object" -> Object(nsid, name, props) + "record" -> Record(nsid, props) + _ -> Ignored + } + } +} + +fn unsupported_reason(props: List(Property)) -> Option(String) { + props + |> list.filter_map(fn(p) { + case unsupported_type(p.field_type) { + Some(kind) -> + Ok("field `" <> p.json_name <> "` has type `" <> kind <> "`") + None -> Error(Nil) + } + }) + |> list.first + |> option.from_result +} + +fn unsupported_type(ft: FieldType) -> Option(String) { + case ft { + TUnsupported(kind) -> Some(kind) + TArray(inner) -> unsupported_type(inner) + _ -> None + } +} + +// --- naming / refs --- + +fn strip_prefix(nsid: String) -> String { + case string.starts_with(nsid, nsid_prefix) { + True -> string.drop_start(nsid, string.length(nsid_prefix)) + False -> nsid + } +} + +fn module_subpath(nsid: String) -> String { + string.replace(strip_prefix(nsid), ".", "/") +} + +fn module_alias(nsid: String) -> String { + string.replace(strip_prefix(nsid), ".", "_") +} + +type Ref { + Ref(nsid: String, def_name: String) +} + +fn parse_ref(current: String, ref: String) -> Ref { + case string.split(ref, "#") { + ["", def_name] -> Ref(current, def_name) + [nsid, def_name] -> Ref(nsid, def_name) + [nsid] -> Ref(nsid, "main") + _ -> Ref(current, ref) + } +} + +fn ref_type_name(ref: Ref) -> String { + case ref.def_name { + "main" -> justin.pascal_case(module_alias(ref.nsid)) + name -> justin.pascal_case(name) + } +} + +fn def_type_name(def: Def) -> String { + case def { + Record(nsid, _) -> justin.pascal_case(module_alias(nsid)) + Object(_, name, _) -> justin.pascal_case(name) + _ -> "" + } +} + +const keywords = [ + "as", "assert", "case", "const", "echo", "fn", "if", "import", "let", "opaque", + "panic", "pub", "todo", "type", "use", +] + +fn field_name(json_name: String) -> String { + let snake = justin.snake_case(json_name) + case list.contains(keywords, snake) { + True -> snake <> "_" + False -> snake + } +} + +// --- expression emitters --- + +fn qualifier(current: String, nsid: String) -> String { + case nsid == current { + True -> "" + False -> module_alias(nsid) <> "." + } +} + +fn gleam_type(current: String, ft: FieldType) -> String { + case ft { + TString -> "String" + TInt -> "Int" + TBool -> "Bool" + TNumber -> "Float" + TRef(ref) -> { + let r = parse_ref(current, ref) + qualifier(current, r.nsid) <> ref_type_name(r) + } + TArray(inner) -> "List(" <> gleam_type(current, inner) <> ")" + TUnsupported(kind) -> "Dynamic_" <> kind + } +} + +fn encoder_expr(current: String, ft: FieldType) -> String { + case ft { + TString -> "json.string" + TInt -> "json.int" + TBool -> "json.bool" + TNumber -> "json.float" + TRef(ref) -> { + let r = parse_ref(current, ref) + qualifier(current, r.nsid) + <> "encode_" + <> justin.snake_case(ref_type_name(r)) + } + TArray(inner) -> + "fn(items) { json.array(items, " <> encoder_expr(current, inner) <> ") }" + TUnsupported(_) -> "json.string" + } +} + +fn decoder_expr(current: String, ft: FieldType) -> String { + case ft { + TString -> "decode.string" + TInt -> "decode.int" + TBool -> "decode.bool" + TNumber -> "decode.float" + TRef(ref) -> { + let r = parse_ref(current, ref) + qualifier(current, r.nsid) + <> justin.snake_case(ref_type_name(r)) + <> "_decoder()" + } + TArray(inner) -> "decode.list(" <> decoder_expr(current, inner) <> ")" + TUnsupported(_) -> "decode.string" + } +} + +// --- module emission --- + +fn emit_lexicon(lex: Lexicon) -> Option(String) { + let emittable = + list.filter(lex.defs, fn(d) { + case d { + Record(..) | Object(..) -> True + _ -> False + } + }) + case emittable { + [] -> None + defs -> { + let bodies = list.map(defs, emit_def(lex.nsid, _)) + let header = emit_header(lex.nsid, defs) + Some(header <> "\n" <> string.join(bodies, "\n\n") <> "\n") + } + } +} + +fn emit_header(nsid: String, defs: List(Def)) -> String { + let props = list.flat_map(defs, def_properties) + let needs_option = list.any(props, fn(p) { !p.required }) + let externals = + props + |> list.flat_map(fn(p) { ref_nsids(p.field_type) }) + |> list.filter(fn(n) { n != nsid }) + |> list.unique + |> list.sort(string.compare) + + let base = [ + "import gleam/dynamic/decode", "import gleam/json", "import gleam/list", + ] + let opt = case needs_option { + True -> [ + "import gleam/option.{type Option}", + "import " <> out_module <> "/internal", + ] + False -> [] + } + let ext = + list.map(externals, fn(n) { + "import " + <> out_module + <> "/" + <> module_subpath(n) + <> " as " + <> module_alias(n) + }) + string.join(list.flatten([base, opt, ext]), "\n") <> "\n" +} + +fn def_properties(def: Def) -> List(Property) { + case def { + Record(_, props) -> props + Object(_, _, props) -> props + _ -> [] + } +} + +fn ref_nsids(ft: FieldType) -> List(String) { + case ft { + TRef(ref) -> { + let r = parse_ref("", ref) + case r.nsid { + "" -> [] + nsid -> [nsid] + } + } + TArray(inner) -> ref_nsids(inner) + _ -> [] + } +} + +fn emit_def(nsid: String, def: Def) -> String { + let name = def_type_name(def) + let props = def_properties(def) + let is_record = case def { + Record(..) -> True + _ -> False + } + string.join( + list.flatten([ + case is_record { + True -> ["pub const collection = \"" <> nsid <> "\""] + False -> [] + }, + [emit_type(nsid, name, props)], + [emit_encoder(nsid, name, props, is_record)], + [emit_decoder(nsid, name, props)], + ]), + "\n\n", + ) +} + +fn emit_type(nsid: String, name: String, props: List(Property)) -> String { + let fields = + list.map(props, fn(p) { + let t = gleam_type(nsid, p.field_type) + let t = case p.required { + True -> t + False -> "Option(" <> t <> ")" + } + " " <> field_name(p.json_name) <> ": " <> t <> "," + }) + "pub type " + <> name + <> " {\n " + <> name + <> "(\n" + <> string.join(fields, "\n") + <> "\n )\n}" +} + +fn emit_encoder( + nsid: String, + name: String, + props: List(Property), + is_record: Bool, +) -> String { + let #(required, optional) = list.partition(props, fn(p) { p.required }) + let required_entries = + list.map(required, fn(p) { + "#(\"" + <> p.json_name + <> "\", " + <> encoder_expr(nsid, p.field_type) + <> "(value." + <> field_name(p.json_name) + <> "))" + }) + let type_entry = case is_record { + True -> ["#(\"$type\", json.string(\"" <> nsid <> "\"))"] + False -> [] + } + let inline = + "[" + <> string.join(list.flatten([type_entry, required_entries]), ", ") + <> "]" + let optional_groups = + list.map(optional, fn(p) { + "internal.opt(\"" + <> p.json_name + <> "\", value." + <> field_name(p.json_name) + <> ", " + <> encoder_expr(nsid, p.field_type) + <> ")" + }) + let groups = string.join([inline, ..optional_groups], ",\n ") + "pub fn encode_" + <> justin.snake_case(name) + <> "(value: " + <> name + <> ") -> json.Json {\n json.object(list.flatten([\n " + <> groups + <> ",\n ]))\n}" +} + +fn emit_decoder(nsid: String, name: String, props: List(Property)) -> String { + let uses = + list.map(props, fn(p) { + let var = field_name(p.json_name) + case p.required { + True -> + " use " + <> var + <> " <- decode.field(\"" + <> p.json_name + <> "\", " + <> decoder_expr(nsid, p.field_type) + <> ")" + False -> + " use " + <> var + <> " <- decode.optional_field(\"" + <> p.json_name + <> "\", option.None, decode.optional(" + <> decoder_expr(nsid, p.field_type) + <> "))" + } + }) + let constructor = + " decode.success(" + <> name + <> "(" + <> string.join( + list.map(props, fn(p) { field_name(p.json_name) <> ":" }), + ", ", + ) + <> "))" + "pub fn " + <> justin.snake_case(name) + <> "_decoder() -> decode.Decoder(" + <> name + <> ") {\n" + <> string.join(uses, "\n") + <> "\n" + <> constructor + <> "\n}" +} + +// --- the fixed `opt` helper module --- + +const internal_module = "import gleam/json +import gleam/option.{type Option, None, Some} + +/// One JSON field, or nothing when the value is absent. Keeps optional +/// properties out of the encoded object entirely rather than emitting null. +pub fn opt( + name: String, + value: Option(a), + to_json: fn(a) -> json.Json, +) -> List(#(String, json.Json)) { + case value { + Some(v) -> [#(name, to_json(v))] + None -> [] + } +} +" + +// --- main --- + +pub fn main() -> Nil { + let assert Ok(files) = simplifile.get_files(lexicons_dir) + let lexicons = + files + |> list.filter(string.ends_with(_, ".json")) + |> list.filter_map(load_lexicon) + + let assert Ok(_) = simplifile.create_directory_all(out_dir) + let assert Ok(_) = + simplifile.write( + to: out_dir <> "/internal.gleam", + contents: internal_module, + ) + + list.each(lexicons, fn(lex) { + list.each(lex.defs, fn(d) { + case d { + Unsupported(nsid, name, reason) -> + io.println(" skip " <> nsid <> "#" <> name <> " (" <> reason <> ")") + _ -> Nil + } + }) + case emit_lexicon(lex) { + Some(source) -> { + let path = out_dir <> "/" <> module_subpath(lex.nsid) <> ".gleam" + let assert Ok(_) = simplifile.create_directory_all(parent_dir(path)) + let assert Ok(_) = simplifile.write(to: path, contents: source) + io.println(" gen " <> path) + } + None -> Nil + } + }) + io.println("codegen: done") +} + +fn load_lexicon(path: String) -> Result(Lexicon, Nil) { + use contents <- result.try(simplifile.read(path) |> result.replace_error(Nil)) + json.parse(contents, lexicon_decoder()) |> result.replace_error(Nil) +} + +fn parent_dir(path: String) -> String { + case string.split(path, "/") |> list.reverse { + [_, ..rest] -> rest |> list.reverse |> string.join("/") + [] -> "." + } +} diff --git a/codegen/test/codegen_test.gleam b/codegen/test/codegen_test.gleam new file mode 100644 index 0000000..fba3c88 --- /dev/null +++ b/codegen/test/codegen_test.gleam @@ -0,0 +1,13 @@ +import gleeunit + +pub fn main() -> Nil { + gleeunit.main() +} + +// gleeunit test functions end in `_test` +pub fn hello_world_test() { + let name = "Joe" + let greeting = "Hello, " <> name <> "!" + + assert greeting == "Hello, Joe!" +} diff --git a/gen.sh b/gen.sh new file mode 100755 index 0000000..5f9a9ac --- /dev/null +++ b/gen.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Generate Gleam lexicon codecs from lexicons/ into shared/src/at_record/gen/. +# The output is gitignored, so this must run before building or testing. +set -euo pipefail +root="$(cd "$(dirname "$0")" && pwd)" + +echo "==> generating lexicon codecs" +(cd "$root/codegen" && gleam run) +(cd "$root/shared" && gleam format src/at_record/gen) +echo "==> generated into shared/src/at_record/gen/" diff --git a/lexicons/dev/mokkenstorm/crate/catalog/edit.json b/lexicons/dev/mokkenstorm/crate/catalog/edit.json new file mode 100644 index 0000000..a5fe70c --- /dev/null +++ b/lexicons/dev/mokkenstorm/crate/catalog/edit.json @@ -0,0 +1,41 @@ +{ + "lexicon": 1, + "id": "dev.mokkenstorm.crate.catalog.edit", + "defs": { + "main": { + "type": "record", + "description": "A signed, on-network contribution to the shared catalog (hybrid-C). Either a new entry or a field-level change.", + "key": "tid", + "record": { + "type": "object", + "required": ["op", "entity", "createdAt"], + "properties": { + "subject": { + "type": "ref", + "ref": "dev.mokkenstorm.crate.defs#catalogRef", + "description": "Omitted for a brand-new entry." + }, + "op": { "type": "string", "knownValues": ["create", "update"] }, + "entity": { + "type": "string", + "knownValues": ["release", "master", "artist", "label"] + }, + "fields": { + "type": "unknown", + "description": "Proposed field values, validated per-entity by the merge worker." + }, + "rationale": { + "type": "string", + "maxGraphemes": 500, + "maxLength": 5000 + }, + "source": { + "type": "ref", + "ref": "dev.mokkenstorm.crate.defs#source" + }, + "createdAt": { "type": "string", "format": "datetime" } + } + } + } + } +} diff --git a/lexicons/dev/mokkenstorm/crate/catalog/release.json b/lexicons/dev/mokkenstorm/crate/catalog/release.json new file mode 100644 index 0000000..0c24b61 --- /dev/null +++ b/lexicons/dev/mokkenstorm/crate/catalog/release.json @@ -0,0 +1,119 @@ +{ + "lexicon": 1, + "id": "dev.mokkenstorm.crate.catalog.release", + "defs": { + "main": { + "type": "record", + "description": "Canonical release record, minted by the catalog authority on first reference (lazy promotion). Holds the merged canonical value.", + "key": "tid", + "record": { + "type": "object", + "required": ["title", "discogsReleaseId", "createdAt"], + "properties": { + "title": { "type": "string" }, + "discogsReleaseId": { "type": "integer" }, + "mbid": { "type": "string", "format": "uuid" }, + "master": { + "type": "ref", + "ref": "dev.mokkenstorm.crate.defs#catalogRef" + }, + "creditedArtists": { + "type": "array", + "items": { "type": "ref", "ref": "#credit" } + }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "#labelCredit" } + }, + "formats": { + "type": "array", + "items": { "type": "ref", "ref": "#format" } + }, + "country": { "type": "string" }, + "released": { + "type": "string", + "description": "Discogs date string, possibly partial (e.g. 1987, 1987-07)." + }, + "genres": { "type": "array", "items": { "type": "string" } }, + "styles": { "type": "array", "items": { "type": "string" } }, + "tracklist": { + "type": "array", + "items": { "type": "ref", "ref": "#track" } + }, + "identifiers": { + "type": "array", + "items": { "type": "ref", "ref": "#identifier" } + }, + "thumbUrl": { "type": "string", "format": "uri" }, + "createdAt": { "type": "string", "format": "datetime" } + } + } + }, + "credit": { + "type": "object", + "required": ["artist", "snapshot"], + "properties": { + "artist": { + "type": "ref", + "ref": "dev.mokkenstorm.crate.defs#catalogRef" + }, + "snapshot": { + "type": "ref", + "ref": "dev.mokkenstorm.crate.defs#snapshot" + }, + "anv": { + "type": "string", + "description": "Artist Name Variation as printed." + }, + "role": { "type": "string" }, + "join": { "type": "string" } + } + }, + "labelCredit": { + "type": "object", + "required": ["label", "snapshot"], + "properties": { + "label": { + "type": "ref", + "ref": "dev.mokkenstorm.crate.defs#catalogRef" + }, + "snapshot": { + "type": "ref", + "ref": "dev.mokkenstorm.crate.defs#snapshot" + }, + "catalogNumber": { "type": "string" } + } + }, + "format": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "quantity": { "type": "integer" }, + "descriptions": { "type": "array", "items": { "type": "string" } } + } + }, + "track": { + "type": "object", + "required": ["position", "title"], + "properties": { + "position": { "type": "string" }, + "title": { "type": "string" }, + "durationSeconds": { "type": "integer" }, + "credits": { + "type": "array", + "items": { "type": "ref", "ref": "#credit" } + } + } + }, + "identifier": { + "type": "object", + "required": ["type", "value"], + "properties": { + "type": { "type": "string" }, + "value": { "type": "string" }, + "description": { "type": "string" } + } + } + } +} diff --git a/lexicons/dev/mokkenstorm/crate/defs.json b/lexicons/dev/mokkenstorm/crate/defs.json new file mode 100644 index 0000000..c8725a6 --- /dev/null +++ b/lexicons/dev/mokkenstorm/crate/defs.json @@ -0,0 +1,41 @@ +{ + "lexicon": 1, + "id": "dev.mokkenstorm.crate.defs", + "defs": { + "catalogRef": { + "type": "object", + "description": "Strong reference to a promoted catalog record plus natural keys for matching.", + "required": ["uri", "cid"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "discogsReleaseId": { "type": "integer" }, + "mbid": { "type": "string", "format": "uuid" } + } + }, + "snapshot": { + "type": "object", + "description": "Denormalized display copy so a record renders without resolving its target.", + "required": ["title", "artistDisplay"], + "properties": { + "title": { "type": "string" }, + "artistDisplay": { "type": "string" }, + "year": { "type": "integer" }, + "format": { "type": "string" }, + "thumbUrl": { "type": "string", "format": "uri" } + } + }, + "source": { + "type": "object", + "description": "Capture provenance: how the record was created and from where.", + "properties": { + "origin": { + "type": "string", + "knownValues": ["manual", "discogs-import"] + }, + "clientAgent": { "type": "string" }, + "originUrl": { "type": "string", "format": "uri" } + } + } + } +} diff --git a/lexicons/dev/mokkenstorm/crate/shelf/item.json b/lexicons/dev/mokkenstorm/crate/shelf/item.json new file mode 100644 index 0000000..3bba7eb --- /dev/null +++ b/lexicons/dev/mokkenstorm/crate/shelf/item.json @@ -0,0 +1,47 @@ +{ + "lexicon": 1, + "id": "dev.mokkenstorm.crate.shelf.item", + "defs": { + "main": { + "type": "record", + "description": "A user's relationship to a release: the pivot between users and records. Status distinguishes owned from wanted. One entry per release per user. v1: `release` is optional until the catalog authority and lazy promotion exist; an unpromoted entry carries only its `snapshot`.", + "key": "tid", + "record": { + "type": "object", + "required": ["snapshot", "status", "createdAt"], + "properties": { + "release": { + "type": "ref", + "ref": "dev.mokkenstorm.crate.defs#catalogRef" + }, + "snapshot": { + "type": "ref", + "ref": "dev.mokkenstorm.crate.defs#snapshot" + }, + "status": { "type": "string", "knownValues": ["owned", "wanted"] }, + "mediaGrade": { + "type": "string", + "knownValues": ["M", "NM", "VG+", "VG", "G+", "G", "F", "P"] + }, + "sleeveGrade": { + "type": "string", + "knownValues": ["M", "NM", "VG+", "VG", "G+", "G", "F", "P"] + }, + "folder": { "type": "string" }, + "rating": { "type": "integer", "minimum": 1, "maximum": 5 }, + "notes": { + "type": "string", + "maxGraphemes": 1000, + "maxLength": 10000 + }, + "acquiredAt": { "type": "string", "format": "datetime" }, + "source": { + "type": "ref", + "ref": "dev.mokkenstorm.crate.defs#source" + }, + "createdAt": { "type": "string", "format": "datetime" } + } + } + } + } +} diff --git a/server/.github/workflows/test.yml b/server/.github/workflows/test.yml new file mode 100644 index 0000000..eb8b0e4 --- /dev/null +++ b/server/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: erlef/setup-beam@v1 + with: + otp-version: "29" + gleam-version: "1.17.0" + rebar3-version: "3" + # elixir-version: "1" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..599be4e --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..d864e95 --- /dev/null +++ b/server/README.md @@ -0,0 +1,24 @@ +# at_record_server + +[![Package Version](https://img.shields.io/hexpm/v/at_record_server)](https://hex.pm/packages/at_record_server) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/at_record_server/) + +```sh +gleam add at_record_server@1 +``` +```gleam +import at_record_server + +pub fn main() -> Nil { + // TODO: An example of the project in use +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/server/gleam.toml b/server/gleam.toml new file mode 100644 index 0000000..1f44a02 --- /dev/null +++ b/server/gleam.toml @@ -0,0 +1,28 @@ +name = "at_record_server" +version = "1.0.0" +target = "erlang" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "", repo = "" } +# links = [{ title = "Website", href = "" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +wisp = ">= 2.2.2 and < 3.0.0" +mist = ">= 6.0.3 and < 7.0.0" +gleam_httpc = ">= 5.0.0 and < 6.0.0" +gleam_json = ">= 3.1.0 and < 4.0.0" +gleam_erlang = ">= 1.3.0 and < 2.0.0" +at_record_shared = { path = "../shared" } +gleam_time = ">= 1.8.0 and < 2.0.0" +gleam_http = ">= 4.3.0 and < 5.0.0" + +[dev_dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/server/manifest.toml b/server/manifest.toml new file mode 100644 index 0000000..77d1e1c --- /dev/null +++ b/server/manifest.toml @@ -0,0 +1,46 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ + { name = "at_record_shared", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../shared" }, + { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, + { name = "envoy", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" }, + { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, + { name = "glisten", version = "9.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "7795AA50830656F3A0316A6B26595F893C83272DA901B3405E31339CAA31A10B" }, + { name = "gramps", version = "6.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "D55636072DEE173F6586A5679D3C02EC7A0DE3F8646B78C351B72908FF223DF7" }, + { name = "houdini", version = "1.2.1", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "BC5F18CE5DD9686100229FE5409BDC3DD5C46D5A7DF2F804AD2D8F0DD6C5060E" }, + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, + { name = "mist", version = "6.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "1B07F321D5FA0CB162D81496F2DE96AEB6EF8980F4F38230A4CC3F849497E020" }, + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "wisp", version = "2.2.2", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "5FF5F1E288C3437252ABB93D8F9CF42FF652CE7AD54480CFE736038DC09C4F22" }, +] + +[requirements] +at_record_shared = { path = "../shared" } +gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } +gleam_http = { version = ">= 4.3.0 and < 5.0.0" } +gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleam_time = { version = ">= 1.8.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +mist = { version = ">= 6.0.3 and < 7.0.0" } +wisp = { version = ">= 2.2.2 and < 3.0.0" } diff --git a/server/priv/static/index.html b/server/priv/static/index.html new file mode 100644 index 0000000..41cb3c8 --- /dev/null +++ b/server/priv/static/index.html @@ -0,0 +1,74 @@ + + + + + + + at-record + + + + +
+ + + diff --git a/server/priv/static/manifest.webmanifest b/server/priv/static/manifest.webmanifest new file mode 100644 index 0000000..110125d --- /dev/null +++ b/server/priv/static/manifest.webmanifest @@ -0,0 +1,10 @@ +{ + "name": "at-record", + "short_name": "at-record", + "description": "Your record crate, stored on your atproto PDS.", + "start_url": "/", + "display": "standalone", + "background_color": "#0f0d0b", + "theme_color": "#0f0d0b", + "icons": [] +} diff --git a/server/src/at_record_server.gleam b/server/src/at_record_server.gleam new file mode 100644 index 0000000..c84e173 --- /dev/null +++ b/server/src/at_record_server.gleam @@ -0,0 +1,37 @@ +import at_record_server/router.{Context} +import gleam/erlang/application +import gleam/erlang/process +import gleam/int +import gleam/result +import mist +import wisp +import wisp/wisp_mist + +const port = 8080 + +pub fn main() -> Nil { + wisp.configure_logger() + let secret_key_base = wisp.random_string(64) + let ctx = Context(static_directory: static_directory()) + + let assert Ok(_) = + wisp_mist.handler( + fn(req) { router.handle_request(req, ctx) }, + secret_key_base, + ) + |> mist.new + |> mist.port(port) + |> mist.start + + wisp.log_info( + "at-record server listening on http://localhost:" <> int.to_string(port), + ) + process.sleep_forever() +} + +fn static_directory() -> String { + let priv = + application.priv_directory("at_record_server") + |> result.unwrap("priv") + priv <> "/static" +} diff --git a/server/src/at_record_server/identity.gleam b/server/src/at_record_server/identity.gleam new file mode 100644 index 0000000..b9c1836 --- /dev/null +++ b/server/src/at_record_server/identity.gleam @@ -0,0 +1,68 @@ +//// Resolve a handle or DID to its PDS endpoint, so the BFF knows where to call +//// createSession. handle -> DID via a public appview resolver; DID -> PDS via +//// the PLC directory's #atproto_pds service entry. (did:web not supported in v1.) + +import gleam/dynamic/decode +import gleam/http/request +import gleam/httpc +import gleam/json +import gleam/list +import gleam/result +import gleam/string + +const handle_resolver = "https://public.api.bsky.app" + +const plc_directory = "https://plc.directory" + +pub fn resolve_pds(identifier: String) -> Result(String, String) { + use did <- result.try(resolve_did(identifier)) + resolve_pds_for_did(did) +} + +fn resolve_did(identifier: String) -> Result(String, String) { + case string.starts_with(identifier, "did:") { + True -> Ok(identifier) + False -> resolve_handle(identifier) + } +} + +fn fetch(url: String) -> Result(String, String) { + use req <- result.try( + request.to(url) |> result.replace_error("bad url: " <> url), + ) + use resp <- result.try( + httpc.send(req) |> result.map_error(fn(e) { string.inspect(e) }), + ) + case resp.status >= 200 && resp.status < 300 { + True -> Ok(resp.body) + False -> Error("status " <> string.inspect(resp.status) <> " from " <> url) + } +} + +fn resolve_handle(handle: String) -> Result(String, String) { + let url = + handle_resolver + <> "/xrpc/com.atproto.identity.resolveHandle?handle=" + <> handle + use body <- result.try(fetch(url)) + json.parse(body, decode.at(["did"], decode.string)) + |> result.replace_error("could not resolve handle: " <> handle) +} + +fn resolve_pds_for_did(did: String) -> Result(String, String) { + use body <- result.try(fetch(plc_directory <> "/" <> did)) + let service_decoder = { + use id <- decode.field("id", decode.string) + use endpoint <- decode.field("serviceEndpoint", decode.string) + decode.success(#(id, endpoint)) + } + let doc_decoder = decode.at(["service"], decode.list(service_decoder)) + use services <- result.try( + json.parse(body, doc_decoder) + |> result.replace_error("malformed DID document for " <> did), + ) + services + |> list.find(fn(s) { s.0 == "#atproto_pds" }) + |> result.map(fn(s) { s.1 }) + |> result.replace_error("no #atproto_pds service for " <> did) +} diff --git a/server/src/at_record_server/router.gleam b/server/src/at_record_server/router.gleam new file mode 100644 index 0000000..c9cf54e --- /dev/null +++ b/server/src/at_record_server/router.gleam @@ -0,0 +1,238 @@ +import at_record/gen/defs.{Snapshot, Source} +import at_record/gen/shelf/item.{ShelfItem, encode_shelf_item} +import at_record/storage +import at_record_server/identity +import at_record_server/session.{type Session} +import at_record_server/xrpc +import gleam/dynamic/decode +import gleam/http.{Delete, Get, Post} +import gleam/http/request +import gleam/json.{type Json} +import gleam/option.{type Option, None, Some} +import gleam/time/calendar +import gleam/time/timestamp +import wisp.{type Request, type Response} + +pub type Context { + Context(static_directory: String) +} + +pub fn handle_request(req: Request, ctx: Context) -> Response { + use <- wisp.serve_static(req, under: "/static", from: ctx.static_directory) + case request.path_segments(req), req.method { + ["api", "login"], Post -> login(req) + ["api", "logout"], Post -> logout(req) + ["api", "shelf"], Get -> list_shelf(req) + ["api", "shelf"], Post -> add_shelf_item(req) + ["api", "shelf", rkey], Delete -> delete_shelf_item(req, rkey) + ["api", ..], _ -> error_json(404, "not found") + _, _ -> serve_index(ctx) + } +} + +fn serve_index(ctx: Context) -> Response { + wisp.response(200) + |> wisp.set_header("content-type", "text/html; charset=utf-8") + |> wisp.set_body(wisp.File( + path: ctx.static_directory <> "/index.html", + offset: 0, + limit: None, + )) +} + +fn error_json(status: Int, message: String) -> Response { + json.object([#("error", json.string(message))]) + |> json.to_string + |> wisp.json_response(status) +} + +fn require_session(req: Request, next: fn(Session) -> Response) -> Response { + case session.get(req) { + Ok(s) -> next(s) + Error(Nil) -> error_json(401, "not logged in") + } +} + +// --- login --- + +type Credentials { + Credentials(identifier: String, app_password: String) +} + +fn credentials_decoder() -> decode.Decoder(Credentials) { + use identifier <- decode.field("identifier", decode.string) + use app_password <- decode.field("appPassword", decode.string) + decode.success(Credentials(identifier:, app_password:)) +} + +fn login(req: Request) -> Response { + use body <- wisp.require_json(req) + case decode.run(body, credentials_decoder()) { + Error(_) -> error_json(400, "expected { identifier, appPassword }") + Ok(creds) -> do_login(req, creds) + } +} + +fn do_login(req: Request, creds: Credentials) -> Response { + case identity.resolve_pds(creds.identifier) { + Error(reason) -> error_json(502, "could not resolve PDS: " <> reason) + Ok(pds) -> + case xrpc.create_session(pds, creds.identifier, creds.app_password) { + Error(_) -> + error_json(401, "login failed (check handle / app password)") + Ok(tokens) -> { + let sess = + session.Session( + did: tokens.did, + handle: tokens.handle, + pds:, + access_jwt: tokens.access_jwt, + refresh_jwt: tokens.refresh_jwt, + ) + json.object([ + #("did", json.string(tokens.did)), + #("handle", json.string(tokens.handle)), + ]) + |> json.to_string + |> wisp.json_response(200) + |> session.set(req, sess) + } + } + } +} + +fn logout(req: Request) -> Response { + wisp.json_response("{}", 200) + |> session.clear(req) +} + +// --- shelf reads/writes --- + +fn list_shelf(req: Request) -> Response { + use sess <- require_session(req) + case xrpc.list_shelf(sess.pds, sess.access_jwt, sess.did) { + Error(_) -> error_json(502, "could not load shelf from PDS") + Ok(items) -> + json.object([ + #("handle", json.string(sess.handle)), + #( + "items", + json.array(items, fn(s) { + storage.encode_stored_item(s, encode_shelf_item) + }), + ), + ]) + |> json.to_string + |> wisp.json_response(200) + } +} + +type AddForm { + AddForm( + title: String, + artist: String, + format: Option(String), + year: Option(Int), + status: String, + media_grade: Option(String), + notes: Option(String), + ) +} + +fn add_form_decoder() -> decode.Decoder(AddForm) { + use title <- decode.field("title", decode.string) + use artist <- decode.field("artist", decode.string) + use format <- decode.optional_field( + "format", + None, + decode.optional(non_empty()), + ) + use year <- decode.optional_field("year", None, decode.optional(decode.int)) + use status <- decode.optional_field("status", "owned", decode.string) + use media_grade <- decode.optional_field( + "mediaGrade", + None, + decode.optional(non_empty()), + ) + use notes <- decode.optional_field( + "notes", + None, + decode.optional(non_empty()), + ) + decode.success(AddForm( + title:, + artist:, + format:, + year:, + status:, + media_grade:, + notes:, + )) +} + +/// Treat an empty string as an absent optional field. +fn non_empty() -> decode.Decoder(String) { + use s <- decode.then(decode.string) + case s { + "" -> decode.failure("", "NonEmptyString") + _ -> decode.success(s) + } +} + +fn add_shelf_item(req: Request) -> Response { + use sess <- require_session(req) + use body <- wisp.require_json(req) + case decode.run(body, add_form_decoder()) { + Error(_) -> error_json(400, "expected { title, artist, ... }") + Ok(form) -> do_add(req, sess, form) + } +} + +fn do_add(_req: Request, sess: Session, form: AddForm) -> Response { + let now = timestamp.to_rfc3339(timestamp.system_time(), calendar.utc_offset) + let record = + ShelfItem( + release: None, + snapshot: Snapshot( + title: form.title, + artist_display: form.artist, + year: form.year, + format: form.format, + thumb_url: None, + ), + status: form.status, + media_grade: form.media_grade, + sleeve_grade: None, + folder: None, + rating: None, + notes: form.notes, + acquired_at: None, + source: Some(Source( + origin: Some("manual"), + client_agent: Some("at-record/0.1"), + origin_url: None, + )), + created_at: now, + ) + let record_json: Json = encode_shelf_item(record) + case + xrpc.create_shelf_item(sess.pds, sess.access_jwt, sess.did, record_json) + { + Error(_) -> error_json(502, "could not write record to PDS") + Ok(created) -> + json.object([ + #("uri", json.string(created.uri)), + #("cid", json.string(created.cid)), + ]) + |> json.to_string + |> wisp.json_response(201) + } +} + +fn delete_shelf_item(req: Request, rkey: String) -> Response { + use sess <- require_session(req) + case xrpc.delete_shelf_item(sess.pds, sess.access_jwt, sess.did, rkey) { + Error(_) -> error_json(502, "could not delete record from PDS") + Ok(Nil) -> wisp.json_response("{}", 200) + } +} diff --git a/server/src/at_record_server/session.gleam b/server/src/at_record_server/session.gleam new file mode 100644 index 0000000..077316e --- /dev/null +++ b/server/src/at_record_server/session.gleam @@ -0,0 +1,68 @@ +//// The signed-cookie session for the BFF. v1 prototype: the cookie is signed +//// (tamper-proof) but client-readable, so the PDS access token is visible to +//// the browser. Acceptable for a single-user prototype; replace with a +//// server-side store + OAuth before any multi-user use. + +import gleam/dynamic/decode +import gleam/json +import gleam/result +import wisp.{type Request, type Response} + +const cookie_name = "ar_session" + +// 7 days. +const max_age = 604_800 + +pub type Session { + Session( + did: String, + handle: String, + pds: String, + access_jwt: String, + refresh_jwt: String, + ) +} + +fn encode(session: Session) -> String { + json.object([ + #("did", json.string(session.did)), + #("handle", json.string(session.handle)), + #("pds", json.string(session.pds)), + #("accessJwt", json.string(session.access_jwt)), + #("refreshJwt", json.string(session.refresh_jwt)), + ]) + |> json.to_string +} + +fn decoder() -> decode.Decoder(Session) { + use did <- decode.field("did", decode.string) + use handle <- decode.field("handle", decode.string) + use pds <- decode.field("pds", decode.string) + use access_jwt <- decode.field("accessJwt", decode.string) + use refresh_jwt <- decode.field("refreshJwt", decode.string) + decode.success(Session(did:, handle:, pds:, access_jwt:, refresh_jwt:)) +} + +pub fn set(response: Response, request: Request, session: Session) -> Response { + wisp.set_cookie( + response, + request, + cookie_name, + encode(session), + wisp.Signed, + max_age, + ) +} + +pub fn get(request: Request) -> Result(Session, Nil) { + case wisp.get_cookie(request, cookie_name, wisp.Signed) { + Ok(value) -> + json.parse(value, decoder()) + |> result.replace_error(Nil) + Error(Nil) -> Error(Nil) + } +} + +pub fn clear(response: Response, request: Request) -> Response { + wisp.set_cookie(response, request, cookie_name, "", wisp.Signed, 0) +} diff --git a/server/src/at_record_server/xrpc.gleam b/server/src/at_record_server/xrpc.gleam new file mode 100644 index 0000000..74b8d88 --- /dev/null +++ b/server/src/at_record_server/xrpc.gleam @@ -0,0 +1,194 @@ +//// Minimal atproto XRPC client over HTTPS: just the four methods the BFF needs. +//// No SDK exists for Gleam, so this is raw gleam_httpc + gleam_json. + +import at_record/gen/shelf/item +import at_record/storage.{type StoredItem, StoredItem} +import gleam/dynamic/decode +import gleam/http +import gleam/http/request +import gleam/http/response.{type Response} +import gleam/httpc +import gleam/json.{type Json} +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string + +pub type XrpcError { + RequestFailed(String) + BadStatus(Int, String) + DecodeFailed(String) +} + +pub type SessionTokens { + SessionTokens( + did: String, + handle: String, + access_jwt: String, + refresh_jwt: String, + ) +} + +pub type CreatedRecord { + CreatedRecord(uri: String, cid: String) +} + +fn with_auth( + req: request.Request(String), + token: Option(String), +) -> request.Request(String) { + case token { + Some(t) -> request.set_header(req, "authorization", "Bearer " <> t) + None -> req + } +} + +fn send(req: request.Request(String)) -> Result(Response(String), XrpcError) { + httpc.send(req) + |> result.map_error(fn(e) { RequestFailed(string.inspect(e)) }) +} + +fn check_ok(resp: Response(String)) -> Result(Response(String), XrpcError) { + case resp.status >= 200 && resp.status < 300 { + True -> Ok(resp) + False -> Error(BadStatus(resp.status, resp.body)) + } +} + +fn parse(body: String, decoder: decode.Decoder(a)) -> Result(a, XrpcError) { + json.parse(body, decoder) + |> result.map_error(fn(e) { DecodeFailed(string.inspect(e)) }) +} + +fn get( + url: String, + token: Option(String), +) -> Result(Response(String), XrpcError) { + use base <- result.try( + request.to(url) |> result.replace_error(RequestFailed("bad url: " <> url)), + ) + base + |> with_auth(token) + |> send + |> result.try(check_ok) +} + +fn post_json( + url: String, + token: Option(String), + body: Json, +) -> Result(Response(String), XrpcError) { + use base <- result.try( + request.to(url) |> result.replace_error(RequestFailed("bad url: " <> url)), + ) + base + |> request.set_method(http.Post) + |> request.set_header("content-type", "application/json") + |> with_auth(token) + |> request.set_body(json.to_string(body)) + |> send + |> result.try(check_ok) +} + +pub fn create_session( + pds: String, + identifier: String, + password: String, +) -> Result(SessionTokens, XrpcError) { + let body = + json.object([ + #("identifier", json.string(identifier)), + #("password", json.string(password)), + ]) + use resp <- result.try(post_json( + pds <> "/xrpc/com.atproto.server.createSession", + None, + body, + )) + let decoder = { + use did <- decode.field("did", decode.string) + use handle <- decode.field("handle", decode.string) + use access_jwt <- decode.field("accessJwt", decode.string) + use refresh_jwt <- decode.field("refreshJwt", decode.string) + decode.success(SessionTokens(did:, handle:, access_jwt:, refresh_jwt:)) + } + parse(resp.body, decoder) +} + +fn rkey_from_uri(uri: String) -> String { + uri + |> string.split("/") + |> list.last + |> result.unwrap("") +} + +pub fn list_shelf( + pds: String, + token: String, + did: String, +) -> Result(List(StoredItem(item.ShelfItem)), XrpcError) { + let url = + pds + <> "/xrpc/com.atproto.repo.listRecords?repo=" + <> did + <> "&collection=" + <> item.collection + <> "&limit=100" + use resp <- result.try(get(url, Some(token))) + let one = { + use uri <- decode.field("uri", decode.string) + use cid <- decode.field("cid", decode.string) + use value <- decode.field("value", item.shelf_item_decoder()) + decode.success(StoredItem(uri:, cid:, rkey: rkey_from_uri(uri), value:)) + } + let records = { + use rows <- decode.field("records", decode.list(one)) + decode.success(rows) + } + parse(resp.body, records) +} + +pub fn create_shelf_item( + pds: String, + token: String, + did: String, + record: Json, +) -> Result(CreatedRecord, XrpcError) { + let body = + json.object([ + #("repo", json.string(did)), + #("collection", json.string(item.collection)), + #("record", record), + ]) + use resp <- result.try(post_json( + pds <> "/xrpc/com.atproto.repo.createRecord", + Some(token), + body, + )) + let decoder = { + use uri <- decode.field("uri", decode.string) + use cid <- decode.field("cid", decode.string) + decode.success(CreatedRecord(uri:, cid:)) + } + parse(resp.body, decoder) +} + +pub fn delete_shelf_item( + pds: String, + token: String, + did: String, + rkey: String, +) -> Result(Nil, XrpcError) { + let body = + json.object([ + #("repo", json.string(did)), + #("collection", json.string(item.collection)), + #("rkey", json.string(rkey)), + ]) + use _ <- result.try(post_json( + pds <> "/xrpc/com.atproto.repo.deleteRecord", + Some(token), + body, + )) + Ok(Nil) +} diff --git a/server/test/at_record_server_test.gleam b/server/test/at_record_server_test.gleam new file mode 100644 index 0000000..fba3c88 --- /dev/null +++ b/server/test/at_record_server_test.gleam @@ -0,0 +1,13 @@ +import gleeunit + +pub fn main() -> Nil { + gleeunit.main() +} + +// gleeunit test functions end in `_test` +pub fn hello_world_test() { + let name = "Joe" + let greeting = "Hello, " <> name <> "!" + + assert greeting == "Hello, Joe!" +} diff --git a/shared/.github/workflows/test.yml b/shared/.github/workflows/test.yml new file mode 100644 index 0000000..eb8b0e4 --- /dev/null +++ b/shared/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: erlef/setup-beam@v1 + with: + otp-version: "29" + gleam-version: "1.17.0" + rebar3-version: "3" + # elixir-version: "1" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/shared/.gitignore b/shared/.gitignore new file mode 100644 index 0000000..599be4e --- /dev/null +++ b/shared/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/shared/README.md b/shared/README.md new file mode 100644 index 0000000..caa1c49 --- /dev/null +++ b/shared/README.md @@ -0,0 +1,24 @@ +# at_record_shared + +[![Package Version](https://img.shields.io/hexpm/v/at_record_shared)](https://hex.pm/packages/at_record_shared) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/at_record_shared/) + +```sh +gleam add at_record_shared@1 +``` +```gleam +import at_record_shared + +pub fn main() -> Nil { + // TODO: An example of the project in use +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/shared/gleam.toml b/shared/gleam.toml new file mode 100644 index 0000000..95d0659 --- /dev/null +++ b/shared/gleam.toml @@ -0,0 +1,20 @@ +name = "at_record_shared" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "", repo = "" } +# links = [{ title = "Website", href = "" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +gleam_json = ">= 3.1.0 and < 4.0.0" + +[dev_dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/shared/manifest.toml b/shared/manifest.toml new file mode 100644 index 0000000..43b726b --- /dev/null +++ b/shared/manifest.toml @@ -0,0 +1,18 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, +] + +[requirements] +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/shared/src/at_record/storage.gleam b/shared/src/at_record/storage.gleam new file mode 100644 index 0000000..4f515da --- /dev/null +++ b/shared/src/at_record/storage.gleam @@ -0,0 +1,32 @@ +//// The repo-storage envelope around any generated record type: addressing +//// (`uri`/`cid`/`rkey`) plus the decoded record `value`. Not a lexicon concept, +//// so it's hand-written and generic over the record. + +import gleam/dynamic/decode +import gleam/json + +pub type StoredItem(record) { + StoredItem(uri: String, cid: String, rkey: String, value: record) +} + +pub fn encode_stored_item( + stored: StoredItem(record), + encode_value: fn(record) -> json.Json, +) -> json.Json { + json.object([ + #("uri", json.string(stored.uri)), + #("cid", json.string(stored.cid)), + #("rkey", json.string(stored.rkey)), + #("value", encode_value(stored.value)), + ]) +} + +pub fn stored_item_decoder( + value_decoder: decode.Decoder(record), +) -> decode.Decoder(StoredItem(record)) { + use uri <- decode.field("uri", decode.string) + use cid <- decode.field("cid", decode.string) + use rkey <- decode.field("rkey", decode.string) + use value <- decode.field("value", value_decoder) + decode.success(StoredItem(uri:, cid:, rkey:, value:)) +} diff --git a/shared/test/at_record_shared_test.gleam b/shared/test/at_record_shared_test.gleam new file mode 100644 index 0000000..6f34981 --- /dev/null +++ b/shared/test/at_record_shared_test.gleam @@ -0,0 +1,71 @@ +import at_record/gen/defs.{Snapshot, Source} +import at_record/gen/shelf/item.{type ShelfItem, ShelfItem} +import at_record/storage.{StoredItem} +import gleam/dynamic/decode +import gleam/json +import gleam/option.{None, Some} +import gleam/result +import gleeunit + +pub fn main() -> Nil { + gleeunit.main() +} + +fn sample() -> ShelfItem { + ShelfItem( + release: None, + snapshot: Snapshot( + title: "Spiderland", + artist_display: "Slint", + year: Some(1991), + format: Some("LP"), + thumb_url: None, + ), + status: "owned", + media_grade: Some("NM"), + sleeve_grade: None, + folder: None, + rating: Some(5), + notes: None, + acquired_at: None, + source: Source(origin: Some("manual"), client_agent: None, origin_url: None) + |> Some, + created_at: "2026-06-26T00:00:00Z", + ) +} + +pub fn shelf_item_round_trips_test() { + let value = sample() + let assert Ok(decoded) = + value + |> item.encode_shelf_item + |> json.to_string + |> json.parse(item.shelf_item_decoder()) + assert decoded == value +} + +pub fn collection_constant_test() { + assert item.collection == "dev.mokkenstorm.crate.shelf.item" +} + +pub fn optional_fields_are_omitted_not_nulled_test() { + // `release` is None, so the key must be absent (not `"release":null`). + let release_present = + sample() + |> item.encode_shelf_item + |> json.to_string + |> json.parse(decode.at(["release"], decode.dynamic)) + |> result.is_ok + assert release_present == False +} + +pub fn stored_item_round_trips_test() { + let stored = + StoredItem(uri: "at://x/y/z", cid: "bafy", rkey: "z", value: sample()) + let assert Ok(decoded) = + stored + |> storage.encode_stored_item(item.encode_shelf_item) + |> json.to_string + |> json.parse(storage.stored_item_decoder(item.shelf_item_decoder())) + assert decoded == stored +} diff --git a/web/.github/workflows/test.yml b/web/.github/workflows/test.yml new file mode 100644 index 0000000..eb8b0e4 --- /dev/null +++ b/web/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: erlef/setup-beam@v1 + with: + otp-version: "29" + gleam-version: "1.17.0" + rebar3-version: "3" + # elixir-version: "1" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..ca614a3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,8 @@ +*.beam +*.ez +/build +erl_crash.dump + +#Added automatically by Lustre Dev Tools +/.lustre +/dist diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..ce5985f --- /dev/null +++ b/web/README.md @@ -0,0 +1,24 @@ +# at_record_web + +[![Package Version](https://img.shields.io/hexpm/v/at_record_web)](https://hex.pm/packages/at_record_web) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/at_record_web/) + +```sh +gleam add at_record_web@1 +``` +```gleam +import at_record_web + +pub fn main() -> Nil { + // TODO: An example of the project in use +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/web/gleam.toml b/web/gleam.toml new file mode 100644 index 0000000..93a3abe --- /dev/null +++ b/web/gleam.toml @@ -0,0 +1,25 @@ +name = "at_record_web" +version = "1.0.0" +target = "javascript" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "", repo = "" } +# links = [{ title = "Website", href = "" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +lustre = ">= 5.7.0 and < 6.0.0" +rsvp = ">= 2.0.0 and < 3.0.0" +gleam_json = ">= 3.1.0 and < 4.0.0" +at_record_shared = { path = "../shared" } + +[dev_dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" +lustre_dev_tools = ">= 2.3.6 and < 3.0.0" diff --git a/web/manifest.toml b/web/manifest.toml new file mode 100644 index 0000000..87ef5ef --- /dev/null +++ b/web/manifest.toml @@ -0,0 +1,59 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ + { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, + { name = "at_record_shared", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../shared" }, + { name = "booklet", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "booklet", source = "hex", outer_checksum = "08E0FDB78DC4D8A5D3C80295B021505C7D2A2E7B6C6D5EAB7286C36F4A53C851" }, + { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, + { name = "envoy", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" }, + { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_community_ansi", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "B5AA433AF84313E23FDF90CCFF752B9380FE9FFCE02B2949D49B7AACCC77B16D" }, + { name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" }, + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_fetch", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "284CE88E37436699545F9F65D413E1DFB6C1EA3FE3824B6EA2018D0ECF088FFC" }, + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, + { name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, + { name = "glint", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "26CCA9BC3ACB56CD9D754ACF598F49B4C281C4FDD81AE61FA21C8E92E9C8218E" }, + { name = "glisten", version = "9.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "7795AA50830656F3A0316A6B26595F893C83272DA901B3405E31339CAA31A10B" }, + { name = "gramps", version = "6.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "D55636072DEE173F6586A5679D3C02EC7A0DE3F8646B78C351B72908FF223DF7" }, + { name = "group_registry", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "group_registry", source = "hex", outer_checksum = "BC798A53D6F2406DB94E27CB45C57052CB56B32ACF7CC16EA20F6BAEC7E36B90" }, + { name = "houdini", version = "1.2.1", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "justin", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "8B1C62269E8607D0A0ED698B7903984CE0BF7B6B3C0DEA3C6C8302B39402837B" }, + { name = "logging", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "BC5F18CE5DD9686100229FE5409BDC3DD5C46D5A7DF2F804AD2D8F0DD6C5060E" }, + { name = "lustre", version = "5.7.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "38C6FCBD7B7BACE994D5BF643202BB69B02576D44250B4C5DCE738387C0E8F87" }, + { name = "lustre_dev_tools", version = "2.3.6", build_tools = ["gleam"], requirements = ["argv", "booklet", "filepath", "gleam_community_ansi", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_regexp", "gleam_stdlib", "glint", "group_registry", "justin", "lustre", "mist", "polly", "simplifile", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "7A650A107C7E767D902A8CB608460B927DE0CDD47D5BA04DED71F9F622FE6AD9" }, + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, + { name = "mist", version = "6.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "1B07F321D5FA0CB162D81496F2DE96AEB6EF8980F4F38230A4CC3F849497E020" }, + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, + { name = "polly", version = "3.1.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_erlang", "gleam_otp", "gleam_stdlib", "simplifile"], otp_app = "polly", source = "hex", outer_checksum = "51FB565D81FF6212FDF3306D44419601F2A7C4EDD1F00FC9DA5C376A00AED4FE" }, + { name = "rsvp", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_fetch", "gleam_http", "gleam_httpc", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "rsvp", source = "hex", outer_checksum = "A38F2DB9657E84A584278DB50FD903693FF370C22C191753B773546FEB2465F0" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "snag", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" }, + { name = "tom", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "DCF04CB7AB35D58CFC598C66EA2E1816D160759802C89B2BA6238780D59BC256" }, + { name = "wisp", version = "2.2.2", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "5FF5F1E288C3437252ABB93D8F9CF42FF652CE7AD54480CFE736038DC09C4F22" }, +] + +[requirements] +at_record_shared = { path = "../shared" } +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +lustre = { version = ">= 5.7.0 and < 6.0.0" } +lustre_dev_tools = { version = ">= 2.3.6 and < 3.0.0" } +rsvp = { version = ">= 2.0.0 and < 3.0.0" } diff --git a/web/src/at_record_web.gleam b/web/src/at_record_web.gleam new file mode 100644 index 0000000..687199c --- /dev/null +++ b/web/src/at_record_web.gleam @@ -0,0 +1,426 @@ +import at_record/gen/shelf/item.{type ShelfItem} +import at_record/storage.{type StoredItem} +import gleam/dynamic/decode +import gleam/int +import gleam/json.{type Json} +import gleam/list +import gleam/option.{type Option, None, Some} +import lustre +import lustre/attribute as attr +import lustre/effect.{type Effect} +import lustre/element.{type Element, text} +import lustre/element/html +import lustre/event +import rsvp + +pub fn main() -> Nil { + let app = lustre.application(init, update, view) + let assert Ok(_) = lustre.start(app, "#app", Nil) + Nil +} + +// --- model --- + +type Auth { + LoggedOut + LoggedIn(handle: String) +} + +type Form { + Form( + title: String, + artist: String, + format: String, + year: String, + status: String, + ) +} + +fn blank_form() -> Form { + Form(title: "", artist: "", format: "", year: "", status: "owned") +} + +type Model { + Model( + auth: Auth, + login_handle: String, + login_password: String, + items: List(StoredItem(ShelfItem)), + form: Form, + notice: Option(String), + busy: Bool, + ) +} + +fn init(_flags) -> #(Model, Effect(Msg)) { + let model = + Model( + auth: LoggedOut, + login_handle: "", + login_password: "", + items: [], + form: blank_form(), + notice: None, + busy: True, + ) + // Attempt to restore an existing session from the cookie. + #(model, load_shelf()) +} + +// --- messages --- + +type LoginInfo { + LoginInfo(handle: String) +} + +type ShelfData { + ShelfData(handle: String, items: List(StoredItem(ShelfItem))) +} + +type Msg { + HandleChanged(String) + PasswordChanged(String) + SubmitLogin + GotLogin(Result(LoginInfo, rsvp.Error(String))) + Logout + GotShelf(Result(ShelfData, rsvp.Error(String))) + FormTitle(String) + FormArtist(String) + FormFormat(String) + FormYear(String) + FormStatus(String) + SubmitAdd + GotAdd(Result(Nil, rsvp.Error(String))) + DeleteItem(String) + GotDelete(Result(Nil, rsvp.Error(String))) +} + +// --- update --- + +fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { + case msg { + HandleChanged(value) -> #( + Model(..model, login_handle: value), + effect.none(), + ) + PasswordChanged(value) -> #( + Model(..model, login_password: value), + effect.none(), + ) + + SubmitLogin -> #(Model(..model, busy: True, notice: None), do_login(model)) + + GotLogin(Ok(info)) -> #( + Model( + ..model, + auth: LoggedIn(info.handle), + login_password: "", + busy: True, + ), + load_shelf(), + ) + GotLogin(Error(_)) -> #( + Model( + ..model, + busy: False, + notice: Some("Login failed. Check your handle and app password."), + ), + effect.none(), + ) + + Logout -> #( + Model(..model, auth: LoggedOut, items: [], notice: None), + do_logout(), + ) + + GotShelf(Ok(data)) -> #( + Model( + ..model, + auth: LoggedIn(data.handle), + items: data.items, + busy: False, + ), + effect.none(), + ) + GotShelf(Error(_)) -> #( + Model(..model, auth: LoggedOut, busy: False), + effect.none(), + ) + + FormTitle(value) -> #( + Model(..model, form: Form(..model.form, title: value)), + effect.none(), + ) + FormArtist(value) -> #( + Model(..model, form: Form(..model.form, artist: value)), + effect.none(), + ) + FormFormat(value) -> #( + Model(..model, form: Form(..model.form, format: value)), + effect.none(), + ) + FormYear(value) -> #( + Model(..model, form: Form(..model.form, year: value)), + effect.none(), + ) + FormStatus(value) -> #( + Model(..model, form: Form(..model.form, status: value)), + effect.none(), + ) + + SubmitAdd -> + case model.form.title, model.form.artist { + "", _ | _, "" -> #( + Model(..model, notice: Some("Title and artist are required.")), + effect.none(), + ) + _, _ -> #(Model(..model, busy: True, notice: None), do_add(model.form)) + } + + GotAdd(Ok(Nil)) -> #( + Model(..model, form: blank_form(), busy: True), + load_shelf(), + ) + GotAdd(Error(_)) -> #( + Model(..model, busy: False, notice: Some("Could not save the record.")), + effect.none(), + ) + + DeleteItem(rkey) -> #(Model(..model, busy: True), do_delete(rkey)) + + GotDelete(Ok(Nil)) -> #(model, load_shelf()) + GotDelete(Error(_)) -> #( + Model(..model, busy: False, notice: Some("Could not delete the record.")), + effect.none(), + ) + } +} + +// --- effects (BFF calls) --- + +fn load_shelf() -> Effect(Msg) { + let decoder = { + use handle <- decode.field("handle", decode.string) + use items <- decode.field( + "items", + decode.list(storage.stored_item_decoder(item.shelf_item_decoder())), + ) + decode.success(ShelfData(handle:, items:)) + } + rsvp.get("/api/shelf", rsvp.expect_json(decoder, GotShelf)) +} + +fn do_login(model: Model) -> Effect(Msg) { + let body = + json.object([ + #("identifier", json.string(model.login_handle)), + #("appPassword", json.string(model.login_password)), + ]) + let decoder = { + use handle <- decode.field("handle", decode.string) + decode.success(LoginInfo(handle:)) + } + rsvp.post("/api/login", body, rsvp.expect_json(decoder, GotLogin)) +} + +fn do_logout() -> Effect(Msg) { + rsvp.post( + "/api/logout", + json.object([]), + rsvp.expect_json(nil_decoder(), fn(_) { GotShelf(Error(rsvp.NetworkError)) }), + ) +} + +fn do_add(form: Form) -> Effect(Msg) { + rsvp.post( + "/api/shelf", + add_body(form), + rsvp.expect_json(nil_decoder(), GotAdd), + ) +} + +fn do_delete(rkey: String) -> Effect(Msg) { + rsvp.delete( + "/api/shelf/" <> rkey, + json.object([]), + rsvp.expect_json(nil_decoder(), GotDelete), + ) +} + +fn nil_decoder() -> decode.Decoder(Nil) { + decode.success(Nil) +} + +fn add_body(form: Form) -> Json { + let base = [ + #("title", json.string(form.title)), + #("artist", json.string(form.artist)), + #("status", json.string(form.status)), + ] + let format = case form.format { + "" -> [] + value -> [#("format", json.string(value))] + } + let year = case int.parse(form.year) { + Ok(value) -> [#("year", json.int(value))] + Error(_) -> [] + } + json.object(list.flatten([base, format, year])) +} + +// --- view --- + +fn view(model: Model) -> Element(Msg) { + html.main([attr.class("wrap")], [ + html.h1([], [text("at-record")]), + html.p([attr.class("tagline")], [text("your crate, stored on your PDS")]), + notice_view(model.notice), + case model.auth { + LoggedOut -> login_view(model) + LoggedIn(handle) -> shelf_view(model, handle) + }, + ]) +} + +fn notice_view(notice: Option(String)) -> Element(Msg) { + case notice { + Some(message) -> html.p([attr.class("notice")], [text(message)]) + None -> element.none() + } +} + +fn login_view(model: Model) -> Element(Msg) { + html.section([attr.class("card")], [ + html.h2([], [text("Sign in")]), + field( + "Handle or DID", + "mokkenstorm.dev", + model.login_handle, + HandleChanged, + "text", + ), + field( + "App password", + "xxxx-xxxx-xxxx-xxxx", + model.login_password, + PasswordChanged, + "password", + ), + html.button( + [ + attr.class("primary"), + event.on_click(SubmitLogin), + attr.disabled(model.busy), + ], + [text("Sign in")], + ), + html.p([attr.class("hint")], [ + text( + "Use an app password from your PDS settings, not your main password.", + ), + ]), + ]) +} + +fn shelf_view(model: Model, handle: String) -> Element(Msg) { + html.div([], [ + html.div([attr.class("topbar")], [ + html.span([], [text("@" <> handle)]), + html.button([event.on_click(Logout)], [text("Sign out")]), + ]), + add_form_view(model.form, model.busy), + items_view(model.items), + ]) +} + +fn add_form_view(form: Form, busy: Bool) -> Element(Msg) { + html.section([attr.class("card")], [ + html.h2([], [text("Add a record")]), + field("Title", "Spiderland", form.title, FormTitle, "text"), + field("Artist", "Slint", form.artist, FormArtist, "text"), + field("Format", "LP", form.format, FormFormat, "text"), + field("Year", "1991", form.year, FormYear, "number"), + html.label([], [ + html.span([], [text("Status")]), + html.select([event.on_change(FormStatus)], [ + status_option("owned", form.status), + status_option("wanted", form.status), + ]), + ]), + html.button( + [attr.class("primary"), event.on_click(SubmitAdd), attr.disabled(busy)], + [text("Add to crate")], + ), + ]) +} + +fn status_option(value: String, selected: String) -> Element(Msg) { + html.option([attr.value(value), attr.selected(value == selected)], value) +} + +fn items_view(items: List(StoredItem(ShelfItem))) -> Element(Msg) { + case items { + [] -> + html.p([attr.class("empty")], [ + text("No records yet. Add your first above."), + ]) + _ -> html.ul([attr.class("shelf")], list.map(items, item_view)) + } +} + +fn item_view(stored: StoredItem(ShelfItem)) -> Element(Msg) { + let snap = stored.value.snapshot + let meta = + [option.Some(stored.value.status), snap.format, format_year(snap.year)] + |> list.filter_map(fn(o) { + case o { + Some(value) -> Ok(value) + None -> Error(Nil) + } + }) + |> string_join(" · ") + html.li([attr.class("item")], [ + html.div([attr.class("item-main")], [ + html.strong([], [text(snap.artist_display)]), + text(" — " <> snap.title), + html.div([attr.class("item-meta")], [text(meta)]), + ]), + html.button( + [attr.class("danger"), event.on_click(DeleteItem(stored.rkey))], + [text("Remove")], + ), + ]) +} + +fn format_year(year: Option(Int)) -> Option(String) { + case year { + Some(value) -> Some(int.to_string(value)) + None -> None + } +} + +fn field( + label: String, + placeholder: String, + value: String, + on_input: fn(String) -> Msg, + input_type: String, +) -> Element(Msg) { + html.label([], [ + html.span([], [text(label)]), + html.input([ + attr.type_(input_type), + attr.placeholder(placeholder), + attr.value(value), + event.on_input(on_input), + ]), + ]) +} + +fn string_join(parts: List(String), separator: String) -> String { + case parts { + [] -> "" + [head, ..tail] -> + list.fold(tail, head, fn(acc, part) { acc <> separator <> part }) + } +} diff --git a/web/test/at_record_web_test.gleam b/web/test/at_record_web_test.gleam new file mode 100644 index 0000000..fba3c88 --- /dev/null +++ b/web/test/at_record_web_test.gleam @@ -0,0 +1,13 @@ +import gleeunit + +pub fn main() -> Nil { + gleeunit.main() +} + +// gleeunit test functions end in `_test` +pub fn hello_world_test() { + let name = "Joe" + let greeting = "Hello, " <> name <> "!" + + assert greeting == "Hello, Joe!" +}