Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,13 @@
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
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
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
286 changes: 286 additions & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Scripts/build.fsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,10 @@ Target.create "Before-Push" doNothing
"Verify-Format"
]


"Format" ?=> "Verify-Format"
"Remove-BOM" ?=> "Verify-Encoding"

"Before-Push" <== [
"Test"
"Prepare-Sources"
Expand Down
2 changes: 1 addition & 1 deletion Scripts/gen-workflow.fsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
39 changes: 28 additions & 11 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions paket.dependencies
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions paket.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
88 changes: 49 additions & 39 deletions src/Ankit.Server/AnkiSyncWorker.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,63 @@ 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
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
[<RequireQualifiedAccess>]
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
[<RequireQualifiedAccess>]
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<AnkiSyncMsg>)
(kind: JobKind)
(job: unit -> Result<'a, 'e>)
(ct: CancellationToken)
: Task<Result<'a, 'e>> =
task {
let reply =
TaskCompletionSource<Result<'a, 'e>>(TaskCreationOptions.RunContinuationsAsynchronously)

type AnkiSyncMsg = AnkiSyncMsg of cmd: AnkiCommand * reply: TaskCompletionSource<Result<obj, AnkiError>>
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<AnkiSyncMsg>,
logger: ILogger<AnkiSyncWorker>,
Expand All @@ -38,22 +70,6 @@ type AnkiSyncWorker
let getDebounceTime () =
TimeSpan.FromMilliseconds(float settings.CurrentValue.SyncDebounceTime)

let applyCommand (cmd: AnkiCommand) : Result<obj, AnkiError> =
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.")
Expand All @@ -65,15 +81,10 @@ type AnkiSyncWorker
let mutable msg = Unchecked.defaultof<AnkiSyncMsg>

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
Expand Down Expand Up @@ -106,8 +117,7 @@ type AnkiSyncWorker
let mutable msg = Unchecked.defaultof<AnkiSyncMsg>

while rd.TryRead(&msg) do
let (AnkiSyncMsg(_, reply)) = msg
reply.TrySetCanceled(stoppingToken) |> ignore
msg.Cancel stoppingToken

logger.LogInformation("AnkiSyncWorker is stopping due to cancellation.")
}
1 change: 1 addition & 0 deletions src/Ankit.Server/Ankit.Server.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<Compile Include="AnkiSyncWorker.fs" />
<Compile Include="ErrorHandler.fs" />
<Compile Include="Handlers.fs" />
<Compile Include="ApiDocs.fs" />
<Compile Include="Program.fs"/>
</ItemGroup>

Expand Down
118 changes: 118 additions & 0 deletions src/Ankit.Server/ApiDocs.fs
Original file line number Diff line number Diff line change
@@ -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<JsonNode>(
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<option<_>>

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<string>, [| "text/plain" |], Status200OK)
let created = ResponseBody(typeof<CreatedDto>, statusCode = Status201Created)

/// Marker for a response without a body, `describe` adds it to the operation itself
let noContent = ResponseBody(typeof<unit>, statusCode = Status204NoContent)

let notFound = ResponseBody(typeof<ApiError>, statusCode = Status404NotFound)

/// Failed validation (with fieldErrors) or a command rslib rejected
let invalid =
ResponseBody(typeof<ValidationError>, statusCode = Status422UnprocessableEntity)

let syncFailed = ResponseBody(typeof<ApiError>, statusCode = Status502BadGateway)

let shuttingDown =
ResponseBody(typeof<ApiError>, 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))
Loading
Loading