diff --git a/.dockerignore b/.dockerignore index af50df1..3485016 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,4 +22,13 @@ **/secrets.dev.yaml **/values.dev.yaml LICENSE -README.md \ No newline at end of file +README.md +# Restored and generated on the host, the image restores and generates its own +packages +paket-files +coverage-results +src/rslib-bindings/target +src/rslib-bindings/tools +src/rslib-bindings/include +src/Ankit.Native/Generated +**/TestResults diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 50a3485..dfbf6b9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,7 +23,7 @@ jobs: - name: Set up .NET SDK uses: actions/setup-dotnet@v6 with: - dotnet-version: 10.0.x + dotnet-version: 10.0.402 - name: Verify sources run: dotnet fsi Scripts/build.fsx -- --no-logo Verify-Sources build: @@ -45,7 +45,7 @@ jobs: - name: Set up .NET SDK uses: actions/setup-dotnet@v6 with: - dotnet-version: 10.0.x + dotnet-version: 10.0.402 - name: Cache Cargo uses: actions/cache@v6 with: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2072a25 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,286 @@ +# CLAUDE.md + +Guidance for AI agents working in this repository. Human-facing docs live in `README.md` and `docs/`; this file is +the condensed working knowledge: how to build, verify and change things without breaking the conventions. + +## What this is + +Ankit is an F# HTTP server that adds decks and notes to an [Anki](https://apps.ankiweb.net) collection and keeps it in +sync with an Anki sync server. It doesn't reimplement Anki: it links Anki's own Rust backend (`rslib`) through a thin +native bridge and talks to it in protobuf, the way Anki's Python and TypeScript clients do. + +``` +Ankit.Server (F#, Oxpecker) HTTP API, services, background sync worker + └── Ankit.Native (C#) RslibBackend: typed client, P/Invoke, error decoding + └── ankit_rslib (Rust) C ABI over rslib, protobuf bytes in/out + └── rslib Anki's backend, sources restored by Paket into paket-files/ +``` + +| Path | What | +|-----------------------------------|-------------------------------------------------------------------------------| +| `src/rslib-bindings/` | Rust crate `ankit-rslib` (cdylib) + `csharp-gen` and `header-gen` generators | +| `src/Ankit.Native/` | C# wrapper: `RslibBackend`, `RslibException`/`BackendException`, `Interop/` | +| `src/Ankit.Server/` | F# server: `Dto`, `Services`, `AnkiSyncWorker`, `Handlers`, `ErrorHandler`, `Program` | +| `tests/Ankit.Native.Tests/` | xunit (C#) tests of the wrapper against a temp collection | +| `tests/Ankit.Server.Tests/` | xunit (F#) tests of services, worker, and sync against a Dockerized sync server | +| `src/rslib-bindings/tests/ffi.rs` | Rust tests of the C ABI | +| `Scripts/build.fsx` | FAKE build script, the single entry point for everything | +| `Scripts/gen-workflow.fsx` | Generates `.github/workflows/main.yml` (Generaptor) | +| `docs/` | `building.md`, `server.md` (config + HTTP API), `testing.md` | + +## Building + +Prerequisites: .NET SDK 10.0 (`global.json`), Rust 1.97.1 (`src/rslib-bindings/rust-toolchain.toml`, kept equal to +Anki's), on Windows the MSVC build tools. Paket and Fantomas are local dotnet tools, restored by the `Restore` target. + +Always build through the wrapper, never with ad-hoc command sequences when a target exists: + +```bash +./build [options] [] # Linux / macOS / Git Bash +build.cmd [options] [] # Windows (cmd or PowerShell: .\build.cmd) +``` + +Without a target `All` runs (Prepare, Build, Test, Test-Rslib). Options: `-c Debug|Release` (default **Release**), +`-p ` (parallel targets), `-s` (single target, no dependencies), `-f` (full clean), `--list`, `--no-logo`. + +| Task | Command | +|----------------------------------------|-------------------------------------------| +| First time after cloning | `./build Prepare` | +| Build everything | `./build Build` | +| .NET tests | `./build Test` (builds first) | +| Rust tests | `./build Test-Rslib` | +| Format F# sources | `./build Format` | +| **Before every commit / push** | `./build Before-Push` | +| Regenerate CI workflow | `./build Gen-Workflow` | +| Run server in Docker (port 5000) | `./build Run`, stop with `./build Stop` | +| Everything gone, start over | `./build -f Clean` then `./build Prepare` | + +### Generated, not committed + +`Prepare` produces files that are gitignored and must exist before the solution compiles: + +- `src/Ankit.Native/Generated/RslibBackend.Methods.g.cs`: one C# method per rslib backend method, with the + `(service, method)` indices of the linked rslib. Generated by `csharp-gen` from `target/anki_descriptors.bin`. + Never edit it; never hardcode service/method indices anywhere. +- `src/rslib-bindings/include/ankit_rslib.h`: C header from cbindgen. Nothing in the build uses it. +- `src/rslib-bindings/tools/protoc`: protoc copied from Grpc.Tools. + +Rerun `./build Prepare` after changing the Anki version or the C ABI. + +### Working with plain dotnet / cargo + +Fine for quick iteration once `Prepare` has run: + +- `dotnet build`, `dotnet test tests/Ankit.Server.Tests` work. `Ankit.Native` copies the native library from + `src/rslib-bindings/target/{debug|release}` matching its own configuration, so for a Debug `dotnet test` run + `cargo build` (debug) in `src/rslib-bindings` first; the Release `./build` targets use `target/release`. A missing + library is only a build warning, and tests then fail with `DllNotFoundException`. +- `cargo build` / `cargo test` in `src/rslib-bindings` work (`.cargo/config.toml` sets `PROTOC` and + `DESCRIPTORS_BIN`). The build script passes `--locked`; if you add a Cargo dependency, update `Cargo.lock` (e.g. + `cargo build --offline` or without `--locked`) and commit it. +- Still finish with `./build Before-Push`: it is what CI effectively checks. + +## Before committing: `Before-Push` + +`./build Before-Push` = `Test` (with `Build`) + `Prepare-Sources` (Fantomas + BOM removal) + `Verify-Sources` +(`Verify-Encoding`, `Verify-Workflow`, `Verify-Format`). It does not run `Prepare`. + +- The user expects `Before-Push` to pass before any commit. Run it, report the result, then commit. +- It formats before it verifies (`Format ?=> Verify-Format`, `Remove-BOM ?=> Verify-Encoding`), so formatting issues + are fixed in place rather than failing. Review the diff afterwards: Fantomas may also reformat code near your + changes, and those changes belong in the commit. +- Server tests include sync tests that need Docker with Linux containers; without Docker they are skipped, not failed. + Say so when reporting results. + +## CI + +- `.github/workflows/main.yml` is **generated** from `Scripts/gen-workflow.fsx`. Never edit the YAML by hand: change the + script, run `./build Gen-Workflow`, commit both. `Verify-Workflow` (part of `Before-Push` and the CI `verify` job) + fails if they differ. +- Jobs: `Verify sources` (Ubuntu, `Verify-Sources`) and `Build and test` on `windows-2025`, `ubuntu-24.04`, `macos-15` + (Restore → Setup-Rslib → Generate client/header → Build-Rslib → Build-Solution → Test → Test-Rslib). Cargo is cached. + Runs on push/PR to `main`, weekly, and manually. +- The .NET SDK is pinned to `10.0.402` in `gen-workflow.fsx` because `10.0.x` resolved to `10.0.403`, which wasn't + downloadable (HTTP 409). Switch back to `10.0.x` once that release is available. +- A full CI run takes ~15 minutes. A failure within the first minute or two is usually setup (SDK download, etc.), + not the code: read the failing step with `gh run view --log-failed` before changing anything. + +## Dependencies + +- **NuGet via Paket only.** Never `dotnet add package` or ``. Add the package to the right group in + `paket.dependencies` (`WebServer`, `Native`, `Test`), list it in the project's `paket.references`, and run + `dotnet paket install` (updates `paket.lock`). Commit all three. +- **Anki version**: `group Anki` → `github ankitects/anki:` in `paket.dependencies`. After a bump: restore, keep + `rust-toolchain.toml` equal to Anki's, `./build Prepare`, rebuild, run all tests. The sync server test image is + built from the same tag automatically. +- **Rust**: `src/rslib-bindings/Cargo.toml`; `anki`, `anki_proto`, `anki_i18n` are path dependencies into + `paket-files/`. The crate is its own workspace so Cargo never attaches it to Anki's. + +## Architecture notes + +### Native bridge (`src/rslib-bindings/src/lib.rs`) + +- Every call is `(service, method, protobuf bytes) → status + AnkitBuffer`. Statuses: `ANKIT_OK`, + `ANKIT_BACKEND_ERROR` (buffer = encoded `anki.backend.BackendError`), `ANKIT_PANIC` (panics never unwind into the + caller: everything runs inside `guarded`), `ANKIT_INVALID_ARGUMENT`. +- Every `AnkitBuffer` handed out must be freed with `ankit_buffer_free`; `AnkitBuffer.TakeBytes()` in C# does that. +- Adding an export: write it in `lib.rs` with a `# Safety` doc section, declare it **by hand** in + `Ankit.Native/Interop/NativeMethods.cs` (`LibraryImport`), wrap it in `RslibBackend`, add a test in + `tests/ffi.rs` and one in `Ankit.Native.Tests`. Regenerating the header is optional. +- `ankit_translation_index` maps a Fluent key (e.g. `sync-wrong-pass`) to the indices `I18nService.TranslateString` + needs; `RslibBackend.Translate(key, args)` uses it. + +### Ankit.Native (C#) + +- `RslibBackend` is a partial class: hand-written part in `RslibBackend.cs`, generated typed methods in + `Generated/`. Calls are synchronous and thread-safe (rslib serializes access internally). +- rslib errors → `BackendException` (with `Error.Kind`); panics / bad calls → `RslibException` (its base class). +- Error messages from rslib are already localized (FTL) in the backend's language + (`BackendInit.PreferredLangs`, `Env.defaultLangs` in the server). Don't compare against hardcoded English text; branch + on `BackendError.Kind`, and get expected texts from `backend.Translate ""` (e.g. `IErrorTextService`). + +### Ankit.Server (F#) + +Compile order (`Ankit.Server.fsproj`): `Utils` → `Env` → `Dto` → `Services` → `AnkiSyncWorker` → `ErrorHandler` → +`Handlers` → `ApiDocs` → `Program`. A new file must be added to the `.fsproj` in the right position. + +- **Services** (`Services.fs`): interface + `Anki*` implementation taking `RslibBackend` (and others) by constructor, + registered as singletons in `Program.fs`. `IErrorTextService`, `IAuthService`, `ISyncService`, `IDeckService`, + `INoteService`, `INotetypeService`. +- **Errors are values.** Services catch only expected backend exceptions with `ServiceUtils.guardCall` / + `guardCommand` / `guardCallSync` and return `Result`; genuine bugs propagate (`reraise ()`) to + `ErrorHandler.errorHandler` (500). Error unions are `[]`: + - `InfraFail`: `BackendFailed (kind, message)` | `NativeFailed message` + - `SyncFailed`: auth, aborted, network, server rejected, full sync required, infra (classified from `BackendError.Kind`) + - `CommandFailed`: `NotFound` (→ 404) | `Rejected` (→ 422) | `InfraFail` (→ 500), via `CommandFailed.ofInfra` + - Use `whenNotFound` to replace rslib's not-found text (written for the desktop app: "…use the Check Database + action") with one naming the missing object. +- **Worker** (`AnkiSyncWorker.fs`): a `BackgroundService` reading `Channel`. It knows nothing about decks + or notes: `AnkiSyncMsg.submit ch kind job ct` queues a `unit -> Result<_, _>` job and awaits its result. Jobs run + one at a time; a successful `JobKind.Change` schedules a sync after `Ankit:SyncDebounceTime` ms, a successful + `JobKind.FullSync` cancels the pending one. Writes go through the worker; reads call services directly and never + trigger a sync. +- **Handlers** (`Handlers.fs`): `query` for reads, `change` for writes, `submit` for full sync. Bind bodies with + `ModelBinder.bindOrDeclineJson` / `bindOrDeclineJson1` (with a route value) / `bindOrDeclineQuery`; they validate + DataAnnotations and answer 422 with `fieldErrors`. Routes live in `Program.fs` (`routef "/notes/{%d}"` for `int64`). +- **OpenAPI** (`ApiDocs.fs`, served at `/openapi/v1.json` via Oxpecker.OpenApi + Microsoft.AspNetCore.OpenApi; + Swagger UI at `/swagger` from `Swashbuckle.AspNetCore.SwaggerUI`, which only serves the UI for that spec): + every route in `Program.fs` must end with `|> describe "" "" + [ ]`, using the response values in `ApiDocs` (`ok<'T>`, `okText`, `created`, `noContent`, `notFound`, + `invalid`, `syncFailed`, `shuttingDown`); add `|> withQuery name description` for query parameters, which can't + be inferred. Path parameter names come from the handler's parameter names, so give `routef` a named lambda + (`fun deckId -> ...`), not `bindOrDeclineJson1`, whose wrapper is named `x`. The generator reads ASP.NET's HTTP + JSON options, not `jsonOptions`; `Program.fs` aligns the parts that matter (strict numbers) and schema transformers + fix F# types: options, fieldless unions (string enums), non-option record fields (not nullable). Check the + generated spec after adding a route or DTO. +- **DTOs** (`Dto.fs`): `[]` records with DataAnnotations (`Required`, `MinLength`, custom `NumericId`). + JSON is camelCase (`Utils.jsonOptions`); options are skippable; fieldless unions are written as their case name in + `SNAKE_UPPER` (`Utils.unionTagNaming`) and read case-insensitively (`"direction": "upload"` works). Conversions + between DTOs and service types belong in `Handlers.fs`, because `Dto.fs` compiles before `Services.fs`. +- **Responses**: create → `201 {"id"}`, update/delete → `204`, reads → `200` JSON, errors → `ApiError` JSON + (`code`, `message`, `traceId`, `timestamp`, `path`). Keep `docs/server.md` in sync when the API changes. +- Configuration: `Env.fs` (`AnkitServerSettings` section `Ankit`, `SyncServerSettings` section `SyncServer`), + `appsettings.json`, overridable with env vars like `SyncServer__Password`. Never commit credentials. + +## Code style + +General (`.editorconfig`, `.gitattributes`): UTF-8 **without BOM** (`Verify-Encoding` fails otherwise), LF line +endings (`*.cmd` are CRLF), 4-space indent (2 for json/yml/xml/proj files), final newline, max line 120 (100 for Rust). + +### F# + +- Formatted by **Fantomas 8** with the settings in `.editorconfig` (Stroustrup brackets, records with more than one + field go multiline, `f x` / `f ()` with a space for lowercase functions, no space for `Method()` calls). Don't fight + it: run `./build Format`. `.fantomasignore` excludes `*.fsx` scripts and `src/Ankit.Server/Program.fs` (its route + lists are formatted by hand). +- Idioms used throughout: `Result` + FsToolkit `result { }` computation expressions (`let!`, `do!`, `return!`), + `[]` unions with a companion module of the same name, `[]` types for services, + pipelines, `Option` instead of null at boundaries, `task { }` for async code. +- Naming: PascalCase types, union cases and members; camelCase `let` functions and values; test names are + double-backticked sentences describing behavior (``` ``Deleted note is gone`` ```). +- Comments: few, short, and about *why* (a quirk of rslib or Oxpecker, a non-obvious decision), not *what*. `///` + doc comments on public types and members where the meaning isn't obvious from the name. + +### C# + +`.editorconfig` rules: file-scoped namespaces, `using`s outside the namespace with System first, Allman braces, +`var` everywhere, expression-bodied members where single-line, collection expressions, primary constructors (see +`RslibException.cs`), `_camelCase` private fields, nullable enabled, `unsafe` + `LibraryImport` for interop. XML doc +comments (``) on public API. + +### Rust + +rustfmt defaults (`cargo fmt`), one `use` per item as Anki does, doc comments on every `extern "C"` function with a +`# Safety` section, no panics across the FFI boundary. + +## Testing + +- `Ankit.Server.Tests`: service tests (`DeckServiceTests`, `NoteServiceTests`, `NotetypeServiceTests`) use + `TempCollection` (fresh collection in a temp dir, disposed per test) and the helpers in `CommandAssert.fs` + (`ok`, `notFound`, `rejected`). `AnkiSyncWorkerTests` uses a counting fake `ISyncService` and a 200 ms debounce; + it is timing-based, so keep its margins generous. `SyncServiceTests` use `[]` and an Anki sync + server built by Testcontainers from `tests/Ankit.Server.Tests/SyncServer/Dockerfile` (first build takes minutes; + `ANKIT_TESTS_CARGO_JOBS` limits cargo parallelism, default 4). +- `Ankit.Native.Tests` (C#, xunit) mirror that with their own `TempCollection`. +- xunit 2.9: async F# tests must return `Task` (annotate `: Task` and upcast), not `Task`. +- New behavior gets a test at the service level; verify HTTP-level behavior (routing, binding, status codes) by + running the server (`./build Run` or `dotnet run --project src/Ankit.Server` with `Ankit__CollectionPath` pointing + to a scratch directory) and calling the endpoints. + +## Known quirks and gotchas + +**rslib** + +- `AddNote` with a missing or **filtered** deck silently puts the cards in the Default deck (`cardgen.rs`), which is why + `INoteService.Add` checks the deck first. A missing notetype comes back as `InvalidInput` "missing note type". +- `RemoveDecks` / `RemoveNotes` ignore unknown ids, so the services look the object up first to answer 404. +- Deleting the Default deck (id 1) only empties it and resets its name. +- Messages contain Unicode isolation marks (U+2068/U+2069) around interpolated values when a language is set; compare + only with text produced by the same backend. +- rslib is built with English strings only: the FTL translation submodules are missing from the Paket archive, and + `Setup-Rslib` creates them empty. + +**Oxpecker 2.1** + +- Query binding is case-sensitive: query DTO fields must be lowercase to match `?query=` (see `SearchNotesDto`). +- Binding an empty query string throws inside Oxpecker; `bindOrDeclineQuery` works around it by validating an empty + model. +- `ResponseBody` needs a body type, so a bodiless 204 is the `ApiDocs.noContent` marker, which `describe` turns into a + real empty response. +- Nullable reference types (`enable`) are not enabled in `Ankit.Server`: in F# that switches on + nullness checking with dozens of warnings in existing code. The nullability schema transformer covers the spec. + +**Docker** (`src/Ankit.Server/Dockerfile`, used by `./build Run` / `compose.yaml`) + +- Alpine images (`sdk:10.0-alpine` builds, `aspnet:10.0-alpine` runs), so the native library is built for musl in the + image itself, through the same build-script targets CI runs. It must never come from the host. +- Grpc.Tools' `protoc` is a glibc binary: the build stage installs `gcompat` + `libstdc++` to run it. +- musl targets link the C runtime statically by default, which can't produce a `cdylib`; + `src/rslib-bindings/.cargo/config.toml` disables `crt-static` for musl only. +- Rider's Docker "fast mode" runs the host build in the container and can't work: the host has no musl + `libankit_rslib.so`. Use regular mode or `./build Run`. +- Alpine .NET images use invariant globalization (no ICU). +- The first build compiles rslib (several minutes); cache mounts keep cargo's registry and `target` between builds. + +**Windows** + +- FAKE could resolve `dotnet` to a bare `dotnet.exe` and put an empty entry at the start of `PATH`, which makes + `cmd.exe` (MSBuild `Exec`, e.g. `dotnet paket restore`) find nothing. `dotnetCli` in `build.fsx` fixes it by using + the script's own host; keep every `DotNet.*` call going through `dotnetCli`. +- Prefer the PowerShell tool for `build.cmd`; from Git Bash call `./build`. +- On this machine `python3` may be the Windows Store alias and hang: don't rely on Python for scripting, use F# scripts + (`dotnet fsi`), PowerShell or POSIX tools. + +## Git and PR conventions + +- Commit subjects: imperative, sentence case, no prefix or trailing period (`Extract deck, note and notetype services + from the sync worker`). The body explains *why* and any API or behavior changes, wrapped at ~72 columns. +- One logical change per commit (e.g. a build fix separate from a feature). +- Work on a feature branch and open a PR to `main` with `gh pr create`. Run `./build Before-Push` first and say in + the PR what was tested. Call out breaking API changes explicitly in the PR description and `docs/server.md`. +- Never commit generated files (`Generated/`, `include/`, `tools/`), `packages/`, `paket-files/`, or secrets. + +## License + +AGPL-3.0-or-later, required by linking Anki's rslib. New source files need no header, but don't add dependencies +with incompatible licenses. diff --git a/Scripts/build.fsx b/Scripts/build.fsx index 14ac779..7290209 100644 --- a/Scripts/build.fsx +++ b/Scripts/build.fsx @@ -481,6 +481,10 @@ Target.create "Before-Push" doNothing "Verify-Format" ] + +"Format" ?=> "Verify-Format" +"Remove-BOM" ?=> "Verify-Encoding" + "Before-Push" <== [ "Test" "Prepare-Sources" diff --git a/Scripts/gen-workflow.fsx b/Scripts/gen-workflow.fsx index e387d01..45120ad 100644 --- a/Scripts/gen-workflow.fsx +++ b/Scripts/gen-workflow.fsx @@ -36,7 +36,7 @@ let workflow = [ let buildTargetStep (name: string) (target: string) = buildCliStep name $"--single-target {target}" - let dotnetVersion = "10.0.x" + let dotnetVersion = "10.0.402" let checkoutAction = Auto "actions/checkout" let dotnetSetupAction = Auto "actions/setup-dotnet" diff --git a/docs/server.md b/docs/server.md index ebff3c6..bd900ef 100644 --- a/docs/server.md +++ b/docs/server.md @@ -23,21 +23,38 @@ In Docker the collection lives in `/app/data`, mount a volume there to keep it w ## API -| Endpoint | Body | Success | -|-------------------|------------------------------------------------------------------|---------| -| `GET /ping` | | `200` | -| `POST /decks` | `{ "name": "Parent::Child" }` | `204` | -| `POST /notes` | `{ "deckId": 1, "noteTypeId": 1, "fields": ["front", "back"], "tags": ["optional"] }` | `204` | -| `POST /sync/full` | `{ "direction": "upload" }` or `{ "direction": "download" }` | `204` | +| Endpoint | Body or query | Success | +|-------------------------|------------------------------------------------------------------|-------------------------| +| `GET /ping` | | `200` | +| `GET /decks` | | `200` `[{ "id", "name" }]` | +| `POST /decks` | `{ "name": "Parent::Child" }` | `201` `{ "id" }` | +| `PUT /decks/{id}` | `{ "name": "New::Name" }` | `204` | +| `DELETE /decks/{id}` | | `204` | +| `GET /note-types` | | `200` `[{ "id", "name", "fields" }]` | +| `GET /notes?query=…` | Anki search, e.g. `query=deck:Japanese tag:verb`; `deck:*` for all | `200` `[id, …]` | +| `GET /notes/{id}` | | `200` `{ "id", "noteTypeId", "fields", "tags" }` | +| `POST /notes` | `{ "deckId": 1, "noteTypeId": 1, "fields": ["front", "back"], "tags": ["optional"] }` | `201` `{ "id" }` | +| `PUT /notes/{id}` | `{ "fields": ["front", "back"], "tags": ["optional"] }` | `204` | +| `DELETE /notes/{id}` | | `204` | +| `POST /sync/full` | `{ "direction": "upload" }` or `{ "direction": "download" }` | `204` | -`fields` holds one value per field of the note type, in its order. Deck and note commands answer once they are applied -to the local collection, without waiting for the sync; `/sync/full` answers when the full sync is done. +The OpenAPI description of the API is served at `GET /openapi/v1.json`, and Swagger UI at `/swagger` to browse and +try the endpoints. Union values such as `direction` are case-insensitive (`upload`, `UPLOAD`). + +`fields` holds one value per field of the note type, in its order; `GET /note-types` lists them. `PUT /notes/{id}` +keeps the tags when `tags` is left out. Deleting a deck deletes its child decks and all of their cards; the default +deck stays: its cards are deleted and its name is reset. Deleting a note deletes its cards. + +Changes answer once they are applied to the local collection, without waiting for the sync; `/sync/full` answers when +the full sync is done. Reads don't trigger a sync. Errors are JSON with `code`, `message`, `traceId`, `timestamp` and `path`: -- `422` with `fieldErrors` (`field`, `issue`, `rejectedValue`) when the body fails validation -- `422` without them when rslib rejects the command, for example a wrong number of fields or an unknown deck -- `400` when the body is not valid JSON +- `404` when the deck or note in the URL doesn't exist +- `422` with `fieldErrors` (`field`, `issue`, `rejectedValue`) when the body or query fails validation +- `422` without them when rslib rejects the command, for example a wrong number of fields, an unknown or filtered + deck, an unknown note type or a malformed search +- `400` when the body is not valid JSON or an id in the URL is not a number - `502` when a full sync fails - `503` when the server shuts down before applying the command diff --git a/paket.dependencies b/paket.dependencies index 2145bf5..3e16642 100644 --- a/paket.dependencies +++ b/paket.dependencies @@ -5,6 +5,8 @@ group WebServer framework: net10.0 nuget Oxpecker 2.1.1 + nuget Oxpecker.OpenApi 2.1.2 + nuget Swashbuckle.AspNetCore.SwaggerUI 10.3.1 nuget FsToolkit.ErrorHandling 5.2.0 nuget FSharp.SystemTextJson 1.4.36 diff --git a/paket.lock b/paket.lock index d07fabe..7b7892f 100644 --- a/paket.lock +++ b/paket.lock @@ -84,15 +84,22 @@ NUGET System.Text.Json (>= 6.0.10) FsToolkit.ErrorHandling (5.2) FSharp.Core (>= 9.0.300) + Microsoft.AspNetCore.OpenApi (10.0.14) + Microsoft.OpenApi (>= 2.12.2 < 3.0) Microsoft.Extensions.ObjectPool (10.0.13) Microsoft.IO.RecyclableMemoryStream (3.0.1) + Microsoft.OpenApi (2.13) Oxpecker (2.1.1) Microsoft.IO.RecyclableMemoryStream (>= 3.0.1) Oxpecker.ViewEngine (>= 2.1) TypeShape (>= 10.0) + Oxpecker.OpenApi (2.1.2) + Microsoft.AspNetCore.OpenApi (>= 10.0) + Oxpecker (>= 2.0.1) Oxpecker.ViewEngine (2.1) FSharp.Core (>= 10.0.100) Microsoft.Extensions.ObjectPool (>= 10.0) + Swashbuckle.AspNetCore.SwaggerUI (10.3.1) System.Text.Json (10.0.13) TypeShape (10.0) FSharp.Core (>= 4.5.4) diff --git a/src/Ankit.Server/AnkiSyncWorker.fs b/src/Ankit.Server/AnkiSyncWorker.fs index a215ee9..d104e05 100644 --- a/src/Ankit.Server/AnkiSyncWorker.fs +++ b/src/Ankit.Server/AnkiSyncWorker.fs @@ -3,8 +3,6 @@ namespace Ankit.Server open System open System.Threading open System.Threading.Tasks -open Ankit.Native -open Ankit.Server.Dto open Ankit.Server.Env open Ankit.Server.Services open Microsoft.Extensions.Hosting @@ -12,22 +10,56 @@ open System.Threading.Channels open Microsoft.Extensions.Logging open Microsoft.Extensions.Options +/// What a job does to the collection, which decides whether a sync has to follow it [] -type AnkiError = - | CreateDeckError - | AddNoteError - | FullSyncError of SyncFailed +type JobKind = + /// Changes the local collection, a sync pushes it to the server later + | Change + /// Makes both sides equal, so changes made before it need no sync + | FullSync + +/// A job queued for the worker, `Run` reports whether it succeeded +type AnkiSyncMsg = { + Kind: JobKind + Run: unit -> bool + Cancel: CancellationToken -> unit +} -type AnkiCommand = - | CreateDeck of Anki.Decks.Deck - | AddNote of Anki.Notes.AddNoteRequest - | FullSync of SyncDirection +[] +module AnkiSyncMsg = + /// Queues `job` and waits until the worker has run it. Exceptions of the job are rethrown to the caller. + let submit + (ch: Channel) + (kind: JobKind) + (job: unit -> Result<'a, 'e>) + (ct: CancellationToken) + : Task> = + task { + let reply = + TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously) -type AnkiSyncMsg = AnkiSyncMsg of cmd: AnkiCommand * reply: TaskCompletionSource> + let run () = + try + let result = job () + reply.TrySetResult result |> ignore + Result.isOk result + with e -> + reply.TrySetException e |> ignore + false + + let msg = { + Kind = kind + Run = run + Cancel = fun token -> reply.TrySetCanceled token |> ignore + } + + do! ch.Writer.WriteAsync(msg, ct) + return! reply.Task.WaitAsync ct + } +/// Runs queued jobs one at a time and syncs the changes they made once `SyncDebounceTime` has passed type AnkiSyncWorker ( - backend: RslibBackend, syncService: ISyncService, ch: Channel, logger: ILogger, @@ -38,22 +70,6 @@ type AnkiSyncWorker let getDebounceTime () = TimeSpan.FromMilliseconds(float settings.CurrentValue.SyncDebounceTime) - let applyCommand (cmd: AnkiCommand) : Result = - try - match cmd with - | FullSync direction -> - syncService.FullSync direction - |> Result.mapError AnkiError.FullSyncError - |> Result.map (fun _ -> box ()) - | CreateDeck deck -> Ok <| backend.AddDeck deck - | AddNote noteRequest -> Ok <| backend.AddNote noteRequest - with :? RslibException as e -> - logger.LogError(e, "Failed to apply {Command}", cmd) - - match cmd with - | CreateDeck _ -> Error AnkiError.CreateDeckError - | _ -> Error AnkiError.AddNoteError - override this.ExecuteAsync(stoppingToken) = task { logger.LogInformation("AnkiSyncWorker is starting.") @@ -65,15 +81,10 @@ type AnkiSyncWorker let mutable msg = Unchecked.defaultof while rd.TryRead(&msg) do - let (AnkiSyncMsg(cmd, reply)) = msg - let result = applyCommand cmd - - match cmd, result with - | FullSync _, Ok _ -> requiresSync.Value <- false - | _, Ok _ -> requiresSync.Value <- true - | _, Error _ -> () - - reply.TrySetResult result |> ignore + match msg.Kind, msg.Run() with + | JobKind.FullSync, true -> requiresSync.Value <- false + | JobKind.Change, true -> requiresSync.Value <- true + | _, false -> () try let mutable running = true @@ -106,8 +117,7 @@ type AnkiSyncWorker let mutable msg = Unchecked.defaultof while rd.TryRead(&msg) do - let (AnkiSyncMsg(_, reply)) = msg - reply.TrySetCanceled(stoppingToken) |> ignore + msg.Cancel stoppingToken logger.LogInformation("AnkiSyncWorker is stopping due to cancellation.") } diff --git a/src/Ankit.Server/Ankit.Server.fsproj b/src/Ankit.Server/Ankit.Server.fsproj index f6cd168..da6c657 100644 --- a/src/Ankit.Server/Ankit.Server.fsproj +++ b/src/Ankit.Server/Ankit.Server.fsproj @@ -15,6 +15,7 @@ + diff --git a/src/Ankit.Server/ApiDocs.fs b/src/Ankit.Server/ApiDocs.fs new file mode 100644 index 0000000..63b1386 --- /dev/null +++ b/src/Ankit.Server/ApiDocs.fs @@ -0,0 +1,118 @@ +/// OpenAPI descriptions of the endpoints, served at /openapi/v1.json +module Ankit.Server.ApiDocs + +open System +open System.Text.Json.Nodes +open System.Threading.Tasks +open Ankit.Server.Dto +open Ankit.Server.Utils +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.OpenApi +open Microsoft.FSharp.Reflection +open Microsoft.OpenApi +open Oxpecker +open Oxpecker.OpenApi + +open type Microsoft.AspNetCore.Http.StatusCodes + +/// Unions without fields are written as their case name (see Utils.jsonOptions), the generator can't see that +type FieldlessUnionSchemaTransformer() = + interface IOpenApiSchemaTransformer with + member _.TransformAsync(schema, context, _) = + let ty = context.JsonTypeInfo.Type + + if FSharpType.IsUnion ty then + let cases = FSharpType.GetUnionCases ty + + if cases |> Array.forall (fun case -> case.GetFields().Length = 0) then + schema.Type <- JsonSchemaType.String + schema.Description <- "Case-insensitive" + + schema.Enum <- + ResizeArray( + cases + |> Seq.map (fun case -> JsonValue.Create(unionTagNaming.ConvertName case.Name) :> JsonNode) + ) + + Task.CompletedTask + +/// F# record fields are only null when they're options, the generator assumes any reference type may be +type RecordFieldNullabilitySchemaTransformer() = + interface IOpenApiSchemaTransformer with + member _.TransformAsync(schema, context, _) = + match context.JsonPropertyInfo with + | null -> () + | property -> + let isOption = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() = typedefof> + + if + FSharpType.IsRecord(property.DeclaringType, true) + && not isOption + && schema.Type.HasValue + then + schema.Type <- Nullable(schema.Type.Value &&& ~~~JsonSchemaType.Null) + + Task.CompletedTask + +let ok<'T> = ResponseBody(typeof<'T>, statusCode = Status200OK) +let okText = ResponseBody(typeof, [| "text/plain" |], Status200OK) +let created = ResponseBody(typeof, statusCode = Status201Created) + +/// Marker for a response without a body, `describe` adds it to the operation itself +let noContent = ResponseBody(typeof, statusCode = Status204NoContent) + +let notFound = ResponseBody(typeof, statusCode = Status404NotFound) + +/// Failed validation (with fieldErrors) or a command rslib rejected +let invalid = + ResponseBody(typeof, statusCode = Status422UnprocessableEntity) + +let syncFailed = ResponseBody(typeof, statusCode = Status502BadGateway) + +let shuttingDown = + ResponseBody(typeof, statusCode = Status503ServiceUnavailable) + +let describe (operationId: string) (summary: string) (request: Type option) (responses: ResponseBody list) = + // A body type is required by ResponseBody, but 204 has no body + let hasNoContent = + responses |> List.exists (fun r -> obj.ReferenceEquals(r, noContent)) + + let responses = + responses |> List.filter (fun r -> not (obj.ReferenceEquals(r, noContent))) + + addOpenApi ( + OpenApiConfig( + ?requestBody = (request |> Option.map (fun t -> RequestBody(t))), + responseBodies = responses, + configureOperation = + fun operation _ _ -> + operation.OperationId <- operationId + operation.Summary <- summary + + if hasNoContent then + operation.Responses.Add("204", OpenApiResponse(Description = "No Content")) + + Task.CompletedTask + ) + ) + +/// Documents a query string parameter, which Oxpecker can't infer from the handler +let withQuery (name: string) (description: string) = + configureEndpoint (fun builder -> + builder.AddOpenApiOperationTransformer(fun operation _ _ -> + if isNull operation.Parameters then + operation.Parameters <- ResizeArray() + + operation.Parameters.Add( + OpenApiParameter( + Name = name, + In = ParameterLocation.Query, + Required = true, + Description = description, + Schema = OpenApiSchema(Type = JsonSchemaType.String) + ) + ) + + Task.CompletedTask)) diff --git a/src/Ankit.Server/Dockerfile b/src/Ankit.Server/Dockerfile index de2d74e..4945d7e 100644 --- a/src/Ankit.Server/Dockerfile +++ b/src/Ankit.Server/Dockerfile @@ -1,21 +1,44 @@ -FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base -USER $APP_UID +# syntax=docker/dockerfile:1 + +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base WORKDIR /app +# The collection lives here, see docs/server.md +RUN mkdir -p /app/data && chown $APP_UID /app/data +USER $APP_UID +EXPOSE 5000 -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build ARG BUILD_CONFIGURATION=Release +# gcompat and libstdc++ run the glibc protoc of Grpc.Tools, which rslib and Ankit.Native generate code with +RUN apk add --no-cache build-base curl gcompat libstdc++ + +ENV RUSTUP_HOME=/usr/local/rustup \ + CARGO_HOME=/usr/local/cargo \ + PATH=/usr/local/cargo/bin:$PATH +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none + +WORKDIR /src/src/rslib-bindings +COPY src/rslib-bindings/rust-toolchain.toml . +RUN rustup toolchain install + +# Packages and Anki sources, in their own layer so that source changes don't download them again WORKDIR /src -COPY ["src/Ankit.Server/Ankit.Server.fsproj", "src/Ankit.Server/"] -RUN dotnet restore "src/Ankit.Server/Ankit.Server.fsproj" +COPY .config/ .config/ +COPY .paket/ .paket/ +COPY global.json paket.dependencies paket.lock ./ +RUN dotnet tool restore && dotnet paket restore + COPY . . -WORKDIR "/src/src/Ankit.Server" -RUN dotnet build "./Ankit.Server.fsproj" -c $BUILD_CONFIGURATION -o /app/build -FROM build AS publish -ARG BUILD_CONFIGURATION=Release -RUN dotnet publish "./Ankit.Server.fsproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false +# The same targets CI runs, the native library and the generated client must exist before publishing +RUN --mount=type=cache,id=ankit-cargo-registry,target=/usr/local/cargo/registry \ + --mount=type=cache,id=ankit-rslib-target-musl,target=/src/src/rslib-bindings/target \ + for target in Restore Setup-Rslib Generate-Rslib-Client Build-Rslib; do \ + dotnet fsi Scripts/build.fsx -- --no-logo -c $BUILD_CONFIGURATION --single-target $target || exit 1; \ + done \ + && dotnet publish src/Ankit.Server/Ankit.Server.fsproj -c $BUILD_CONFIGURATION -o /app/publish -p:UseAppHost=false \ + && test -f /app/publish/libankit_rslib.so FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . +COPY --from=build /app/publish . ENTRYPOINT ["dotnet", "Ankit.Server.dll"] diff --git a/src/Ankit.Server/Dto.fs b/src/Ankit.Server/Dto.fs index fd0c15a..b4de200 100644 --- a/src/Ankit.Server/Dto.fs +++ b/src/Ankit.Server/Dto.fs @@ -30,6 +30,48 @@ type AddNoteDto = { Tags: string array option } +[] +type RenameDeckDto = { + [] + Name: string +} + +[] +type UpdateNoteDto = { + [] + Fields: string array + + /// Replaces the tags when given, otherwise they are kept + Tags: string array option +} + +[] +type SearchNotesDto = { + /// Anki search syntax, e.g. "deck:Japanese tag:verb". Lowercase, because query binding is case-sensitive + [] + query: string +} + +type CreatedDto = { Id: int64 } + +type DeckDto = { + Id: int64 + Name: string +} + +type NoteDto = { + Id: int64 + NoteTypeId: int64 + Fields: string array + Tags: string array +} + +type NoteTypeDto = { + Id: int64 + Name: string + Fields: string array +} + type SyncDirection = | Upload | Download @@ -133,17 +175,3 @@ let validateModel (f: 'T -> EndpointHandler) : ModelValidationResult<'T> -> Http ctx.SetStatusCode(Status422UnprocessableEntity) return! json valError ctx }) - -let toDeck (dto: CreateDeckDto) : Anki.Decks.Deck = - // Mirrors Deck::new_normal in rslib - Anki.Decks.Deck( - Name = dto.Name.Trim(), - Common = Anki.Decks.Deck.Types.Common(StudyCollapsed = true, BrowserCollapsed = true), - Normal = Anki.Decks.Deck.Types.Normal(ConfigId = 1L) - ) - -let toAddNoteRequest (dto: AddNoteDto) : Anki.Notes.AddNoteRequest = - let note = Anki.Notes.Note(NotetypeId = dto.NoteTypeId) - note.Fields.AddRange dto.Fields - dto.Tags |> Option.iter note.Tags.AddRange - Anki.Notes.AddNoteRequest(Note = note, DeckId = dto.DeckId) diff --git a/src/Ankit.Server/Handlers.fs b/src/Ankit.Server/Handlers.fs index 420a987..c86515d 100644 --- a/src/Ankit.Server/Handlers.fs +++ b/src/Ankit.Server/Handlers.fs @@ -5,6 +5,7 @@ open System.Threading.Channels open System.Threading.Tasks open Ankit.Server open Ankit.Server.Dto +open Ankit.Server.Services open Microsoft.AspNetCore.Http open Microsoft.Extensions.Logging open Oxpecker @@ -29,32 +30,52 @@ let writeError (ctx: HttpContext) (code: int) (msg: string) (details: obj option ctx.WriteJson <| error -let private describe (error: AnkiError) = +let private writeCommandError (ctx: HttpContext) (error: CommandFailed) = match error with - | AnkiError.CreateDeckError -> "Failed to create the deck" - | AnkiError.AddNoteError -> "Failed to add the note, check the deck, the notetype and the number of fields" - | AnkiError.FullSyncError failure -> $"Full sync failed: %A{failure}" + | CommandFailed.NotFound msg -> writeError ctx Status404NotFound msg None + | CommandFailed.Rejected msg -> writeError ctx Status422UnprocessableEntity msg None + | CommandFailed.InfraFail failure -> + ctx.GetLogger().LogError("Command failed in the backend: {Failure}", failure) + writeError ctx Status500InternalServerError "Internal server error: the backend failed" None + +let private writeJson (code: int) (value: 'T) (ctx: HttpContext) = + ctx.SetStatusCode code + ctx.WriteJson value + +let private noContent (ctx: HttpContext) : Task = + ctx.SetStatusCode Status204NoContent + Task.CompletedTask + +/// Runs a read, which needs no sync and so doesn't go through the worker +let private query (job: unit -> CommandResult<'a>) (onOk: 'a -> HttpContext -> Task) : EndpointHandler = + fun ctx -> + match job () with + | Ok value -> onOk value ctx + | Error e -> writeCommandError ctx e + +/// Queues a job for the worker and answers once it has run +let private submit + (kind: JobKind) + (job: unit -> Result<'a, 'e>) + (onOk: 'a -> HttpContext -> Task) + (onError: 'e -> HttpContext -> Task) + : EndpointHandler = + fun ctx -> + task { + let ch = ctx.GetService>() + + try + match! AnkiSyncMsg.submit ch kind job ctx.RequestAborted with + | Ok value -> return! onOk value ctx + | Error e -> return! onError e ctx + with :? TaskCanceledException when not ctx.RequestAborted.IsCancellationRequested -> + return! writeError ctx Status503ServiceUnavailable "Server is shutting down" None + } -let private statusOf (error: AnkiError) = - match error with - | AnkiError.FullSyncError _ -> Status502BadGateway - | _ -> Status422UnprocessableEntity - -let private runCommand (ctx: HttpContext) (cmd: AnkiCommand) : Task = - task { - let reply = - TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously) - - let ch = ctx.GetService>() - do! ch.Writer.WriteAsync(AnkiSyncMsg(cmd, reply), ctx.RequestAborted) - - try - match! reply.Task.WaitAsync(ctx.RequestAborted) with - | Ok _ -> ctx.SetStatusCode Status204NoContent - | Error e -> return! writeError ctx (statusOf e) (describe e) None - with :? TaskCanceledException when not ctx.RequestAborted.IsCancellationRequested -> - return! writeError ctx Status503ServiceUnavailable "Server is shutting down" None - } +let private change (job: unit -> CommandResult<'a>) (onOk: 'a -> HttpContext -> Task) : EndpointHandler = + submit JobKind.Change job onOk (fun e ctx -> writeCommandError ctx e) + +let private created (id: int64) = writeJson Status201Created { CreatedDto.Id = id } module ModelBinder = let bindOrDeclineJson<'T when 'T: not null> (f: 'T -> EndpointHandler) : EndpointHandler = @@ -64,21 +85,109 @@ module ModelBinder = fun x -> bindOrDeclineJson (f >> (fun g -> g x)) let bindOrDeclineQuery<'T when 'T: not null> (f: 'T -> EndpointHandler) : EndpointHandler = - bindAndValidateQuery<'T> <| Dto.validateModel f + fun ctx -> + // Oxpecker's query binder throws on an empty query string, so validate an empty model instead + if ctx.Request.Query.Count = 0 then + Dto.validateModel f (validateModel (Activator.CreateInstance<'T>())) ctx + else + bindAndValidateQuery<'T> (Dto.validateModel f) ctx let bindOrDeclineQuery1 (f: 'T0 -> 'T1 -> EndpointHandler) : 'T1 -> EndpointHandler = fun x -> bindOrDeclineQuery (f >> (fun g -> g x)) -let createDeck (createDeck: CreateDeckDto) : EndpointHandler = - fun (ctx: HttpContext) -> - task { - let deck = toDeck createDeck - return! runCommand ctx (CreateDeck deck) +// --- Decks --- + +let listDecks: EndpointHandler = + fun ctx -> + let decks = ctx.GetService() + + let toDto (deck: Anki.Decks.DeckNameId) : DeckDto = { + Id = deck.Id + Name = deck.Name + } + + query decks.List (fun list -> writeJson Status200OK (list |> List.map toDto)) ctx + +let createDeck (dto: CreateDeckDto) : EndpointHandler = + fun ctx -> + let decks = ctx.GetService() + change (fun () -> decks.Create dto.Name) created ctx + +let renameDeck (dto: RenameDeckDto) (deckId: int64) : EndpointHandler = + fun ctx -> + let decks = ctx.GetService() + change (fun () -> decks.Rename(deckId, dto.Name)) (fun () -> noContent) ctx + +let deleteDeck (deckId: int64) : EndpointHandler = + fun ctx -> + let decks = ctx.GetService() + change (fun () -> decks.Delete deckId) (fun () -> noContent) ctx + +// --- Notes --- + +let private toNoteDto (note: Anki.Notes.Note) : NoteDto = { + Id = note.Id + NoteTypeId = note.NotetypeId + Fields = Array.ofSeq note.Fields + Tags = Array.ofSeq note.Tags +} + +let getNote (noteId: int64) : EndpointHandler = + fun ctx -> + let notes = ctx.GetService() + query (fun () -> notes.Get noteId) (toNoteDto >> writeJson Status200OK) ctx + +let searchNotes (dto: SearchNotesDto) : EndpointHandler = + fun ctx -> + let notes = ctx.GetService() + query (fun () -> notes.Search dto.query) (Array.ofList >> writeJson Status200OK) ctx + +let addNote (dto: AddNoteDto) : EndpointHandler = + fun ctx -> + let notes = ctx.GetService() + + let content = { + Fields = List.ofArray dto.Fields + Tags = dto.Tags |> Option.map List.ofArray |> Option.defaultValue [] } -let addNote (addNote: AddNoteDto) : EndpointHandler = - let note = toAddNoteRequest addNote - fun (ctx: HttpContext) -> runCommand ctx (AddNote note) + change (fun () -> notes.Add(dto.DeckId, dto.NoteTypeId, content)) created ctx + +let updateNote (dto: UpdateNoteDto) (noteId: int64) : EndpointHandler = + fun ctx -> + let notes = ctx.GetService() + let tags = dto.Tags |> Option.map List.ofArray + + change (fun () -> notes.Update(noteId, List.ofArray dto.Fields, tags)) (fun () -> noContent) ctx + +let deleteNote (noteId: int64) : EndpointHandler = + fun ctx -> + let notes = ctx.GetService() + change (fun () -> notes.Delete noteId) (fun () -> noContent) ctx + +// --- Note types --- + +let listNoteTypes: EndpointHandler = + fun ctx -> + let notetypes = ctx.GetService() + + let toDto (notetype: NotetypeInfo) : NoteTypeDto = { + Id = notetype.Id + Name = notetype.Name + Fields = Array.ofList notetype.Fields + } + + query notetypes.List (fun list -> writeJson Status200OK (list |> List.map toDto)) ctx + +// --- Sync --- + +let fullSync (dto: FullSyncDto) : EndpointHandler = + fun ctx -> + let sync = ctx.GetService() -let fullSync (fullSync: FullSyncDto) : EndpointHandler = - fun (ctx: HttpContext) -> runCommand ctx (FullSync fullSync.Direction) + submit + JobKind.FullSync + (fun () -> sync.FullSync dto.Direction) + (fun () -> noContent) + (fun failure ctx -> writeError ctx Status502BadGateway $"Full sync failed: %A{failure}" None) + ctx diff --git a/src/Ankit.Server/Program.fs b/src/Ankit.Server/Program.fs index 5bd48b3..bfed0fe 100644 --- a/src/Ankit.Server/Program.fs +++ b/src/Ankit.Server/Program.fs @@ -2,6 +2,7 @@ module Ankit.Server.Main open System open System.IO +open System.Text.Json.Serialization open System.Threading.Channels open Anki.Collection open Ankit.Server @@ -10,7 +11,10 @@ open Ankit.Server.Env open Ankit.Server.Services open Ankit.Native open Ankit.Server.Utils +open Ankit.Server.ApiDocs +open Ankit.Server.Dto open Oxpecker +open Oxpecker.OpenApi open Microsoft.AspNetCore.Builder open Microsoft.Extensions.Configuration open Microsoft.Extensions.DependencyInjection @@ -19,11 +23,50 @@ open Microsoft.Extensions.Options let endpoints = [ GET [ route "/ping" Handlers.ping + |> describe "Ping" "Check that the server is up" None [ okText ] + + route "/decks" Handlers.listDecks + |> describe "ListDecks" "List all decks" None [ ok ] + + route "/notes" <| bindOrDeclineQuery Handlers.searchNotes + |> describe "SearchNotes" "Find the ids of the notes matching an Anki search" None [ ok; invalid ] + |> withQuery "query" "Anki search, e.g. \"deck:Japanese tag:verb\"; \"deck:*\" matches all notes" + + routef "/notes/{%d}" Handlers.getNote + |> describe "GetNote" "Get a note" None [ ok; notFound ] + + route "/note-types" Handlers.listNoteTypes + |> describe "ListNoteTypes" "List note types with their fields in order" None [ ok ] ] POST [ route "/decks" <| bindOrDeclineJson Handlers.createDeck + |> describe "CreateDeck" "Create a deck, \"::\" nests it under parent decks" + (Some typeof) [ created; invalid; shuttingDown ] + route "/notes" <| bindOrDeclineJson Handlers.addNote + |> describe "AddNote" "Add a note to a normal deck" + (Some typeof) [ created; invalid; shuttingDown ] + route "/sync/full" <| bindOrDeclineJson Handlers.fullSync + |> describe "FullSync" "Replace the server's collection with the local one (upload) or the other way round (download)" + (Some typeof) [ noContent; invalid; syncFailed; shuttingDown ] + ] + PUT [ + routef "/decks/{%d}" (fun deckId -> bindOrDeclineJson (fun dto -> Handlers.renameDeck dto deckId)) + |> describe "RenameDeck" "Rename a deck" + (Some typeof) [ noContent; notFound; invalid; shuttingDown ] + + routef "/notes/{%d}" (fun noteId -> bindOrDeclineJson (fun dto -> Handlers.updateNote dto noteId)) + |> describe "UpdateNote" "Replace the fields of a note, and its tags when given" + (Some typeof) [ noContent; notFound; invalid; shuttingDown ] + ] + DELETE [ + routef "/decks/{%d}" Handlers.deleteDeck + |> describe "DeleteDeck" "Delete a deck with its child decks and their cards; the default deck is only emptied" + None [ noContent; notFound; shuttingDown ] + + routef "/notes/{%d}" Handlers.deleteNote + |> describe "DeleteNote" "Delete a note with its cards" None [ noContent; notFound; shuttingDown ] ] ] @@ -52,6 +95,14 @@ let configureServices (configuration: IConfiguration) (services: IServiceCollect .Configure(configuration.GetSection SyncServerSettingsSection) .AddRouting() .AddOxpecker() + .AddOpenApi(fun options -> + options + .AddSchemaTransformer() + .AddSchemaTransformer() + .AddSchemaTransformer() + |> ignore) + // The OpenAPI generator reads these, not jsonOptions: the API doesn't accept numbers as strings + .ConfigureHttpJsonOptions(fun options -> options.SerializerOptions.NumberHandling <- JsonNumberHandling.Strict) .AddSingleton(SystemTextJsonSerializer(jsonOptions)) .AddSingleton(openBackend) .AddSingleton>( @@ -60,12 +111,18 @@ let configureServices (configuration: IConfiguration) (services: IServiceCollect .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddHostedService() |> ignore let configureApp (app: IApplicationBuilder) = app .UseRouting() + .UseSwaggerUI(fun options -> + options.SwaggerEndpoint("/openapi/v1.json", "Ankit") + options.DocumentTitle <- "Ankit API") .Use(ErrorHandler.errorHandler) .UseOxpecker(endpoints) .Run(ErrorHandler.notFoundHandler) @@ -75,5 +132,6 @@ configureServices builder.Configuration builder.Services let app = builder.Build() configureApp app +app.MapOpenApi() |> ignore app.Run() diff --git a/src/Ankit.Server/Services.fs b/src/Ankit.Server/Services.fs index 3bc25df..e910288 100644 --- a/src/Ankit.Server/Services.fs +++ b/src/Ankit.Server/Services.fs @@ -1,6 +1,10 @@ module Ankit.Server.Services open Anki.Backend +open Anki.Decks +open Anki.Notes +open Anki.Notetypes +open Anki.Search open Anki.Sync open Ankit.Native open Ankit.Server.Dto @@ -46,6 +50,26 @@ module SyncFailed = | InfraFail.BackendFailed(k, m) -> classify (k, m) | e -> SyncFailed.InfraFail e +/// Why a deck, note or notetype command failed +[] +type CommandFailed = + /// The deck, note or notetype doesn't exist + | NotFound of message: string + /// rslib refused the input, e.g. a wrong number of fields or a malformed search + | Rejected of message: string + | InfraFail of InfraFail + +type CommandResult<'a> = Result<'a, CommandFailed> + +[] +module CommandFailed = + let ofInfra (error: InfraFail) : CommandFailed = + match error with + | InfraFail.BackendFailed(BackendErrorKind.NotFoundError, msg) -> CommandFailed.NotFound msg + | InfraFail.BackendFailed((BackendErrorKind.InvalidInput | BackendErrorKind.SearchError | BackendErrorKind.CardTypeError | BackendErrorKind.Exists), + msg) -> CommandFailed.Rejected msg + | e -> CommandFailed.InfraFail e + module ServiceUtils = /// Converts expected backend/native exceptions into `Error`. Everything else @@ -61,6 +85,15 @@ module ServiceUtils = let guardCallSync (f: unit -> 'a) : SyncResult<'a> = guardCall f |> Result.mapError SyncFailed.ofInfra + let guardCommand (f: unit -> 'a) : CommandResult<'a> = guardCall f |> Result.mapError CommandFailed.ofInfra + + /// Replaces rslib's not-found text, which is written for the desktop app ("...use the Check Database action") + let whenNotFound (error: CommandFailed) (result: CommandResult<'a>) : CommandResult<'a> = + result + |> Result.mapError (function + | CommandFailed.NotFound _ -> error + | e -> e) + open ServiceUtils [] @@ -185,5 +218,142 @@ type AnkiSyncService(backend: RslibBackend, authService: IAuthService, logger: I } |> Result.teeError logFailure -type NoteService(logger: ILogger) = - member _.AddNote() = () +[] +type IDeckService = + abstract List: unit -> CommandResult + /// Creates a normal deck, "::" in the name nests it under parent decks + abstract Create: name: string -> CommandResult + abstract Rename: deckId: int64 * name: string -> CommandResult + /// Removes the deck and its child decks along with their cards. The default deck stays, emptied and with its name reset + abstract Delete: deckId: int64 -> CommandResult + +type AnkiDeckService(backend: RslibBackend) = + let missing (deckId: int64) = CommandFailed.NotFound $"Deck {deckId} doesn't exist" + + interface IDeckService with + member _.List() = + guardCommand (fun () -> + backend.GetDeckNames(GetDeckNamesRequest(IncludeFiltered = true)).Entries + |> List.ofSeq) + + member _.Create name = + guardCommand (fun () -> + let deck = backend.NewDeck() + deck.Name <- name.Trim() + backend.AddDeck(deck).Id) + + member _.Rename(deckId, name) = + guardCommand (fun () -> + backend.RenameDeck(RenameDeckRequest(DeckId = deckId, NewName = name.Trim())) + |> ignore) + |> whenNotFound (missing deckId) + + member _.Delete deckId = + guardCommand (fun () -> + // rslib skips unknown decks silently + backend.GetDeck(DeckId(Did = deckId)) |> ignore + let ids = DeckIds() + ids.Dids.Add deckId + backend.RemoveDecks ids |> ignore) + |> whenNotFound (missing deckId) + +/// The editable part of a note, fields are in the order of its notetype +type NoteContent = { + Fields: string list + Tags: string list +} + +[] +type INoteService = + abstract Get: noteId: int64 -> CommandResult + /// Ids of the notes matching an Anki search, e.g. "deck:Japanese tag:verb" + abstract Search: query: string -> CommandResult + /// Adds the note with its cards in `deckId`, which must be a normal deck + abstract Add: deckId: int64 * notetypeId: int64 * content: NoteContent -> CommandResult + /// Replaces the fields, and the tags when given + abstract Update: noteId: int64 * fields: string list * tags: string list option -> CommandResult + /// Removes the note with all of its cards + abstract Delete: noteId: int64 -> CommandResult + +type AnkiNoteService(backend: RslibBackend) = + let getNote (noteId: int64) = backend.GetNote(NoteId(Nid = noteId)) + + let missing (noteId: int64) = CommandFailed.NotFound $"Note {noteId} doesn't exist" + + interface INoteService with + member _.Get noteId = + guardCommand (fun () -> getNote noteId) |> whenNotFound (missing noteId) + + member _.Search query = + guardCommand (fun () -> backend.SearchNotes(SearchRequest(Search = query)).Ids |> List.ofSeq) + + member _.Add(deckId, notetypeId, content) = + result { + // rslib puts the cards into the default deck when the deck is missing or filtered, without telling + let! deck = + guardCommand (fun () -> backend.GetDeck(DeckId(Did = deckId))) + |> whenNotFound (CommandFailed.Rejected $"Deck {deckId} doesn't exist") + + if deck.KindCase = Deck.KindOneofCase.Filtered then + return! Error(CommandFailed.Rejected $"Deck {deckId} is filtered, notes can't be added to it") + + // rslib only says "missing note type" + do! + guardCommand (fun () -> backend.GetNotetype(NotetypeId(Ntid = notetypeId)) |> ignore) + |> whenNotFound (CommandFailed.Rejected $"Note type {notetypeId} doesn't exist") + + return! + guardCommand (fun () -> + let note = Note(NotetypeId = notetypeId) + note.Fields.AddRange content.Fields + note.Tags.AddRange content.Tags + backend.AddNote(AddNoteRequest(Note = note, DeckId = deckId)).NoteId) + } + + member _.Update(noteId, fields, tags) = + guardCommand (fun () -> + let note = getNote noteId + note.Fields.Clear() + note.Fields.AddRange fields + + tags + |> Option.iter (fun tags -> + note.Tags.Clear() + note.Tags.AddRange tags) + + let request = UpdateNotesRequest() + request.Notes.Add note + backend.UpdateNotes request |> ignore) + |> whenNotFound (missing noteId) + + member _.Delete noteId = + guardCommand (fun () -> + // rslib skips unknown notes silently + getNote noteId |> ignore + let request = RemoveNotesRequest() + request.NoteIds.Add noteId + backend.RemoveNotes request |> ignore) + |> whenNotFound (missing noteId) + +type NotetypeInfo = { + Id: int64 + Name: string + /// Field names in the order notes list their fields + Fields: string list +} + +[] +type INotetypeService = + abstract List: unit -> CommandResult + +type AnkiNotetypeService(backend: RslibBackend) = + interface INotetypeService with + member _.List() = + guardCommand (fun () -> + backend.GetNotetypeNames().Entries + |> Seq.map (fun entry -> { + Id = entry.Id + Name = entry.Name + Fields = backend.GetFieldNames(NotetypeId(Ntid = entry.Id)).Vals |> List.ofSeq + }) + |> List.ofSeq) diff --git a/src/Ankit.Server/Utils.fs b/src/Ankit.Server/Utils.fs index bdf1435..a329cbd 100644 --- a/src/Ankit.Server/Utils.fs +++ b/src/Ankit.Server/Utils.fs @@ -4,6 +4,9 @@ open System.ComponentModel.DataAnnotations open System.Text.Json open System.Text.Json.Serialization +/// How union case names are written, e.g. FullUpload as "FULL_UPLOAD"; reading ignores the case +let unionTagNaming = JsonNamingPolicy.SnakeCaseUpper + let jsonOptions = let opts = JsonSerializerOptions() opts.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase @@ -16,7 +19,8 @@ let jsonOptions = JsonFSharpOptions .Default() .WithUnionUnwrapFieldlessTags() - .WithUnionTagNamingPolicy(JsonNamingPolicy.SnakeCaseUpper) + .WithUnionTagNamingPolicy(unionTagNaming) + .WithUnionTagCaseInsensitive() .WithUnionNamedFields() .WithUnionTagName("code") .WithUnionInternalTag() diff --git a/src/Ankit.Server/paket.references b/src/Ankit.Server/paket.references index d955110..d3be082 100644 --- a/src/Ankit.Server/paket.references +++ b/src/Ankit.Server/paket.references @@ -2,3 +2,5 @@ group WebServer Oxpecker FsToolkit.ErrorHandling FSharp.SystemTextJson + Oxpecker.OpenApi + Swashbuckle.AspNetCore.SwaggerUI diff --git a/src/rslib-bindings/.cargo/config.toml b/src/rslib-bindings/.cargo/config.toml index 90636a9..f4777d1 100644 --- a/src/rslib-bindings/.cargo/config.toml +++ b/src/rslib-bindings/.cargo/config.toml @@ -4,3 +4,7 @@ PROTOC = { value = "tools/protoc/bin/protoc", relative = true } # Descriptor set of Anki's protos, written by rslib's build scripts. Kept at a stable path so that # Ankit.Native can generate its client (service and method indices) from it DESCRIPTORS_BIN = { value = "target/anki_descriptors.bin", relative = true } + +# musl targets (Alpine) link the C runtime statically by default, which rules out building a cdylib +[target.'cfg(target_env = "musl")'] +rustflags = ["-C", "target-feature=-crt-static"] diff --git a/tests/Ankit.Server.Tests/AnkiSyncWorkerTests.fs b/tests/Ankit.Server.Tests/AnkiSyncWorkerTests.fs new file mode 100644 index 0000000..0a160f4 --- /dev/null +++ b/tests/Ankit.Server.Tests/AnkiSyncWorkerTests.fs @@ -0,0 +1,108 @@ +namespace Ankit.Server.Tests + +open System +open System.Threading +open System.Threading.Channels +open System.Threading.Tasks +open Ankit.Server +open Ankit.Server.Env +open Ankit.Server.Services +open Microsoft.Extensions.Logging.Abstractions +open Microsoft.Extensions.Options +open Xunit + +/// Counts the syncs the worker starts, without a sync server +type private CountingSyncService() = + let mutable syncs = 0 + let synced = new SemaphoreSlim(0) + + member _.Syncs = Volatile.Read &syncs + + member _.WaitForSync(timeout: TimeSpan) = synced.WaitAsync timeout + + interface ISyncService with + member _.TrySync() = + Interlocked.Increment &syncs |> ignore + synced.Release() |> ignore + Ok() + + member _.FullSync _ = Ok() + +type AnkiSyncWorkerTests() = + let debounce = TimeSpan.FromMilliseconds 200. + let sync = CountingSyncService() + + let ch = + Channel.CreateUnbounded(UnboundedChannelOptions(SingleReader = true)) + + let settings = + let value = { + SyncDebounceTime = int64 debounce.TotalMilliseconds + CollectionPath = "" + } + + { + new IOptionsMonitor with + member _.CurrentValue = value + member _.Get _ = value + member _.OnChange _ = null + } + + let worker = + new AnkiSyncWorker(sync, ch, NullLogger.Instance, settings) + + do worker.StartAsync(CancellationToken.None).Wait() + + let submit kind (job: unit -> Result<'a, string>) = AnkiSyncMsg.submit ch kind job CancellationToken.None + + /// Long enough for a debounce window to close and its sync to start + let settle () = Task.Delay(debounce * 3.) + + [] + member _.``Changes in one window are synced once``() : Task = + task { + for i in 1..3 do + let! result = submit JobKind.Change (fun () -> Ok i) + Assert.Equal(Ok i, result) + + let! synced = sync.WaitForSync(TimeSpan.FromSeconds 10.) + Assert.True(synced, "No sync after the changes") + do! settle () + Assert.Equal(1, sync.Syncs) + } + + [] + member _.``Failed change is not synced``() : Task = + task { + let! result = submit JobKind.Change (fun () -> Error "rejected") + Assert.Equal(Error "rejected", result) + + do! settle () + Assert.Equal(0, sync.Syncs) + } + + [] + member _.``Full sync makes the pending sync unnecessary``() : Task = + task { + let! _ = submit JobKind.Change (fun () -> Ok()) + let! _ = submit JobKind.FullSync (fun () -> Ok()) + + do! settle () + Assert.Equal(0, sync.Syncs) + } + + [] + member _.``Exception of a job reaches the caller and the worker keeps running``() : Task = + task { + let! _ = + Assert.ThrowsAsync(fun () -> + submit JobKind.Change (fun () -> invalidOp "boom") :> Task) + + let! result = submit JobKind.Change (fun () -> Ok 42) + Assert.Equal(Ok 42, result) + } + + interface IDisposable with + member _.Dispose() = + worker.StopAsync(CancellationToken.None).Wait() + worker.Dispose() diff --git a/tests/Ankit.Server.Tests/Ankit.Server.Tests.fsproj b/tests/Ankit.Server.Tests/Ankit.Server.Tests.fsproj index c81dff8..c77adb3 100644 --- a/tests/Ankit.Server.Tests/Ankit.Server.Tests.fsproj +++ b/tests/Ankit.Server.Tests/Ankit.Server.Tests.fsproj @@ -8,7 +8,12 @@ + + + + + diff --git a/tests/Ankit.Server.Tests/CommandAssert.fs b/tests/Ankit.Server.Tests/CommandAssert.fs new file mode 100644 index 0000000..6936746 --- /dev/null +++ b/tests/Ankit.Server.Tests/CommandAssert.fs @@ -0,0 +1,19 @@ +module Ankit.Server.Tests.CommandAssert + +open Ankit.Server.Services +open Xunit + +let ok (result: CommandResult<'a>) : 'a = + match result with + | Ok value -> value + | Error e -> failwith $"Expected success, got %A{e}" + +let notFound (result: CommandResult<'a>) = + match result with + | Error(CommandFailed.NotFound _) -> () + | other -> Assert.Fail $"Expected NotFound, got %A{other}" + +let rejected (result: CommandResult<'a>) = + match result with + | Error(CommandFailed.Rejected _) -> () + | other -> Assert.Fail $"Expected Rejected, got %A{other}" diff --git a/tests/Ankit.Server.Tests/DeckServiceTests.fs b/tests/Ankit.Server.Tests/DeckServiceTests.fs new file mode 100644 index 0000000..e418ed8 --- /dev/null +++ b/tests/Ankit.Server.Tests/DeckServiceTests.fs @@ -0,0 +1,66 @@ +namespace Ankit.Server.Tests + +open System +open Ankit.Server.Services +open Ankit.Server.Tests.CommandAssert +open Xunit + +type DeckServiceTests() = + let collection = new TempCollection() + let decks: IDeckService = AnkiDeckService collection.Backend + + let listed () = + ok (decks.List()) |> List.map (fun deck -> deck.Id, deck.Name) + + [] + member _.``New collection lists the default deck``() = Assert.Contains((1L, "Default"), listed ()) + + [] + member _.``Created deck is listed under its id with a trimmed name``() = + let id = ok (decks.Create " Japanese ") + + Assert.Contains((id, "Japanese"), listed ()) + + [] + member _.``Nested deck creates its parents``() = + let id = ok (decks.Create "Japanese::Kanji") + + let names = listed () + Assert.Contains((id, "Japanese::Kanji"), names) + Assert.Contains("Japanese", names |> List.map snd) + + [] + member _.``Renamed deck keeps its id``() = + let id = ok (decks.Create "Japanese") + + ok (decks.Rename(id, "日本語")) + + let names = listed () + Assert.Contains((id, "日本語"), names) + Assert.DoesNotContain("Japanese", names |> List.map snd) + + [] + member _.``Renaming a missing deck is not found``() = + Assert.Equal(Error(CommandFailed.NotFound "Deck 424242 doesn't exist"), decks.Rename(424242L, "Nowhere")) + + [] + member _.``Deleted deck is no longer listed``() = + let id = ok (decks.Create "Temporary") + + ok (decks.Delete id) + + Assert.DoesNotContain(id, listed () |> List.map fst) + + [] + member _.``Deleting the default deck keeps it under its default name``() = + ok (decks.Rename(1L, "Inbox")) + + ok (decks.Delete 1L) + + Assert.Contains((1L, "Default"), listed ()) + + [] + member _.``Deleting a missing deck is not found``() = notFound (decks.Delete 424242L) + + interface IDisposable with + member _.Dispose() = (collection :> IDisposable).Dispose() diff --git a/tests/Ankit.Server.Tests/NoteServiceTests.fs b/tests/Ankit.Server.Tests/NoteServiceTests.fs new file mode 100644 index 0000000..a38fa3a --- /dev/null +++ b/tests/Ankit.Server.Tests/NoteServiceTests.fs @@ -0,0 +1,116 @@ +namespace Ankit.Server.Tests + +open System +open Ankit.Server.Services +open Ankit.Server.Tests.CommandAssert +open Xunit + +type NoteServiceTests() = + let collection = new TempCollection() + let notes: INoteService = AnkiNoteService collection.Backend + + let basic = + collection.Backend.GetNotetypeIdByName(Anki.Generic.String(Val = "Basic")).Ntid + + let defaultDeck = 1L + + let content fields tags = { + Fields = fields + Tags = tags + } + + let addBasic front back tags = + ok (notes.Add(defaultDeck, basic, content [ front; back ] tags)) + + [] + member _.``Added note can be read back``() = + let id = addBasic "犬" "dog" [ "animal" ] + + let note = ok (notes.Get id) + + Assert.Equal(id, note.Id) + Assert.Equal(basic, note.NotetypeId) + Assert.Equal([ "犬"; "dog" ], List.ofSeq note.Fields) + Assert.Equal([ "animal" ], List.ofSeq note.Tags) + + [] + member _.``Note with a wrong number of fields is rejected``() = + rejected (notes.Add(defaultDeck, basic, content [ "only front" ] [])) + + [] + member _.``Note of a missing note type is rejected``() = + rejected (notes.Add(defaultDeck, 424242L, content [ "犬"; "dog" ] [])) + + [] + member _.``Note in a missing deck is rejected``() = + rejected (notes.Add(424242L, basic, content [ "犬"; "dog" ] [])) + + [] + member _.``Note in a filtered deck is rejected``() = + addBasic "犬" "dog" [] |> ignore + + let filtered = + collection.Backend.GetOrCreateFilteredDeck(Anki.Decks.DeckId(Did = 0L)) + + filtered.Name <- "Review" + filtered.Config.SearchTerms[0].Search <- "dog" + let filteredId = collection.Backend.AddOrUpdateFilteredDeck(filtered).Id + + rejected (notes.Add(filteredId, basic, content [ "猫"; "cat" ] [])) + + [] + member _.``Reading a missing note is not found``() = notFound (notes.Get 424242L) + + [] + member _.``Search finds notes by content and tag``() = + let dog = addBasic "犬" "dog" [ "animal" ] + let cat = addBasic "猫" "cat" [ "animal" ] + let _ = addBasic "水" "water" [] + + Assert.Equal([ dog ], ok (notes.Search "dog")) + Assert.Equal>(set [ dog; cat ], set (ok (notes.Search "tag:animal"))) + + [] + member _.``Malformed search is rejected``() = rejected (notes.Search "(dog") + + [] + member _.``Update replaces the fields and keeps the tags``() = + let id = addBasic "犬" "dgo" [ "animal" ] + + ok (notes.Update(id, [ "犬"; "dog" ], None)) + + let note = ok (notes.Get id) + Assert.Equal([ "犬"; "dog" ], List.ofSeq note.Fields) + Assert.Equal([ "animal" ], List.ofSeq note.Tags) + + [] + member _.``Update replaces the tags when given``() = + let id = addBasic "犬" "dog" [ "animal" ] + + ok (notes.Update(id, [ "犬"; "dog" ], Some [ "pet"; "n5" ])) + + Assert.Equal>(set [ "pet"; "n5" ], set (ok (notes.Get id)).Tags) + + [] + member _.``Update with a wrong number of fields is rejected``() = + let id = addBasic "犬" "dog" [] + + rejected (notes.Update(id, [ "犬"; "dog"; "extra" ], None)) + + [] + member _.``Updating a missing note is not found``() = notFound (notes.Update(424242L, [ "犬"; "dog" ], None)) + + [] + member _.``Deleted note is gone``() = + let id = addBasic "犬" "dog" [] + + ok (notes.Delete id) + + notFound (notes.Get id) + Assert.Empty(ok (notes.Search "dog")) + + [] + member _.``Deleting a missing note is not found``() = notFound (notes.Delete 424242L) + + interface IDisposable with + member _.Dispose() = (collection :> IDisposable).Dispose() diff --git a/tests/Ankit.Server.Tests/NotetypeServiceTests.fs b/tests/Ankit.Server.Tests/NotetypeServiceTests.fs new file mode 100644 index 0000000..326db47 --- /dev/null +++ b/tests/Ankit.Server.Tests/NotetypeServiceTests.fs @@ -0,0 +1,29 @@ +namespace Ankit.Server.Tests + +open System +open Ankit.Server.Services +open Ankit.Server.Tests.CommandAssert +open Xunit + +type NotetypeServiceTests() = + let collection = new TempCollection() + let notetypes: INotetypeService = AnkiNotetypeService collection.Backend + + [] + member _.``Stock note types are listed with their fields in order``() = + let listed = ok (notetypes.List()) + let fieldsOf name = (listed |> List.find (fun nt -> nt.Name = name)).Fields + + Assert.Equal([ "Front"; "Back" ], fieldsOf "Basic") + Assert.Equal([ "Text"; "Back Extra" ], fieldsOf "Cloze") + + [] + member _.``Listed ids identify the note types``() = + for nt in ok (notetypes.List()) do + let byName = + collection.Backend.GetNotetypeIdByName(Anki.Generic.String(Val = nt.Name)).Ntid + + Assert.Equal(byName, nt.Id) + + interface IDisposable with + member _.Dispose() = (collection :> IDisposable).Dispose()