From 5bce3fa1b4e96c2c1e5e7cdae40404750b2a8911 Mon Sep 17 00:00:00 2001 From: Nils Wrenger Date: Sun, 18 Aug 2024 12:03:17 +0200 Subject: [PATCH] gluer integration (#19) * :sparkles: Added gluer * :wrench: Added custom error type to api gen * :wrench: gluer runs now completely on macro expansion (compile time) * :wrench: Added fittingly to serde metadata struct conversions * :sparkles: Added more specific project routes for the api generation * :sparkles: Finished integration on frontend and in build * :fire: Removed generated api from git * :wrench: Improved conversion of optional fields --- .github/workflows/dist.yml | 44 +- .gitignore | 3 + Cargo.toml | 3 +- lib-view/package.json | 32 +- lib-view/src/lib/api.ts | 520 ------------------ .../components/ui/date-input/DateInput.svelte | 4 +- .../ui/dialog/dialog-content.svelte | 4 +- .../components/ui/dialog/dialog-header.svelte | 2 +- .../ui/select-account/SelectAccount.svelte | 5 +- .../ui/virtual-list/VirtualList.svelte | 6 +- lib-view/src/lib/index.ts | 86 +++ lib-view/src/routes/+layout.svelte | 13 +- lib-view/src/routes/Reminder.svelte | 23 +- lib-view/src/routes/books/+page.svelte | 8 +- lib-view/src/routes/books/BookDialog.svelte | 26 +- lib-view/src/routes/books/BookDisplay.svelte | 16 +- lib-view/src/routes/books/BookSelect.svelte | 2 +- lib-view/src/routes/books/DeleteDialog.svelte | 4 +- lib-view/src/routes/books/LendDialog.svelte | 6 +- .../src/routes/books/ReleaseDialog.svelte | 4 +- .../src/routes/books/ReserveDialog.svelte | 4 +- lib-view/src/routes/books/ReturnDialog.svelte | 28 +- lib-view/src/routes/info/+page.svelte | 19 +- lib-view/src/routes/overdues/+page.svelte | 3 +- .../src/routes/settings/EditCategory.svelte | 10 +- lib-view/src/routes/settings/Global.svelte | 8 +- lib-view/src/routes/users/+page.svelte | 2 +- lib-view/src/routes/users/DeleteDialog.svelte | 4 +- lib-view/src/routes/users/UserDialog.svelte | 16 +- lib-view/src/routes/users/UserDisplay.svelte | 4 +- lib-view/src/routes/users/UserItem.svelte | 8 +- lib-view/src/routes/users/UserSelect.svelte | 2 +- src/db/book.rs | 9 + src/db/category.rs | 2 + src/db/mod.rs | 6 + src/db/user.rs | 5 + src/error.rs | 5 +- src/provider/dnb.rs | 2 + src/server/api.rs | 133 +++-- src/server/auth.rs | 3 + 40 files changed, 374 insertions(+), 710 deletions(-) delete mode 100644 lib-view/src/lib/api.ts diff --git a/.github/workflows/dist.yml b/.github/workflows/dist.yml index 08aa493..086f699 100644 --- a/.github/workflows/dist.yml +++ b/.github/workflows/dist.yml @@ -9,28 +9,6 @@ env: CARGO_TERM_COLOR: always jobs: - build-svelte: - name: Build Svelte - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: "latest" - - name: Install Svelte - run: bun install - working-directory: lib-view - - name: Build Svelte - run: bun run build - working-directory: lib-view - - name: Upload - uses: actions/upload-artifact@v4 - with: - name: lib-view - path: lib-view/build - build-rust: name: Build ${{ matrix.os }} runs-on: ${{ matrix.os }} @@ -53,6 +31,28 @@ jobs: name: ${{ matrix.exe }} path: target/release/${{ matrix.exe }} + build-svelte: + name: Build Svelte + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: "latest" + - name: Install Svelte + run: bun install + working-directory: lib-view + - name: Build Svelte + run: bun run build + working-directory: lib-view + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: lib-view + path: lib-view/build + package: name: Package ${{ matrix.platform }} runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 8b06601..e845165 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ build /test/cert/* !/test/cert/gen.sh +# Generated API +lib-view/src/lib/api.ts + # Misc .vscode .DS_Store diff --git a/Cargo.toml b/Cargo.toml index 10334b3..56c4dce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "schiller-lib" description = "Schiller library software" -version = "0.10.0" +version = "0.10.1" authors = ["Lars Wrenger ", "Nils Wrenger "] edition = "2021" readme = "README.md" @@ -23,6 +23,7 @@ chrono = "0.4" clap = { version = "4.5", features = ["derive"] } csv = "1.3" email_address = "0.2" +gluer = "0.8.2" hyper = "1.4" hyper-util = "0.1" lettre = { version = "0.11", default-features = false, features = [ diff --git a/lib-view/package.json b/lib-view/package.json index a79e6c7..d1ca7e1 100644 --- a/lib-view/package.json +++ b/lib-view/package.json @@ -1,6 +1,6 @@ { "name": "schiller-lib", - "version": "0.10.0", + "version": "0.10.1", "private": true, "scripts": { "dev": "vite dev", @@ -12,38 +12,38 @@ "format": "prettier --write ." }, "devDependencies": { - "@sveltejs/adapter-auto": "^3.2.2", - "@sveltejs/kit": "^2.5.18", + "@sveltejs/adapter-auto": "^3.2.3", + "@sveltejs/kit": "^2.5.21", "@sveltejs/vite-plugin-svelte": "^3.1.1", "prettier": "^3.3.3", "prettier-plugin-svelte": "^3.2.6", "svelte": "^4.2.18", - "svelte-check": "^3.8.4", + "svelte-check": "^3.8.5", "tslib": "^2.6.3", - "typescript": "^5.5.3", - "vite": "^5.3.4", - "tailwindcss": "^3.4.6", - "postcss": "^8.4.39", - "autoprefixer": "^10.4.19", + "typescript": "^5.5.4", + "vite": "^5.4.0", + "tailwindcss": "^3.4.9", + "postcss": "^8.4.41", + "autoprefixer": "^10.4.20", "prettier-plugin-tailwindcss": "^0.5.14" }, "type": "module", "dependencies": { "@internationalized/date": "^3.5.5", - "@sveltejs/adapter-static": "^3.0.2", + "@sveltejs/adapter-static": "^3.0.3", "@types/luxon": "^3.4.2", - "ajv": "^8.17.1", - "bits-ui": "^0.21.12", + "bits-ui": "^0.21.13", + "clsx": "^2.1.1", "cmdk-sv": "^0.0.17", - "lucide-svelte": "^0.414.0", - "luxon": "^3.4.4", - "mode-watcher": "^0.4.0", + "lucide-svelte": "^0.427.0", + "luxon": "^3.5.0", + "mode-watcher": "^0.4.1", "paneforge": "^0.0.5", "sass": "^1.77.8", "svelte-i18n": "^4.0.0", "svelte-persisted-store": "^0.11.0", "svelte-sonner": "^0.3.27", - "tailwind-merge": "^2.4.0", + "tailwind-merge": "^2.5.1", "tailwind-variants": "^0.2.1" } } diff --git a/lib-view/src/lib/api.ts b/lib-view/src/lib/api.ts deleted file mode 100644 index 6007f27..0000000 --- a/lib-view/src/lib/api.ts +++ /dev/null @@ -1,520 +0,0 @@ -import { _ } from 'svelte-i18n'; -import Ajv, { type JTDParser, type JTDSchemaType } from 'ajv/dist/jtd'; -import { toast } from 'svelte-sonner'; - -namespace api { - const ajv = new Ajv(); - - export interface About { - name: string; - version: string; - repository: string; - authors: string[]; - description: string; - license: string; - } - const parse_about = ajv.compileParser({ - properties: { - name: { type: 'string' }, - version: { type: 'string' }, - repository: { type: 'string' }, - authors: { elements: { type: 'string' } }, - description: { type: 'string' }, - license: { type: 'string' } - } - }); - - export interface Stats { - books: number; - users: number; - categories: number; - borrows: number; - reservations: number; - overdues: number; - } - const parse_stats = ajv.compileParser({ - properties: { - books: { type: 'uint32' }, - users: { type: 'uint32' }, - categories: { type: 'uint32' }, - borrows: { type: 'uint32' }, - reservations: { type: 'uint32' }, - overdues: { type: 'uint32' } - } - }); - - export interface Session { - id: string; - username: string; - } - const parse_session = ajv.compileParser({ - properties: { - id: { type: 'string' }, - username: { type: 'string' } - } - }); - - export interface MailTemplate { - subject: string; - body: string; - } - const schema_mail_template: JTDSchemaType = { - properties: { - subject: { type: 'string' }, - body: { type: 'string' } - } - }; - - export interface Settings { - // Borrowing - borrowing_duration: number; - // DNB - dnb_token: string; - // Mail - mail_last_reminder: string; - mail_from: string; - mail_host: string; - mail_password: string; - // Mail Templates - mail_info: MailTemplate; - mail_overdue: MailTemplate; - mail_overdue2: MailTemplate; - } - const parse_settings = ajv.compileParser({ - properties: { - borrowing_duration: { type: 'uint32' }, - dnb_token: { type: 'string' }, - mail_last_reminder: { type: 'string' }, - mail_from: { type: 'string' }, - mail_host: { type: 'string' }, - mail_password: { type: 'string' }, - mail_info: schema_mail_template, - mail_overdue: schema_mail_template, - mail_overdue2: schema_mail_template - } - }); - - export interface Borrower { - user: string; - deadline: string; - } - - export interface Book { - id: string; - isbn: string; - title: string; - publisher: string; - year: number; - costs: number; - note?: string; - borrowable: boolean; - category: string; - authors: string; - borrower?: Borrower; - reservation?: string; - } - const schema_book: JTDSchemaType = { - properties: { - id: { type: 'string' }, - isbn: { type: 'string' }, - title: { type: 'string' }, - publisher: { type: 'string' }, - year: { type: 'uint32' }, - costs: { type: 'float32' }, - borrowable: { type: 'boolean' }, - category: { type: 'string' }, - authors: { type: 'string' } - }, - optionalProperties: { - note: { type: 'string' }, - reservation: { type: 'string' }, - borrower: { - properties: { - user: { type: 'string' }, - deadline: { type: 'string' } - } - } - } - }; - - const parse_book = ajv.compileParser(schema_book); - const parse_partial_book = ajv.compileParser>({ - optionalProperties: { - ...schema_book.properties, - ...schema_book.optionalProperties - } - }); - - export type BookState = 'None' | 'Borrowable' | 'NotBorrowable' | 'Borrowed' | 'Reserved'; - - export interface BookSearch { - query?: string; - category?: string; - state?: BookState; - offset?: number; - limit?: number; - } - - export interface User { - account: string; - forename: string; - surname: string; - role: string; - may_borrow: boolean; - } - function userDef(u: Partial): User { - return { - account: u.account ?? '', - forename: u.forename ?? '', - surname: u.surname ?? '', - role: u.role ?? '', - may_borrow: u.may_borrow ?? true - }; - } - const schema_user: JTDSchemaType> = { - optionalProperties: { - account: { type: 'string' }, - forename: { type: 'string' }, - surname: { type: 'string' }, - role: { type: 'string' }, - may_borrow: { type: 'boolean' } - } - }; - const parse_user = ajv.compileParser(schema_user); - - export interface UserSearch { - query?: string; - may_borrow?: boolean; - offset?: number; - limit?: number; - } - - export interface Category { - id: string; - name: string; - section: string; - } - const schema_category: JTDSchemaType = { - properties: { - id: { type: 'string' }, - name: { type: 'string' }, - section: { type: 'string' } - } - }; - const parse_categories = ajv.compileParser({ - elements: schema_category - }); - - export interface MailBody { - account: string; - subject: string; - body: string; - } - - export interface Limited { - total: number; - rows: T[]; - } - const parse_limited_books = ajv.compileParser>({ - properties: { - total: { type: 'uint32' }, - rows: { elements: schema_book } - } - }); - const parse_limited_users = ajv.compileParser>>({ - properties: { - total: { type: 'uint32' }, - rows: { elements: schema_user } - } - }); - - export interface Overdue { - book: Book; - user: User; - } - const parse_overdues = ajv.compileParser<{ book: Book; user: Partial }[]>({ - elements: { - properties: { - book: schema_book, - user: schema_user - } - } - }); - - export type QueryParam = Record; - - export function keys(obj: T) { - return Object.keys(obj) as Array; - } - - // ------------------------------------------------------------------------- - // General - // ------------------------------------------------------------------------- - - export async function about(): Promise { - return get('api/about', parse_about); - } - export async function stats(): Promise { - return get('api/stats', parse_stats); - } - export async function session(): Promise { - return get('api/session', parse_session); - } - - export async function settings(): Promise { - return get('api/settings', parse_settings); - } - export async function settings_update(settings: Partial) { - await post('api/settings', settings); - } - - // ------------------------------------------------------------------------- - // Book - // ------------------------------------------------------------------------- - - export async function book_search(query: BookSearch): Promise> { - return get('api/book', parse_limited_books, query); - } - export async function book_add(book: Book) { - await post('api/book', book); - } - export async function book(id: string): Promise { - return get('api/book/' + encodeURIComponent(id), parse_book); - } - export async function book_update(id: string, book: Book) { - await post('api/book/' + encodeURIComponent(id), book); - } - export async function book_delete(id: string) { - await del('api/book/' + encodeURIComponent(id)); - } - export async function book_id(book: Book): Promise { - const parse_book_id = ajv.compileParser({ type: 'string' }); - return post_get('api/book-id', book, parse_book_id); - } - export async function book_fetch(isbn: string): Promise> { - return get('api/book-fetch/' + encodeURIComponent(isbn), parse_partial_book); - } - - // ------------------------------------------------------------------------- - // User - // ------------------------------------------------------------------------- - - export async function user_search(query: UserSearch): Promise> { - return get('api/user', parse_limited_users, query).then((l) => ({ - total: l.total, - rows: l.rows.map(userDef) - })); - } - export async function user_add(user: User) { - await post('api/user', user); - } - export async function user(account: string): Promise { - return get('api/user/' + encodeURIComponent(account), parse_user).then(userDef); - } - export async function user_update(account: string, user: User) { - await post('api/user/' + encodeURIComponent(account), user); - } - export async function user_delete(account: string) { - await del('api/user/' + encodeURIComponent(account)); - } - export async function user_fetch(account: string): Promise { - return get('api/user-fetch/' + encodeURIComponent(account), parse_user).then(userDef); - } - export async function user_update_roles() { - await post('api/user-update-roles', {}); - } - - // ------------------------------------------------------------------------- - // Category - // ------------------------------------------------------------------------- - - export async function categories(): Promise { - return get('api/category', parse_categories); - } - export async function category_add(category: Category) { - await post('api/category', category); - } - export async function category_update(id: string, category: Category) { - await post('api/category/' + encodeURIComponent(id), category); - } - export async function category_delete(id: string) { - await del('api/category/' + encodeURIComponent(id)); - } - - // ------------------------------------------------------------------------- - // Lending - // ------------------------------------------------------------------------- - - export async function lend(id: string, account: string, deadline: string | null): Promise { - return post_get('api/lending/lend', {}, parse_book, { id, account, deadline }); - } - export async function return_back(id: string): Promise { - return post_get('api/lending/return', {}, parse_book, { id }); - } - export async function reserve(id: string, account: string): Promise { - return post_get('api/lending/reserve', {}, parse_book, { id, account }); - } - export async function release(id: string): Promise { - return post_get('api/lending/release', {}, parse_book, { id }); - } - - // ------------------------------------------------------------------------- - // Mail - // ------------------------------------------------------------------------- - - export async function mail(mails: MailBody[]) { - await post('api/notify', mails); - } - - // ------------------------------------------------------------------------- - // Overdues - // ------------------------------------------------------------------------- - - export async function overdues(): Promise { - return get('api/overdues', parse_overdues).then((o) => - o.map((e) => ({ - book: e.book, - user: userDef(e.user) - })) - ); - } - - /** Fetches the data, throwing an exception if something went wrong */ - async function get(url: string, parse: JTDParser, query: QueryParam = {}): Promise { - let response = await fetch(url + query_str(query), { method: 'GET' }); - if (response.ok) { - let result = parse(await response.text()); - if (result) return result; - console.error(parse.message); - error('InvalidFormat'); - } - error(await response.json()); - } - - /** Posts/updates the data, throwing an exception if something went wrong */ - async function post(url: string, data: any, query: QueryParam = {}) { - let response = await fetch(url + query_str(query), { - method: 'POST', - headers: { - 'Content-Type': 'application/json; charset=utf-8' - }, - body: JSON.stringify(data) - }); - if (response.ok) return; - - error(await response.json()); - } - - /** Posts/updates the data, throwing an exception if something went wrong */ - async function post_get( - url: string, - data: any, - parse: JTDParser, - query: QueryParam = {} - ): Promise { - let response = await fetch(url + query_str(query), { - method: 'POST', - headers: { - 'Content-Type': 'application/json; charset=utf-8' - }, - body: JSON.stringify(data) - }); - if (response.ok) { - let result = parse(await response.text()); - if (result) return result; - console.error(parse.message); - error('InvalidFormat'); - } - - error(await response.json()); - } - - /** Deletes the data, throwing an exception if something went wrong */ - async function del(url: string, query: QueryParam = {}) { - let response = await fetch(url + query_str(query), { method: 'DELETE' }); - if (response.ok) return; - - error(await response.json()); - } - - /** Safely create a valid query string from the provided query parameters */ - function query_str(params: QueryParam): string { - if (params) { - let data: Record = {}; - for (let key in params) { - if (params[key] != null) data[key] = params[key].toString(); - } - // the URLSearchParams escapes any problematic values - return '?' + new URLSearchParams(data).toString(); - } - return ''; - } - - /** For api errors, Opens a toast */ - function error(error: string): never { - let errorLocalized: string = ''; - _.subscribe((_) => (errorLocalized = _(error_msg(error)))); - - toast.error(errorLocalized); - - throw error; - } - - /** Server Error translations */ - export function error_msg(string: any): string { - switch (string) { - case 'Arguments': - return '.error.input'; - case 'FileOpen': - return '.error.file-open'; - case 'Network': - return '.error.network'; - case 'InvalidFormat': - return '.error.format'; - case 'NothingFound': - return '.error.none'; - case 'ReferencedUser': - return '.user.referenced.del'; - case 'ReferencedCategory': - return '.category.not-empty.del'; - case 'InvalidBook': - return '.book.invalid'; - case 'InvalidUser': - return '.user.invalid'; - case 'LendingUserMayNotBorrow': - return '.error.lending.user'; - case 'LendingBookNotBorrowable': - return '.error.lending.book'; - case 'LendingBookAlreadyBorrowed': - return '.error.lending.already-borrowed'; - case 'LendingBookAlreadyBorrowedByUser': - return '.error.lending.already-borrowed-by'; - case 'LendingBookNotBorrowed': - return '.error.lending.not-borrowed'; - case 'LendingBookAlreadyReserved': - return '.error.lending.already-reserved'; - case 'LendingBookNotReserved': - return '.error.lending.not-reserved'; - case 'UnsupportedProjectVersion': - return '.error.update'; - default: - return '.error.unknown'; - } - } - - /** Replaces the placeholders in the mail templates */ - export function mail_replace( - template: MailTemplate, - booktitle: string, - username: string - ): MailTemplate { - return { - subject: template.subject - .replaceAll('{booktitle}', booktitle) - .replaceAll('{username}', username), - body: template.body.replaceAll('{booktitle}', booktitle).replaceAll('{username}', username) - }; - } -} - -export default api; diff --git a/lib-view/src/lib/components/ui/date-input/DateInput.svelte b/lib-view/src/lib/components/ui/date-input/DateInput.svelte index 89c81da..37cde31 100644 --- a/lib-view/src/lib/components/ui/date-input/DateInput.svelte +++ b/lib-view/src/lib/components/ui/date-input/DateInput.svelte @@ -16,9 +16,7 @@
- + Close diff --git a/lib-view/src/lib/components/ui/dialog/dialog-header.svelte b/lib-view/src/lib/components/ui/dialog/dialog-header.svelte index ac663bc..2597fc1 100644 --- a/lib-view/src/lib/components/ui/dialog/dialog-header.svelte +++ b/lib-view/src/lib/components/ui/dialog/dialog-header.svelte @@ -8,6 +8,6 @@ export { className as class }; -
+
diff --git a/lib-view/src/lib/components/ui/select-account/SelectAccount.svelte b/lib-view/src/lib/components/ui/select-account/SelectAccount.svelte index 2512b78..5a8112a 100644 --- a/lib-view/src/lib/components/ui/select-account/SelectAccount.svelte +++ b/lib-view/src/lib/components/ui/select-account/SelectAccount.svelte @@ -8,6 +8,7 @@ import { onMount, tick } from 'svelte'; import api from '$lib/api'; import { _ } from 'svelte-i18n'; + import { handle_result } from '$lib'; export let account: string; export let id: string = ''; @@ -28,7 +29,9 @@ } async function fetch(value: string) { - items = await api.user_search({ query: value, limit: LIMIT }); + items = handle_result( + await api.user_search({ query: value, may_borrow: null, offset: 0, limit: LIMIT }) + ); items.rows.sort(); } diff --git a/lib-view/src/lib/components/ui/virtual-list/VirtualList.svelte b/lib-view/src/lib/components/ui/virtual-list/VirtualList.svelte index 4356c25..a01d583 100644 --- a/lib-view/src/lib/components/ui/virtual-list/VirtualList.svelte +++ b/lib-view/src/lib/components/ui/virtual-list/VirtualList.svelte @@ -1,4 +1,6 @@
diff --git a/lib-view/src/routes/books/DeleteDialog.svelte b/lib-view/src/routes/books/DeleteDialog.svelte index 4b24fda..edcd7a2 100644 --- a/lib-view/src/routes/books/DeleteDialog.svelte +++ b/lib-view/src/routes/books/DeleteDialog.svelte @@ -1,6 +1,6 @@ @@ -21,7 +34,7 @@
- {#await api.session() then session} + {#await session() then session}

{$_('.info.session')} @@ -32,7 +45,7 @@

{/await}
- {#await api.stats() then stats} + {#await stats() then stats}

{$_('.info.stats')} @@ -48,7 +61,7 @@

{/await} - {#await api.about() then about} + {#await about() then about}

{$_('.info.about', { diff --git a/lib-view/src/routes/overdues/+page.svelte b/lib-view/src/routes/overdues/+page.svelte index e58ebfb..0b494b4 100644 --- a/lib-view/src/routes/overdues/+page.svelte +++ b/lib-view/src/routes/overdues/+page.svelte @@ -4,9 +4,10 @@ import { DateTime } from 'luxon'; import api from '$lib/api'; import { count } from '$lib/store'; + import { handle_result } from '$lib'; async function loadOverdues() { - let overdues = await api.overdues(); + let overdues = handle_result(await api.lending_overdues()); $count.overdues = overdues.length; return overdues; } diff --git a/lib-view/src/routes/settings/EditCategory.svelte b/lib-view/src/routes/settings/EditCategory.svelte index 63b3778..dc99a30 100644 --- a/lib-view/src/routes/settings/EditCategory.svelte +++ b/lib-view/src/routes/settings/EditCategory.svelte @@ -7,7 +7,7 @@ import Button from '$lib/components/ui/button/button.svelte'; import { categories } from '$lib/store'; import api from '$lib/api'; - import { areObjectsEqual } from '$lib'; + import { areObjectsEqual, handle_result } from '$lib'; import Spinner from '$lib/components/ui/spinner/Spinner.svelte'; let categoryMode = 'add'; @@ -30,19 +30,19 @@ let addResponse: Promise; async function add() { - await api.category_add(emptyNew); + handle_result(await api.category_add(emptyNew)); await onChange(); } let editResponse: Promise; async function edit() { - await api.category_update(selected.id, { id, name, section }); + handle_result(await api.category_update(selected.id, { id, name, section })); await onChange(); } let deleteResponse: Promise; async function del() { - await api.category_delete(selected.id); + handle_result(await api.category_delete(selected.id)); await onChange(); } @@ -56,7 +56,7 @@ } async function reload() { - let data = await api.categories(); + let data = handle_result(await api.category_list()); categories.set(data); if (selected != null) { let sid = categoryMode == 'add' ? emptyNew.id : selected.id; diff --git a/lib-view/src/routes/settings/Global.svelte b/lib-view/src/routes/settings/Global.svelte index 327c744..9245789 100644 --- a/lib-view/src/routes/settings/Global.svelte +++ b/lib-view/src/routes/settings/Global.svelte @@ -7,7 +7,7 @@ import { DateTime } from 'luxon'; import { settingsGlobal, type GlobalSettings } from '$lib/store'; import api from '$lib/api'; - import { areObjectsEqual } from '$lib'; + import { areObjectsEqual, handle_result } from '$lib'; import { Button } from '$lib/components/ui/button'; import Spinner from '$lib/components/ui/spinner/Spinner.svelte'; import { Textarea } from '$lib/components/ui/textarea'; @@ -59,7 +59,7 @@ let userResponse: Promise; async function userUpdate() { - await api.user_update_roles(); + await handle_result(api.user_update_roles()); } let saveResponse: Promise; @@ -69,8 +69,8 @@ mail_last_reminder: settings?.mail_last_reminder.toISODate() ?? '' }; - await api.settings_update(data); - if (settings) settingsGlobal.set(settings); + handle_result(await api.settings_update(data)); + settingsGlobal.set(settings); } diff --git a/lib-view/src/routes/users/+page.svelte b/lib-view/src/routes/users/+page.svelte index 8c43d57..936944d 100644 --- a/lib-view/src/routes/users/+page.svelte +++ b/lib-view/src/routes/users/+page.svelte @@ -11,7 +11,7 @@ import UserDisplay from './UserDisplay.svelte'; let active: api.User | null; - let search: api.UserSearch = { query: '' }; + let search: api.UserSearch = { query: '', may_borrow: null, offset: 0, limit: 200 }; let layout: Layout; // layout mobile display, won't work without binding open let open: boolean; diff --git a/lib-view/src/routes/users/DeleteDialog.svelte b/lib-view/src/routes/users/DeleteDialog.svelte index 8d8c7b8..f4bcf08 100644 --- a/lib-view/src/routes/users/DeleteDialog.svelte +++ b/lib-view/src/routes/users/DeleteDialog.svelte @@ -1,6 +1,6 @@

diff --git a/lib-view/src/routes/users/UserSelect.svelte b/lib-view/src/routes/users/UserSelect.svelte index 9a869ca..3d4d155 100644 --- a/lib-view/src/routes/users/UserSelect.svelte +++ b/lib-view/src/routes/users/UserSelect.svelte @@ -13,7 +13,7 @@ let may_borrow: string = params?.may_borrow == null ? 'None' : params?.may_borrow ? 'MayBorrow' : 'MayNotBorrow'; - $: params.may_borrow = may_borrow != 'None' ? may_borrow == 'MayBorrow' : undefined; + $: params.may_borrow = may_borrow != 'None' ? may_borrow == 'MayBorrow' : null;
diff --git a/src/db/book.rs b/src/db/book.rs index 0a86c79..0c2cce3 100644 --- a/src/db/book.rs +++ b/src/db/book.rs @@ -2,6 +2,7 @@ use std::collections::btree_map::Entry; use std::collections::BTreeMap; use chrono::NaiveDate; +use gluer::metadata; use serde::{Deserialize, Serialize}; use unicode_normalization::UnicodeNormalization; @@ -11,6 +12,7 @@ use crate::error::{Error, Result}; use crate::isbn; /// Data object for book. +#[metadata] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(test, derive(PartialEq, Default))] pub struct Book { @@ -20,21 +22,26 @@ pub struct Book { pub publisher: String, pub year: i64, pub costs: f64, + #[meta(optional)] #[serde(default, skip_serializing_if = "String::is_empty")] pub note: String, pub borrowable: bool, pub category: String, pub authors: String, + #[meta(optional, into = Borrower)] #[serde(default, skip_serializing_if = "Option::is_none")] pub borrower: Option, + #[meta(optional, into = String)] #[serde(default, skip_serializing_if = "Option::is_none")] pub reservation: Option, } +#[metadata] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(test, derive(PartialEq, Default))] pub struct Borrower { pub user: String, + #[meta(into = String)] pub deadline: NaiveDate, } @@ -59,6 +66,7 @@ impl Book { } /// Book search parameters +#[metadata] #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct BookSearch { @@ -82,6 +90,7 @@ impl Default for BookSearch { } /// Borrow status of a book +#[metadata] #[repr(i64)] #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum BookState { diff --git a/src/db/category.rs b/src/db/category.rs index 7c49f23..934478b 100644 --- a/src/db/category.rs +++ b/src/db/category.rs @@ -1,12 +1,14 @@ use std::collections::btree_map::Entry; use std::collections::BTreeMap; +use gluer::metadata; use serde::{Deserialize, Serialize}; use super::Books; use crate::error::{Error, Result}; /// Data object for categories +#[metadata] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Category { pub id: String, diff --git a/src/db/mod.rs b/src/db/mod.rs index 1498e7b..bb5bccd 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -8,6 +8,7 @@ use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use std::{fmt, fs}; use chrono::{Local, NaiveDate}; +use gluer::metadata; use serde::{Deserialize, Serialize}; use tracing::{error, info}; @@ -30,6 +31,7 @@ mod sorted; mod legacy; /// Library settings +#[metadata] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct Settings { // Borrowing @@ -37,6 +39,7 @@ pub struct Settings { // DNB pub dnb_token: String, // Mail + #[meta(into = String)] pub mail_last_reminder: NaiveDate, pub mail_from: String, pub mail_host: String, @@ -48,6 +51,7 @@ pub struct Settings { } /// Template for a mail notification +#[metadata] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Default)] pub struct MailTemplate { pub subject: String, @@ -87,6 +91,7 @@ impl Default for Settings { } /// Data object for book. +#[metadata] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(test, derive(PartialEq, Default))] pub struct Stats { @@ -109,6 +114,7 @@ pub struct Database { } /// Borrowed books that missed the deadline +#[metadata] #[derive(Serialize)] pub struct Overdue { pub book: Book, diff --git a/src/db/user.rs b/src/db/user.rs index e985911..649cd27 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -1,6 +1,7 @@ use std::collections::btree_map::Entry; use std::collections::BTreeMap; +use gluer::metadata; use serde::{Deserialize, Serialize}; use super::Books; @@ -8,6 +9,7 @@ use crate::error::{Error, Result}; use crate::mail::account_is_valid; /// Data object for a user. +#[metadata] #[derive(Debug, Clone, Deserialize, Serialize)] #[cfg_attr(test, derive(PartialEq))] #[serde(default)] @@ -15,8 +17,10 @@ pub struct User { pub account: String, pub forename: String, pub surname: String, + #[meta(optional)] #[serde(skip_serializing_if = "String::is_empty")] pub role: String, + #[meta(optional)] #[serde(skip_serializing_if = "Clone::clone")] // <- skip if true pub may_borrow: bool, } @@ -44,6 +48,7 @@ impl User { } /// Parameters for the normal search +#[metadata] #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct UserSearch { diff --git a/src/error.rs b/src/error.rs index e92acd0..a33778d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,15 +2,17 @@ use std::fmt; use axum::response::IntoResponse; use axum::Json; +use gluer::metadata; use hyper::StatusCode; use serde::Serialize; use tracing::error; /// The api compatible error type. -/// On the godot side there are specific error messages displayed for each of the error types. +/// On the frontend there are specific error messages displayed for each of the error types. /// /// More specific error messages are removed to be api compatible. /// Those messages are logged however. +#[metadata] #[repr(i64)] #[derive(Debug, Clone, Copy, Serialize)] pub enum Error { @@ -156,4 +158,5 @@ impl IntoResponse for Error { } /// Result type using the api error. +#[metadata] pub type Result = std::result::Result; diff --git a/src/provider/dnb.rs b/src/provider/dnb.rs index 7559ffe..d628ba3 100644 --- a/src/provider/dnb.rs +++ b/src/provider/dnb.rs @@ -1,9 +1,11 @@ +use gluer::metadata; use reqwest::Client; use serde::Serialize; use unicode_normalization::UnicodeNormalization; use crate::error::{Error, Result}; +#[metadata] #[derive(Debug, Default, PartialEq, Serialize)] pub struct BookData { pub title: String, diff --git a/src/server/api.rs b/src/server/api.rs index bbf52df..bc61a30 100644 --- a/src/server/api.rs +++ b/src/server/api.rs @@ -6,6 +6,7 @@ use axum::middleware::from_extractor_with_state; use axum::routing::{get, post}; use axum::{Json, Router}; use chrono::NaiveDate; +use gluer::{generate, metadata}; use hyper::StatusCode; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -16,6 +17,7 @@ use crate::db::*; use crate::error::{Error, Result}; use crate::mail::{self, account_is_valid}; use crate::provider; +use crate::provider::dnb::BookData; /// User configuration. #[derive(Debug, Clone)] @@ -51,49 +53,52 @@ impl Project { } pub fn routes(state: Project) -> Router { - Router::new() - // general - .route("/about", get(about)) - .route("/settings", get(settings_get).post(settings_update)) - .route("/stats", get(stats)) - .route("/session", get(session)) - // books - .route("/book", get(book_search).post(book_add)) - .route( - "/book/:id", - get(book_fetch).post(book_update).delete(book_delete), - ) - .route("/book-id", post(book_generate_id)) - .route("/book-fetch/:isbn", get(book_fetch_data)) - // user - .route("/user", get(user_search).post(user_add)) - .route( - "/user/:account", - get(user_fetch).post(user_update).delete(user_delete), - ) - .route("/user-fetch/:account", get(user_fetch_data)) - .route("/user-update-roles", post(user_update_roles)) - // category - .route("/category", get(category_list).post(category_add)) - .route( - "/category/:id", - post(category_update).delete(category_delete), - ) - .route("/category-refs/:id", get(category_references)) - // lending - .route("/lending/lend", post(lending_lend)) - .route("/lending/return", post(lending_return)) - .route("/lending/reserve", post(lending_reserve)) - .route("/lending/release", post(lending_release)) - .route("/overdues", get(lending_overdues)) - // mail - .route("/notify", post(mail_notify)) - // all routes require authorization - .route_layer(from_extractor_with_state::(state.auth.clone())) - .fallback(|| async { (StatusCode::NOT_FOUND, Json(Error::NothingFound)) }) - .with_state(state) + generate! { + prefix = "/api", + routes = { + // general + "/about" = get(about), + "/settings" = get(settings_get).post(settings_update), + "/stats" = get(stats), + "/session" = get(session), + // books + "/book" = get(book_search).post(book_add), + "/book/:id" = get(book_fetch).post(book_update).delete(book_delete), + "/book-id" = post(book_generate_id), + "/book-fetch/:isbn" = get(book_fetch_data), + // user + "/user" = get(user_search).post(user_add), + "/user/:account" = get(user_fetch).post(user_update).delete(user_delete), + "/user-fetch/:account" = get(user_fetch_data), + "/user-update-roles" = post(user_update_roles), + // category + "/category" = get(category_list).post(category_add), + "/category/:id" = post(category_update).delete(category_delete), + "/category-refs/:id" = get(category_references), + // lending + "/lending/lend" = post(lending_lend), + "/lending/return" = post(lending_return), + "/lending/reserve" = post(lending_reserve), + "/lending/release" = post(lending_release), + "/overdues" = get(lending_overdues), + // mail + "/notify" = post(mail_notify), + }, + files = [ + "src/db", + "src/server", + "src/error.rs", + "src/provider/dnb.rs" + ], + output = "lib-view/src/lib/api.ts", + } + // all routes require authorization + .route_layer(from_extractor_with_state::(state.auth.clone())) + .fallback(|| async { (StatusCode::NOT_FOUND, Json(Error::NothingFound)) }) + .with_state(state) } +#[metadata] #[derive(Debug, Serialize)] struct About { name: &'static str, @@ -105,6 +110,7 @@ struct About { } /// Returns info about this project. +#[metadata] async fn about() -> Json { use crate::*; Json(About { @@ -120,11 +126,13 @@ async fn about() -> Json { /// Returns the project settings. /// They are fetched when opening a project, so that this function only /// returns copies of the cached version. +#[metadata(custom = [Result])] async fn settings_get(State(project): State) -> Result> { Ok(Json(project.db.read().settings())) } /// Updates project settings. +#[metadata(custom = [Result])] async fn settings_update( State(project): State, Json(settings): Json, @@ -134,11 +142,13 @@ async fn settings_update( } /// Returns the project statistics. +#[metadata(custom = [Result])] async fn stats(State(project): State) -> Result> { Ok(Json(project.db.read().stats()?)) } /// Returns the project statistics. +#[metadata(custom = [Result])] async fn session(login: Login) -> Result> { Ok(Json(login)) } @@ -146,6 +156,7 @@ async fn session(login: Login) -> Result> { // Book /// Returns the book with the given `id`. +#[metadata(custom = [Result])] async fn book_fetch(State(project): State, Path(id): Path) -> Result> { Ok(Json(project.db.read().books.fetch(&id)?)) } @@ -168,7 +179,8 @@ impl Default for Search { } } -/// Search result containting the total number of found records. +/// Search result containing the total number of found records. +#[metadata] #[derive(Serialize)] struct Limited { /// Total number of results (without limit) @@ -185,6 +197,7 @@ impl From<(usize, Vec)> for Limited { } /// Preforms a simple media search with the given `query`. +#[metadata(custom = [Result])] async fn book_search( State(project): State, Query(params): Query, @@ -193,12 +206,14 @@ async fn book_search( } /// Adds a new book. +#[metadata(custom = [Result])] async fn book_add(State(project): State, Json(book): Json) -> Result> { let db = &mut *project.db.write(); Ok(Json(db.books.add(book, &db.categories)?)) } /// Updates the book and all references if its id changes. +#[metadata(custom = [Result])] async fn book_update( State(project): State, Path(id): Path, @@ -210,11 +225,13 @@ async fn book_update( /// Deletes the book including the its authors. /// Also borrowers & reservations for this book are removed. +#[metadata(custom = [Result])] async fn book_delete(State(project): State, Path(id): Path) -> Result<()> { project.db.write().books.delete(&id) } /// Generates a new book id. +#[metadata(custom = [Result])] async fn book_generate_id( State(project): State, Json(book): Json, @@ -223,10 +240,11 @@ async fn book_generate_id( } /// Fetch the data of the book from the DNB an their like. +#[metadata(custom = [Result])] async fn book_fetch_data( State(project): State, Path(isbn): Path, -) -> Result> { +) -> Result> { let settings = project.db.read().settings(); Ok(Json( provider::dnb::fetch(project.client, &settings.dnb_token, &isbn).await?, @@ -236,6 +254,7 @@ async fn book_fetch_data( // User /// Returns the user with the given `account`. +#[metadata(custom = [Result])] async fn user_fetch( State(project): State, Path(account): Path, @@ -244,6 +263,7 @@ async fn user_fetch( } /// Performs a simple user search with the given `text`. +#[metadata(custom = [Result])] async fn user_search( State(project): State, Query(params): Query, @@ -252,11 +272,13 @@ async fn user_search( } /// Adds a new user. +#[metadata(custom = [Result])] async fn user_add(State(project): State, Json(user): Json) -> Result> { Ok(Json(project.db.write().users.add(user)?)) } /// Updates the user and all references if its account changes. +#[metadata(custom = [Result])] async fn user_update( State(project): State, Path(account): Path, @@ -269,12 +291,14 @@ async fn user_update( /// Deletes the user. /// /// Returns a `Error::StillReferenced` if there are any borrows or reservations left. +#[metadata(custom = [Result])] async fn user_delete(State(project): State, Path(account): Path) -> Result<()> { let db = &mut *project.db.write(); db.users.delete(&account, &db.books) } -/// Fetch the data of the book from the DNB an their like. +/// Fetch the data of the user from the specified user file. +#[metadata(custom = [Result])] async fn user_fetch_data( State(project): State, Path(account): Path, @@ -293,6 +317,7 @@ async fn user_fetch_data( /// Deletes the roles from all users and inserts the new roles. /// /// The roles of all users not contained in the given list are cleared. +#[metadata(custom = [Result])] async fn user_update_roles(State(project): State) -> Result<()> { if let Some(user) = &project.user { let users = super::provider::user::load_roles(&user.file, user.delimiter)?; @@ -305,11 +330,13 @@ async fn user_update_roles(State(project): State) -> Result<()> { // Category /// Fetches and returns all categories. +#[metadata(custom = [Result])] async fn category_list(State(project): State) -> Result>> { Ok(Json(project.db.read().categories.list()?)) } /// Adds a new category. +#[metadata(custom = [Result])] async fn category_add( State(project): State, Json(category): Json, @@ -318,6 +345,7 @@ async fn category_add( } /// Updates the category and all references. +#[metadata(custom = [Result])] async fn category_update( State(project): State, Path(id): Path, @@ -328,12 +356,14 @@ async fn category_update( } /// Removes the category or returns a `Error::StillReferenced` if it is still in use. +#[metadata(custom = [Result])] async fn category_delete(State(project): State, Path(id): Path) -> Result<()> { let db = &mut *project.db.write(); db.categories.delete(&id, &db.books) } /// Returns the number of books in this category. +#[metadata(custom = [Result])] async fn category_references( State(project): State, Path(id): Path, @@ -342,16 +372,18 @@ async fn category_references( } // Lending - +#[metadata] #[derive(Debug, Deserialize)] struct LendParams { id: String, account: String, + #[meta(into = String)] /// ISO date format: YYYY-MM-DD deadline: NaiveDate, } /// Lends the book to the specified user. +#[metadata(custom = [Result])] async fn lending_lend( State(project): State, Query(params): Query, @@ -362,20 +394,21 @@ async fn lending_lend( params.deadline, )?)) } - +#[metadata] #[derive(Debug, Deserialize)] struct ReturnParams { id: String, } /// Returns the book. +#[metadata(custom = [Result])] async fn lending_return( State(project): State, Query(params): Query, ) -> Result> { Ok(Json(project.db.write().return_back(¶ms.id)?)) } - +#[metadata] #[derive(Debug, Deserialize)] struct ReserveParams { id: String, @@ -383,6 +416,7 @@ struct ReserveParams { } /// Creates a reservation for the borrowed book. +#[metadata(custom = [Result])] async fn lending_reserve( State(project): State, Query(params): Query, @@ -393,6 +427,7 @@ async fn lending_reserve( } /// Removes the reservation from the specified book. +#[metadata(custom = [Result])] async fn lending_release( State(project): State, Query(params): Query, @@ -401,12 +436,13 @@ async fn lending_release( } /// Returns the list of expired borrowing periods. +#[metadata(custom = [Result])] async fn lending_overdues(State(project): State) -> Result>> { Ok(Json(project.db.read().overdues()?)) } // Mail Notifications - +#[metadata] #[derive(Debug, Deserialize)] struct Message { account: String, @@ -414,6 +450,7 @@ struct Message { body: String, } +#[metadata(custom = [Result])] async fn mail_notify( State(project): State, Json(messages): Json>, diff --git a/src/server/auth.rs b/src/server/auth.rs index 5fd1280..af46965 100644 --- a/src/server/auth.rs +++ b/src/server/auth.rs @@ -13,6 +13,7 @@ use axum_extra::headers::Cookie; use axum_extra::TypedHeader; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; +use gluer::metadata; use hyper::{HeaderMap, StatusCode}; use oauth2::basic::BasicClient; use oauth2::{ @@ -71,11 +72,13 @@ pub async fn background(auth: Auth) { /// The user data we'll get back from oauth. /// /// E.g. Discord: https://discord.com/developers/docs/resources/user#user-object-user-structure +#[metadata] #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct Login { id: String, username: String, /// Custom data storing how long the session is valid + #[meta(skip)] #[serde(skip)] expires: u64, }