Merge backend-foundation: multi-user API, three clients, Gitea CI/CD, Docker
This commit is contained in:
commit
7f0cb01954
122 files changed
+13897
-777
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
bin/
|
||||||
|
|
||||||
|
web/node_modules
|
||||||
|
web/dist
|
||||||
|
web/.angular
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
go:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
# cmd/gui (Fyne) uses cgo and links against the system's OpenGL/X11
|
||||||
|
# libraries, so building or testing ./... needs these present even
|
||||||
|
# though the GUI itself never opens a window in CI.
|
||||||
|
- name: Install system dependencies for the Fyne GUI build
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgl1-mesa-dev xorg-dev
|
||||||
|
|
||||||
|
- name: gofmt
|
||||||
|
run: |
|
||||||
|
fmt_out="$(gofmt -l .)"
|
||||||
|
if [ -n "$fmt_out" ]; then
|
||||||
|
echo "The following files are not gofmt'd:"
|
||||||
|
echo "$fmt_out"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: go build
|
||||||
|
run: go build ./...
|
||||||
|
|
||||||
|
- name: go test
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: go mod tidy (no diff expected)
|
||||||
|
run: |
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code -- go.mod go.sum
|
||||||
|
|
||||||
|
sqlc:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: sqlc-dev/setup-sqlc@v4
|
||||||
|
with:
|
||||||
|
sqlc-version: "1.31.1"
|
||||||
|
|
||||||
|
- name: sqlc diff (generated code must match the schema/queries)
|
||||||
|
run: sqlc diff
|
||||||
|
|
||||||
|
web:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: web
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
# Builds the image on every push as a health check (catches a broken
|
||||||
|
# Dockerfile immediately); doesn't push anywhere. release.yml does the
|
||||||
|
# same build and pushes it.
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: false
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
#
|
||||||
|
# .gitea/workflows/release.yml
|
||||||
|
#
|
||||||
|
# On a version tag (e.g. v1.2.3): builds every client, pushes the API's
|
||||||
|
# Docker image, uploads all build artifacts to the Gitea generic package
|
||||||
|
# registry, and creates a Gitea release with those same files attached.
|
||||||
|
#
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*.*.*"]
|
||||||
|
|
||||||
|
env:
|
||||||
|
PROJECT_NAME: Completed
|
||||||
|
REGISTRY: gitea.sanplex.xyz
|
||||||
|
REGISTRY_OWNER: sansan
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# cmd/api and cmd/tui are pure Go (no cgo), so they cross-compile cleanly
|
||||||
|
# from one Linux runner for every OS/arch below.
|
||||||
|
build-go:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
- goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
- goos: darwin
|
||||||
|
goarch: amd64
|
||||||
|
- goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
- goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Build api and tui
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
CGO_ENABLED: "0"
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
ext=""
|
||||||
|
if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi
|
||||||
|
go build -o "dist/completed-api${ext}" ./cmd/api
|
||||||
|
go build -o "dist/completed-tui${ext}" ./cmd/tui
|
||||||
|
|
||||||
|
- name: Archive
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
base="${PROJECT_NAME}_${{ matrix.goos }}_${{ matrix.goarch }}"
|
||||||
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
|
zip "../${base}.zip" completed-api.exe completed-tui.exe
|
||||||
|
else
|
||||||
|
tar czf "../${base}.tar.gz" completed-api completed-tui
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ env.PROJECT_NAME }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||||
|
path: ${{ env.PROJECT_NAME }}_${{ matrix.goos }}_${{ matrix.goarch }}.*
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# cmd/gui (Fyne) uses cgo, so unlike build-go above it can't just cross-
|
||||||
|
# compile with CGO_ENABLED=0. Linux is native to this runner; Windows
|
||||||
|
# cross-compiles cleanly with the mingw-w64 toolchain (verified locally:
|
||||||
|
# a real PE32+ GUI-subsystem .exe, linking Fyne's OpenGL/glfw backend,
|
||||||
|
# with no changes needed beyond CC and the usual GOOS/GOARCH). macOS
|
||||||
|
# isn't included - cross-compiling cgo to Darwin from Linux has no
|
||||||
|
# equivalent well-supported toolchain, so that would need an actual
|
||||||
|
# macOS runner, which isn't registered for this repo.
|
||||||
|
build-gui:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
packages: libgl1-mesa-dev xorg-dev
|
||||||
|
- goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
packages: gcc-mingw-w64-x86-64
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y ${{ matrix.packages }}
|
||||||
|
|
||||||
|
- name: Build gui
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
CGO_ENABLED: "1"
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
|
CC=x86_64-w64-mingw32-gcc go build -ldflags "-H=windowsgui" -o dist/completed-gui.exe ./cmd/gui
|
||||||
|
else
|
||||||
|
go build -o dist/completed-gui ./cmd/gui
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Archive
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
base="../${PROJECT_NAME}-gui_${{ matrix.goos }}_${{ matrix.goarch }}"
|
||||||
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
|
zip "${base}.zip" completed-gui.exe
|
||||||
|
else
|
||||||
|
tar czf "${base}.tar.gz" completed-gui
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ env.PROJECT_NAME }}-gui_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||||
|
path: ${{ env.PROJECT_NAME }}-gui_${{ matrix.goos }}_${{ matrix.goarch }}.*
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
build-web:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: web
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Archive
|
||||||
|
run: tar czf ${PROJECT_NAME}-web_dist.tar.gz -C web/dist/web/browser .
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ env.PROJECT_NAME }}-web_dist
|
||||||
|
path: ${{ env.PROJECT_NAME }}-web_dist.tar.gz
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# Builds and pushes the API's image, which embeds the web build as its
|
||||||
|
# static UI (see the Dockerfile and cmd/api/main.go's registerWebUI).
|
||||||
|
build-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
config-inline: |
|
||||||
|
[registry."${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}"]
|
||||||
|
http = true
|
||||||
|
insecure = true
|
||||||
|
|
||||||
|
- name: Login to Gitea
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/completed:${{ gitea.ref_name }}
|
||||||
|
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/completed:latest
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: [build-go, build-gui, build-web]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Upload to the Gitea generic package registry
|
||||||
|
env:
|
||||||
|
VERSION: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
for f in artifacts/*; do
|
||||||
|
name="$(basename "$f")"
|
||||||
|
echo "Uploading $name"
|
||||||
|
curl --fail --user "${{ github.repository_owner }}:${{ secrets.TOKEN }}" \
|
||||||
|
--upload-file "$f" \
|
||||||
|
"https://${{ env.REGISTRY }}/api/packages/${{ env.REGISTRY_OWNER }}/generic/${{ env.PROJECT_NAME }}/${VERSION}/${name}"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
uses: akkuman/gitea-release-action@v1
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: "--experimental-fetch" # if nodejs < 18
|
||||||
|
with:
|
||||||
|
files: |-
|
||||||
|
artifacts/**
|
||||||
+2
-43
@@ -25,46 +25,5 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# ---> Lua
|
# Justfile build output (bin/api, bin/tui, bin/gui)
|
||||||
# Compiled Lua sources
|
bin/
|
||||||
luac.out
|
|
||||||
|
|
||||||
# luarocks build files
|
|
||||||
*.src.rock
|
|
||||||
*.zip
|
|
||||||
*.tar.gz
|
|
||||||
|
|
||||||
# Object files
|
|
||||||
*.o
|
|
||||||
*.os
|
|
||||||
*.ko
|
|
||||||
*.obj
|
|
||||||
*.elf
|
|
||||||
|
|
||||||
# Precompiled Headers
|
|
||||||
*.gch
|
|
||||||
*.pch
|
|
||||||
|
|
||||||
# Libraries
|
|
||||||
*.lib
|
|
||||||
*.a
|
|
||||||
*.la
|
|
||||||
*.lo
|
|
||||||
*.def
|
|
||||||
*.exp
|
|
||||||
|
|
||||||
# Shared objects (inc. Windows DLLs)
|
|
||||||
*.dll
|
|
||||||
*.so
|
|
||||||
*.so.*
|
|
||||||
*.dylib
|
|
||||||
|
|
||||||
# Executables
|
|
||||||
*.exe
|
|
||||||
*.out
|
|
||||||
*.app
|
|
||||||
*.i*86
|
|
||||||
*.x86_64
|
|
||||||
*.hex
|
|
||||||
|
|
||||||
|
|
||||||
+26
-7
@@ -1,5 +1,18 @@
|
|||||||
# Build Stage
|
# Web build stage - the API serves this directory itself (see
|
||||||
FROM golang:1.23-alpine AS builder
|
# registerWebUI in cmd/api/main.go); there's no separate nginx/web image.
|
||||||
|
FROM node:22-alpine AS build_web
|
||||||
|
|
||||||
|
WORKDIR /app/web
|
||||||
|
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
# Output: /app/web/dist/web/browser
|
||||||
|
|
||||||
|
# API build stage
|
||||||
|
FROM golang:1.25-alpine AS build_go
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -18,7 +31,7 @@ COPY . .
|
|||||||
# Build the application
|
# Build the application
|
||||||
# CGO_ENABLED=0 ensures a static binary
|
# CGO_ENABLED=0 ensures a static binary
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o api-server ./cmd/api/main.go
|
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o api-server ./cmd/api
|
||||||
|
|
||||||
# Runtime Stage
|
# Runtime Stage
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
@@ -28,11 +41,16 @@ WORKDIR /app
|
|||||||
# Install ca-certificates for database connections (TLS)
|
# Install ca-certificates for database connections (TLS)
|
||||||
RUN apk add --no-cache ca-certificates
|
RUN apk add --no-cache ca-certificates
|
||||||
|
|
||||||
# Copy the binary from the builder stage
|
# Copy the binary from the builder stage. Migrations are embedded in the
|
||||||
COPY --from=builder /app/api-server .
|
# binary (see db/migrations/embed.go), so there's nothing else to copy for
|
||||||
|
# the API itself.
|
||||||
|
COPY --from=build_go /app/api-server .
|
||||||
|
|
||||||
# Copy migrations
|
# The web app's build output. registerWebUI (cmd/api/main.go) serves this
|
||||||
COPY --from=builder /app/db/migrations ./db/migrations
|
# directory as a single-page app, with unknown paths falling back to
|
||||||
|
# index.html for Angular's router; it no-ops if WEB_DIST_DIR is missing, so
|
||||||
|
# this layer is the only thing that turns that serving on.
|
||||||
|
COPY --from=build_web /app/web/dist/web/browser /frontend
|
||||||
|
|
||||||
# Expose the application port
|
# Expose the application port
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
@@ -40,6 +58,7 @@ EXPOSE 8080
|
|||||||
# Environment variables with defaults (can be overridden)
|
# Environment variables with defaults (can be overridden)
|
||||||
ENV PORT=8080
|
ENV PORT=8080
|
||||||
ENV DATABASE_URL=""
|
ENV DATABASE_URL=""
|
||||||
|
ENV WEB_DIST_DIR=/frontend
|
||||||
|
|
||||||
# Run the binary
|
# Run the binary
|
||||||
CMD ["./api-server"]
|
CMD ["./api-server"]
|
||||||
@@ -12,15 +12,15 @@ build-api:
|
|||||||
|
|
||||||
# Run the API server locally
|
# Run the API server locally
|
||||||
run-api:
|
run-api:
|
||||||
go run ./cmd/api/main.go
|
go run ./cmd/api
|
||||||
|
|
||||||
# Start the API and Database using Docker Compose
|
# Start the API and Database using Docker Compose
|
||||||
docker-up:
|
docker-up:
|
||||||
docker-compose up --build -d
|
docker compose up --build -d
|
||||||
|
|
||||||
# Stop Docker Compose services
|
# Stop Docker Compose services
|
||||||
docker-down:
|
docker-down:
|
||||||
docker-compose down
|
docker compose down
|
||||||
|
|
||||||
# Run all tests
|
# Run all tests
|
||||||
test:
|
test:
|
||||||
@@ -42,17 +42,35 @@ build-tui:
|
|||||||
|
|
||||||
# Run the Terminal UI
|
# Run the Terminal UI
|
||||||
run-tui:
|
run-tui:
|
||||||
go run ./cmd/tui/main.go
|
go run ./cmd/tui
|
||||||
|
|
||||||
# --- GUI ---
|
# --- GUI ---
|
||||||
|
|
||||||
# Build the GUI (Raylib)
|
# Build the GUI (Fyne)
|
||||||
build-gui:
|
build-gui:
|
||||||
go build -o bin/gui ./cmd/gui
|
go build -o bin/gui ./cmd/gui
|
||||||
|
|
||||||
# Run the GUI
|
# Run the GUI
|
||||||
run-gui:
|
run-gui:
|
||||||
go run ./cmd/gui/main.go
|
go run ./cmd/gui
|
||||||
|
|
||||||
|
# --- Web ---
|
||||||
|
|
||||||
|
# Install web dependencies
|
||||||
|
web-install:
|
||||||
|
cd web && npm ci
|
||||||
|
|
||||||
|
# Run the web app's dev server
|
||||||
|
web-dev:
|
||||||
|
cd web && npm start
|
||||||
|
|
||||||
|
# Build the web app for production (output: web/dist/web)
|
||||||
|
web-build:
|
||||||
|
cd web && npm run build
|
||||||
|
|
||||||
|
# Run the web app's tests
|
||||||
|
web-test:
|
||||||
|
cd web && npm test
|
||||||
|
|
||||||
# --- Database ---
|
# --- Database ---
|
||||||
|
|
||||||
@@ -60,6 +78,10 @@ run-gui:
|
|||||||
sqlc:
|
sqlc:
|
||||||
sqlc generate
|
sqlc generate
|
||||||
|
|
||||||
|
# Write the OpenAPI spec to api/openapi.json
|
||||||
|
openapi:
|
||||||
|
go run ./cmd/openapi
|
||||||
|
|
||||||
# Connect to the running database via psql
|
# Connect to the running database via psql
|
||||||
db-shell:
|
db-shell:
|
||||||
docker-compose exec db psql -U postgres -d gamedb
|
docker compose exec db psql -U postgres -d gamedb
|
||||||
@@ -1,2 +1,125 @@
|
|||||||
# Completed
|
# Completed
|
||||||
|
|
||||||
|
A tracker for a video game collection: what you own, which platform it's on,
|
||||||
|
your score for it, and whether (and when) you finished it.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Each account has its own private game list, but all accounts share one
|
||||||
|
platform list (only an admin can add/rename/delete a platform - the first
|
||||||
|
account ever registered becomes an admin automatically). The API is usable,
|
||||||
|
and serves its own web UI (see below). There are three small-base clients:
|
||||||
|
a terminal UI, a desktop GUI (Fyne), and a web app (Angular) — each can log
|
||||||
|
in and show your games, with more to come.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
just docker-up # starts Postgres and the API on :8080
|
||||||
|
just docker-down # stops them
|
||||||
|
```
|
||||||
|
|
||||||
|
Or against a Postgres you already have running locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export DATABASE_URL="postgres://postgres:password@localhost:5432/gamedb?sslmode=disable"
|
||||||
|
just run-api
|
||||||
|
```
|
||||||
|
|
||||||
|
The API creates its database and runs migrations on startup if needed.
|
||||||
|
Browse `http://localhost:8080/docs` for the generated API docs, or
|
||||||
|
`http://localhost:8080/` for the web UI - the Docker image bakes in a
|
||||||
|
production build of `web/` and the API serves it itself (see
|
||||||
|
`registerWebUI` in `cmd/api/main.go`); unknown paths fall back to its
|
||||||
|
`index.html` so Angular's router handles deep links like `/games`. Running
|
||||||
|
the API outside Docker (`just run-api`) skips this and serves the API
|
||||||
|
only, unless `WEB_DIST_DIR` points at a build of `web/` (`just web-build`
|
||||||
|
outputs one to `web/dist/web/browser`).
|
||||||
|
|
||||||
|
## Using the API
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Register (also logs you in; the first account ever registered becomes
|
||||||
|
# an admin, which is required to create platforms - see Status above)
|
||||||
|
curl -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"you@example.com","password":"a long password","name":"You"}'
|
||||||
|
# -> {"token": "...", "user": {...}}
|
||||||
|
|
||||||
|
TOKEN=... # the token from above
|
||||||
|
|
||||||
|
curl -X POST localhost:8080/api/platforms -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"name":"PC"}'
|
||||||
|
|
||||||
|
curl localhost:8080/api/games -H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
A browser-based client can skip the `Authorization` header: register/login
|
||||||
|
also set an httpOnly `session` cookie.
|
||||||
|
|
||||||
|
## Clients
|
||||||
|
|
||||||
|
- `just run-tui` — a terminal UI (Bubble Tea). Log in, see your games in a
|
||||||
|
scrollable table, `r` to refresh.
|
||||||
|
- `just run-gui` — a desktop GUI (Fyne). Log in, see your games in a table,
|
||||||
|
refresh.
|
||||||
|
- `just web-dev` — a web app (Angular + Material) at `http://localhost:4200`,
|
||||||
|
proxying nothing itself; it talks directly to the API at `localhost:8080`
|
||||||
|
(CORS is permissive enough for local dev as-is). Log in, see your games.
|
||||||
|
This is for frontend development with hot reload; the API's own `/` (see
|
||||||
|
above) serves a production build of the same app with nothing extra to
|
||||||
|
run.
|
||||||
|
|
||||||
|
All three point at `http://localhost:8080` by default. The TUI and GUI read
|
||||||
|
`COMPLETED_API_URL` to use a different API; the web app's URL is a build-time
|
||||||
|
constant in `web/src/app/app.config.ts`.
|
||||||
|
|
||||||
|
Each client is a small base - listing is there, but adding/editing a game
|
||||||
|
or managing platforms isn't yet.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
just test # go test ./...
|
||||||
|
just sqlc # regenerate internal/db after changing db/query.sql or a migration
|
||||||
|
just openapi # regenerate api/openapi.json after changing a route
|
||||||
|
just web-test # web app unit tests (Vitest)
|
||||||
|
just web-build # production build of the web app
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema changes go in `db/migrations` as a new `<timestamp>_<name>.up.sql` /
|
||||||
|
`.down.sql` pair; `sqlc.yaml` reads the schema from that directory directly,
|
||||||
|
so there's no separate schema file to keep in sync.
|
||||||
|
|
||||||
|
The TUI and GUI share `internal/client`, a typed Go HTTP client for the API.
|
||||||
|
The web app's API client (`web/src/app/api/`) is generated from
|
||||||
|
`api/openapi.json` by `ng-openapi-gen` (`cd web && npx ng-openapi-gen`) and
|
||||||
|
shouldn't be hand-edited.
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
`.gitea/workflows/ci.yml` builds and tests everything on every push to
|
||||||
|
`main` or `develop`: Go (gofmt/vet/build/test/tidy), sqlc, the web app, and
|
||||||
|
a Docker build-only check (the same image `release.yml` later pushes).
|
||||||
|
|
||||||
|
`.gitea/workflows/release.yml` runs on a `vX.Y.Z` tag:
|
||||||
|
- cross-compiles `completed-api`/`completed-tui` for linux, darwin and
|
||||||
|
windows (amd64 + arm64 where it makes sense - both are pure Go, no cgo)
|
||||||
|
- cross-compiles the Fyne GUI for linux/amd64 (native) and windows/amd64
|
||||||
|
(via mingw-w64; verified locally - a real GUI-subsystem `.exe`, no
|
||||||
|
console window). macOS isn't built, since cgo has no equivalent
|
||||||
|
cross-toolchain and this repo has no registered macOS runner
|
||||||
|
- builds the web app
|
||||||
|
- builds and pushes the API's Docker image (which embeds that same web
|
||||||
|
build) to `gitea.sanplex.xyz/sansan/completed`, tagged with the version
|
||||||
|
and `latest`
|
||||||
|
- uploads every build artifact to the Gitea generic package registry and
|
||||||
|
attaches them to a Gitea release
|
||||||
|
|
||||||
|
Both workflows were run for real against this repo - the Go test suite,
|
||||||
|
`sqlc diff`, `go mod tidy -diff`, the Angular build and test suite, the
|
||||||
|
Fyne-for-Windows cross-compile, and the actual multi-stage Docker build
|
||||||
|
(built, run against a real Postgres container, and hit with curl) all
|
||||||
|
passed. The workflow YAML itself (trigger/job wiring, the Gitea API calls
|
||||||
|
for uploading packages and creating the release) is written to match this
|
||||||
|
Gitea instance's other projects, but wasn't run through Gitea Actions
|
||||||
|
itself from this environment - worth watching the first real run of each.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"Line truncated
|
||||||
+111
-56
@@ -11,21 +11,26 @@ import (
|
|||||||
"github.com/danielgtaylor/huma/v2"
|
"github.com/danielgtaylor/huma/v2"
|
||||||
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
|
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
|
||||||
"github.com/golang-migrate/migrate/v4"
|
"github.com/golang-migrate/migrate/v4"
|
||||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
_ "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
||||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
_ "github.com/lib/pq"
|
|
||||||
|
|
||||||
|
"completed/db/migrations"
|
||||||
"completed/internal/api"
|
"completed/internal/api"
|
||||||
"completed/internal/db"
|
"completed/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ensureDatabase creates the target database if it doesn't exist yet, by
|
||||||
|
// connecting to the server's default "postgres" database first. This is a
|
||||||
|
// convenience for local/compose use; a real deployment would provision the
|
||||||
|
// database up front and this would be a no-op every time.
|
||||||
func ensureDatabase(dbURL string) error {
|
func ensureDatabase(dbURL string) error {
|
||||||
u, err := url.Parse(dbURL)
|
u, err := url.Parse(dbURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("parse DATABASE_URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dbName := strings.TrimPrefix(u.Path, "/")
|
dbName := strings.TrimPrefix(u.Path, "/")
|
||||||
@@ -33,103 +38,153 @@ func ensureDatabase(dbURL string) error {
|
|||||||
return fmt.Errorf("database name is empty in URL: %s", dbURL)
|
return fmt.Errorf("database name is empty in URL: %s", dbURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to 'postgres' database to create the new DB
|
|
||||||
u.Path = "/postgres"
|
u.Path = "/postgres"
|
||||||
db, err := sql.Open("postgres", u.String())
|
adminDB, err := sql.Open("pgx", u.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("open admin connection: %w", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer adminDB.Close()
|
||||||
|
|
||||||
// Check if DB exists
|
|
||||||
var exists bool
|
var exists bool
|
||||||
err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", dbName).Scan(&exists)
|
err = adminDB.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", dbName).Scan(&exists)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("check database existence: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if exists {
|
||||||
fmt.Printf("Database '%s' does not exist. Creating...\n", dbName)
|
return nil
|
||||||
_, err = db.Exec(fmt.Sprintf("CREATE DATABASE \"%s\"", dbName))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("Database '%s' created successfully.\n", dbName)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Database '%s' already exists.\n", dbName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Database %q does not exist. Creating...\n", dbName)
|
||||||
|
// Table/database identifiers can't be parameterized; dbName comes from
|
||||||
|
// our own DATABASE_URL, not untrusted request input.
|
||||||
|
if _, err := adminDB.Exec(fmt.Sprintf(`CREATE DATABASE "%s"`, dbName)); err != nil {
|
||||||
|
return fmt.Errorf("create database %q: %w", dbName, err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Database %q created.\n", dbName)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runMigrations(dbURL string) {
|
// runMigrations applies pending migrations, embedded into the binary via
|
||||||
m, err := migrate.New(
|
// db/migrations/embed.go. A failure here is fatal: the alternative is an API
|
||||||
"file:///app/db/migrations", // Path inside Docker container
|
// that starts and answers requests against a schema that was never created.
|
||||||
dbURL,
|
func runMigrations(dbURL string) error {
|
||||||
)
|
source, err := iofs.New(migrations.FS, ".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback for local development if not in /app
|
return fmt.Errorf("load embedded migrations: %w", err)
|
||||||
if os.Getenv("MIGRATION_PATH") != "" {
|
|
||||||
m, err = migrate.New(
|
|
||||||
"file://"+os.Getenv("MIGRATION_PATH"),
|
|
||||||
dbURL,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Try relative path default
|
|
||||||
m, err = migrate.New(
|
|
||||||
"file://db/migrations",
|
|
||||||
dbURL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// golang-migrate's pgx/v5 driver is registered under the "pgx5" scheme;
|
||||||
|
// swap it in regardless of whether DATABASE_URL says postgres:// or
|
||||||
|
// postgresql://.
|
||||||
|
u, err := url.Parse(dbURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Migration initialization failed: %v\n", err)
|
return fmt.Errorf("parse DATABASE_URL: %w", err)
|
||||||
|
}
|
||||||
|
u.Scheme = "pgx5"
|
||||||
|
|
||||||
|
m, err := migrate.NewWithSourceInstance("iofs", source, u.String())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init migrator: %w", err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
||||||
|
return fmt.Errorf("apply migrations: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Migrations applied.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reservedAPIPrefixes are the paths the API itself owns: everything under
|
||||||
|
// /api, plus what Huma's default config registers for docs/schemas. The
|
||||||
|
// web UI's SPA fallback (below) must never shadow these.
|
||||||
|
var reservedAPIPrefixes = []string{"/api/", "/docs", "/schemas/", "/openapi.json", "/openapi.yaml", "/hello"}
|
||||||
|
|
||||||
|
// registerWebUI serves the Angular build as a single-page app if one has
|
||||||
|
// been placed at dir (WEB_DIST_DIR, default /frontend - matching where the
|
||||||
|
// Dockerfile's web build stage copies it to). Unknown paths fall back to
|
||||||
|
// index.html so Angular's router can handle a direct link to e.g. /games.
|
||||||
|
//
|
||||||
|
// Outside the Docker image, dir won't exist; this is a deliberate no-op in
|
||||||
|
// that case; so `go run ./cmd/api` and the CI build keep working without
|
||||||
|
// ever needing Node, and the API alone can still be deployed without a UI.
|
||||||
|
func registerWebUI(e *echo.Echo) {
|
||||||
|
dir := os.Getenv("WEB_DIST_DIR")
|
||||||
|
if dir == "" {
|
||||||
|
dir = "/frontend"
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||||
|
fmt.Printf("WEB_DIST_DIR %q not found; serving the API only, no web UI.\n", dir)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||||
fmt.Fprintf(os.Stderr, "Migration failed: %v\n", err)
|
Root: dir,
|
||||||
} else {
|
HTML5: true,
|
||||||
fmt.Println("Migrations ran successfully")
|
Skipper: func(c echo.Context) bool {
|
||||||
}
|
p := c.Request().URL.Path
|
||||||
|
for _, prefix := range reservedAPIPrefixes {
|
||||||
|
if strings.HasPrefix(p, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
fmt.Printf("Serving the web UI from %q.\n", dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// 1. Database connection
|
|
||||||
dbURL := os.Getenv("DATABASE_URL")
|
dbURL := os.Getenv("DATABASE_URL")
|
||||||
if dbURL == "" {
|
if dbURL == "" {
|
||||||
dbURL = "postgres://user:password@localhost:5432/dbname?sslmode=disable"
|
// Matches docker-compose.yaml's db service, so `just run-api`
|
||||||
|
// against `just docker-up`'s database works without extra setup.
|
||||||
|
dbURL = "postgres://postgres:password@localhost:5432/gamedb?sslmode=disable"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Database Exists
|
|
||||||
if err := ensureDatabase(dbURL); err != nil {
|
if err := ensureDatabase(dbURL); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Failed to ensure database: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Failed to ensure database exists: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run Migrations
|
if err := runMigrations(dbURL); err != nil {
|
||||||
runMigrations(dbURL)
|
fmt.Fprintf(os.Stderr, "Failed to run migrations: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
pool, err := pgxpool.New(context.Background(), dbURL)
|
pool, err := pgxpool.New(context.Background(), dbURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Unable to create connection pool: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
|
if err := pool.Ping(context.Background()); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
queries := db.New(pool)
|
queries := db.New(pool)
|
||||||
|
|
||||||
// 2. Setup Echo
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
e.Use(middleware.Logger())
|
e.Use(middleware.Logger())
|
||||||
e.Use(middleware.Recover())
|
e.Use(middleware.Recover())
|
||||||
|
// The web client (e.g. the Angular dev server on localhost:4200) calls
|
||||||
|
// this API from a different origin. It authenticates with a bearer
|
||||||
|
// token, not cookies, so this doesn't need AllowCredentials - which
|
||||||
|
// means a wildcard origin is safe here. Tighten AllowOrigins to the
|
||||||
|
// real deployed origin(s) if this ever serves more than local dev.
|
||||||
|
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||||
|
AllowOrigins: []string{"*"},
|
||||||
|
AllowHeaders: []string{echo.HeaderContentType, echo.HeaderAuthorization},
|
||||||
|
}))
|
||||||
|
|
||||||
// 3. Setup Huma
|
humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0"))
|
||||||
humaApi := humaecho.New(e, huma.DefaultConfig("My API", "1.0.0"))
|
|
||||||
|
|
||||||
// 4. Register Routes
|
|
||||||
api.RegisterRoutes(humaApi, queries)
|
api.RegisterRoutes(humaApi, queries)
|
||||||
|
registerWebUI(e)
|
||||||
|
|
||||||
// 5. Start Server
|
|
||||||
port := os.Getenv("PORT")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "8080"
|
port = "8080"
|
||||||
|
|||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
|
||||||
|
"completed/internal/client"
|
||||||
|
"completed/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// guiApp holds the window and the single client all screens share. It's a
|
||||||
|
// small base: log in, see your games in a table, refresh. Adding/editing
|
||||||
|
// games is for a later pass.
|
||||||
|
type guiApp struct {
|
||||||
|
client *client.Client
|
||||||
|
window fyne.Window
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGUIApp(c *client.Client, w fyne.Window) *guiApp {
|
||||||
|
return &guiApp{client: c, window: w}
|
||||||
|
}
|
||||||
|
|
||||||
|
// showLogin builds and displays the login form. It's the window's initial
|
||||||
|
// content.
|
||||||
|
func (a *guiApp) showLogin() {
|
||||||
|
email := widget.NewEntry()
|
||||||
|
email.SetPlaceHolder("email")
|
||||||
|
password := widget.NewPasswordEntry()
|
||||||
|
password.SetPlaceHolder("password")
|
||||||
|
status := widget.NewLabel("")
|
||||||
|
|
||||||
|
form := widget.NewForm(
|
||||||
|
widget.NewFormItem("Email", email),
|
||||||
|
widget.NewFormItem("Password", password),
|
||||||
|
)
|
||||||
|
form.SubmitText = "Log in"
|
||||||
|
form.OnSubmit = func() {
|
||||||
|
if err := validateLogin(email.Text, password.Text); err != nil {
|
||||||
|
status.SetText(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status.SetText("Logging in...")
|
||||||
|
a.login(email.Text, password.Text, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.window.SetContent(container.NewVBox(
|
||||||
|
widget.NewLabelWithStyle("Completed", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
||||||
|
form,
|
||||||
|
status,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// login runs the login request off the UI goroutine, then either shows the
|
||||||
|
// games screen or reports the error back on the login form - both via
|
||||||
|
// fyne.Do, since Fyne widgets may only be touched from the UI goroutine.
|
||||||
|
func (a *guiApp) login(email, password string, status *widget.Label) {
|
||||||
|
go func() {
|
||||||
|
_, err := a.client.Login(context.Background(), email, password)
|
||||||
|
if err != nil {
|
||||||
|
fyne.Do(func() { status.SetText(err.Error()) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fyne.Do(a.showGames)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// showGames displays the games table and fetches the current list.
|
||||||
|
func (a *guiApp) showGames() {
|
||||||
|
var games []model.Game
|
||||||
|
|
||||||
|
table := widget.NewTable(
|
||||||
|
func() (int, int) { return gameTableSize(games) },
|
||||||
|
func() fyne.CanvasObject { return widget.NewLabel("") },
|
||||||
|
func(id widget.TableCellID, obj fyne.CanvasObject) {
|
||||||
|
obj.(*widget.Label).SetText(gameCellText(games, id.Row, id.Col))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for i := range gameColumns {
|
||||||
|
table.SetColumnWidth(i, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := widget.NewLabel("Loading...")
|
||||||
|
refresh := widget.NewButton("Refresh", func() {
|
||||||
|
a.fetchGames(table, &games, status)
|
||||||
|
})
|
||||||
|
|
||||||
|
a.window.SetContent(container.NewBorder(
|
||||||
|
container.NewVBox(
|
||||||
|
widget.NewLabelWithStyle("Completed", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
||||||
|
container.NewHBox(refresh, status),
|
||||||
|
),
|
||||||
|
nil, nil, nil,
|
||||||
|
table,
|
||||||
|
))
|
||||||
|
|
||||||
|
a.fetchGames(table, &games, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *guiApp) fetchGames(table *widget.Table, games *[]model.Game, status *widget.Label) {
|
||||||
|
go func() {
|
||||||
|
result, err := a.client.ListGames(context.Background())
|
||||||
|
fyne.Do(func() {
|
||||||
|
if err != nil {
|
||||||
|
status.SetText("Error: " + err.Error())
|
||||||
|
dialog.ShowError(err, a.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*games = result
|
||||||
|
status.SetText("")
|
||||||
|
table.Refresh()
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
+12
-135
@@ -1,145 +1,22 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"os"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
rl "github.com/gen2brain/raylib-go/raylib"
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/app"
|
||||||
|
|
||||||
|
"completed/internal/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Game struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
PlatformID int64 `json:"platform_id"`
|
|
||||||
Score int32 `json:"score"`
|
|
||||||
ReleaseYear string `json:"release_year"`
|
|
||||||
Finished string `json:"finished,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GamesResponse struct {
|
|
||||||
Body []Game `json:"body"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
rl.InitWindow(800, 600, "My Game Collection - Raylib")
|
fyneApp := app.New()
|
||||||
defer rl.CloseWindow()
|
window := fyneApp.NewWindow("Completed")
|
||||||
|
window.Resize(fyne.NewSize(700, 500))
|
||||||
|
|
||||||
rl.SetTargetFPS(60)
|
c := client.New(os.Getenv("COMPLETED_API_URL"))
|
||||||
|
a := newGUIApp(c, window)
|
||||||
|
a.showLogin()
|
||||||
|
|
||||||
loading := false
|
window.ShowAndRun()
|
||||||
var err error
|
|
||||||
var games []Game
|
|
||||||
|
|
||||||
// Channel to receive data from background goroutine
|
|
||||||
dataChan := make(chan []Game)
|
|
||||||
errChan := make(chan error)
|
|
||||||
|
|
||||||
func() {
|
|
||||||
loading = true
|
|
||||||
go fetchGames(dataChan, errChan)
|
|
||||||
}()
|
|
||||||
|
|
||||||
for !rl.WindowShouldClose() {
|
|
||||||
rl.BeginDrawing()
|
|
||||||
rl.ClearBackground(rl.RayWhite)
|
|
||||||
|
|
||||||
// Header
|
|
||||||
rl.DrawRectangle(0, 0, 800, 60, rl.NewColor(100, 100, 255, 255))
|
|
||||||
rl.DrawText("My Game Collection", 20, 15, 30, rl.White)
|
|
||||||
|
|
||||||
startY := 80
|
|
||||||
|
|
||||||
if loading {
|
|
||||||
rl.DrawText("Loading games...", 20, int32(startY), 20, rl.Gray)
|
|
||||||
// Simple spinner animation
|
|
||||||
if int(rl.GetTime()*4)%2 == 0 {
|
|
||||||
rl.DrawText(".", 180, int32(startY), 20, rl.Gray)
|
|
||||||
}
|
|
||||||
} else if err != nil {
|
|
||||||
rl.DrawText(fmt.Sprintf("Error: %v", err), 20, int32(startY), 20, rl.Red)
|
|
||||||
rl.DrawText("Press SPACE to retry", 20, int32(startY)+30, 20, rl.DarkGray)
|
|
||||||
} else {
|
|
||||||
if len(games) == 0 {
|
|
||||||
rl.DrawText("No games found in database.", 20, int32(startY), 20, rl.DarkGray)
|
|
||||||
} else {
|
|
||||||
for i, g := range games {
|
|
||||||
yPos := int32(startY + (i * 40))
|
|
||||||
|
|
||||||
// Row background (alternating)
|
|
||||||
if i%2 == 0 {
|
|
||||||
rl.DrawRectangle(20, yPos-5, 760, 35, rl.NewColor(240, 240, 240, 255))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Game Name
|
|
||||||
rl.DrawText(g.Name, 30, yPos, 20, rl.Black)
|
|
||||||
|
|
||||||
// Year
|
|
||||||
rl.DrawText(g.ReleaseYear, 350, yPos, 20, rl.DarkGray)
|
|
||||||
|
|
||||||
// Score
|
|
||||||
scoreColor := rl.Orange
|
|
||||||
if g.Score >= 90 {
|
|
||||||
scoreColor = rl.Green
|
|
||||||
} else if g.Score < 70 {
|
|
||||||
scoreColor = rl.Red
|
|
||||||
}
|
|
||||||
rl.DrawText(fmt.Sprintf("%d", g.Score), 500, yPos, 20, scoreColor)
|
|
||||||
|
|
||||||
// Finished Status
|
|
||||||
if g.Finished != "" {
|
|
||||||
rl.DrawText("Finished", 600, yPos, 20, rl.NewColor(0, 200, 0, 255))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Footer / Help
|
|
||||||
rl.DrawText("Press SPACE to refresh", 20, 570, 10, rl.Gray)
|
|
||||||
|
|
||||||
rl.EndDrawing()
|
|
||||||
|
|
||||||
// Inputs
|
|
||||||
if rl.IsKeyPressed(rl.KeySpace) && !loading {
|
|
||||||
loading = true
|
|
||||||
err = nil
|
|
||||||
games = nil
|
|
||||||
go fetchGames(dataChan, errChan)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check channels (non-blocking)
|
|
||||||
select {
|
|
||||||
case data := <-dataChan:
|
|
||||||
loading = false
|
|
||||||
games = data
|
|
||||||
case e := <-errChan:
|
|
||||||
loading = false
|
|
||||||
err = e
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchGames(dataChan chan<- []Game, errChan chan<- error) {
|
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
|
||||||
resp, err := client.Get("http://localhost:8080/games")
|
|
||||||
if err != nil {
|
|
||||||
errChan <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
errChan <- fmt.Errorf("status code: %d", resp.StatusCode)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var data GamesResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
|
||||||
errChan <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dataChan <- data.Body
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"completed/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gameColumns are the games table's column headers, in order. Kept next to
|
||||||
|
// gameCellText so the two can't drift apart.
|
||||||
|
var gameColumns = []string{"Name", "Platform", "Score", "Released", "Finished"}
|
||||||
|
|
||||||
|
// gameTableSize returns the (rows, cols) Fyne's widget.Table needs from its
|
||||||
|
// Length function: one row per game, plus a header row.
|
||||||
|
func gameTableSize(games []model.Game) (rows, cols int) {
|
||||||
|
return len(games) + 1, len(gameColumns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameCellText returns the text for row/col in the games table. row 0 is
|
||||||
|
// the header; row i (i>0) is games[i-1]. It has no Fyne dependency, so it's
|
||||||
|
// tested directly without needing any widget or display.
|
||||||
|
func gameCellText(games []model.Game, row, col int) string {
|
||||||
|
if row == 0 {
|
||||||
|
return gameColumns[col]
|
||||||
|
}
|
||||||
|
g := games[row-1]
|
||||||
|
switch col {
|
||||||
|
case 0:
|
||||||
|
return g.Name
|
||||||
|
case 1:
|
||||||
|
return g.PlatformName
|
||||||
|
case 2:
|
||||||
|
return fmt.Sprintf("%d", g.Score)
|
||||||
|
case 3:
|
||||||
|
return g.ReleaseDate
|
||||||
|
case 4:
|
||||||
|
if g.Finished != "" {
|
||||||
|
return "✓"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"completed/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGameTableSize(t *testing.T) {
|
||||||
|
games := []model.Game{{Name: "Doom"}, {Name: "Half-Life"}}
|
||||||
|
|
||||||
|
rows, cols := gameTableSize(games)
|
||||||
|
if rows != 3 {
|
||||||
|
t.Errorf("rows = %d, want 3 (header + 2 games)", rows)
|
||||||
|
}
|
||||||
|
if cols != len(gameColumns) {
|
||||||
|
t.Errorf("cols = %d, want %d", cols, len(gameColumns))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameTableSizeEmpty(t *testing.T) {
|
||||||
|
rows, _ := gameTableSize(nil)
|
||||||
|
if rows != 1 {
|
||||||
|
t.Errorf("rows = %d, want 1 (header only)", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameCellTextHeader(t *testing.T) {
|
||||||
|
for col, want := range gameColumns {
|
||||||
|
if got := gameCellText(nil, 0, col); got != want {
|
||||||
|
t.Errorf("gameCellText(nil, 0, %d) = %q, want %q", col, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameCellTextDataRows(t *testing.T) {
|
||||||
|
games := []model.Game{
|
||||||
|
{Name: "Doom", PlatformName: "PC", Score: 95, ReleaseDate: "1993-12-10"},
|
||||||
|
{Name: "Half-Life", PlatformName: "PC", Score: 96, ReleaseDate: "1998-11-19", Finished: "1999-01-01"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
row, col int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{1, 0, "Doom"},
|
||||||
|
{1, 1, "PC"},
|
||||||
|
{1, 2, "95"},
|
||||||
|
{1, 3, "1993-12-10"},
|
||||||
|
{1, 4, ""},
|
||||||
|
{2, 0, "Half-Life"},
|
||||||
|
{2, 4, "✓"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := gameCellText(games, tt.row, tt.col); got != tt.want {
|
||||||
|
t.Errorf("gameCellText(games, %d, %d) = %q, want %q", tt.row, tt.col, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// validateLogin checks the login form before it's sent to the server, so a
|
||||||
|
// blank field shows an immediate, specific error instead of an HTTP
|
||||||
|
// round trip followed by a generic one.
|
||||||
|
func validateLogin(email, password string) error {
|
||||||
|
if email == "" {
|
||||||
|
return errors.New("email is required")
|
||||||
|
}
|
||||||
|
if password == "" {
|
||||||
|
return errors.New("password is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateLogin(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, email, password string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid", "a@example.com", "secret", false},
|
||||||
|
{"empty email", "", "secret", true},
|
||||||
|
{"empty password", "a@example.com", "", true},
|
||||||
|
{"both empty", "", "", true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := validateLogin(tt.email, tt.password)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("validateLogin(%q, %q) error = %v, wantErr %v", tt.email, tt.password, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Command openapi writes the API's OpenAPI spec to api/openapi.json.
|
||||||
|
// Clients (the web frontend, generated SDKs) treat that file as the
|
||||||
|
// contract for what the server exposes; regenerate it after any route
|
||||||
|
// change with `just openapi`.
|
||||||
|
//
|
||||||
|
// It builds the same route tree as cmd/api, but against a stub echo
|
||||||
|
// server with no real database connection: registering routes only
|
||||||
|
// inspects Go types via reflection and never calls the database, so a nil
|
||||||
|
// *db.Queries (which still satisfies db.Querier) is enough.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
"completed/internal/api"
|
||||||
|
"completed/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
e := echo.New()
|
||||||
|
humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0"))
|
||||||
|
|
||||||
|
var queries db.Querier = (*db.Queries)(nil)
|
||||||
|
api.RegisterRoutes(humaApi, queries)
|
||||||
|
|
||||||
|
data, err := humaApi.OpenAPI().MarshalJSON()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "marshal OpenAPI spec: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const outPath = "api/openapi.json"
|
||||||
|
if err := os.MkdirAll("api", 0o755); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "create api/ directory: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(outPath, append(data, '\n'), 0o644); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "write %s: %v\n", outPath, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Wrote %s\n", outPath)
|
||||||
|
}
|
||||||
+6
-136
@@ -1,150 +1,20 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
|
"completed/internal/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Styles
|
|
||||||
var (
|
|
||||||
titleStyle = lipgloss.NewStyle().
|
|
||||||
Bold(true).
|
|
||||||
Foreground(lipgloss.Color("#FAFAFA")).
|
|
||||||
Background(lipgloss.Color("#7D56F4")).
|
|
||||||
Padding(0, 1)
|
|
||||||
|
|
||||||
itemStyle = lipgloss.NewStyle().
|
|
||||||
PaddingLeft(2)
|
|
||||||
|
|
||||||
scoreStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#FFD700")) // Gold color
|
|
||||||
|
|
||||||
errorStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#FF5555")).
|
|
||||||
Bold(true)
|
|
||||||
)
|
|
||||||
|
|
||||||
type Game struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
PlatformID int64 `json:"platform_id"`
|
|
||||||
Score int32 `json:"score"`
|
|
||||||
ReleaseYear string `json:"release_year"`
|
|
||||||
Finished string `json:"finished,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GamesResponse struct {
|
|
||||||
Body []Game `json:"body"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Model struct {
|
|
||||||
spinner spinner.Model
|
|
||||||
loading bool
|
|
||||||
err error
|
|
||||||
games []Game
|
|
||||||
quitting bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func InitialModel() Model {
|
|
||||||
s := spinner.New()
|
|
||||||
s.Spinner = spinner.Dot
|
|
||||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
|
||||||
return Model{spinner: s, loading: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
type loadedMsg []Game
|
|
||||||
type errMsg error
|
|
||||||
|
|
||||||
func fetchGames() tea.Msg {
|
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
|
||||||
resp, err := client.Get("http://localhost:8080/games")
|
|
||||||
if err != nil {
|
|
||||||
return errMsg(err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return errMsg(fmt.Errorf("status code: %d", resp.StatusCode))
|
|
||||||
}
|
|
||||||
|
|
||||||
var data GamesResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
|
||||||
return errMsg(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return loadedMsg(data.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
|
||||||
return tea.Batch(m.spinner.Tick, fetchGames)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
if msg.String() == "q" || msg.String() == "ctrl+c" {
|
|
||||||
m.quitting = true
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
case spinner.TickMsg:
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.spinner, cmd = m.spinner.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
case loadedMsg:
|
|
||||||
m.loading = false
|
|
||||||
m.games = msg
|
|
||||||
return m, nil
|
|
||||||
case errMsg:
|
|
||||||
m.loading = false
|
|
||||||
m.err = msg
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) View() string {
|
|
||||||
if m.quitting {
|
|
||||||
return "Bye!\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
s := titleStyle.Render("My Game Collection") + "\n\n"
|
|
||||||
|
|
||||||
if m.loading {
|
|
||||||
s += fmt.Sprintf("%s Loading games...", m.spinner.View())
|
|
||||||
} else if m.err != nil {
|
|
||||||
s += errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
|
|
||||||
s += "\nMake sure the API is running on localhost:8080"
|
|
||||||
} else {
|
|
||||||
if len(m.games) == 0 {
|
|
||||||
s += "No games found."
|
|
||||||
} else {
|
|
||||||
for _, g := range m.games {
|
|
||||||
score := scoreStyle.Render(fmt.Sprintf("★ %d", g.Score))
|
|
||||||
line := fmt.Sprintf("• %s (%s) - %s", g.Name, g.ReleaseYear, score)
|
|
||||||
if g.Finished != "" {
|
|
||||||
line += " [✓ Finished]"
|
|
||||||
}
|
|
||||||
s += itemStyle.Render(line) + "\n"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s += strings.Repeat("\n", 2) + lipgloss.NewStyle().Foreground(lipgloss.Color("#555")).Render("Press 'q' to quit")
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
p := tea.NewProgram(InitialModel())
|
c := client.New(os.Getenv("COMPLETED_API_URL"))
|
||||||
|
|
||||||
|
p := tea.NewProgram(NewModel(c))
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Printf("Alas, there's been an error: %v", err)
|
fmt.Fprintf(os.Stderr, "Alas, there's been an error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
"github.com/charmbracelet/bubbles/table"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"completed/internal/client"
|
||||||
|
"completed/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// screen is which part of the app is currently showing. The model only
|
||||||
|
// ever does one thing at a time, so a single enum (rather than a flag per
|
||||||
|
// feature) keeps Update's branching simple.
|
||||||
|
type screen int
|
||||||
|
|
||||||
|
const (
|
||||||
|
screenLogin screen = iota
|
||||||
|
screenLoading
|
||||||
|
screenGames
|
||||||
|
)
|
||||||
|
|
||||||
|
// focusField is which of the two login inputs has keyboard focus.
|
||||||
|
type focusField int
|
||||||
|
|
||||||
|
const (
|
||||||
|
focusEmail focusField = iota
|
||||||
|
focusPassword
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
titleStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("#FAFAFA")).
|
||||||
|
Background(lipgloss.Color("#7D56F4")).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
errorStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#FF5555")).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#555"))
|
||||||
|
|
||||||
|
focusedInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model is the TUI's whole state. It's a small base: log in, see your
|
||||||
|
// games in a scrollable table, refresh. Adding/editing games is for a
|
||||||
|
// later pass; the point here is a working, tested skeleton the rest of
|
||||||
|
// the TUI can grow from.
|
||||||
|
type Model struct {
|
||||||
|
client *client.Client
|
||||||
|
|
||||||
|
screen screen
|
||||||
|
err error
|
||||||
|
|
||||||
|
// Login screen.
|
||||||
|
emailInput textinput.Model
|
||||||
|
passwordInput textinput.Model
|
||||||
|
focus focusField
|
||||||
|
|
||||||
|
// Loading screen (used for both the initial post-login fetch and a
|
||||||
|
// manual refresh).
|
||||||
|
spinner spinner.Model
|
||||||
|
|
||||||
|
// Games screen.
|
||||||
|
table table.Model
|
||||||
|
user model.User
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewModel builds the initial Model, showing the login screen.
|
||||||
|
func NewModel(c *client.Client) Model {
|
||||||
|
email := textinput.New()
|
||||||
|
email.Placeholder = "email"
|
||||||
|
email.Focus()
|
||||||
|
email.CharLimit = 254
|
||||||
|
|
||||||
|
password := textinput.New()
|
||||||
|
password.Placeholder = "password"
|
||||||
|
password.EchoMode = textinput.EchoPassword
|
||||||
|
password.CharLimit = 254
|
||||||
|
|
||||||
|
s := spinner.New()
|
||||||
|
s.Spinner = spinner.Dot
|
||||||
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||||
|
|
||||||
|
return Model{
|
||||||
|
client: c,
|
||||||
|
screen: screenLogin,
|
||||||
|
emailInput: email,
|
||||||
|
passwordInput: password,
|
||||||
|
spinner: s,
|
||||||
|
table: newGamesTable(nil),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGamesTable(games []model.Game) table.Model {
|
||||||
|
columns := []table.Column{
|
||||||
|
{Title: "Name", Width: 30},
|
||||||
|
{Title: "Platform", Width: 16},
|
||||||
|
{Title: "Score", Width: 7},
|
||||||
|
{Title: "Released", Width: 12},
|
||||||
|
{Title: "Finished", Width: 10},
|
||||||
|
}
|
||||||
|
t := table.New(
|
||||||
|
table.WithColumns(columns),
|
||||||
|
table.WithRows(gamesToRows(games)),
|
||||||
|
table.WithFocused(true),
|
||||||
|
table.WithHeight(15),
|
||||||
|
)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func gamesToRows(games []model.Game) []table.Row {
|
||||||
|
rows := make([]table.Row, len(games))
|
||||||
|
for i, g := range games {
|
||||||
|
finished := ""
|
||||||
|
if g.Finished != "" {
|
||||||
|
finished = "✓"
|
||||||
|
}
|
||||||
|
rows[i] = table.Row{g.Name, g.PlatformName, fmt.Sprintf("%d", g.Score), g.ReleaseDate, finished}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Messages ---
|
||||||
|
|
||||||
|
type loginSucceededMsg struct{ user model.User }
|
||||||
|
type loginFailedMsg struct{ err error }
|
||||||
|
type gamesLoadedMsg struct{ games []model.Game }
|
||||||
|
type gamesLoadFailedMsg struct{ err error }
|
||||||
|
|
||||||
|
// --- Commands ---
|
||||||
|
|
||||||
|
func doLogin(c *client.Client, email, password string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
resp, err := c.Login(context.Background(), email, password)
|
||||||
|
if err != nil {
|
||||||
|
return loginFailedMsg{err: err}
|
||||||
|
}
|
||||||
|
return loginSucceededMsg{user: resp.User}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func doListGames(c *client.Client) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
games, err := c.ListGames(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return gamesLoadFailedMsg{err: err}
|
||||||
|
}
|
||||||
|
return gamesLoadedMsg{games: games}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- bubbletea.Model ---
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
return tea.Batch(m.spinner.Tick, textinput.Blink)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
return m.updateKey(msg)
|
||||||
|
|
||||||
|
case spinner.TickMsg:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.spinner, cmd = m.spinner.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case loginSucceededMsg:
|
||||||
|
m.user = msg.user
|
||||||
|
m.err = nil
|
||||||
|
m.screen = screenLoading
|
||||||
|
return m, doListGames(m.client)
|
||||||
|
|
||||||
|
case loginFailedMsg:
|
||||||
|
m.err = msg.err
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case gamesLoadedMsg:
|
||||||
|
m.table = newGamesTable(msg.games)
|
||||||
|
m.err = nil
|
||||||
|
m.screen = screenGames
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case gamesLoadFailedMsg:
|
||||||
|
m.err = msg.err
|
||||||
|
m.screen = screenGames
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if msg.String() == "ctrl+c" {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
|
||||||
|
switch m.screen {
|
||||||
|
case screenLogin:
|
||||||
|
return m.updateLoginKey(msg)
|
||||||
|
case screenGames:
|
||||||
|
if msg.String() == "q" {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
if msg.String() == "r" {
|
||||||
|
m.screen = screenLoading
|
||||||
|
m.err = nil
|
||||||
|
return m, doListGames(m.client)
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.table, cmd = m.table.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateLoginKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "tab", "shift+tab", "down", "up":
|
||||||
|
if m.focus == focusEmail {
|
||||||
|
m.focus = focusPassword
|
||||||
|
m.emailInput.Blur()
|
||||||
|
m.passwordInput.Focus()
|
||||||
|
} else {
|
||||||
|
m.focus = focusEmail
|
||||||
|
m.passwordInput.Blur()
|
||||||
|
m.emailInput.Focus()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "enter":
|
||||||
|
m.err = nil
|
||||||
|
m.screen = screenLoading
|
||||||
|
return m, doLogin(m.client, m.emailInput.Value(), m.passwordInput.Value())
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
if m.focus == focusEmail {
|
||||||
|
m.emailInput, cmd = m.emailInput.Update(msg)
|
||||||
|
} else {
|
||||||
|
m.passwordInput, cmd = m.passwordInput.Update(msg)
|
||||||
|
}
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) View() string {
|
||||||
|
switch m.screen {
|
||||||
|
case screenLogin:
|
||||||
|
return m.viewLogin()
|
||||||
|
case screenLoading:
|
||||||
|
return fmt.Sprintf("%s Loading...\n", m.spinner.View())
|
||||||
|
case screenGames:
|
||||||
|
return m.viewGames()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewLogin() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleStyle.Render("Completed") + "\n\n")
|
||||||
|
|
||||||
|
emailLabel, passwordLabel := "Email", "Password"
|
||||||
|
if m.focus == focusEmail {
|
||||||
|
emailLabel = focusedInputStyle.Render(emailLabel)
|
||||||
|
} else {
|
||||||
|
passwordLabel = focusedInputStyle.Render(passwordLabel)
|
||||||
|
}
|
||||||
|
b.WriteString(emailLabel + "\n" + m.emailInput.View() + "\n\n")
|
||||||
|
b.WriteString(passwordLabel + "\n" + m.passwordInput.View() + "\n\n")
|
||||||
|
|
||||||
|
if m.err != nil {
|
||||||
|
b.WriteString(errorStyle.Render("Error: "+m.err.Error()) + "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(helpStyle.Render("tab: switch field · enter: log in · ctrl+c: quit"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewGames() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleStyle.Render("Completed") + " " + m.user.Name + "\n\n")
|
||||||
|
|
||||||
|
if m.err != nil {
|
||||||
|
b.WriteString(errorStyle.Render("Error: "+m.err.Error()) + "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(m.table.View() + "\n\n")
|
||||||
|
b.WriteString(helpStyle.Render("r: refresh · q: quit"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"completed/internal/client"
|
||||||
|
"completed/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// key builds the tea.KeyMsg Update expects for a named key, matching what
|
||||||
|
// bubbletea itself constructs from terminal input.
|
||||||
|
func key(s string) tea.KeyMsg {
|
||||||
|
switch s {
|
||||||
|
case "enter":
|
||||||
|
return tea.KeyMsg{Type: tea.KeyEnter}
|
||||||
|
case "tab":
|
||||||
|
return tea.KeyMsg{Type: tea.KeyTab}
|
||||||
|
case "ctrl+c":
|
||||||
|
return tea.KeyMsg{Type: tea.KeyCtrlC}
|
||||||
|
default:
|
||||||
|
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(t *testing.T, m Model, msg tea.Msg) Model {
|
||||||
|
t.Helper()
|
||||||
|
newModel, _ := m.Update(msg)
|
||||||
|
m, ok := newModel.(Model)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Update returned %T, want Model", newModel)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypingFillsFocusedField(t *testing.T) {
|
||||||
|
m := NewModel(client.New(""))
|
||||||
|
|
||||||
|
m = update(t, m, key("a"))
|
||||||
|
m = update(t, m, key("b"))
|
||||||
|
if got := m.emailInput.Value(); got != "ab" {
|
||||||
|
t.Errorf("emailInput.Value() = %q, want %q", got, "ab")
|
||||||
|
}
|
||||||
|
if got := m.passwordInput.Value(); got != "" {
|
||||||
|
t.Errorf("passwordInput.Value() = %q, want empty while email is focused", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
m = update(t, m, key("tab"))
|
||||||
|
m = update(t, m, key("c"))
|
||||||
|
if got := m.passwordInput.Value(); got != "c" {
|
||||||
|
t.Errorf("passwordInput.Value() = %q, want %q after tab switches focus", got, "c")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnterOnLoginStartsLoading(t *testing.T) {
|
||||||
|
m := NewModel(client.New(""))
|
||||||
|
m.emailInput.SetValue("a@example.com")
|
||||||
|
m.passwordInput.SetValue("secret")
|
||||||
|
|
||||||
|
newModel, cmd := m.Update(key("enter"))
|
||||||
|
m = newModel.(Model)
|
||||||
|
|
||||||
|
if m.screen != screenLoading {
|
||||||
|
t.Errorf("screen = %v, want screenLoading", m.screen)
|
||||||
|
}
|
||||||
|
if cmd == nil {
|
||||||
|
t.Error("expected a command to perform the login request, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSucceededTransitionsToLoadingGames(t *testing.T) {
|
||||||
|
m := NewModel(client.New(""))
|
||||||
|
m.screen = screenLoading
|
||||||
|
|
||||||
|
newModel, cmd := m.Update(loginSucceededMsg{user: model.User{Name: "Alice"}})
|
||||||
|
m = newModel.(Model)
|
||||||
|
|
||||||
|
if m.screen != screenLoading {
|
||||||
|
t.Errorf("screen = %v, want to stay screenLoading while games load", m.screen)
|
||||||
|
}
|
||||||
|
if m.user.Name != "Alice" {
|
||||||
|
t.Errorf("user.Name = %q, want Alice", m.user.Name)
|
||||||
|
}
|
||||||
|
if cmd == nil {
|
||||||
|
t.Error("expected a command to fetch games, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginFailedStaysOnLoginWithError(t *testing.T) {
|
||||||
|
m := NewModel(client.New(""))
|
||||||
|
m.screen = screenLoading
|
||||||
|
wantErr := errors.New("invalid email or password")
|
||||||
|
|
||||||
|
m = update(t, m, loginFailedMsg{err: wantErr})
|
||||||
|
|
||||||
|
if m.err != wantErr {
|
||||||
|
t.Errorf("err = %v, want %v", m.err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGamesLoadedPopulatesTableAndSwitchesScreen(t *testing.T) {
|
||||||
|
m := NewModel(client.New(""))
|
||||||
|
m.screen = screenLoading
|
||||||
|
games := []model.Game{
|
||||||
|
{ID: "g1", Name: "Doom", PlatformName: "PC", Score: 95, ReleaseDate: "1993-12-10"},
|
||||||
|
{ID: "g2", Name: "Half-Life", PlatformName: "PC", Score: 96, ReleaseDate: "1998-11-19", Finished: "1999-01-01"},
|
||||||
|
}
|
||||||
|
|
||||||
|
m = update(t, m, gamesLoadedMsg{games: games})
|
||||||
|
|
||||||
|
if m.screen != screenGames {
|
||||||
|
t.Errorf("screen = %v, want screenGames", m.screen)
|
||||||
|
}
|
||||||
|
rows := m.table.Rows()
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("table has %d rows, want 2", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0][0] != "Doom" || rows[0][4] != "" {
|
||||||
|
t.Errorf("row 0 = %v, want Doom unfinished", rows[0])
|
||||||
|
}
|
||||||
|
if rows[1][0] != "Half-Life" || rows[1][4] != "✓" {
|
||||||
|
t.Errorf("row 1 = %v, want Half-Life finished", rows[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGamesLoadFailedShowsErrorOnGamesScreen(t *testing.T) {
|
||||||
|
m := NewModel(client.New(""))
|
||||||
|
m.screen = screenLoading
|
||||||
|
wantErr := errors.New("network error")
|
||||||
|
|
||||||
|
m = update(t, m, gamesLoadFailedMsg{err: wantErr})
|
||||||
|
|
||||||
|
if m.screen != screenGames {
|
||||||
|
t.Errorf("screen = %v, want screenGames (so 'r' can retry)", m.screen)
|
||||||
|
}
|
||||||
|
if m.err != wantErr {
|
||||||
|
t.Errorf("err = %v, want %v", m.err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshOnGamesScreenReloads(t *testing.T) {
|
||||||
|
m := NewModel(client.New(""))
|
||||||
|
m.screen = screenGames
|
||||||
|
m.table = newGamesTable([]model.Game{{ID: "g1", Name: "Doom"}})
|
||||||
|
|
||||||
|
newModel, cmd := m.Update(key("r"))
|
||||||
|
m = newModel.(Model)
|
||||||
|
|
||||||
|
if m.screen != screenLoading {
|
||||||
|
t.Errorf("screen = %v, want screenLoading", m.screen)
|
||||||
|
}
|
||||||
|
if cmd == nil {
|
||||||
|
t.Error("expected a command to refetch games, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuitOnGamesScreen(t *testing.T) {
|
||||||
|
m := NewModel(client.New(""))
|
||||||
|
m.screen = screenGames
|
||||||
|
|
||||||
|
_, cmd := m.Update(key("q"))
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected tea.Quit, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCtrlCQuitsFromAnyScreen(t *testing.T) {
|
||||||
|
for _, s := range []screen{screenLogin, screenLoading, screenGames} {
|
||||||
|
m := NewModel(client.New(""))
|
||||||
|
m.screen = s
|
||||||
|
|
||||||
|
_, cmd := m.Update(key("ctrl+c"))
|
||||||
|
if cmd == nil {
|
||||||
|
t.Errorf("screen %v: expected tea.Quit on ctrl+c, got nil", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
ALTER TABLE users
|
|
||||||
DROP COLUMN email,
|
|
||||||
DROP COLUMN password_hash;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
ALTER TABLE users
|
|
||||||
ADD COLUMN email TEXT NOT NULL UNIQUE,
|
|
||||||
ADD COLUMN password_hash TEXT NOT NULL;
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
DROP TRIGGER IF EXISTS game_touch_change_seq ON game;
|
||||||
|
DROP TRIGGER IF EXISTS platform_touch_change_seq ON platform;
|
||||||
|
DROP FUNCTION IF EXISTS touch_change_seq();
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS game;
|
||||||
|
DROP TABLE IF EXISTS platform;
|
||||||
|
DROP TABLE IF EXISTS sessions;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
|
||||||
|
DROP SEQUENCE IF EXISTS change_seq_seq;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
-- Requires pgcrypto for gen_random_uuid(); UUIDs are v7 when the app generates
|
||||||
|
-- them (see internal/model), but the DB default falls back to v4 for rows
|
||||||
|
-- inserted outside the app (e.g. manual SQL).
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS citext;
|
||||||
|
|
||||||
|
-- change_seq is a single global sequence shared by platform and game so a
|
||||||
|
-- client can ask "what changed since N?" across both tables in one pass.
|
||||||
|
CREATE SEQUENCE change_seq_seq;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email citext NOT NULL UNIQUE,
|
||||||
|
password_hash text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
bio text,
|
||||||
|
-- Only admins may add/rename/delete from the shared platform list (see
|
||||||
|
-- the platform table below); everyone can read it. The first account
|
||||||
|
-- ever registered becomes an admin automatically (see CreateUser in
|
||||||
|
-- db/query.sql) so there's always at least one without manual setup;
|
||||||
|
-- promoting anyone beyond that is a manual
|
||||||
|
-- `UPDATE users SET is_admin = true WHERE email = ...` for now.
|
||||||
|
is_admin boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
token_hash bytea PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
last_seen_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||||
|
|
||||||
|
-- Platforms are shared: every user picks from and adds to the same list,
|
||||||
|
-- unlike games, which are private per user_id.
|
||||||
|
CREATE TABLE platform (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name text NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
change_seq bigint NOT NULL DEFAULT nextval('change_seq_seq'),
|
||||||
|
UNIQUE (name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_platform_change_seq ON platform(change_seq);
|
||||||
|
|
||||||
|
CREATE TABLE game (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
platform_id uuid NOT NULL REFERENCES platform(id),
|
||||||
|
name text NOT NULL,
|
||||||
|
score int NOT NULL CHECK (score BETWEEN 0 AND 100),
|
||||||
|
release_date date NOT NULL,
|
||||||
|
finished date,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
change_seq bigint NOT NULL DEFAULT nextval('change_seq_seq')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_game_user_change_seq ON game(user_id, change_seq);
|
||||||
|
|
||||||
|
-- Keep updated_at/change_seq current on every UPDATE so sync can detect
|
||||||
|
-- changes without clients having to set them manually.
|
||||||
|
CREATE OR REPLACE FUNCTION touch_change_seq() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at := now();
|
||||||
|
NEW.change_seq := nextval('change_seq_seq');
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER platform_touch_change_seq
|
||||||
|
BEFORE UPDATE ON platform
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION touch_change_seq();
|
||||||
|
|
||||||
|
CREATE TRIGGER game_touch_change_seq
|
||||||
|
BEFORE UPDATE ON game
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION touch_change_seq();
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Package migrations embeds the SQL migration files into the binary, so the
|
||||||
|
// server doesn't depend on a db/migrations directory existing next to it at
|
||||||
|
// runtime (which previously broke outside of a specific Docker layout; see
|
||||||
|
// cmd/api/main.go).
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed *.sql
|
||||||
|
var FS embed.FS
|
||||||
+106
-46
@@ -1,67 +1,127 @@
|
|||||||
-- name: GetUser :one
|
|
||||||
SELECT * FROM users
|
|
||||||
WHERE id = $1 LIMIT 1;
|
|
||||||
|
|
||||||
-- name: ListUsers :many
|
|
||||||
SELECT * FROM users
|
|
||||||
ORDER BY name;
|
|
||||||
|
|
||||||
-- name: CreateUser :one
|
-- name: CreateUser :one
|
||||||
|
-- The first account ever registered becomes an admin automatically; see
|
||||||
|
-- the is_admin column's comment in db/migrations for why and the race this
|
||||||
|
-- accepts. Everyone after that registers as a non-admin.
|
||||||
INSERT INTO users (
|
INSERT INTO users (
|
||||||
name, bio
|
email, password_hash, name, bio, is_admin
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2
|
$1, $2, $3, $4, NOT EXISTS (SELECT 1 FROM users)
|
||||||
)
|
)
|
||||||
RETURNING *;
|
RETURNING id, email, name, bio, is_admin, created_at;
|
||||||
|
|
||||||
-- name: DeleteUser :exec
|
-- name: GetUserByEmail :one
|
||||||
DELETE FROM users
|
SELECT id, email, password_hash, name, bio, is_admin, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE email = $1;
|
||||||
|
|
||||||
|
-- name: GetUserByID :one
|
||||||
|
SELECT id, email, name, bio, is_admin, created_at
|
||||||
|
FROM users
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: CreateSession :exec
|
||||||
|
INSERT INTO sessions (
|
||||||
|
token_hash, user_id, expires_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3
|
||||||
|
);
|
||||||
|
|
||||||
|
-- name: GetSessionAndUser :one
|
||||||
|
SELECT users.id, users.email, users.name, users.bio, users.is_admin, sessions.expires_at
|
||||||
|
FROM sessions
|
||||||
|
JOIN users ON users.id = sessions.user_id
|
||||||
|
WHERE sessions.token_hash = $1 AND sessions.expires_at > now();
|
||||||
|
|
||||||
|
-- name: TouchSession :exec
|
||||||
|
UPDATE sessions
|
||||||
|
SET last_seen_at = now()
|
||||||
|
WHERE token_hash = $1;
|
||||||
|
|
||||||
|
-- name: DeleteSession :exec
|
||||||
|
DELETE FROM sessions
|
||||||
|
WHERE token_hash = $1;
|
||||||
|
|
||||||
-- name: CreatePlatform :one
|
-- name: CreatePlatform :one
|
||||||
INSERT INTO platform (
|
INSERT INTO platform (
|
||||||
name
|
name
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1
|
$1
|
||||||
)
|
)
|
||||||
RETURNING *;
|
RETURNING id, name;
|
||||||
|
|
||||||
|
-- name: GetPlatform :one
|
||||||
|
SELECT id, name
|
||||||
|
FROM platform
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL;
|
||||||
|
|
||||||
-- name: ListPlatforms :many
|
-- name: ListPlatforms :many
|
||||||
SELECT * FROM platform
|
SELECT id, name
|
||||||
|
FROM platform
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
ORDER BY name;
|
ORDER BY name;
|
||||||
|
|
||||||
-- name: CreateGame :one
|
|
||||||
INSERT INTO game (
|
|
||||||
name, platform_id, score, release_year, finished
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5
|
|
||||||
)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: GetGame :one
|
|
||||||
SELECT * FROM game
|
|
||||||
WHERE id = $1 LIMIT 1;
|
|
||||||
|
|
||||||
-- name: ListGames :many
|
|
||||||
SELECT * FROM game
|
|
||||||
ORDER BY name;
|
|
||||||
|
|
||||||
-- name: UpdateGame :one
|
|
||||||
UPDATE game
|
|
||||||
SET name = $2, platform_id = $3, score = $4, release_year = $5, finished = $6
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: DeleteGame :exec
|
|
||||||
DELETE FROM game
|
|
||||||
WHERE id = $1;
|
|
||||||
|
|
||||||
-- name: UpdatePlatform :one
|
-- name: UpdatePlatform :one
|
||||||
UPDATE platform
|
UPDATE platform
|
||||||
SET name = $2
|
SET name = $2
|
||||||
WHERE id = $1
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
RETURNING *;
|
RETURNING id, name;
|
||||||
|
|
||||||
-- name: DeletePlatform :exec
|
-- name: DeletePlatform :execrows
|
||||||
DELETE FROM platform
|
-- Soft delete, so the FK on game.platform_id is never touched and never
|
||||||
WHERE id = $1;
|
-- blocks this on its own; the NOT EXISTS guard is what stops a platform
|
||||||
|
-- that still has active games - from any user, since the list is shared -
|
||||||
|
-- from being deleted. Call GetPlatform first to tell "not found" apart from
|
||||||
|
-- "blocked by games" when this affects 0 rows.
|
||||||
|
UPDATE platform
|
||||||
|
SET deleted_at = now()
|
||||||
|
WHERE platform.id = $1 AND platform.deleted_at IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM game
|
||||||
|
WHERE game.platform_id = platform.id AND game.deleted_at IS NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- name: CreateGame :one
|
||||||
|
WITH inserted AS (
|
||||||
|
INSERT INTO game (
|
||||||
|
user_id, platform_id, name, score, release_date, finished
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6
|
||||||
|
)
|
||||||
|
RETURNING id, platform_id, name, score, release_date, finished
|
||||||
|
)
|
||||||
|
SELECT inserted.id, inserted.name, inserted.platform_id, platform.name AS platform_name,
|
||||||
|
inserted.score, inserted.release_date, inserted.finished
|
||||||
|
FROM inserted
|
||||||
|
JOIN platform ON platform.id = inserted.platform_id;
|
||||||
|
|
||||||
|
-- name: GetGame :one
|
||||||
|
SELECT game.id, game.name, game.platform_id, platform.name AS platform_name,
|
||||||
|
game.score, game.release_date, game.finished
|
||||||
|
FROM game
|
||||||
|
JOIN platform ON platform.id = game.platform_id
|
||||||
|
WHERE game.id = $1 AND game.user_id = $2 AND game.deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- name: ListGames :many
|
||||||
|
SELECT game.id, game.name, game.platform_id, platform.name AS platform_name,
|
||||||
|
game.score, game.release_date, game.finished
|
||||||
|
FROM game
|
||||||
|
JOIN platform ON platform.id = game.platform_id
|
||||||
|
WHERE game.user_id = $1 AND game.deleted_at IS NULL
|
||||||
|
ORDER BY game.name;
|
||||||
|
|
||||||
|
-- name: UpdateGame :one
|
||||||
|
WITH updated AS (
|
||||||
|
UPDATE game
|
||||||
|
SET platform_id = $3, name = $4, score = $5, release_date = $6, finished = $7
|
||||||
|
WHERE game.id = $1 AND game.user_id = $2 AND game.deleted_at IS NULL
|
||||||
|
RETURNING id, platform_id, name, score, release_date, finished
|
||||||
|
)
|
||||||
|
SELECT updated.id, updated.name, updated.platform_id, platform.name AS platform_name,
|
||||||
|
updated.score, updated.release_date, updated.finished
|
||||||
|
FROM updated
|
||||||
|
JOIN platform ON platform.id = updated.platform_id;
|
||||||
|
|
||||||
|
-- name: DeleteGame :execrows
|
||||||
|
UPDATE game
|
||||||
|
SET deleted_at = now()
|
||||||
|
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL;
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
CREATE TABLE users (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
name text NOT NULL,
|
|
||||||
email text NOT NULL UNIQUE,
|
|
||||||
password_hash text NOT NULL,
|
|
||||||
bio text
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE platform (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
name text NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE game (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
name text NOT NULL,
|
|
||||||
platform_id BIGINT NOT NULL REFERENCES platform(id),
|
|
||||||
score INT NOT NULL,
|
|
||||||
release_year DATE NOT NULL,
|
|
||||||
finished DATE
|
|
||||||
);
|
|
||||||
+7
-3
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
@@ -9,7 +7,8 @@ services:
|
|||||||
- PORT=8080
|
- PORT=8080
|
||||||
- DATABASE_URL=postgres://postgres:password@db:5432/gamedb?sslmode=disable
|
- DATABASE_URL=postgres://postgres:password@db:5432/gamedb?sslmode=disable
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
|
|
||||||
db:
|
db:
|
||||||
@@ -23,6 +22,11 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d gamedb"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
@@ -3,46 +3,74 @@ module completed
|
|||||||
go 1.25.4
|
go 1.25.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
fyne.io/fyne/v2 v2.8.1
|
||||||
github.com/charmbracelet/bubbles v0.21.0
|
github.com/charmbracelet/bubbles v0.21.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/danielgtaylor/huma/v2 v2.34.1
|
github.com/danielgtaylor/huma/v2 v2.34.1
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.7.6
|
github.com/jackc/pgx/v5 v5.7.6
|
||||||
github.com/labstack/echo/v4 v4.14.0
|
github.com/labstack/echo/v4 v4.14.0
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
golang.org/x/crypto v0.46.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 // indirect
|
||||||
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
|
github.com/FyshOS/fancyfs v0.0.1 // indirect
|
||||||
|
github.com/anthonynsimon/bild v0.14.0 // indirect
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/ebitengine/purego v0.7.1 // indirect
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/gen2brain/raylib-go/raylib v0.55.1 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
|
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
|
||||||
|
github.com/fyne-io/glfw-js v0.4.0 // indirect
|
||||||
|
github.com/fyne-io/image v0.1.1 // indirect
|
||||||
|
github.com/fyne-io/oksvg v0.2.0 // indirect
|
||||||
|
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect
|
||||||
|
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a // indirect
|
||||||
|
github.com/go-text/render v0.2.1 // indirect
|
||||||
|
github.com/go-text/typesetting v0.3.4 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||||
|
github.com/hack-pad/safejs v0.1.0 // indirect
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
github.com/lib/pq v1.10.9 // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/stretchr/testify v1.11.1 // indirect
|
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||||
|
github.com/rymdport/portal v0.4.2 // indirect
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/crypto v0.46.0 // indirect
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
|
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
|
||||||
|
golang.org/x/image v0.24.0 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
|
fyne.io/fyne/v2 v2.8.1 h1:EztGuE2W3Qhd0cWVmU+h5rkzNezUD1To6UqsoLQYUIM=
|
||||||
|
fyne.io/fyne/v2 v2.8.1/go.mod h1:kpeuFrClm0fiAgJYr2soTfwKMT5rzNcSKzmgGjxvHOY=
|
||||||
|
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 h1:149/+Wa5EsLLXfyj2pdTmvnQf2VIlgCIwSjcCTHYhIo=
|
||||||
|
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg=
|
||||||
|
github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
|
||||||
|
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
@@ -12,25 +30,73 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7
|
|||||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||||
github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ=
|
github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ=
|
||||||
github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk=
|
github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA=
|
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||||
github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
|
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||||
|
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/gen2brain/raylib-go/raylib v0.55.1 h1:1rdc10WvvYjtj7qijHnV9T38/WuvlT6IIL+PaZ6cNA8=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/gen2brain/raylib-go/raylib v0.55.1/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8=
|
||||||
|
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
|
||||||
|
github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo=
|
||||||
|
github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU=
|
||||||
|
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
||||||
|
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
||||||
|
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
|
||||||
|
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
||||||
|
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI=
|
||||||
|
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||||
|
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a h1:HWK0MBggT/T6YH7VffE10xBIhqeTq8JzIUPJXrRy87g=
|
||||||
|
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg=
|
||||||
|
github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A=
|
||||||
|
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
|
||||||
|
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||||
|
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
|
||||||
|
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -39,6 +105,14 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
|||||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/labstack/echo/v4 v4.14.0 h1:+tiMrDLxwv6u0oKtD03mv+V1vXXB3wCqPHJqPuIe+7M=
|
github.com/labstack/echo/v4 v4.14.0 h1:+tiMrDLxwv6u0oKtD03mv+V1vXXB3wCqPHJqPuIe+7M=
|
||||||
github.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
github.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
@@ -53,21 +127,43 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
|
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
|
||||||
|
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -79,12 +175,24 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
|
|||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||||
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
|
||||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
|
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
|
||||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
|
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
|
||||||
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
@@ -98,6 +206,8 @@ golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
|||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"completed/internal/auth"
|
||||||
|
"completed/internal/db"
|
||||||
|
"completed/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type authOutput struct {
|
||||||
|
SetCookie string `header:"Set-Cookie"`
|
||||||
|
Body model.AuthResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionCookie builds the Set-Cookie value for a successful login. Secure
|
||||||
|
// is left unset because the app is also meant to run over plain HTTP in
|
||||||
|
// local/docker-compose use; put a TLS-terminating proxy in front for
|
||||||
|
// anything reachable over the network and set Secure there instead.
|
||||||
|
func sessionCookie(token string) string {
|
||||||
|
c := http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: auth.SessionDuration,
|
||||||
|
}
|
||||||
|
return c.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearedSessionCookie() string {
|
||||||
|
c := http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
}
|
||||||
|
return c.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSession creates and stores a session for userID, returning the token
|
||||||
|
// to hand back to the client (only its hash is ever persisted).
|
||||||
|
func newSession(ctx context.Context, queries db.Querier, userID uuid.UUID) (string, error) {
|
||||||
|
token, tokenHash, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
err = queries.CreateSession(ctx, db.CreateSessionParams{
|
||||||
|
TokenHash: tokenHash,
|
||||||
|
UserID: userID,
|
||||||
|
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(auth.SessionDuration * time.Second), Valid: true},
|
||||||
|
})
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAuthRoutes(api huma.API, queries db.Querier) {
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "register",
|
||||||
|
Tags: []string{"Auth"},
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/api/auth/register",
|
||||||
|
Summary: "Register a new account",
|
||||||
|
}, func(ctx context.Context, input *struct{ Body model.RegisterInput }) (*authOutput, error) {
|
||||||
|
hash, err := auth.HashPassword(input.Body.Password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, huma.Error500InternalServerError("could not process password")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := queries.CreateUser(ctx, db.CreateUserParams{
|
||||||
|
Email: input.Body.Email,
|
||||||
|
PasswordHash: hash,
|
||||||
|
Name: input.Body.Name,
|
||||||
|
Bio: pgtype.Text{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := newSession(ctx, queries, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, huma.Error500InternalServerError("could not create session")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &authOutput{
|
||||||
|
SetCookie: sessionCookie(token),
|
||||||
|
Body: model.AuthResponse{
|
||||||
|
Token: token,
|
||||||
|
User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "login",
|
||||||
|
Tags: []string{"Auth"},
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/api/auth/login",
|
||||||
|
Summary: "Log in to an existing account",
|
||||||
|
}, func(ctx context.Context, input *struct{ Body model.LoginInput }) (*authOutput, error) {
|
||||||
|
u, err := queries.GetUserByEmail(ctx, input.Body.Email)
|
||||||
|
if err != nil {
|
||||||
|
// Same message whether the email is unknown or the password is
|
||||||
|
// wrong, so a client can't use this endpoint to enumerate
|
||||||
|
// registered emails.
|
||||||
|
return nil, huma.Error401Unauthorized("invalid email or password")
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := auth.VerifyPassword(input.Body.Password, u.PasswordHash)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return nil, huma.Error401Unauthorized("invalid email or password")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := newSession(ctx, queries, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, huma.Error500InternalServerError("could not create session")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &authOutput{
|
||||||
|
SetCookie: sessionCookie(token),
|
||||||
|
Body: model.AuthResponse{
|
||||||
|
Token: token,
|
||||||
|
User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
logoutOp := huma.Operation{
|
||||||
|
OperationID: "logout",
|
||||||
|
Tags: []string{"Auth"},
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/api/auth/logout",
|
||||||
|
Summary: "Log out of the current session",
|
||||||
|
}
|
||||||
|
addAuth(&logoutOp, api, queries)
|
||||||
|
huma.Register(api, logoutOp, func(ctx context.Context, input *struct{}) (*struct {
|
||||||
|
SetCookie string `header:"Set-Cookie"`
|
||||||
|
}, error) {
|
||||||
|
_ = queries.DeleteSession(ctx, auth.HashSessionToken(tokenFromContext(ctx)))
|
||||||
|
return &struct {
|
||||||
|
SetCookie string `header:"Set-Cookie"`
|
||||||
|
}{SetCookie: clearedSessionCookie()}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
meOp := huma.Operation{
|
||||||
|
OperationID: "get-me",
|
||||||
|
Tags: []string{"Auth"},
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/api/auth/me",
|
||||||
|
Summary: "Get the current user",
|
||||||
|
}
|
||||||
|
addAuth(&meOp, api, queries)
|
||||||
|
huma.Register(api, meOp, func(ctx context.Context, input *struct{}) (*struct{ Body model.User }, error) {
|
||||||
|
return &struct{ Body model.User }{Body: userFromContext(ctx).toModel()}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"completed/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// authedUser is what AuthMiddleware stores in the request context after
|
||||||
|
// validating a session token. Handlers read it with userFromContext.
|
||||||
|
type authedUser struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
Bio string
|
||||||
|
IsAdmin bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ctxKey string
|
||||||
|
|
||||||
|
const userCtxKey ctxKey = "completed.user"
|
||||||
|
|
||||||
|
func (u authedUser) toModel() model.User {
|
||||||
|
return model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, Bio: u.Bio, IsAdmin: u.IsAdmin}
|
||||||
|
}
|
||||||
|
|
||||||
|
// userFromContext returns the authenticated user set by AuthMiddleware. It
|
||||||
|
// panics if called from a route that isn't behind AuthMiddleware, since that
|
||||||
|
// is a programming error (a handler trusting a context value that was never
|
||||||
|
// guaranteed to be there), not a condition callers should need to check.
|
||||||
|
func userFromContext(ctx context.Context) authedUser {
|
||||||
|
u, ok := ctx.Value(userCtxKey).(authedUser)
|
||||||
|
if !ok {
|
||||||
|
panic("api: userFromContext called outside AuthMiddleware")
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Postgres error codes this package maps to specific HTTP statuses.
|
||||||
|
// See https://www.postgresql.org/docs/current/errcodes-appendix.html
|
||||||
|
const (
|
||||||
|
pgCodeUniqueViolation = "23505"
|
||||||
|
pgCodeForeignKeyViolation = "23503"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dbError turns a database error into the huma error a handler should
|
||||||
|
// return, so Postgres's own error text never reaches a client and callers
|
||||||
|
// get the right HTTP status instead of a generic 500.
|
||||||
|
func dbError(err error) error {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return huma.Error404NotFound("not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
switch pgErr.Code {
|
||||||
|
case pgCodeUniqueViolation:
|
||||||
|
return huma.Error409Conflict("already exists")
|
||||||
|
case pgCodeForeignKeyViolation:
|
||||||
|
// A foreign key failure here is either a reference to a platform
|
||||||
|
// that doesn't exist (create/update) or a delete blocked by
|
||||||
|
// games that still reference this platform.
|
||||||
|
return huma.Error409Conflict("referenced by another record, or refers to one that doesn't exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("unmapped database error: %v", err)
|
||||||
|
return huma.Error500InternalServerError("internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// notFoundIfZero turns the "0 rows affected" result of a soft-delete
|
||||||
|
// UPDATE into a 404, since the row either never existed or was already
|
||||||
|
// deleted.
|
||||||
|
func notFoundIfZero(rows int64) error {
|
||||||
|
if rows == 0 {
|
||||||
|
return huma.Error404NotFound("not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"completed/internal/auth"
|
||||||
|
"completed/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionCookieName is also used by the auth routes when setting/clearing
|
||||||
|
// the cookie, so it lives here as the one shared constant.
|
||||||
|
const sessionCookieName = "session"
|
||||||
|
|
||||||
|
// bearerToken extracts the token from "Authorization: Bearer <token>", or
|
||||||
|
// the empty string if the header is missing or malformed.
|
||||||
|
func bearerToken(header string) string {
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if !strings.HasPrefix(header, prefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(header, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenFromRequest looks for a session token in the Authorization header
|
||||||
|
// first (used by the TUI and Fyne clients), falling back to the session
|
||||||
|
// cookie (used by the web client).
|
||||||
|
func tokenFromRequest(ctx huma.Context) string {
|
||||||
|
if t := bearerToken(ctx.Header("Authorization")); t != "" {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
if c, err := huma.ReadCookie(ctx, sessionCookieName); err == nil && c != nil {
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAuthMiddleware returns Huma middleware that requires a valid session,
|
||||||
|
// set via AuthMiddleware on individual operations. On success it stores the
|
||||||
|
// authenticated user in the request context for userFromContext to read.
|
||||||
|
func newAuthMiddleware(api huma.API, queries db.Querier) func(huma.Context, func(huma.Context)) {
|
||||||
|
return func(ctx huma.Context, next func(huma.Context)) {
|
||||||
|
token := tokenFromRequest(ctx)
|
||||||
|
if token == "" {
|
||||||
|
huma.WriteErr(api, ctx, 401, "authentication required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := queries.GetSessionAndUser(ctx.Context(), auth.HashSessionToken(token))
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
// Fail closed: an unreachable DB fails auth, not through.
|
||||||
|
huma.WriteErr(api, ctx, 401, "authentication required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
huma.WriteErr(api, ctx, 401, "session expired or invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort; a failure to record activity shouldn't fail the request.
|
||||||
|
_ = queries.TouchSession(ctx.Context(), auth.HashSessionToken(token))
|
||||||
|
|
||||||
|
user := authedUser{ID: row.ID, Email: row.Email, Name: row.Name, Bio: row.Bio.String, IsAdmin: row.IsAdmin}
|
||||||
|
newCtx := huma.WithValue(ctx, userCtxKey, user)
|
||||||
|
newCtx = huma.WithValue(newCtx, tokenCtxKey, token)
|
||||||
|
next(newCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addAuth attaches the auth middleware to op, so it requires a valid
|
||||||
|
// session. Call it on the *huma.Operation value before huma.Register.
|
||||||
|
func addAuth(op *huma.Operation, api huma.API, queries db.Querier) {
|
||||||
|
op.Middlewares = append(op.Middlewares, newAuthMiddleware(api, queries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireAdminMiddleware runs after newAuthMiddleware (see addAdminAuth) and
|
||||||
|
// rejects the request unless the authenticated user is an admin. It reads
|
||||||
|
// the context value that middleware set directly, rather than calling
|
||||||
|
// userFromContext, so a wiring mistake (this middleware attached without
|
||||||
|
// the auth one ahead of it) fails the request with 403 instead of panicking.
|
||||||
|
func requireAdminMiddleware(api huma.API) func(huma.Context, func(huma.Context)) {
|
||||||
|
return func(ctx huma.Context, next func(huma.Context)) {
|
||||||
|
u, ok := ctx.Context().Value(userCtxKey).(authedUser)
|
||||||
|
if !ok || !u.IsAdmin {
|
||||||
|
huma.WriteErr(api, ctx, 403, "admin access required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addAdminAuth attaches both the auth and the admin-only middleware to op,
|
||||||
|
// for routes that mutate data every user can see (the shared platform
|
||||||
|
// list) but only an admin should be able to change.
|
||||||
|
func addAdminAuth(op *huma.Operation, api huma.API, queries db.Querier) {
|
||||||
|
addAuth(op, api, queries)
|
||||||
|
op.Middlewares = append(op.Middlewares, requireAdminMiddleware(api))
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenCtxKey holds the raw bearer/cookie token for the current request, so
|
||||||
|
// the logout handler can hash and delete the exact session that was used
|
||||||
|
// without re-parsing the request.
|
||||||
|
const tokenCtxKey ctxKey = "completed.token"
|
||||||
|
|
||||||
|
func tokenFromContext(ctx context.Context) string {
|
||||||
|
t, _ := ctx.Value(tokenCtxKey).(string)
|
||||||
|
return t
|
||||||
|
}
|
||||||
@@ -3,61 +3,84 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"completed/internal/db"
|
"completed/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MockQuerier is a hand-written fake implementing db.Querier for handler
|
||||||
|
// tests. Each test sets only the *Func fields its handler path reaches;
|
||||||
|
// any other field left nil panics on call, which is deliberate: it makes an
|
||||||
|
// unexpectedly-reached query visible as a test failure instead of a nil
|
||||||
|
// pointer deep in sqlc-generated code.
|
||||||
type MockQuerier struct {
|
type MockQuerier struct {
|
||||||
CreateGameFunc func(ctx context.Context, arg db.CreateGameParams) (db.Game, error)
|
CreateGameFunc func(ctx context.Context, arg db.CreateGameParams) (db.CreateGameRow, error)
|
||||||
CreatePlatformFunc func(ctx context.Context, name string) (db.Platform, error)
|
CreatePlatformFunc func(ctx context.Context, name string) (db.CreatePlatformRow, error)
|
||||||
CreateUserFunc func(ctx context.Context, arg db.CreateUserParams) (db.User, error)
|
CreateSessionFunc func(ctx context.Context, arg db.CreateSessionParams) error
|
||||||
DeleteGameFunc func(ctx context.Context, id int64) error
|
CreateUserFunc func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error)
|
||||||
DeletePlatformFunc func(ctx context.Context, id int64) error
|
DeleteGameFunc func(ctx context.Context, arg db.DeleteGameParams) (int64, error)
|
||||||
DeleteUserFunc func(ctx context.Context, id int64) error
|
DeletePlatformFunc func(ctx context.Context, id uuid.UUID) (int64, error)
|
||||||
GetGameFunc func(ctx context.Context, id int64) (db.Game, error)
|
DeleteSessionFunc func(ctx context.Context, tokenHash []byte) error
|
||||||
GetUserFunc func(ctx context.Context, id int64) (db.User, error)
|
GetGameFunc func(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error)
|
||||||
ListGamesFunc func(ctx context.Context) ([]db.Game, error)
|
GetPlatformFunc func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error)
|
||||||
ListPlatformsFunc func(ctx context.Context) ([]db.Platform, error)
|
GetSessionAndUserFunc func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error)
|
||||||
ListUsersFunc func(ctx context.Context) ([]db.User, error)
|
GetUserByEmailFunc func(ctx context.Context, email string) (db.GetUserByEmailRow, error)
|
||||||
UpdateGameFunc func(ctx context.Context, arg db.UpdateGameParams) (db.Game, error)
|
GetUserByIDFunc func(ctx context.Context, id uuid.UUID) (db.GetUserByIDRow, error)
|
||||||
UpdatePlatformFunc func(ctx context.Context, arg db.UpdatePlatformParams) (db.Platform, error)
|
ListGamesFunc func(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error)
|
||||||
|
ListPlatformsFunc func(ctx context.Context) ([]db.ListPlatformsRow, error)
|
||||||
|
TouchSessionFunc func(ctx context.Context, tokenHash []byte) error
|
||||||
|
UpdateGameFunc func(ctx context.Context, arg db.UpdateGameParams) (db.UpdateGameRow, error)
|
||||||
|
UpdatePlatformFunc func(ctx context.Context, arg db.UpdatePlatformParams) (db.UpdatePlatformRow, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockQuerier) CreateGame(ctx context.Context, arg db.CreateGameParams) (db.Game, error) {
|
func (m *MockQuerier) CreateGame(ctx context.Context, arg db.CreateGameParams) (db.CreateGameRow, error) {
|
||||||
return m.CreateGameFunc(ctx, arg)
|
return m.CreateGameFunc(ctx, arg)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) CreatePlatform(ctx context.Context, name string) (db.Platform, error) {
|
func (m *MockQuerier) CreatePlatform(ctx context.Context, name string) (db.CreatePlatformRow, error) {
|
||||||
return m.CreatePlatformFunc(ctx, name)
|
return m.CreatePlatformFunc(ctx, name)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) CreateUser(ctx context.Context, arg db.CreateUserParams) (db.User, error) {
|
func (m *MockQuerier) CreateSession(ctx context.Context, arg db.CreateSessionParams) error {
|
||||||
|
return m.CreateSessionFunc(ctx, arg)
|
||||||
|
}
|
||||||
|
func (m *MockQuerier) CreateUser(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) {
|
||||||
return m.CreateUserFunc(ctx, arg)
|
return m.CreateUserFunc(ctx, arg)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) DeleteGame(ctx context.Context, id int64) error {
|
func (m *MockQuerier) DeleteGame(ctx context.Context, arg db.DeleteGameParams) (int64, error) {
|
||||||
return m.DeleteGameFunc(ctx, id)
|
return m.DeleteGameFunc(ctx, arg)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) DeletePlatform(ctx context.Context, id int64) error {
|
func (m *MockQuerier) DeletePlatform(ctx context.Context, id uuid.UUID) (int64, error) {
|
||||||
return m.DeletePlatformFunc(ctx, id)
|
return m.DeletePlatformFunc(ctx, id)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) DeleteUser(ctx context.Context, id int64) error {
|
func (m *MockQuerier) DeleteSession(ctx context.Context, tokenHash []byte) error {
|
||||||
return m.DeleteUserFunc(ctx, id)
|
return m.DeleteSessionFunc(ctx, tokenHash)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) GetGame(ctx context.Context, id int64) (db.Game, error) {
|
func (m *MockQuerier) GetGame(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) {
|
||||||
return m.GetGameFunc(ctx, id)
|
return m.GetGameFunc(ctx, arg)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) GetUser(ctx context.Context, id int64) (db.User, error) {
|
func (m *MockQuerier) GetPlatform(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) {
|
||||||
return m.GetUserFunc(ctx, id)
|
return m.GetPlatformFunc(ctx, id)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) ListGames(ctx context.Context) ([]db.Game, error) {
|
func (m *MockQuerier) GetSessionAndUser(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) {
|
||||||
return m.ListGamesFunc(ctx)
|
return m.GetSessionAndUserFunc(ctx, tokenHash)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) ListPlatforms(ctx context.Context) ([]db.Platform, error) {
|
func (m *MockQuerier) GetUserByEmail(ctx context.Context, email string) (db.GetUserByEmailRow, error) {
|
||||||
|
return m.GetUserByEmailFunc(ctx, email)
|
||||||
|
}
|
||||||
|
func (m *MockQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (db.GetUserByIDRow, error) {
|
||||||
|
return m.GetUserByIDFunc(ctx, id)
|
||||||
|
}
|
||||||
|
func (m *MockQuerier) ListGames(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error) {
|
||||||
|
return m.ListGamesFunc(ctx, userID)
|
||||||
|
}
|
||||||
|
func (m *MockQuerier) ListPlatforms(ctx context.Context) ([]db.ListPlatformsRow, error) {
|
||||||
return m.ListPlatformsFunc(ctx)
|
return m.ListPlatformsFunc(ctx)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) ListUsers(ctx context.Context) ([]db.User, error) {
|
func (m *MockQuerier) TouchSession(ctx context.Context, tokenHash []byte) error {
|
||||||
return m.ListUsersFunc(ctx)
|
return m.TouchSessionFunc(ctx, tokenHash)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) UpdateGame(ctx context.Context, arg db.UpdateGameParams) (db.Game, error) {
|
func (m *MockQuerier) UpdateGame(ctx context.Context, arg db.UpdateGameParams) (db.UpdateGameRow, error) {
|
||||||
return m.UpdateGameFunc(ctx, arg)
|
return m.UpdateGameFunc(ctx, arg)
|
||||||
}
|
}
|
||||||
func (m *MockQuerier) UpdatePlatform(ctx context.Context, arg db.UpdatePlatformParams) (db.Platform, error) {
|
func (m *MockQuerier) UpdatePlatform(ctx context.Context, arg db.UpdatePlatformParams) (db.UpdatePlatformRow, error) {
|
||||||
return m.UpdatePlatformFunc(ctx, arg)
|
return m.UpdatePlatformFunc(ctx, arg)
|
||||||
}
|
}
|
||||||
+206
-108
@@ -6,15 +6,18 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/danielgtaylor/huma/v2"
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"completed/internal/db"
|
"completed/internal/db"
|
||||||
|
"completed/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterRoutes(api huma.API, queries db.Querier) {
|
func RegisterRoutes(api huma.API, queries db.Querier) {
|
||||||
// Root/Hello Route
|
// Root/Hello Route
|
||||||
huma.Register(api, huma.Operation{
|
huma.Register(api, huma.Operation{
|
||||||
OperationID: "get-hello",
|
OperationID: "get-hello",
|
||||||
|
Tags: []string{"Misc"},
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Path: "/hello",
|
Path: "/hello",
|
||||||
Summary: "Say Hello",
|
Summary: "Say Hello",
|
||||||
@@ -33,83 +36,125 @@ func RegisterRoutes(api huma.API, queries db.Querier) {
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
registerAuthRoutes(api, queries)
|
||||||
registerPlatformRoutes(api, queries)
|
registerPlatformRoutes(api, queries)
|
||||||
registerGameRoutes(api, queries)
|
registerGameRoutes(api, queries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Platform Routes ---
|
// --- Platform Routes ---
|
||||||
|
//
|
||||||
// PlatformDTO ensures consistent JSON output
|
// Platforms are a shared list: every authenticated user reads the same
|
||||||
type PlatformDTO struct {
|
// rows, unlike games, which are private per user. None of the queries
|
||||||
ID int64 `json:"id"`
|
// below filter by user_id, because platform has no such column. Reading
|
||||||
Name string `json:"name"`
|
// the list only requires being logged in (addAuth); adding, renaming or
|
||||||
}
|
// deleting a platform requires being an admin (addAdminAuth), since that
|
||||||
|
// changes what everyone else sees.
|
||||||
type PlatformInput struct {
|
|
||||||
Name string `json:"name" doc:"Name of the platform"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerPlatformRoutes(api huma.API, queries db.Querier) {
|
func registerPlatformRoutes(api huma.API, queries db.Querier) {
|
||||||
huma.Register(api, huma.Operation{
|
listOp := huma.Operation{
|
||||||
OperationID: "list-platforms",
|
OperationID: "list-platforms",
|
||||||
|
Tags: []string{"Platforms"},
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Path: "/platforms",
|
Path: "/api/platforms",
|
||||||
Summary: "List Platforms",
|
Summary: "List Platforms",
|
||||||
}, func(ctx context.Context, input *struct{}) (*struct{ Body []PlatformDTO }, error) {
|
}
|
||||||
|
addAuth(&listOp, api, queries)
|
||||||
|
huma.Register(api, listOp, func(ctx context.Context, input *struct{}) (*struct{ Body []model.Platform }, error) {
|
||||||
platforms, err := queries.ListPlatforms(ctx)
|
platforms, err := queries.ListPlatforms(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, huma.Error500InternalServerError("Database error: " + err.Error())
|
return nil, dbError(err)
|
||||||
}
|
}
|
||||||
dtos := make([]PlatformDTO, len(platforms))
|
dtos := make([]model.Platform, len(platforms))
|
||||||
for i, p := range platforms {
|
for i, p := range platforms {
|
||||||
dtos[i] = PlatformDTO{ID: p.ID, Name: p.Name}
|
dtos[i] = model.Platform{ID: p.ID.String(), Name: p.Name}
|
||||||
}
|
}
|
||||||
return &struct{ Body []PlatformDTO }{Body: dtos}, nil
|
return &struct{ Body []model.Platform }{Body: dtos}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
huma.Register(api, huma.Operation{
|
getOp := huma.Operation{
|
||||||
|
OperationID: "get-platform",
|
||||||
|
Tags: []string{"Platforms"},
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/api/platforms/{id}",
|
||||||
|
Summary: "Get Platform",
|
||||||
|
}
|
||||||
|
addAuth(&getOp, api, queries)
|
||||||
|
huma.Register(api, getOp, func(ctx context.Context, input *struct {
|
||||||
|
ID uuid.UUID `path:"id"`
|
||||||
|
}) (*struct{ Body model.Platform }, error) {
|
||||||
|
p, err := queries.GetPlatform(ctx, input.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbError(err)
|
||||||
|
}
|
||||||
|
return &struct{ Body model.Platform }{Body: model.Platform{ID: p.ID.String(), Name: p.Name}}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
createOp := huma.Operation{
|
||||||
OperationID: "create-platform",
|
OperationID: "create-platform",
|
||||||
|
Tags: []string{"Platforms"},
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/platforms",
|
Path: "/api/platforms",
|
||||||
Summary: "Create Platform",
|
Summary: "Create Platform",
|
||||||
}, func(ctx context.Context, input *struct{ Body PlatformInput }) (*struct{ Body PlatformDTO }, error) {
|
}
|
||||||
|
addAdminAuth(&createOp, api, queries)
|
||||||
|
huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.PlatformInput }) (*struct{ Body model.Platform }, error) {
|
||||||
platform, err := queries.CreatePlatform(ctx, input.Body.Name)
|
platform, err := queries.CreatePlatform(ctx, input.Body.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, huma.Error500InternalServerError("Database error: " + err.Error())
|
return nil, dbError(err)
|
||||||
}
|
}
|
||||||
return &struct{ Body PlatformDTO }{Body: PlatformDTO{ID: platform.ID, Name: platform.Name}}, nil
|
return &struct{ Body model.Platform }{Body: model.Platform{ID: platform.ID.String(), Name: platform.Name}}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
huma.Register(api, huma.Operation{
|
updateOp := huma.Operation{
|
||||||
OperationID: "update-platform",
|
OperationID: "update-platform",
|
||||||
|
Tags: []string{"Platforms"},
|
||||||
Method: http.MethodPut,
|
Method: http.MethodPut,
|
||||||
Path: "/platforms/{id}",
|
Path: "/api/platforms/{id}",
|
||||||
Summary: "Update Platform",
|
Summary: "Update Platform",
|
||||||
}, func(ctx context.Context, input *struct {
|
}
|
||||||
ID int64 `path:"id"`
|
addAdminAuth(&updateOp, api, queries)
|
||||||
Body PlatformInput
|
huma.Register(api, updateOp, func(ctx context.Context, input *struct {
|
||||||
}) (*struct{ Body PlatformDTO }, error) {
|
ID uuid.UUID `path:"id"`
|
||||||
|
Body model.PlatformInput
|
||||||
|
}) (*struct{ Body model.Platform }, error) {
|
||||||
platform, err := queries.UpdatePlatform(ctx, db.UpdatePlatformParams{
|
platform, err := queries.UpdatePlatform(ctx, db.UpdatePlatformParams{
|
||||||
ID: input.ID,
|
ID: input.ID,
|
||||||
Name: input.Body.Name,
|
Name: input.Body.Name,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, huma.Error500InternalServerError("Database error: " + err.Error())
|
return nil, dbError(err)
|
||||||
}
|
}
|
||||||
return &struct{ Body PlatformDTO }{Body: PlatformDTO{ID: platform.ID, Name: platform.Name}}, nil
|
return &struct{ Body model.Platform }{Body: model.Platform{ID: platform.ID.String(), Name: platform.Name}}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
huma.Register(api, huma.Operation{
|
deleteOp := huma.Operation{
|
||||||
OperationID: "delete-platform",
|
OperationID: "delete-platform",
|
||||||
|
Tags: []string{"Platforms"},
|
||||||
Method: http.MethodDelete,
|
Method: http.MethodDelete,
|
||||||
Path: "/platforms/{id}",
|
Path: "/api/platforms/{id}",
|
||||||
Summary: "Delete Platform",
|
Summary: "Delete Platform",
|
||||||
}, func(ctx context.Context, input *struct {
|
}
|
||||||
ID int64 `path:"id"`
|
addAdminAuth(&deleteOp, api, queries)
|
||||||
|
huma.Register(api, deleteOp, func(ctx context.Context, input *struct {
|
||||||
|
ID uuid.UUID `path:"id"`
|
||||||
}) (*struct{}, error) {
|
}) (*struct{}, error) {
|
||||||
err := queries.DeletePlatform(ctx, input.ID)
|
// Checked separately so a 0-rows result from DeletePlatform below can
|
||||||
|
// only mean "blocked by games referencing it", not "doesn't exist" -
|
||||||
|
// soft delete means the FK alone can't tell the two apart (see
|
||||||
|
// DeletePlatform's comment in db/query.sql). Accepts a race where
|
||||||
|
// the platform is deleted between these two queries; harmless, since
|
||||||
|
// the second query would then just affect 0 rows and report 409
|
||||||
|
// instead of the 404 a plain retry would hit anyway.
|
||||||
|
if _, err := queries.GetPlatform(ctx, input.ID); err != nil {
|
||||||
|
return nil, dbError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := queries.DeletePlatform(ctx, input.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, huma.Error500InternalServerError("Database error: " + err.Error())
|
return nil, dbError(err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return nil, huma.Error409Conflict("platform still has games; delete or reassign them first")
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
})
|
})
|
||||||
@@ -117,40 +162,21 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) {
|
|||||||
|
|
||||||
// --- Game Routes ---
|
// --- Game Routes ---
|
||||||
|
|
||||||
// GameDTO for API communication (Clean JSON)
|
// formatDate and parseDate convert between the API's YYYY-MM-DD strings and
|
||||||
type GameDTO struct {
|
// pgtype.Date. CreateGame, GetGame, ListGames and UpdateGame each return a
|
||||||
ID int64 `json:"id"`
|
// distinct sqlc row type with identical fields (id/name/platform_id/
|
||||||
Name string `json:"name"`
|
// platform_name/score/release_date/finished), so the DTO conversion itself
|
||||||
PlatformID int64 `json:"platform_id"`
|
// is written out at each call site rather than factored into one function.
|
||||||
Score int32 `json:"score"`
|
func formatDate(d pgtype.Date) string {
|
||||||
ReleaseYear string `json:"release_year" format:"date" doc:"YYYY-MM-DD"`
|
if !d.Valid {
|
||||||
Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"`
|
return ""
|
||||||
}
|
|
||||||
|
|
||||||
type GameInput struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
PlatformID int64 `json:"platform_id"`
|
|
||||||
Score int32 `json:"score"`
|
|
||||||
ReleaseYear string `json:"release_year" format:"date" doc:"YYYY-MM-DD"`
|
|
||||||
Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to convert DB Game to DTO
|
|
||||||
func toGameDTO(g db.Game) GameDTO {
|
|
||||||
dto := GameDTO{
|
|
||||||
ID: g.ID,
|
|
||||||
Name: g.Name,
|
|
||||||
PlatformID: g.PlatformID,
|
|
||||||
Score: g.Score,
|
|
||||||
ReleaseYear: g.ReleaseYear.Time.Format("2006-01-02"),
|
|
||||||
}
|
}
|
||||||
if g.Finished.Valid {
|
return d.Time.Format("2006-01-02")
|
||||||
dto.Finished = g.Finished.Time.Format("2006-01-02")
|
|
||||||
}
|
|
||||||
return dto
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to parse date string to pgtype.Date
|
// parseDate parses a YYYY-MM-DD string into a pgtype.Date. An empty string
|
||||||
|
// parses to an invalid/NULL date, which is valid for the optional
|
||||||
|
// "finished" field but rejected separately for the required release_date.
|
||||||
func parseDate(s string) (pgtype.Date, error) {
|
func parseDate(s string) (pgtype.Date, error) {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return pgtype.Date{}, nil
|
return pgtype.Date{}, nil
|
||||||
@@ -163,95 +189,167 @@ func parseDate(s string) (pgtype.Date, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func registerGameRoutes(api huma.API, queries db.Querier) {
|
func registerGameRoutes(api huma.API, queries db.Querier) {
|
||||||
huma.Register(api, huma.Operation{
|
listOp := huma.Operation{
|
||||||
OperationID: "list-games",
|
OperationID: "list-games",
|
||||||
|
Tags: []string{"Games"},
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Path: "/games",
|
Path: "/api/games",
|
||||||
Summary: "List Games",
|
Summary: "List Games",
|
||||||
}, func(ctx context.Context, input *struct{}) (*struct{ Body []GameDTO }, error) {
|
}
|
||||||
games, err := queries.ListGames(ctx)
|
addAuth(&listOp, api, queries)
|
||||||
|
huma.Register(api, listOp, func(ctx context.Context, input *struct{}) (*struct{ Body []model.Game }, error) {
|
||||||
|
games, err := queries.ListGames(ctx, userFromContext(ctx).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, huma.Error500InternalServerError("Database error: " + err.Error())
|
return nil, dbError(err)
|
||||||
}
|
}
|
||||||
dtos := make([]GameDTO, len(games))
|
dtos := make([]model.Game, len(games))
|
||||||
for i, g := range games {
|
for i, g := range games {
|
||||||
dtos[i] = toGameDTO(g)
|
dtos[i] = model.Game{
|
||||||
|
ID: g.ID.String(),
|
||||||
|
Name: g.Name,
|
||||||
|
PlatformID: g.PlatformID.String(),
|
||||||
|
PlatformName: g.PlatformName,
|
||||||
|
Score: g.Score,
|
||||||
|
ReleaseDate: formatDate(g.ReleaseDate),
|
||||||
|
Finished: formatDate(g.Finished),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return &struct{ Body []GameDTO }{Body: dtos}, nil
|
return &struct{ Body []model.Game }{Body: dtos}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
huma.Register(api, huma.Operation{
|
getOp := huma.Operation{
|
||||||
OperationID: "create-game",
|
OperationID: "get-game",
|
||||||
Method: http.MethodPost,
|
Tags: []string{"Games"},
|
||||||
Path: "/games",
|
Method: http.MethodGet,
|
||||||
Summary: "Create Game",
|
Path: "/api/games/{id}",
|
||||||
}, func(ctx context.Context, input *struct{ Body GameInput }) (*struct{ Body GameDTO }, error) {
|
Summary: "Get Game",
|
||||||
ry, err := parseDate(input.Body.ReleaseYear)
|
}
|
||||||
|
addAuth(&getOp, api, queries)
|
||||||
|
huma.Register(api, getOp, func(ctx context.Context, input *struct {
|
||||||
|
ID uuid.UUID `path:"id"`
|
||||||
|
}) (*struct{ Body model.Game }, error) {
|
||||||
|
g, err := queries.GetGame(ctx, db.GetGameParams{ID: input.ID, UserID: userFromContext(ctx).ID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, huma.Error400BadRequest("Invalid release_year format (expected YYYY-MM-DD)")
|
return nil, dbError(err)
|
||||||
|
}
|
||||||
|
return &struct{ Body model.Game }{Body: model.Game{
|
||||||
|
ID: g.ID.String(),
|
||||||
|
Name: g.Name,
|
||||||
|
PlatformID: g.PlatformID.String(),
|
||||||
|
PlatformName: g.PlatformName,
|
||||||
|
Score: g.Score,
|
||||||
|
ReleaseDate: formatDate(g.ReleaseDate),
|
||||||
|
Finished: formatDate(g.Finished),
|
||||||
|
}}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
createOp := huma.Operation{
|
||||||
|
OperationID: "create-game",
|
||||||
|
Tags: []string{"Games"},
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/api/games",
|
||||||
|
Summary: "Create Game",
|
||||||
|
}
|
||||||
|
addAuth(&createOp, api, queries)
|
||||||
|
huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.GameInput }) (*struct{ Body model.Game }, error) {
|
||||||
|
platformID, err := uuid.Parse(input.Body.PlatformID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, huma.Error400BadRequest("invalid platform_id")
|
||||||
|
}
|
||||||
|
ry, err := parseDate(input.Body.ReleaseDate)
|
||||||
|
if err != nil || !ry.Valid {
|
||||||
|
return nil, huma.Error400BadRequest("invalid or missing release_date (expected YYYY-MM-DD)")
|
||||||
}
|
}
|
||||||
fin, err := parseDate(input.Body.Finished)
|
fin, err := parseDate(input.Body.Finished)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, huma.Error400BadRequest("Invalid finished format (expected YYYY-MM-DD)")
|
return nil, huma.Error400BadRequest("invalid finished date (expected YYYY-MM-DD)")
|
||||||
}
|
}
|
||||||
|
|
||||||
game, err := queries.CreateGame(ctx, db.CreateGameParams{
|
game, err := queries.CreateGame(ctx, db.CreateGameParams{
|
||||||
|
UserID: userFromContext(ctx).ID,
|
||||||
|
PlatformID: platformID,
|
||||||
Name: input.Body.Name,
|
Name: input.Body.Name,
|
||||||
PlatformID: input.Body.PlatformID,
|
|
||||||
Score: input.Body.Score,
|
Score: input.Body.Score,
|
||||||
ReleaseYear: ry,
|
ReleaseDate: ry,
|
||||||
Finished: fin,
|
Finished: fin,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, huma.Error500InternalServerError("Database error: " + err.Error())
|
return nil, dbError(err)
|
||||||
}
|
}
|
||||||
return &struct{ Body GameDTO }{Body: toGameDTO(game)}, nil
|
return &struct{ Body model.Game }{Body: model.Game{
|
||||||
|
ID: game.ID.String(),
|
||||||
|
Name: game.Name,
|
||||||
|
PlatformID: game.PlatformID.String(),
|
||||||
|
PlatformName: game.PlatformName,
|
||||||
|
Score: game.Score,
|
||||||
|
ReleaseDate: formatDate(game.ReleaseDate),
|
||||||
|
Finished: formatDate(game.Finished),
|
||||||
|
}}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
huma.Register(api, huma.Operation{
|
updateOp := huma.Operation{
|
||||||
OperationID: "update-game",
|
OperationID: "update-game",
|
||||||
|
Tags: []string{"Games"},
|
||||||
Method: http.MethodPut,
|
Method: http.MethodPut,
|
||||||
Path: "/games/{id}",
|
Path: "/api/games/{id}",
|
||||||
Summary: "Update Game",
|
Summary: "Update Game",
|
||||||
}, func(ctx context.Context, input *struct {
|
}
|
||||||
ID int64 `path:"id"`
|
addAuth(&updateOp, api, queries)
|
||||||
Body GameInput
|
huma.Register(api, updateOp, func(ctx context.Context, input *struct {
|
||||||
}) (*struct{ Body GameDTO }, error) {
|
ID uuid.UUID `path:"id"`
|
||||||
ry, err := parseDate(input.Body.ReleaseYear)
|
Body model.GameInput
|
||||||
|
}) (*struct{ Body model.Game }, error) {
|
||||||
|
platformID, err := uuid.Parse(input.Body.PlatformID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, huma.Error400BadRequest("Invalid release_year format (expected YYYY-MM-DD)")
|
return nil, huma.Error400BadRequest("invalid platform_id")
|
||||||
|
}
|
||||||
|
ry, err := parseDate(input.Body.ReleaseDate)
|
||||||
|
if err != nil || !ry.Valid {
|
||||||
|
return nil, huma.Error400BadRequest("invalid or missing release_date (expected YYYY-MM-DD)")
|
||||||
}
|
}
|
||||||
fin, err := parseDate(input.Body.Finished)
|
fin, err := parseDate(input.Body.Finished)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, huma.Error400BadRequest("Invalid finished format (expected YYYY-MM-DD)")
|
return nil, huma.Error400BadRequest("invalid finished date (expected YYYY-MM-DD)")
|
||||||
}
|
}
|
||||||
|
|
||||||
game, err := queries.UpdateGame(ctx, db.UpdateGameParams{
|
game, err := queries.UpdateGame(ctx, db.UpdateGameParams{
|
||||||
ID: input.ID,
|
ID: input.ID,
|
||||||
|
UserID: userFromContext(ctx).ID,
|
||||||
|
PlatformID: platformID,
|
||||||
Name: input.Body.Name,
|
Name: input.Body.Name,
|
||||||
PlatformID: input.Body.PlatformID,
|
|
||||||
Score: input.Body.Score,
|
Score: input.Body.Score,
|
||||||
ReleaseYear: ry,
|
ReleaseDate: ry,
|
||||||
Finished: fin,
|
Finished: fin,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, huma.Error500InternalServerError("Database error: " + err.Error())
|
return nil, dbError(err)
|
||||||
}
|
}
|
||||||
return &struct{ Body GameDTO }{Body: toGameDTO(game)}, nil
|
return &struct{ Body model.Game }{Body: model.Game{
|
||||||
|
ID: game.ID.String(),
|
||||||
|
Name: game.Name,
|
||||||
|
PlatformID: game.PlatformID.String(),
|
||||||
|
PlatformName: game.PlatformName,
|
||||||
|
Score: game.Score,
|
||||||
|
ReleaseDate: formatDate(game.ReleaseDate),
|
||||||
|
Finished: formatDate(game.Finished),
|
||||||
|
}}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
huma.Register(api, huma.Operation{
|
deleteOp := huma.Operation{
|
||||||
OperationID: "delete-game",
|
OperationID: "delete-game",
|
||||||
|
Tags: []string{"Games"},
|
||||||
Method: http.MethodDelete,
|
Method: http.MethodDelete,
|
||||||
Path: "/games/{id}",
|
Path: "/api/games/{id}",
|
||||||
Summary: "Delete Game",
|
Summary: "Delete Game",
|
||||||
}, func(ctx context.Context, input *struct {
|
}
|
||||||
ID int64 `path:"id"`
|
addAuth(&deleteOp, api, queries)
|
||||||
|
huma.Register(api, deleteOp, func(ctx context.Context, input *struct {
|
||||||
|
ID uuid.UUID `path:"id"`
|
||||||
}) (*struct{}, error) {
|
}) (*struct{}, error) {
|
||||||
err := queries.DeleteGame(ctx, input.ID)
|
rows, err := queries.DeleteGame(ctx, db.DeleteGameParams{ID: input.ID, UserID: userFromContext(ctx).ID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, huma.Error500InternalServerError("Database error: " + err.Error())
|
return nil, dbError(err)
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, notFoundIfZero(rows)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+236
-29
@@ -9,58 +9,265 @@ import (
|
|||||||
|
|
||||||
"github.com/danielgtaylor/huma/v2"
|
"github.com/danielgtaylor/huma/v2"
|
||||||
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
|
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"completed/internal/auth"
|
||||||
"completed/internal/db"
|
"completed/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestListGames(t *testing.T) {
|
// newTestAPI wires mockDB into a fresh Huma/Echo instance exactly as
|
||||||
// 1. Setup Mock
|
// cmd/api/main.go does, so tests exercise routing and middleware, not just
|
||||||
mockDB := &MockQuerier{
|
// the handler bodies.
|
||||||
ListGamesFunc: func(ctx context.Context) ([]db.Game, error) {
|
func newTestAPI(mockDB db.Querier) (*echo.Echo, huma.API) {
|
||||||
return []db.Game{
|
|
||||||
{ID: 1, Name: "Test Game", Score: 90},
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Setup API
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0"))
|
api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0"))
|
||||||
RegisterRoutes(api, mockDB)
|
RegisterRoutes(api, mockDB)
|
||||||
|
return e, api
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Execute Request
|
// a fixed valid session, returned by GetSessionAndUserFunc so authed
|
||||||
req := httptest.NewRequest(http.MethodGet, "/games", nil)
|
// requests don't need a real database.
|
||||||
|
var testUserID = uuid.New()
|
||||||
|
|
||||||
|
func authedMock() *MockQuerier {
|
||||||
|
return &MockQuerier{
|
||||||
|
GetSessionAndUserFunc: func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) {
|
||||||
|
return db.GetSessionAndUserRow{ID: testUserID, Email: "a@example.com", Name: "Alice"}, nil
|
||||||
|
},
|
||||||
|
TouchSessionFunc: func(ctx context.Context, tokenHash []byte) error { return nil },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminMock is authedMock for a session belonging to an admin, for the
|
||||||
|
// platform-mutation routes that require one.
|
||||||
|
func adminMock() *MockQuerier {
|
||||||
|
m := authedMock()
|
||||||
|
m.GetSessionAndUserFunc = func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) {
|
||||||
|
return db.GetSessionAndUserRow{ID: testUserID, Email: "a@example.com", Name: "Alice", IsAdmin: true}, nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func authedRequest(method, path, body string) *http.Request {
|
||||||
|
var req *http.Request
|
||||||
|
if body == "" {
|
||||||
|
req = httptest.NewRequest(method, path, nil)
|
||||||
|
} else {
|
||||||
|
req = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer any-token-the-mock-accepts")
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListGamesRequiresAuth(t *testing.T) {
|
||||||
|
e, _ := newTestAPI(&MockQuerier{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/games", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
e.ServeHTTP(rec, req)
|
e.ServeHTTP(rec, req)
|
||||||
|
|
||||||
// 4. Assert
|
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListGames(t *testing.T) {
|
||||||
|
mockDB := authedMock()
|
||||||
|
mockDB.ListGamesFunc = func(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error) {
|
||||||
|
assert.Equal(t, testUserID, userID, "handler must scope the query to the authenticated user")
|
||||||
|
return []db.ListGamesRow{
|
||||||
|
{ID: uuid.New(), Name: "Test Game", Score: 90, PlatformName: "PC"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
e, _ := newTestAPI(mockDB)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/games", ""))
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
assert.Contains(t, rec.Body.String(), "Test Game")
|
assert.Contains(t, rec.Body.String(), "Test Game")
|
||||||
|
assert.Contains(t, rec.Body.String(), `"platform_name":"PC"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetGameNotFoundForOtherUsersGame(t *testing.T) {
|
||||||
|
// A game that exists, but belongs to someone else: the query is scoped
|
||||||
|
// by user_id, so sqlc/postgres report it as not found, same as a
|
||||||
|
// genuinely missing id. The handler must turn that into 404, not 500.
|
||||||
|
mockDB := authedMock()
|
||||||
|
mockDB.GetGameFunc = func(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) {
|
||||||
|
assert.Equal(t, testUserID, arg.UserID, "handler must scope the lookup to the authenticated user")
|
||||||
|
return db.GetGameRow{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
e, _ := newTestAPI(mockDB)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/games/"+uuid.New().String(), ""))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteGameNotFoundWhenNoRowsAffected(t *testing.T) {
|
||||||
|
mockDB := authedMock()
|
||||||
|
mockDB.DeleteGameFunc = func(ctx context.Context, arg db.DeleteGameParams) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
e, _ := newTestAPI(mockDB)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/games/"+uuid.New().String(), ""))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) {
|
||||||
|
// Soft delete means the FK is never actually checked by Postgres (see
|
||||||
|
// db/query.sql's DeletePlatform comment), so the handler has to notice
|
||||||
|
// the 0-rows result itself and turn it into 409, not just pass through
|
||||||
|
// whatever error (if any) the query returns.
|
||||||
|
mockDB := adminMock()
|
||||||
|
mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) {
|
||||||
|
return db.GetPlatformRow{ID: id, Name: "PC"}, nil
|
||||||
|
}
|
||||||
|
mockDB.DeletePlatformFunc = func(ctx context.Context, id uuid.UUID) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
e, _ := newTestAPI(mockDB)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/platforms/"+uuid.New().String(), ""))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusConflict, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletePlatformNotFound(t *testing.T) {
|
||||||
|
mockDB := adminMock()
|
||||||
|
mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) {
|
||||||
|
return db.GetPlatformRow{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
e, _ := newTestAPI(mockDB)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/platforms/"+uuid.New().String(), ""))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateGameRejectsScoreOutOfRange(t *testing.T) {
|
||||||
|
mockDB := authedMock()
|
||||||
|
// No CreateGameFunc set: validation must reject the request before the
|
||||||
|
// handler ever calls the database, or this test would panic.
|
||||||
|
|
||||||
|
e, _ := newTestAPI(mockDB)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
body := `{"name":"Bad","platform_id":"` + uuid.New().String() + `","score":150,"release_date":"2020-01-01"}`
|
||||||
|
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/games", body))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreatePlatform(t *testing.T) {
|
func TestCreatePlatform(t *testing.T) {
|
||||||
// 1. Setup Mock
|
mockDB := adminMock()
|
||||||
|
mockDB.CreatePlatformFunc = func(ctx context.Context, name string) (db.CreatePlatformRow, error) {
|
||||||
|
return db.CreatePlatformRow{ID: uuid.New(), Name: name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
e, _ := newTestAPI(mockDB)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/platforms", `{"name": "Sega Genesis"}`))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
assert.Contains(t, rec.Body.String(), "Sega Genesis")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePlatformForbiddenForNonAdmin(t *testing.T) {
|
||||||
|
mockDB := authedMock()
|
||||||
|
// No CreatePlatformFunc set: the admin check must reject the request
|
||||||
|
// before the handler ever calls the database, or this test would panic.
|
||||||
|
|
||||||
|
e, _ := newTestAPI(mockDB)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/platforms", `{"name": "Sega Genesis"}`))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPlatformsIsSharedAcrossUsers(t *testing.T) {
|
||||||
|
// Unlike games, the platform list has no user_id to scope by: every
|
||||||
|
// authenticated user sees the same rows. ListPlatformsFunc taking no
|
||||||
|
// user id (beyond ctx) is itself part of what's being checked here -
|
||||||
|
// this test would fail to compile if a user_id parameter crept back in.
|
||||||
|
calls := 0
|
||||||
|
mockDB := authedMock()
|
||||||
|
mockDB.ListPlatformsFunc = func(ctx context.Context) ([]db.ListPlatformsRow, error) {
|
||||||
|
calls++
|
||||||
|
return []db.ListPlatformsRow{{ID: uuid.New(), Name: "PC"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
e, _ := newTestAPI(mockDB)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/platforms", ""))
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
assert.Contains(t, rec.Body.String(), "PC")
|
||||||
|
assert.Equal(t, 1, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterDuplicateEmailConflicts(t *testing.T) {
|
||||||
mockDB := &MockQuerier{
|
mockDB := &MockQuerier{
|
||||||
CreatePlatformFunc: func(ctx context.Context, name string) (db.Platform, error) {
|
CreateUserFunc: func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) {
|
||||||
return db.Platform{ID: 1, Name: name}, nil
|
return db.CreateUserRow{}, &pgconn.PgError{Code: pgCodeUniqueViolation}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Setup API
|
e, _ := newTestAPI(mockDB)
|
||||||
e := echo.New()
|
|
||||||
api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0"))
|
|
||||||
RegisterRoutes(api, mockDB)
|
|
||||||
|
|
||||||
// 3. Execute Request
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/platforms", strings.NewReader(`{"name": "Sega Genesis"}`))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
e.ServeHTTP(rec, req)
|
body := `{"email":"a@example.com","password":"password123","name":"Alice"}`
|
||||||
|
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/register", body))
|
||||||
|
|
||||||
// 4. Assert
|
assert.Equal(t, http.StatusConflict, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginWrongPasswordUnauthorized(t *testing.T) {
|
||||||
|
hash, err := auth.HashPassword("correct-password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mockDB := &MockQuerier{
|
||||||
|
GetUserByEmailFunc: func(ctx context.Context, email string) (db.GetUserByEmailRow, error) {
|
||||||
|
return db.GetUserByEmailRow{ID: testUserID, Email: email, PasswordHash: hash}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
e, _ := newTestAPI(mockDB)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
body := `{"email":"a@example.com","password":"wrong-password"}`
|
||||||
|
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/login", body))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSuccessSetsSessionCookie(t *testing.T) {
|
||||||
|
hash, err := auth.HashPassword("correct-password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mockDB := &MockQuerier{
|
||||||
|
GetUserByEmailFunc: func(ctx context.Context, email string) (db.GetUserByEmailRow, error) {
|
||||||
|
return db.GetUserByEmailRow{ID: testUserID, Email: email, Name: "Alice", PasswordHash: hash}, nil
|
||||||
|
},
|
||||||
|
CreateSessionFunc: func(ctx context.Context, arg db.CreateSessionParams) error {
|
||||||
|
assert.Equal(t, testUserID, arg.UserID)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
e, _ := newTestAPI(mockDB)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
body := `{"email":"a@example.com","password":"correct-password"}`
|
||||||
|
e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/login", body))
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
assert.Contains(t, rec.Body.String(), "Sega Genesis")
|
assert.Contains(t, rec.Header().Get("Set-Cookie"), "session=")
|
||||||
assert.Contains(t, rec.Body.String(), `"id":1`)
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Package auth handles password hashing and session tokens.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Argon2id parameters, taken from the RFC 9106 "low memory" recommendation
|
||||||
|
// (2nd choice) as a reasonable default for a server handling interactive
|
||||||
|
// logins without dedicated hardware.
|
||||||
|
const (
|
||||||
|
argonTime = 2
|
||||||
|
argonMemory = 19 * 1024 // KiB
|
||||||
|
argonThreads = 1
|
||||||
|
argonKeyLen = 32
|
||||||
|
saltLen = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashPassword returns a self-describing hash string: algorithm, parameters,
|
||||||
|
// salt and key are all encoded together so the parameters can change later
|
||||||
|
// without invalidating hashes already stored.
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
salt := make([]byte, saltLen)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", fmt.Errorf("generate salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||||
|
|
||||||
|
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||||
|
b64Key := base64.RawStdEncoding.EncodeToString(key)
|
||||||
|
return fmt.Sprintf("argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||||
|
argonMemory, argonTime, argonThreads, b64Salt, b64Key), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword checks password against an encoded hash produced by
|
||||||
|
// HashPassword. It returns an error only on malformed input; a wrong
|
||||||
|
// password is reported via the bool return.
|
||||||
|
func VerifyPassword(password, encoded string) (bool, error) {
|
||||||
|
parts := strings.Split(encoded, "$")
|
||||||
|
if len(parts) != 5 || parts[0] != "argon2id" {
|
||||||
|
return false, errors.New("unrecognized hash format")
|
||||||
|
}
|
||||||
|
|
||||||
|
var memory, time uint32
|
||||||
|
var threads uint8
|
||||||
|
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
|
||||||
|
return false, fmt.Errorf("parse hash params: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("decode salt: %w", err)
|
||||||
|
}
|
||||||
|
wantKey, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("decode key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotKey := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(wantKey)))
|
||||||
|
return subtle.ConstantTimeCompare(gotKey, wantKey) == 1, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHashAndVerifyPassword(t *testing.T) {
|
||||||
|
hash, err := HashPassword("correct horse battery staple")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := VerifyPassword("correct horse battery staple", hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyPassword: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Error("VerifyPassword returned false for the correct password")
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err = VerifyPassword("wrong password", hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyPassword: %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Error("VerifyPassword returned true for an incorrect password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyPasswordMalformedHash(t *testing.T) {
|
||||||
|
if _, err := VerifyPassword("anything", "not-a-valid-hash"); err == nil {
|
||||||
|
t.Error("expected an error for a malformed hash, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSessionTokenIsUnpredictableAndHashVerifiable(t *testing.T) {
|
||||||
|
token1, hash1, err := NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSessionToken: %v", err)
|
||||||
|
}
|
||||||
|
token2, _, err := NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSessionToken: %v", err)
|
||||||
|
}
|
||||||
|
if token1 == token2 {
|
||||||
|
t.Error("two calls to NewSessionToken produced the same token")
|
||||||
|
}
|
||||||
|
|
||||||
|
gotHash := HashSessionToken(token1)
|
||||||
|
if string(gotHash) != string(hash1) {
|
||||||
|
t.Error("HashSessionToken(token) does not match the hash returned alongside it")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionDuration controls how long a login stays valid without activity.
|
||||||
|
// TouchSession bumps last_seen_at on each authenticated request, but
|
||||||
|
// expires_at (set at login) is what actually gates access.
|
||||||
|
const SessionDuration = 30 * 24 * 60 * 60 // 30 days, in seconds
|
||||||
|
|
||||||
|
// tokenLen is deliberately large: this is the only secret standing between
|
||||||
|
// an attacker and a full account take-over, so it gets more entropy than a
|
||||||
|
// password would need.
|
||||||
|
const tokenLen = 32
|
||||||
|
|
||||||
|
// NewSessionToken returns a random token to hand to the client, and the hash
|
||||||
|
// of it to store in the database. Only the hash is ever persisted, so a
|
||||||
|
// leaked database dump doesn't also leak usable session tokens.
|
||||||
|
func NewSessionToken() (token string, hash []byte, err error) {
|
||||||
|
raw := make([]byte, tokenLen)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("generate token: %w", err)
|
||||||
|
}
|
||||||
|
token = base64.RawURLEncoding.EncodeToString(raw)
|
||||||
|
return token, HashSessionToken(token), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashSessionToken hashes a token presented by a client so it can be looked
|
||||||
|
// up against the stored hash.
|
||||||
|
func HashSessionToken(token string) []byte {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// Package client is a thin, typed HTTP client for the Completed API. It's
|
||||||
|
// the shared base for the TUI and the Fyne GUI: both hold a *Client and
|
||||||
|
// call its methods instead of each hand-rolling net/http calls, so request
|
||||||
|
// building, auth, and error handling live in one tested place.
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"completed/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultBaseURL is used when no other base URL is configured. It matches
|
||||||
|
// cmd/api's own default port.
|
||||||
|
const DefaultBaseURL = "http://localhost:8080"
|
||||||
|
|
||||||
|
// Client talks to the Completed API. The zero value is not usable; create
|
||||||
|
// one with New. A Client is safe for concurrent use once Token is no
|
||||||
|
// longer being written concurrently with reads - in practice, callers set
|
||||||
|
// it only right after Login/Register, before starting concurrent calls.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Client pointed at baseURL. An empty baseURL is replaced
|
||||||
|
// with DefaultBaseURL.
|
||||||
|
func New(baseURL string) *Client {
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = DefaultBaseURL
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIError is returned for any non-2xx response. It carries the status
|
||||||
|
// code so callers can distinguish, say, a 401 (show "wrong password") from
|
||||||
|
// a 500 (show "something went wrong").
|
||||||
|
type APIError struct {
|
||||||
|
Status int
|
||||||
|
Title string
|
||||||
|
Detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
if e.Detail != "" {
|
||||||
|
return e.Detail
|
||||||
|
}
|
||||||
|
if e.Title != "" {
|
||||||
|
return e.Title
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("request failed with status %d", e.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// huma's error body shape; see internal/api's ErrorModel (from the huma
|
||||||
|
// package) in api/openapi.json.
|
||||||
|
type errorBody struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// do sends an HTTP request and decodes a JSON response into out (if out is
|
||||||
|
// non-nil). It attaches the bearer token, if set, and turns any non-2xx
|
||||||
|
// response into an *APIError.
|
||||||
|
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode request body: %w", err)
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
var eb errorBody
|
||||||
|
_ = json.Unmarshal(data, &eb) // best-effort; fall back to status alone
|
||||||
|
return &APIError{Status: resp.StatusCode, Title: eb.Title, Detail: eb.Detail}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out != nil && len(data) > 0 {
|
||||||
|
if err := json.Unmarshal(data, out); err != nil {
|
||||||
|
return fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
|
||||||
|
func (c *Client) Register(ctx context.Context, email, password, name string) (model.AuthResponse, error) {
|
||||||
|
var resp model.AuthResponse
|
||||||
|
err := c.do(ctx, http.MethodPost, "/api/auth/register", model.RegisterInput{
|
||||||
|
Email: email, Password: password, Name: name,
|
||||||
|
}, &resp)
|
||||||
|
if err == nil {
|
||||||
|
c.Token = resp.Token
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Login(ctx context.Context, email, password string) (model.AuthResponse, error) {
|
||||||
|
var resp model.AuthResponse
|
||||||
|
err := c.do(ctx, http.MethodPost, "/api/auth/login", model.LoginInput{
|
||||||
|
Email: email, Password: password,
|
||||||
|
}, &resp)
|
||||||
|
if err == nil {
|
||||||
|
c.Token = resp.Token
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Logout(ctx context.Context) error {
|
||||||
|
err := c.do(ctx, http.MethodPost, "/api/auth/logout", nil, nil)
|
||||||
|
c.Token = ""
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Me(ctx context.Context) (model.User, error) {
|
||||||
|
var u model.User
|
||||||
|
err := c.do(ctx, http.MethodGet, "/api/auth/me", nil, &u)
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Platforms ---
|
||||||
|
|
||||||
|
func (c *Client) ListPlatforms(ctx context.Context) ([]model.Platform, error) {
|
||||||
|
var platforms []model.Platform
|
||||||
|
err := c.do(ctx, http.MethodGet, "/api/platforms", nil, &platforms)
|
||||||
|
return platforms, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetPlatform(ctx context.Context, id string) (model.Platform, error) {
|
||||||
|
var p model.Platform
|
||||||
|
err := c.do(ctx, http.MethodGet, "/api/platforms/"+id, nil, &p)
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePlatform requires the current user to be an admin; see
|
||||||
|
// internal/api/middleware.go's addAdminAuth.
|
||||||
|
func (c *Client) CreatePlatform(ctx context.Context, name string) (model.Platform, error) {
|
||||||
|
var p model.Platform
|
||||||
|
err := c.do(ctx, http.MethodPost, "/api/platforms", model.PlatformInput{Name: name}, &p)
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdatePlatform(ctx context.Context, id, name string) (model.Platform, error) {
|
||||||
|
var p model.Platform
|
||||||
|
err := c.do(ctx, http.MethodPut, "/api/platforms/"+id, model.PlatformInput{Name: name}, &p)
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeletePlatform(ctx context.Context, id string) error {
|
||||||
|
return c.do(ctx, http.MethodDelete, "/api/platforms/"+id, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Games ---
|
||||||
|
|
||||||
|
func (c *Client) ListGames(ctx context.Context) ([]model.Game, error) {
|
||||||
|
var games []model.Game
|
||||||
|
err := c.do(ctx, http.MethodGet, "/api/games", nil, &games)
|
||||||
|
return games, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetGame(ctx context.Context, id string) (model.Game, error) {
|
||||||
|
var g model.Game
|
||||||
|
err := c.do(ctx, http.MethodGet, "/api/games/"+id, nil, &g)
|
||||||
|
return g, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateGame(ctx context.Context, in model.GameInput) (model.Game, error) {
|
||||||
|
var g model.Game
|
||||||
|
err := c.do(ctx, http.MethodPost, "/api/games", in, &g)
|
||||||
|
return g, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdateGame(ctx context.Context, id string, in model.GameInput) (model.Game, error) {
|
||||||
|
var g model.Game
|
||||||
|
err := c.do(ctx, http.MethodPut, "/api/games/"+id, in, &g)
|
||||||
|
return g, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteGame(ctx context.Context, id string) error {
|
||||||
|
return c.do(ctx, http.MethodDelete, "/api/games/"+id, nil, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"completed/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return New(srv.URL), srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, w http.ResponseWriter, status int, v any) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
t.Fatalf("encode response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSuccessStoresToken(t *testing.T) {
|
||||||
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost || r.URL.Path != "/api/auth/login" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
var in model.LoginInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
t.Fatalf("decode body: %v", err)
|
||||||
|
}
|
||||||
|
if in.Email != "alice@example.com" || in.Password != "secret" {
|
||||||
|
t.Fatalf("unexpected credentials: %+v", in)
|
||||||
|
}
|
||||||
|
writeJSON(t, w, http.StatusOK, model.AuthResponse{
|
||||||
|
Token: "tok-123",
|
||||||
|
User: model.User{ID: "u1", Email: in.Email, Name: "Alice"},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := c.Login(t.Context(), "alice@example.com", "secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Token != "tok-123" {
|
||||||
|
t.Errorf("Token = %q, want tok-123", resp.Token)
|
||||||
|
}
|
||||||
|
if c.Token != "tok-123" {
|
||||||
|
t.Errorf("Client.Token = %q, want tok-123 (Login must store it)", c.Token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginFailureDoesNotStoreToken(t *testing.T) {
|
||||||
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(t, w, http.StatusUnauthorized, map[string]any{
|
||||||
|
"title": "Unauthorized", "status": 401, "detail": "invalid email or password",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
c.Token = "" // sanity
|
||||||
|
|
||||||
|
_, err := c.Login(t.Context(), "alice@example.com", "wrong")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got nil")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if apiErr.Status != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Status = %d, want 401", apiErr.Status)
|
||||||
|
}
|
||||||
|
if apiErr.Error() != "invalid email or password" {
|
||||||
|
t.Errorf("Error() = %q, want the detail message", apiErr.Error())
|
||||||
|
}
|
||||||
|
if c.Token != "" {
|
||||||
|
t.Errorf("Token = %q, want empty after a failed login", c.Token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListGamesSendsBearerToken(t *testing.T) {
|
||||||
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer tok-abc" {
|
||||||
|
t.Errorf("Authorization header = %q, want %q", got, "Bearer tok-abc")
|
||||||
|
}
|
||||||
|
writeJSON(t, w, http.StatusOK, []model.Game{
|
||||||
|
{ID: "g1", Name: "Doom", PlatformName: "PC", Score: 95, ReleaseDate: "1993-12-10"},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
c.Token = "tok-abc"
|
||||||
|
|
||||||
|
games, err := c.ListGames(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListGames: %v", err)
|
||||||
|
}
|
||||||
|
if len(games) != 1 || games[0].Name != "Doom" {
|
||||||
|
t.Errorf("games = %+v, want one game named Doom", games)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListGamesUnauthorized(t *testing.T) {
|
||||||
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(t, w, http.StatusUnauthorized, map[string]any{
|
||||||
|
"title": "Unauthorized", "status": 401, "detail": "authentication required",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// No token set.
|
||||||
|
|
||||||
|
_, err := c.ListGames(t.Context())
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if apiErr.Status != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Status = %d, want 401", apiErr.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateGameSendsBodyAndParsesResponse(t *testing.T) {
|
||||||
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost || r.URL.Path != "/api/games" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
var in model.GameInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
t.Fatalf("decode body: %v", err)
|
||||||
|
}
|
||||||
|
if in.Name != "Half-Life" || in.Score != 96 {
|
||||||
|
t.Fatalf("unexpected body: %+v", in)
|
||||||
|
}
|
||||||
|
writeJSON(t, w, http.StatusOK, model.Game{
|
||||||
|
ID: "g2", Name: in.Name, PlatformID: in.PlatformID, Score: in.Score, ReleaseDate: in.ReleaseDate,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
g, err := c.CreateGame(t.Context(), model.GameInput{
|
||||||
|
Name: "Half-Life", PlatformID: "p1", Score: 96, ReleaseDate: "1998-11-19",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateGame: %v", err)
|
||||||
|
}
|
||||||
|
if g.ID != "g2" {
|
||||||
|
t.Errorf("ID = %q, want g2", g.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePlatformForbidden(t *testing.T) {
|
||||||
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(t, w, http.StatusForbidden, map[string]any{
|
||||||
|
"title": "Forbidden", "status": 403, "detail": "admin access required",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
c.Token = "tok-non-admin"
|
||||||
|
|
||||||
|
_, err := c.CreatePlatform(t.Context(), "PC")
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *APIError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if apiErr.Status != http.StatusForbidden {
|
||||||
|
t.Errorf("Status = %d, want 403", apiErr.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogoutClearsTokenEvenOnError(t *testing.T) {
|
||||||
|
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
c.Token = "tok-abc"
|
||||||
|
|
||||||
|
_ = c.Logout(t.Context())
|
||||||
|
if c.Token != "" {
|
||||||
|
t.Errorf("Token = %q, want empty after Logout regardless of the response", c.Token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultBaseURL(t *testing.T) {
|
||||||
|
c := New("")
|
||||||
|
if c.BaseURL != DefaultBaseURL {
|
||||||
|
t.Errorf("BaseURL = %q, want %q", c.BaseURL, DefaultBaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package db
|
package db
|
||||||
|
|
||||||
|
|||||||
+30
-9
@@ -1,29 +1,50 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Game struct {
|
type Game struct {
|
||||||
ID int64
|
ID uuid.UUID
|
||||||
|
UserID uuid.UUID
|
||||||
|
PlatformID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
PlatformID int64
|
|
||||||
Score int32
|
Score int32
|
||||||
ReleaseYear pgtype.Date
|
ReleaseDate pgtype.Date
|
||||||
Finished pgtype.Date
|
Finished pgtype.Date
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
DeletedAt pgtype.Timestamptz
|
||||||
|
ChangeSeq int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Platform struct {
|
type Platform struct {
|
||||||
ID int64
|
ID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
DeletedAt pgtype.Timestamptz
|
||||||
|
ChangeSeq int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
TokenHash []byte
|
||||||
|
UserID uuid.UUID
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
ExpiresAt pgtype.Timestamptz
|
||||||
|
LastSeenAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64
|
ID uuid.UUID
|
||||||
Name string
|
Email string
|
||||||
Bio pgtype.Text
|
PasswordHash string
|
||||||
|
Name string
|
||||||
|
Bio pgtype.Text
|
||||||
|
IsAdmin bool
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
+28
-14
@@ -1,27 +1,41 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Querier interface {
|
type Querier interface {
|
||||||
CreateGame(ctx context.Context, arg CreateGameParams) (Game, error)
|
CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error)
|
||||||
CreatePlatform(ctx context.Context, name string) (Platform, error)
|
CreatePlatform(ctx context.Context, name string) (CreatePlatformRow, error)
|
||||||
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
|
CreateSession(ctx context.Context, arg CreateSessionParams) error
|
||||||
DeleteGame(ctx context.Context, id int64) error
|
// The first account ever registered becomes an admin automatically; see
|
||||||
DeletePlatform(ctx context.Context, id int64) error
|
// the is_admin column's comment in db/migrations for why and the race this
|
||||||
DeleteUser(ctx context.Context, id int64) error
|
// accepts. Everyone after that registers as a non-admin.
|
||||||
GetGame(ctx context.Context, id int64) (Game, error)
|
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
|
||||||
GetUser(ctx context.Context, id int64) (User, error)
|
DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error)
|
||||||
ListGames(ctx context.Context) ([]Game, error)
|
// Soft delete, so the FK on game.platform_id is never touched and never
|
||||||
ListPlatforms(ctx context.Context) ([]Platform, error)
|
// blocks this on its own; the NOT EXISTS guard is what stops a platform
|
||||||
ListUsers(ctx context.Context) ([]User, error)
|
// that still has active games - from any user, since the list is shared -
|
||||||
UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, error)
|
// from being deleted. Call GetPlatform first to tell "not found" apart from
|
||||||
UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (Platform, error)
|
// "blocked by games" when this affects 0 rows.
|
||||||
|
DeletePlatform(ctx context.Context, id uuid.UUID) (int64, error)
|
||||||
|
DeleteSession(ctx context.Context, tokenHash []byte) error
|
||||||
|
GetGame(ctx context.Context, arg GetGameParams) (GetGameRow, error)
|
||||||
|
GetPlatform(ctx context.Context, id uuid.UUID) (GetPlatformRow, error)
|
||||||
|
GetSessionAndUser(ctx context.Context, tokenHash []byte) (GetSessionAndUserRow, error)
|
||||||
|
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
|
||||||
|
GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error)
|
||||||
|
ListGames(ctx context.Context, userID uuid.UUID) ([]ListGamesRow, error)
|
||||||
|
ListPlatforms(ctx context.Context) ([]ListPlatformsRow, error)
|
||||||
|
TouchSession(ctx context.Context, tokenHash []byte) error
|
||||||
|
UpdateGame(ctx context.Context, arg UpdateGameParams) (UpdateGameRow, error)
|
||||||
|
UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (UpdatePlatformRow, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ Querier = (*Queries)(nil)
|
var _ Querier = (*Queries)(nil)
|
||||||
+343
-104
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
// source: query.sql
|
// source: query.sql
|
||||||
|
|
||||||
package db
|
package db
|
||||||
@@ -8,41 +8,61 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
const createGame = `-- name: CreateGame :one
|
const createGame = `-- name: CreateGame :one
|
||||||
INSERT INTO game (
|
WITH inserted AS (
|
||||||
name, platform_id, score, release_year, finished
|
INSERT INTO game (
|
||||||
) VALUES (
|
user_id, platform_id, name, score, release_date, finished
|
||||||
$1, $2, $3, $4, $5
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6
|
||||||
|
)
|
||||||
|
RETURNING id, platform_id, name, score, release_date, finished
|
||||||
)
|
)
|
||||||
RETURNING id, name, platform_id, score, release_year, finished
|
SELECT inserted.id, inserted.name, inserted.platform_id, platform.name AS platform_name,
|
||||||
|
inserted.score, inserted.release_date, inserted.finished
|
||||||
|
FROM inserted
|
||||||
|
JOIN platform ON platform.id = inserted.platform_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateGameParams struct {
|
type CreateGameParams struct {
|
||||||
|
UserID uuid.UUID
|
||||||
|
PlatformID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
PlatformID int64
|
|
||||||
Score int32
|
Score int32
|
||||||
ReleaseYear pgtype.Date
|
ReleaseDate pgtype.Date
|
||||||
Finished pgtype.Date
|
Finished pgtype.Date
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) {
|
type CreateGameRow struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Name string
|
||||||
|
PlatformID uuid.UUID
|
||||||
|
PlatformName string
|
||||||
|
Score int32
|
||||||
|
ReleaseDate pgtype.Date
|
||||||
|
Finished pgtype.Date
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error) {
|
||||||
row := q.db.QueryRow(ctx, createGame,
|
row := q.db.QueryRow(ctx, createGame,
|
||||||
arg.Name,
|
arg.UserID,
|
||||||
arg.PlatformID,
|
arg.PlatformID,
|
||||||
|
arg.Name,
|
||||||
arg.Score,
|
arg.Score,
|
||||||
arg.ReleaseYear,
|
arg.ReleaseDate,
|
||||||
arg.Finished,
|
arg.Finished,
|
||||||
)
|
)
|
||||||
var i Game
|
var i CreateGameRow
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.PlatformID,
|
&i.PlatformID,
|
||||||
|
&i.PlatformName,
|
||||||
&i.Score,
|
&i.Score,
|
||||||
&i.ReleaseYear,
|
&i.ReleaseDate,
|
||||||
&i.Finished,
|
&i.Finished,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
@@ -57,115 +77,317 @@ INSERT INTO platform (
|
|||||||
RETURNING id, name
|
RETURNING id, name
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) CreatePlatform(ctx context.Context, name string) (Platform, error) {
|
type CreatePlatformRow struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreatePlatform(ctx context.Context, name string) (CreatePlatformRow, error) {
|
||||||
row := q.db.QueryRow(ctx, createPlatform, name)
|
row := q.db.QueryRow(ctx, createPlatform, name)
|
||||||
var i Platform
|
var i CreatePlatformRow
|
||||||
err := row.Scan(&i.ID, &i.Name)
|
err := row.Scan(&i.ID, &i.Name)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createSession = `-- name: CreateSession :exec
|
||||||
|
INSERT INTO sessions (
|
||||||
|
token_hash, user_id, expires_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateSessionParams struct {
|
||||||
|
TokenHash []byte
|
||||||
|
UserID uuid.UUID
|
||||||
|
ExpiresAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, createSession, arg.TokenHash, arg.UserID, arg.ExpiresAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const createUser = `-- name: CreateUser :one
|
const createUser = `-- name: CreateUser :one
|
||||||
INSERT INTO users (
|
INSERT INTO users (
|
||||||
name, bio
|
email, password_hash, name, bio, is_admin
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2
|
$1, $2, $3, $4, NOT EXISTS (SELECT 1 FROM users)
|
||||||
)
|
)
|
||||||
RETURNING id, name, bio
|
RETURNING id, email, name, bio, is_admin, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateUserParams struct {
|
type CreateUserParams struct {
|
||||||
Name string
|
Email string
|
||||||
Bio pgtype.Text
|
PasswordHash string
|
||||||
|
Name string
|
||||||
|
Bio pgtype.Text
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
|
type CreateUserRow struct {
|
||||||
row := q.db.QueryRow(ctx, createUser, arg.Name, arg.Bio)
|
ID uuid.UUID
|
||||||
var i User
|
Email string
|
||||||
err := row.Scan(&i.ID, &i.Name, &i.Bio)
|
Name string
|
||||||
|
Bio pgtype.Text
|
||||||
|
IsAdmin bool
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first account ever registered becomes an admin automatically; see
|
||||||
|
// the is_admin column's comment in db/migrations for why and the race this
|
||||||
|
// accepts. Everyone after that registers as a non-admin.
|
||||||
|
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createUser,
|
||||||
|
arg.Email,
|
||||||
|
arg.PasswordHash,
|
||||||
|
arg.Name,
|
||||||
|
arg.Bio,
|
||||||
|
)
|
||||||
|
var i CreateUserRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.Name,
|
||||||
|
&i.Bio,
|
||||||
|
&i.IsAdmin,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteGame = `-- name: DeleteGame :exec
|
const deleteGame = `-- name: DeleteGame :execrows
|
||||||
DELETE FROM game
|
UPDATE game
|
||||||
WHERE id = $1
|
SET deleted_at = now()
|
||||||
|
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteGame(ctx context.Context, id int64) error {
|
type DeleteGameParams struct {
|
||||||
_, err := q.db.Exec(ctx, deleteGame, id)
|
ID uuid.UUID
|
||||||
return err
|
UserID uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletePlatform = `-- name: DeletePlatform :exec
|
func (q *Queries) DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error) {
|
||||||
DELETE FROM platform
|
result, err := q.db.Exec(ctx, deleteGame, arg.ID, arg.UserID)
|
||||||
WHERE id = $1
|
if err != nil {
|
||||||
`
|
return 0, err
|
||||||
|
}
|
||||||
func (q *Queries) DeletePlatform(ctx context.Context, id int64) error {
|
return result.RowsAffected(), nil
|
||||||
_, err := q.db.Exec(ctx, deletePlatform, id)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteUser = `-- name: DeleteUser :exec
|
const deletePlatform = `-- name: DeletePlatform :execrows
|
||||||
DELETE FROM users
|
UPDATE platform
|
||||||
WHERE id = $1
|
SET deleted_at = now()
|
||||||
|
WHERE platform.id = $1 AND platform.deleted_at IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM game
|
||||||
|
WHERE game.platform_id = platform.id AND game.deleted_at IS NULL
|
||||||
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteUser(ctx context.Context, id int64) error {
|
// Soft delete, so the FK on game.platform_id is never touched and never
|
||||||
_, err := q.db.Exec(ctx, deleteUser, id)
|
// blocks this on its own; the NOT EXISTS guard is what stops a platform
|
||||||
|
// that still has active games - from any user, since the list is shared -
|
||||||
|
// from being deleted. Call GetPlatform first to tell "not found" apart from
|
||||||
|
// "blocked by games" when this affects 0 rows.
|
||||||
|
func (q *Queries) DeletePlatform(ctx context.Context, id uuid.UUID) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, deletePlatform, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSession = `-- name: DeleteSession :exec
|
||||||
|
DELETE FROM sessions
|
||||||
|
WHERE token_hash = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteSession(ctx context.Context, tokenHash []byte) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteSession, tokenHash)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getGame = `-- name: GetGame :one
|
const getGame = `-- name: GetGame :one
|
||||||
SELECT id, name, platform_id, score, release_year, finished FROM game
|
SELECT game.id, game.name, game.platform_id, platform.name AS platform_name,
|
||||||
WHERE id = $1 LIMIT 1
|
game.score, game.release_date, game.finished
|
||||||
|
FROM game
|
||||||
|
JOIN platform ON platform.id = game.platform_id
|
||||||
|
WHERE game.id = $1 AND game.user_id = $2 AND game.deleted_at IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetGame(ctx context.Context, id int64) (Game, error) {
|
type GetGameParams struct {
|
||||||
row := q.db.QueryRow(ctx, getGame, id)
|
ID uuid.UUID
|
||||||
var i Game
|
UserID uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetGameRow struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Name string
|
||||||
|
PlatformID uuid.UUID
|
||||||
|
PlatformName string
|
||||||
|
Score int32
|
||||||
|
ReleaseDate pgtype.Date
|
||||||
|
Finished pgtype.Date
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetGame(ctx context.Context, arg GetGameParams) (GetGameRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getGame, arg.ID, arg.UserID)
|
||||||
|
var i GetGameRow
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.PlatformID,
|
&i.PlatformID,
|
||||||
|
&i.PlatformName,
|
||||||
&i.Score,
|
&i.Score,
|
||||||
&i.ReleaseYear,
|
&i.ReleaseDate,
|
||||||
&i.Finished,
|
&i.Finished,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUser = `-- name: GetUser :one
|
const getPlatform = `-- name: GetPlatform :one
|
||||||
SELECT id, name, bio FROM users
|
SELECT id, name
|
||||||
WHERE id = $1 LIMIT 1
|
FROM platform
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) {
|
type GetPlatformRow struct {
|
||||||
row := q.db.QueryRow(ctx, getUser, id)
|
ID uuid.UUID
|
||||||
var i User
|
Name string
|
||||||
err := row.Scan(&i.ID, &i.Name, &i.Bio)
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetPlatform(ctx context.Context, id uuid.UUID) (GetPlatformRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getPlatform, id)
|
||||||
|
var i GetPlatformRow
|
||||||
|
err := row.Scan(&i.ID, &i.Name)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSessionAndUser = `-- name: GetSessionAndUser :one
|
||||||
|
SELECT users.id, users.email, users.name, users.bio, users.is_admin, sessions.expires_at
|
||||||
|
FROM sessions
|
||||||
|
JOIN users ON users.id = sessions.user_id
|
||||||
|
WHERE sessions.token_hash = $1 AND sessions.expires_at > now()
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetSessionAndUserRow struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
Bio pgtype.Text
|
||||||
|
IsAdmin bool
|
||||||
|
ExpiresAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetSessionAndUser(ctx context.Context, tokenHash []byte) (GetSessionAndUserRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getSessionAndUser, tokenHash)
|
||||||
|
var i GetSessionAndUserRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.Name,
|
||||||
|
&i.Bio,
|
||||||
|
&i.IsAdmin,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||||
|
SELECT id, email, password_hash, name, bio, is_admin, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE email = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetUserByEmailRow struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Email string
|
||||||
|
PasswordHash string
|
||||||
|
Name string
|
||||||
|
Bio pgtype.Text
|
||||||
|
IsAdmin bool
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getUserByEmail, email)
|
||||||
|
var i GetUserByEmailRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.PasswordHash,
|
||||||
|
&i.Name,
|
||||||
|
&i.Bio,
|
||||||
|
&i.IsAdmin,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserByID = `-- name: GetUserByID :one
|
||||||
|
SELECT id, email, name, bio, is_admin, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetUserByIDRow struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
Bio pgtype.Text
|
||||||
|
IsAdmin bool
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getUserByID, id)
|
||||||
|
var i GetUserByIDRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.Name,
|
||||||
|
&i.Bio,
|
||||||
|
&i.IsAdmin,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const listGames = `-- name: ListGames :many
|
const listGames = `-- name: ListGames :many
|
||||||
SELECT id, name, platform_id, score, release_year, finished FROM game
|
SELECT game.id, game.name, game.platform_id, platform.name AS platform_name,
|
||||||
ORDER BY name
|
game.score, game.release_date, game.finished
|
||||||
|
FROM game
|
||||||
|
JOIN platform ON platform.id = game.platform_id
|
||||||
|
WHERE game.user_id = $1 AND game.deleted_at IS NULL
|
||||||
|
ORDER BY game.name
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListGames(ctx context.Context) ([]Game, error) {
|
type ListGamesRow struct {
|
||||||
rows, err := q.db.Query(ctx, listGames)
|
ID uuid.UUID
|
||||||
|
Name string
|
||||||
|
PlatformID uuid.UUID
|
||||||
|
PlatformName string
|
||||||
|
Score int32
|
||||||
|
ReleaseDate pgtype.Date
|
||||||
|
Finished pgtype.Date
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListGames(ctx context.Context, userID uuid.UUID) ([]ListGamesRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listGames, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []Game
|
var items []ListGamesRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i Game
|
var i ListGamesRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.PlatformID,
|
&i.PlatformID,
|
||||||
|
&i.PlatformName,
|
||||||
&i.Score,
|
&i.Score,
|
||||||
&i.ReleaseYear,
|
&i.ReleaseDate,
|
||||||
&i.Finished,
|
&i.Finished,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -179,19 +401,26 @@ func (q *Queries) ListGames(ctx context.Context) ([]Game, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listPlatforms = `-- name: ListPlatforms :many
|
const listPlatforms = `-- name: ListPlatforms :many
|
||||||
SELECT id, name FROM platform
|
SELECT id, name
|
||||||
|
FROM platform
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListPlatforms(ctx context.Context) ([]Platform, error) {
|
type ListPlatformsRow struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListPlatforms(ctx context.Context) ([]ListPlatformsRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listPlatforms)
|
rows, err := q.db.Query(ctx, listPlatforms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []Platform
|
var items []ListPlatformsRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i Platform
|
var i ListPlatformsRow
|
||||||
if err := rows.Scan(&i.ID, &i.Name); err != nil {
|
if err := rows.Scan(&i.ID, &i.Name); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -203,63 +432,68 @@ func (q *Queries) ListPlatforms(ctx context.Context) ([]Platform, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const listUsers = `-- name: ListUsers :many
|
const touchSession = `-- name: TouchSession :exec
|
||||||
SELECT id, name, bio FROM users
|
UPDATE sessions
|
||||||
ORDER BY name
|
SET last_seen_at = now()
|
||||||
|
WHERE token_hash = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
|
func (q *Queries) TouchSession(ctx context.Context, tokenHash []byte) error {
|
||||||
rows, err := q.db.Query(ctx, listUsers)
|
_, err := q.db.Exec(ctx, touchSession, tokenHash)
|
||||||
if err != nil {
|
return err
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []User
|
|
||||||
for rows.Next() {
|
|
||||||
var i User
|
|
||||||
if err := rows.Scan(&i.ID, &i.Name, &i.Bio); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateGame = `-- name: UpdateGame :one
|
const updateGame = `-- name: UpdateGame :one
|
||||||
UPDATE game
|
WITH updated AS (
|
||||||
SET name = $2, platform_id = $3, score = $4, release_year = $5, finished = $6
|
UPDATE game
|
||||||
WHERE id = $1
|
SET platform_id = $3, name = $4, score = $5, release_date = $6, finished = $7
|
||||||
RETURNING id, name, platform_id, score, release_year, finished
|
WHERE game.id = $1 AND game.user_id = $2 AND game.deleted_at IS NULL
|
||||||
|
RETURNING id, platform_id, name, score, release_date, finished
|
||||||
|
)
|
||||||
|
SELECT updated.id, updated.name, updated.platform_id, platform.name AS platform_name,
|
||||||
|
updated.score, updated.release_date, updated.finished
|
||||||
|
FROM updated
|
||||||
|
JOIN platform ON platform.id = updated.platform_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateGameParams struct {
|
type UpdateGameParams struct {
|
||||||
ID int64
|
ID uuid.UUID
|
||||||
|
UserID uuid.UUID
|
||||||
|
PlatformID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
PlatformID int64
|
|
||||||
Score int32
|
Score int32
|
||||||
ReleaseYear pgtype.Date
|
ReleaseDate pgtype.Date
|
||||||
Finished pgtype.Date
|
Finished pgtype.Date
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, error) {
|
type UpdateGameRow struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Name string
|
||||||
|
PlatformID uuid.UUID
|
||||||
|
PlatformName string
|
||||||
|
Score int32
|
||||||
|
ReleaseDate pgtype.Date
|
||||||
|
Finished pgtype.Date
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (UpdateGameRow, error) {
|
||||||
row := q.db.QueryRow(ctx, updateGame,
|
row := q.db.QueryRow(ctx, updateGame,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
arg.Name,
|
arg.UserID,
|
||||||
arg.PlatformID,
|
arg.PlatformID,
|
||||||
|
arg.Name,
|
||||||
arg.Score,
|
arg.Score,
|
||||||
arg.ReleaseYear,
|
arg.ReleaseDate,
|
||||||
arg.Finished,
|
arg.Finished,
|
||||||
)
|
)
|
||||||
var i Game
|
var i UpdateGameRow
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.PlatformID,
|
&i.PlatformID,
|
||||||
|
&i.PlatformName,
|
||||||
&i.Score,
|
&i.Score,
|
||||||
&i.ReleaseYear,
|
&i.ReleaseDate,
|
||||||
&i.Finished,
|
&i.Finished,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
@@ -268,18 +502,23 @@ func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, e
|
|||||||
const updatePlatform = `-- name: UpdatePlatform :one
|
const updatePlatform = `-- name: UpdatePlatform :one
|
||||||
UPDATE platform
|
UPDATE platform
|
||||||
SET name = $2
|
SET name = $2
|
||||||
WHERE id = $1
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
RETURNING id, name
|
RETURNING id, name
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdatePlatformParams struct {
|
type UpdatePlatformParams struct {
|
||||||
ID int64
|
ID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (Platform, error) {
|
type UpdatePlatformRow struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (UpdatePlatformRow, error) {
|
||||||
row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.Name)
|
row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.Name)
|
||||||
var i Platform
|
var i UpdatePlatformRow
|
||||||
err := row.Scan(&i.ID, &i.Name)
|
err := row.Scan(&i.ID, &i.Name)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Package model holds the plain data types shared between the API and its
|
||||||
|
// clients. It has no dependency on Huma, Echo or the database, so clients
|
||||||
|
// can import it without pulling in the server.
|
||||||
|
package model
|
||||||
|
|
||||||
|
// User is the public view of an account; it never carries the password hash.
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Bio string `json:"bio,omitempty"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterInput struct {
|
||||||
|
Email string `json:"email" format:"email"`
|
||||||
|
Password string `json:"password" minLength:"8"`
|
||||||
|
Name string `json:"name" minLength:"1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginInput struct {
|
||||||
|
Email string `json:"email" format:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthResponse is returned by register and login. Token is also set as an
|
||||||
|
// httpOnly cookie, but is included in the body so non-browser clients (the
|
||||||
|
// TUI, the Fyne GUI) can store and send it themselves.
|
||||||
|
type AuthResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
User User `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Platform struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlatformInput struct {
|
||||||
|
Name string `json:"name" minLength:"1" doc:"Name of the platform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Game struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PlatformID string `json:"platform_id"`
|
||||||
|
PlatformName string `json:"platform_name"`
|
||||||
|
Score int32 `json:"score"`
|
||||||
|
ReleaseDate string `json:"release_date" format:"date" doc:"YYYY-MM-DD"`
|
||||||
|
Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameInput struct {
|
||||||
|
Name string `json:"name" minLength:"1"`
|
||||||
|
PlatformID string `json:"platform_id"`
|
||||||
|
Score int32 `json:"score" minimum:"0" maximum:"100"`
|
||||||
|
ReleaseDate string `json:"release_date" format:"date" doc:"YYYY-MM-DD"`
|
||||||
|
Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"`
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
sql:
|
sql:
|
||||||
- schema: "db/schema.sql"
|
- schema: "db/migrations"
|
||||||
queries: "db/query.sql"
|
queries: "db/query.sql"
|
||||||
engine: "postgresql"
|
engine: "postgresql"
|
||||||
gen:
|
gen:
|
||||||
@@ -9,3 +9,6 @@ sql:
|
|||||||
out: "internal/db"
|
out: "internal/db"
|
||||||
sql_package: "pgx/v5"
|
sql_package: "pgx/v5"
|
||||||
emit_interface: true
|
emit_interface: true
|
||||||
|
overrides:
|
||||||
|
- db_type: "uuid"
|
||||||
|
go_type: "github.com/google/uuid.UUID"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
ij_typescript_use_double_quotes = false
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/mcp.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||||
|
"recommendations": ["angular.ng-template"]
|
||||||
|
}
|
||||||
Vendored
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "ng serve",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: start",
|
||||||
|
"url": "http://localhost:4200/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ng test",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: test",
|
||||||
|
"url": "http://localhost:9876/debug.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+42
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "start",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "Changes detected"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation (complete|failed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "test",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "Changes detected"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation (complete|failed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Web
|
||||||
|
|
||||||
|
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.2.1.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
To start a local development server, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||||
|
|
||||||
|
## Code scaffolding
|
||||||
|
|
||||||
|
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate component component-name
|
||||||
|
```
|
||||||
|
|
||||||
|
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the project run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
For end-to-end (e2e) testing, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"cli": {
|
||||||
|
"packageManager": "npm"
|
||||||
|
},
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"web": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:application",
|
||||||
|
"options": {
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["src/material-theme.scss", "src/styles.css"]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kB",
|
||||||
|
"maximumError": "1MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kB",
|
||||||
|
"maximumError": "8kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular/build:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "web:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "web:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:unit-test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "node_modules/ng-openapi-gen/ng-openapi-gen-schema.json",
|
||||||
|
"input": "../api/openapi.json",
|
||||||
|
"output": "src/app/api",
|
||||||
|
"ignoreUnusedModels": false,
|
||||||
|
"services": true
|
||||||
|
}
|
||||||
Generated
+7131
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "npm@11.19.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^22.2.1",
|
||||||
|
"@angular/cdk": "^22.2.1",
|
||||||
|
"@angular/common": "^22.2.0",
|
||||||
|
"@angular/compiler": "^22.2.0",
|
||||||
|
"@angular/core": "^22.2.0",
|
||||||
|
"@angular/forms": "^22.2.0",
|
||||||
|
"@angular/material": "^22.2.1",
|
||||||
|
"@angular/platform-browser": "^22.2.0",
|
||||||
|
"@angular/router": "^22.2.0",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/build": "^22.2.1",
|
||||||
|
"@angular/cli": "^22.2.1",
|
||||||
|
"@angular/compiler-cli": "^22.2.0",
|
||||||
|
"jsdom": "^30.0.0",
|
||||||
|
"ng-openapi-gen": "^1.1.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vitest": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the ApiConfiguration configuration object with a given root URL.
|
||||||
|
*/
|
||||||
|
export function provideApiConfiguration(rootUrl: string) {
|
||||||
|
var config = new ApiConfiguration();
|
||||||
|
config.rootUrl = rootUrl;
|
||||||
|
return {
|
||||||
|
provide: ApiConfiguration,
|
||||||
|
useValue: config
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global configuration
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class ApiConfiguration {
|
||||||
|
rootUrl: string = '';
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable, firstValueFrom } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { ApiConfiguration } from './api-configuration';
|
||||||
|
import { StrictHttpResponse } from './strict-http-response';
|
||||||
|
|
||||||
|
export type ApiFnOptional<P, R> = (http: HttpClient, rootUrl: string, params?: P, context?: HttpContext) => Observable<StrictHttpResponse<R>>;
|
||||||
|
export type ApiFnRequired<P, R> = (http: HttpClient, rootUrl: string, params: P, context?: HttpContext) => Observable<StrictHttpResponse<R>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper service to call API functions directly
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class Api {
|
||||||
|
constructor(
|
||||||
|
private config: ApiConfiguration,
|
||||||
|
private http: HttpClient
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private _rootUrl?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the root url for API operations. If not set directly here,
|
||||||
|
* will fallback to `ApiConfiguration.rootUrl`.
|
||||||
|
*/
|
||||||
|
get rootUrl(): string {
|
||||||
|
return this._rootUrl || this.config.rootUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the root URL for API operations
|
||||||
|
*/
|
||||||
|
set rootUrl(rootUrl: string) {
|
||||||
|
this._rootUrl = rootUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes an API call, returning the response body only
|
||||||
|
*/
|
||||||
|
invoke<P, R>(fn: ApiFnRequired<P, R>, params: P, context?: HttpContext): Promise<R>;
|
||||||
|
invoke<P, R>(fn: ApiFnOptional<P, R>, params?: P, context?: HttpContext): Promise<R>;
|
||||||
|
async invoke<P, R>(fn: ApiFnRequired<P, R> | ApiFnOptional<P, R>, params: P, context?: HttpContext): Promise<R> {
|
||||||
|
const resp = this.invoke$Response(fn, params, context);
|
||||||
|
return (await resp).body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes an API call, returning the entire response
|
||||||
|
*/
|
||||||
|
invoke$Response<P, R>(fn: ApiFnRequired<P, R>, params: P, context?: HttpContext): Promise<StrictHttpResponse<R>>;
|
||||||
|
invoke$Response<P, R>(fn: ApiFnOptional<P, R>, params?: P, context?: HttpContext): Promise<StrictHttpResponse<R>>;
|
||||||
|
invoke$Response<P, R>(fn: ApiFnRequired<P, R> | ApiFnOptional<P, R>, params: P, context?: HttpContext): Promise<StrictHttpResponse<R>> {
|
||||||
|
const obs = fn(this.http, this.rootUrl, params, context)
|
||||||
|
.pipe(
|
||||||
|
filter(r => r instanceof HttpResponse),
|
||||||
|
map(r => r as StrictHttpResponse<R>));
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { ApiConfiguration } from './api-configuration';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for services
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BaseService {
|
||||||
|
constructor(
|
||||||
|
protected config: ApiConfiguration,
|
||||||
|
protected http: HttpClient
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private _rootUrl?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the root url for all operations in this service. If not set directly in this
|
||||||
|
* service, will fallback to `ApiConfiguration.rootUrl`.
|
||||||
|
*/
|
||||||
|
get rootUrl(): string {
|
||||||
|
return this._rootUrl || this.config.rootUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the root URL for API operations in this service.
|
||||||
|
*/
|
||||||
|
set rootUrl(rootUrl: string) {
|
||||||
|
this._rootUrl = rootUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
import { User } from '../../models/user';
|
||||||
|
|
||||||
|
export interface GetMe$Params {
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMe(http: HttpClient, rootUrl: string, params?: GetMe$Params, context?: HttpContext): Observable<StrictHttpResponse<User>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, getMe.PATH, 'get');
|
||||||
|
if (params) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'json', accept: 'application/json', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return r as StrictHttpResponse<User>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMe.PATH = '/api/auth/me';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
import { AuthResponse } from '../../models/auth-response';
|
||||||
|
import { LoginInput } from '../../models/login-input';
|
||||||
|
|
||||||
|
export interface Login$Params {
|
||||||
|
body: LoginInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export function login(http: HttpClient, rootUrl: string, params: Login$Params, context?: HttpContext): Observable<StrictHttpResponse<AuthResponse>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, login.PATH, 'post');
|
||||||
|
if (params) {
|
||||||
|
rb.body(params.body, 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'json', accept: 'application/json', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return r as StrictHttpResponse<AuthResponse>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
login.PATH = '/api/auth/login';
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
|
||||||
|
export interface Logout$Params {
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(http: HttpClient, rootUrl: string, params?: Logout$Params, context?: HttpContext): Observable<StrictHttpResponse<void>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, logout.PATH, 'post');
|
||||||
|
if (params) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'text', accept: '*/*', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return (r as HttpResponse<any>).clone({ body: undefined }) as StrictHttpResponse<void>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout.PATH = '/api/auth/logout';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
import { AuthResponse } from '../../models/auth-response';
|
||||||
|
import { RegisterInput } from '../../models/register-input';
|
||||||
|
|
||||||
|
export interface Register$Params {
|
||||||
|
body: RegisterInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(http: HttpClient, rootUrl: string, params: Register$Params, context?: HttpContext): Observable<StrictHttpResponse<AuthResponse>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, register.PATH, 'post');
|
||||||
|
if (params) {
|
||||||
|
rb.body(params.body, 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'json', accept: 'application/json', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return r as StrictHttpResponse<AuthResponse>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
register.PATH = '/api/auth/register';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
import { Game } from '../../models/game';
|
||||||
|
import { GameInput } from '../../models/game-input';
|
||||||
|
|
||||||
|
export interface CreateGame$Params {
|
||||||
|
body: GameInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGame(http: HttpClient, rootUrl: string, params: CreateGame$Params, context?: HttpContext): Observable<StrictHttpResponse<Game>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, createGame.PATH, 'post');
|
||||||
|
if (params) {
|
||||||
|
rb.body(params.body, 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'json', accept: 'application/json', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return r as StrictHttpResponse<Game>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createGame.PATH = '/api/games';
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
|
||||||
|
export interface DeleteGame$Params {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteGame(http: HttpClient, rootUrl: string, params: DeleteGame$Params, context?: HttpContext): Observable<StrictHttpResponse<void>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, deleteGame.PATH, 'delete');
|
||||||
|
if (params) {
|
||||||
|
rb.path('id', params.id, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'text', accept: '*/*', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return (r as HttpResponse<any>).clone({ body: undefined }) as StrictHttpResponse<void>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGame.PATH = '/api/games/{id}';
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
import { Game } from '../../models/game';
|
||||||
|
|
||||||
|
export interface GetGame$Params {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGame(http: HttpClient, rootUrl: string, params: GetGame$Params, context?: HttpContext): Observable<StrictHttpResponse<Game>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, getGame.PATH, 'get');
|
||||||
|
if (params) {
|
||||||
|
rb.path('id', params.id, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'json', accept: 'application/json', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return r as StrictHttpResponse<Game>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getGame.PATH = '/api/games/{id}';
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
import { Game } from '../../models/game';
|
||||||
|
|
||||||
|
export interface ListGames$Params {
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listGames(http: HttpClient, rootUrl: string, params?: ListGames$Params, context?: HttpContext): Observable<StrictHttpResponse<(Array<Game> | null)>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, listGames.PATH, 'get');
|
||||||
|
if (params) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'json', accept: 'application/json', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return r as StrictHttpResponse<(Array<Game> | null)>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
listGames.PATH = '/api/games';
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
import { Game } from '../../models/game';
|
||||||
|
import { GameInput } from '../../models/game-input';
|
||||||
|
|
||||||
|
export interface UpdateGame$Params {
|
||||||
|
id: string;
|
||||||
|
body: GameInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGame(http: HttpClient, rootUrl: string, params: UpdateGame$Params, context?: HttpContext): Observable<StrictHttpResponse<Game>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, updateGame.PATH, 'put');
|
||||||
|
if (params) {
|
||||||
|
rb.path('id', params.id, {});
|
||||||
|
rb.body(params.body, 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'json', accept: 'application/json', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return r as StrictHttpResponse<Game>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGame.PATH = '/api/games/{id}';
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
import { GetHelloResponse } from '../../models/get-hello-response';
|
||||||
|
|
||||||
|
export interface GetHello$Params {
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHello(http: HttpClient, rootUrl: string, params?: GetHello$Params, context?: HttpContext): Observable<StrictHttpResponse<GetHelloResponse>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, getHello.PATH, 'get');
|
||||||
|
if (params) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'json', accept: 'application/json', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return r as StrictHttpResponse<GetHelloResponse>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getHello.PATH = '/hello';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
import { Platform } from '../../models/platform';
|
||||||
|
import { PlatformInput } from '../../models/platform-input';
|
||||||
|
|
||||||
|
export interface CreatePlatform$Params {
|
||||||
|
body: PlatformInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlatform(http: HttpClient, rootUrl: string, params: CreatePlatform$Params, context?: HttpContext): Observable<StrictHttpResponse<Platform>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, createPlatform.PATH, 'post');
|
||||||
|
if (params) {
|
||||||
|
rb.body(params.body, 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'json', accept: 'application/json', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return r as StrictHttpResponse<Platform>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createPlatform.PATH = '/api/platforms';
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
|
||||||
|
export interface DeletePlatform$Params {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePlatform(http: HttpClient, rootUrl: string, params: DeletePlatform$Params, context?: HttpContext): Observable<StrictHttpResponse<void>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, deletePlatform.PATH, 'delete');
|
||||||
|
if (params) {
|
||||||
|
rb.path('id', params.id, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'text', accept: '*/*', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return (r as HttpResponse<any>).clone({ body: undefined }) as StrictHttpResponse<void>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePlatform.PATH = '/api/platforms/{id}';
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
import { Platform } from '../../models/platform';
|
||||||
|
|
||||||
|
export interface GetPlatform$Params {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlatform(http: HttpClient, rootUrl: string, params: GetPlatform$Params, context?: HttpContext): Observable<StrictHttpResponse<Platform>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, getPlatform.PATH, 'get');
|
||||||
|
if (params) {
|
||||||
|
rb.path('id', params.id, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'json', accept: 'application/json', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return r as StrictHttpResponse<Platform>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlatform.PATH = '/api/platforms/{id}';
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
import { Platform } from '../../models/platform';
|
||||||
|
|
||||||
|
export interface ListPlatforms$Params {
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPlatforms(http: HttpClient, rootUrl: string, params?: ListPlatforms$Params, context?: HttpContext): Observable<StrictHttpResponse<(Array<Platform> | null)>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, listPlatforms.PATH, 'get');
|
||||||
|
if (params) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'json', accept: 'application/json', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return r as StrictHttpResponse<(Array<Platform> | null)>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
listPlatforms.PATH = '/api/platforms';
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
|
import { StrictHttpResponse } from '../../strict-http-response';
|
||||||
|
import { RequestBuilder } from '../../request-builder';
|
||||||
|
|
||||||
|
import { Platform } from '../../models/platform';
|
||||||
|
import { PlatformInput } from '../../models/platform-input';
|
||||||
|
|
||||||
|
export interface UpdatePlatform$Params {
|
||||||
|
id: string;
|
||||||
|
body: PlatformInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePlatform(http: HttpClient, rootUrl: string, params: UpdatePlatform$Params, context?: HttpContext): Observable<StrictHttpResponse<Platform>> {
|
||||||
|
const rb = new RequestBuilder(rootUrl, updatePlatform.PATH, 'put');
|
||||||
|
if (params) {
|
||||||
|
rb.path('id', params.id, {});
|
||||||
|
rb.body(params.body, 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.request(
|
||||||
|
rb.build({ responseType: 'json', accept: 'application/json', context })
|
||||||
|
).pipe(
|
||||||
|
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
|
||||||
|
map((r: HttpResponse<any>) => {
|
||||||
|
return r as StrictHttpResponse<Platform>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlatform.PATH = '/api/platforms/{id}';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
export type { Login$Params as Login$Params } from './fn/auth/login';
|
||||||
|
export { login as login } from './fn/auth/login';
|
||||||
|
export type { Logout$Params as Logout$Params } from './fn/auth/logout';
|
||||||
|
export { logout as logout } from './fn/auth/logout';
|
||||||
|
export type { GetMe$Params as GetMe$Params } from './fn/auth/get-me';
|
||||||
|
export { getMe as getMe } from './fn/auth/get-me';
|
||||||
|
export type { Register$Params as Register$Params } from './fn/auth/register';
|
||||||
|
export { register as register } from './fn/auth/register';
|
||||||
|
export type { ListGames$Params as ListGames$Params } from './fn/games/list-games';
|
||||||
|
export { listGames as listGames } from './fn/games/list-games';
|
||||||
|
export type { CreateGame$Params as CreateGame$Params } from './fn/games/create-game';
|
||||||
|
export { createGame as createGame } from './fn/games/create-game';
|
||||||
|
export type { GetGame$Params as GetGame$Params } from './fn/games/get-game';
|
||||||
|
export { getGame as getGame } from './fn/games/get-game';
|
||||||
|
export type { UpdateGame$Params as UpdateGame$Params } from './fn/games/update-game';
|
||||||
|
export { updateGame as updateGame } from './fn/games/update-game';
|
||||||
|
export type { DeleteGame$Params as DeleteGame$Params } from './fn/games/delete-game';
|
||||||
|
export { deleteGame as deleteGame } from './fn/games/delete-game';
|
||||||
|
export type { ListPlatforms$Params as ListPlatforms$Params } from './fn/platforms/list-platforms';
|
||||||
|
export { listPlatforms as listPlatforms } from './fn/platforms/list-platforms';
|
||||||
|
export type { CreatePlatform$Params as CreatePlatform$Params } from './fn/platforms/create-platform';
|
||||||
|
export { createPlatform as createPlatform } from './fn/platforms/create-platform';
|
||||||
|
export type { GetPlatform$Params as GetPlatform$Params } from './fn/platforms/get-platform';
|
||||||
|
export { getPlatform as getPlatform } from './fn/platforms/get-platform';
|
||||||
|
export type { UpdatePlatform$Params as UpdatePlatform$Params } from './fn/platforms/update-platform';
|
||||||
|
export { updatePlatform as updatePlatform } from './fn/platforms/update-platform';
|
||||||
|
export type { DeletePlatform$Params as DeletePlatform$Params } from './fn/platforms/delete-platform';
|
||||||
|
export { deletePlatform as deletePlatform } from './fn/platforms/delete-platform';
|
||||||
|
export type { GetHello$Params as GetHello$Params } from './fn/misc/get-hello';
|
||||||
|
export { getHello as getHello } from './fn/misc/get-hello';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
export type { AuthResponse } from './models/auth-response';
|
||||||
|
export type { ErrorDetail } from './models/error-detail';
|
||||||
|
export type { ErrorModel } from './models/error-model';
|
||||||
|
export type { Game } from './models/game';
|
||||||
|
export type { GameInput } from './models/game-input';
|
||||||
|
export type { GetHelloResponse } from './models/get-hello-response';
|
||||||
|
export type { LoginInput } from './models/login-input';
|
||||||
|
export type { Platform } from './models/platform';
|
||||||
|
export type { PlatformInput } from './models/platform-input';
|
||||||
|
export type { RegisterInput } from './models/register-input';
|
||||||
|
export type { User } from './models/user';
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { User } from '../models/user';
|
||||||
|
export interface AuthResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL to the JSON Schema for this object.
|
||||||
|
*/
|
||||||
|
'$schema'?: string;
|
||||||
|
token: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
export interface ErrorDetail {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'
|
||||||
|
*/
|
||||||
|
location?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error message text
|
||||||
|
*/
|
||||||
|
message?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value at the given location
|
||||||
|
*/
|
||||||
|
value?: any;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { ErrorDetail } from '../models/error-detail';
|
||||||
|
export interface ErrorModel {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL to the JSON Schema for this object.
|
||||||
|
*/
|
||||||
|
'$schema'?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A human-readable explanation specific to this occurrence of the problem.
|
||||||
|
*/
|
||||||
|
detail?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional list of individual error details
|
||||||
|
*/
|
||||||
|
errors?: (Array<ErrorDetail> | null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URI reference that identifies the specific occurrence of the problem.
|
||||||
|
*/
|
||||||
|
instance?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP status code
|
||||||
|
*/
|
||||||
|
status?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short, human-readable summary of the problem type. This value should not change between occurrences of the error.
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URI reference to human-readable documentation for the error.
|
||||||
|
*/
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
export interface GameInput {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL to the JSON Schema for this object.
|
||||||
|
*/
|
||||||
|
'$schema'?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YYYY-MM-DD (optional)
|
||||||
|
*/
|
||||||
|
finished?: string;
|
||||||
|
name: string;
|
||||||
|
platform_id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
release_date: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
export interface Game {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL to the JSON Schema for this object.
|
||||||
|
*/
|
||||||
|
'$schema'?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YYYY-MM-DD (optional)
|
||||||
|
*/
|
||||||
|
finished?: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
platform_id: string;
|
||||||
|
platform_name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
release_date: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
export interface GetHelloResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL to the JSON Schema for this object.
|
||||||
|
*/
|
||||||
|
'$schema'?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
export interface LoginInput {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL to the JSON Schema for this object.
|
||||||
|
*/
|
||||||
|
'$schema'?: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
export interface PlatformInput {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL to the JSON Schema for this object.
|
||||||
|
*/
|
||||||
|
'$schema'?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name of the platform
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
export interface Platform {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL to the JSON Schema for this object.
|
||||||
|
*/
|
||||||
|
'$schema'?: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
export interface RegisterInput {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL to the JSON Schema for this object.
|
||||||
|
*/
|
||||||
|
'$schema'?: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL to the JSON Schema for this object.
|
||||||
|
*/
|
||||||
|
'$schema'?: string;
|
||||||
|
bio?: string;
|
||||||
|
email: string;
|
||||||
|
id: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpRequest, HttpParameterCodec, HttpParams, HttpHeaders, HttpContext } from '@angular/common/http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom parameter codec to correctly handle the plus sign in parameter
|
||||||
|
* values. See https://github.com/angular/angular/issues/18261
|
||||||
|
*/
|
||||||
|
class ParameterCodec implements HttpParameterCodec {
|
||||||
|
encodeKey(key: string): string {
|
||||||
|
return encodeURIComponent(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
encodeValue(value: string): string {
|
||||||
|
return encodeURIComponent(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
decodeKey(key: string): string {
|
||||||
|
return decodeURIComponent(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
decodeValue(value: string): string {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ParameterCodecInstance = new ParameterCodec();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the options for appending a parameter
|
||||||
|
*/
|
||||||
|
interface ParameterOptions {
|
||||||
|
style?: string;
|
||||||
|
explode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for a parameter
|
||||||
|
*/
|
||||||
|
abstract class Parameter {
|
||||||
|
constructor(public name: string, public value: any, public options: ParameterOptions, defaultStyle: string, defaultExplode: boolean) {
|
||||||
|
this.options = options || {};
|
||||||
|
if (this.options.style === null || this.options.style === undefined) {
|
||||||
|
this.options.style = defaultStyle;
|
||||||
|
}
|
||||||
|
if (this.options.explode === null || this.options.explode === undefined) {
|
||||||
|
this.options.explode = defaultExplode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serializeValue(value: any, separator = ','): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
} else if (value instanceof Array) {
|
||||||
|
return value.map(v => this.serializeValue(v).split(separator).join(encodeURIComponent(separator))).join(separator);
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
const array: string[] = [];
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
let propVal = value[key];
|
||||||
|
if (propVal !== null && propVal !== undefined) {
|
||||||
|
propVal = this.serializeValue(propVal).split(separator).join(encodeURIComponent(separator));
|
||||||
|
if (this.options.explode) {
|
||||||
|
array.push(`${key}=${propVal}`);
|
||||||
|
} else {
|
||||||
|
array.push(key);
|
||||||
|
array.push(propVal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array.join(separator);
|
||||||
|
} else {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parameter in the operation path
|
||||||
|
*/
|
||||||
|
class PathParameter extends Parameter {
|
||||||
|
constructor(name: string, value: any, options: ParameterOptions) {
|
||||||
|
super(name, value, options, 'simple', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
append(path: string): string {
|
||||||
|
let value = this.value;
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
value = '';
|
||||||
|
}
|
||||||
|
let prefix = this.options.style === 'label' ? '.' : '';
|
||||||
|
let separator = this.options.explode ? prefix === '' ? ',' : prefix : ',';
|
||||||
|
let alreadySerialized = false;
|
||||||
|
if (this.options.style === 'matrix') {
|
||||||
|
// The parameter name is just used as prefix, except in some cases...
|
||||||
|
prefix = `;${this.name}=`;
|
||||||
|
if (this.options.explode && typeof value === 'object') {
|
||||||
|
prefix = ';';
|
||||||
|
if (value instanceof Array) {
|
||||||
|
// For arrays we have to repeat the name for each element
|
||||||
|
value = value.map(v => `${this.name}=${this.serializeValue(v, ';')}`);
|
||||||
|
value = value.join(';');
|
||||||
|
alreadySerialized = true;
|
||||||
|
} else {
|
||||||
|
// For objects we have to put each the key / value pairs
|
||||||
|
value = this.serializeValue(value, ';');
|
||||||
|
alreadySerialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value = prefix + (alreadySerialized ? value : this.serializeValue(value, separator));
|
||||||
|
// Replace both the plain variable and the corresponding variant taking in the prefix and explode into account
|
||||||
|
path = path.replace(`{${this.name}}`, value);
|
||||||
|
path = path.replace(`{${prefix}${this.name}${this.options.explode ? '*' : ''}}`, value);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
serializeValue(value: any, separator = ','): string {
|
||||||
|
var result = typeof value === 'string' ? encodeURIComponent(value) : super.serializeValue(value, separator);
|
||||||
|
result = result.replace(/%3D/g, '=');
|
||||||
|
result = result.replace(/%3B/g, ';');
|
||||||
|
result = result.replace(/%2C/g, ',');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parameter in the query
|
||||||
|
*/
|
||||||
|
class QueryParameter extends Parameter {
|
||||||
|
constructor(name: string, value: any, options: ParameterOptions) {
|
||||||
|
super(name, value, options, 'form', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
append(params: HttpParams): HttpParams {
|
||||||
|
if (this.value instanceof Array) {
|
||||||
|
// Array serialization
|
||||||
|
if (this.options.explode) {
|
||||||
|
for (const v of this.value) {
|
||||||
|
params = params.append(this.name, this.serializeValue(v));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const separator = this.options.style === 'spaceDelimited'
|
||||||
|
? ' ' : this.options.style === 'pipeDelimited'
|
||||||
|
? '|' : ',';
|
||||||
|
return params.append(this.name, this.serializeValue(this.value, separator));
|
||||||
|
}
|
||||||
|
} else if (this.value !== null && typeof this.value === 'object') {
|
||||||
|
// Object serialization
|
||||||
|
if (this.options.style === 'deepObject') {
|
||||||
|
// Append a parameter for each key, in the form `name[key]`
|
||||||
|
for (const key of Object.keys(this.value)) {
|
||||||
|
const propVal = this.value[key];
|
||||||
|
if (propVal !== null && propVal !== undefined) {
|
||||||
|
params = params.append(`${this.name}[${key}]`, this.serializeValue(propVal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.options.explode) {
|
||||||
|
// Append a parameter for each key without using the parameter name
|
||||||
|
for (const key of Object.keys(this.value)) {
|
||||||
|
const propVal = this.value[key];
|
||||||
|
if (propVal !== null && propVal !== undefined) {
|
||||||
|
params = params.append(key, this.serializeValue(propVal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Append a single parameter whose values are a comma-separated list of key,value,key,value...
|
||||||
|
const array: any[] = [];
|
||||||
|
for (const key of Object.keys(this.value)) {
|
||||||
|
const propVal = this.value[key];
|
||||||
|
if (propVal !== null && propVal !== undefined) {
|
||||||
|
array.push(key);
|
||||||
|
array.push(propVal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params = params.append(this.name, this.serializeValue(array));
|
||||||
|
}
|
||||||
|
} else if (this.value !== null && this.value !== undefined) {
|
||||||
|
// Plain value
|
||||||
|
params = params.append(this.name, this.serializeValue(this.value));
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parameter in the HTTP request header
|
||||||
|
*/
|
||||||
|
class HeaderParameter extends Parameter {
|
||||||
|
constructor(name: string, value: any, options: ParameterOptions) {
|
||||||
|
super(name, value, options, 'simple', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
append(headers: HttpHeaders): HttpHeaders {
|
||||||
|
if (this.value !== null && this.value !== undefined) {
|
||||||
|
if (this.value instanceof Array) {
|
||||||
|
for (const v of this.value) {
|
||||||
|
headers = headers.append(this.name, this.serializeValue(v));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
headers = headers.append(this.name, this.serializeValue(this.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to build http requests from parameters
|
||||||
|
*/
|
||||||
|
export class RequestBuilder {
|
||||||
|
|
||||||
|
private _path = new Map<string, PathParameter>();
|
||||||
|
private _query = new Map<string, QueryParameter>();
|
||||||
|
private _header = new Map<string, HeaderParameter>();
|
||||||
|
_bodyContent: any | null;
|
||||||
|
_bodyContentType?: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public rootUrl: string,
|
||||||
|
public operationPath: string,
|
||||||
|
public method: string) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a path parameter
|
||||||
|
*/
|
||||||
|
path(name: string, value: any, options?: ParameterOptions): void {
|
||||||
|
this._path.set(name, new PathParameter(name, value, options || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a query parameter
|
||||||
|
*/
|
||||||
|
query(name: string, value: any, options?: ParameterOptions): void {
|
||||||
|
this._query.set(name, new QueryParameter(name, value, options || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a header parameter
|
||||||
|
*/
|
||||||
|
header(name: string, value: any, options?: ParameterOptions): void {
|
||||||
|
this._header.set(name, new HeaderParameter(name, value, options || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the body content, along with the content type
|
||||||
|
*/
|
||||||
|
body(value: any, contentType = 'application/json'): void {
|
||||||
|
if (value instanceof Blob) {
|
||||||
|
this._bodyContentType = value.type;
|
||||||
|
} else {
|
||||||
|
this._bodyContentType = contentType;
|
||||||
|
}
|
||||||
|
if (this._bodyContentType === 'application/x-www-form-urlencoded' && value !== null && typeof value === 'object') {
|
||||||
|
// Handle URL-encoded data
|
||||||
|
const pairs: Array<[string, string]> = [];
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
let val = value[key];
|
||||||
|
if (!(val instanceof Array)) {
|
||||||
|
val = [val];
|
||||||
|
}
|
||||||
|
for (const v of val) {
|
||||||
|
const formValue = this.formDataValue(v);
|
||||||
|
if (formValue !== null) {
|
||||||
|
pairs.push([key, formValue]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._bodyContent = pairs.map(p => `${encodeURIComponent(p[0])}=${encodeURIComponent(p[1])}`).join('&');
|
||||||
|
} else if (this._bodyContentType === 'multipart/form-data') {
|
||||||
|
// Handle multipart form data
|
||||||
|
const formData = new FormData();
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
const val = value[key];
|
||||||
|
if (val instanceof Array) {
|
||||||
|
for (const v of val) {
|
||||||
|
const toAppend = this.formDataValue(v);
|
||||||
|
if (toAppend !== null) {
|
||||||
|
formData.append(key, toAppend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const toAppend = this.formDataValue(val);
|
||||||
|
if (toAppend !== null) {
|
||||||
|
formData.set(key, toAppend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._bodyContent = formData;
|
||||||
|
} else {
|
||||||
|
// The body is the plain content
|
||||||
|
this._bodyContent = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formDataValue(value: any): any {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value instanceof Blob) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return new Blob([JSON.stringify(value)], {type: 'application/json'})
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the request with the current set parameters
|
||||||
|
*/
|
||||||
|
build<T = any>(options?: {
|
||||||
|
/** Which content types to accept */
|
||||||
|
accept?: string;
|
||||||
|
|
||||||
|
/** The expected response type */
|
||||||
|
responseType?: 'json' | 'text' | 'blob' | 'arraybuffer';
|
||||||
|
|
||||||
|
/** Whether to report progress on uploads / downloads */
|
||||||
|
reportProgress?: boolean;
|
||||||
|
|
||||||
|
/** Allow passing HttpContext for HttpClient */
|
||||||
|
context?: HttpContext;
|
||||||
|
}): HttpRequest<T> {
|
||||||
|
|
||||||
|
options = options || {};
|
||||||
|
|
||||||
|
// Path parameters
|
||||||
|
let path = this.operationPath;
|
||||||
|
for (const pathParam of this._path.values()) {
|
||||||
|
path = pathParam.append(path);
|
||||||
|
}
|
||||||
|
const url = this.rootUrl + path;
|
||||||
|
|
||||||
|
// Query parameters
|
||||||
|
let httpParams = new HttpParams({
|
||||||
|
encoder: ParameterCodecInstance
|
||||||
|
});
|
||||||
|
for (const queryParam of this._query.values()) {
|
||||||
|
httpParams = queryParam.append(httpParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header parameters
|
||||||
|
let httpHeaders = new HttpHeaders();
|
||||||
|
if (options.accept) {
|
||||||
|
httpHeaders = httpHeaders.append('Accept', options.accept);
|
||||||
|
}
|
||||||
|
for (const headerParam of this._header.values()) {
|
||||||
|
httpHeaders = headerParam.append(httpHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request content headers
|
||||||
|
if (this._bodyContentType && !(this._bodyContent instanceof FormData)) {
|
||||||
|
httpHeaders = httpHeaders.set('Content-Type', this._bodyContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the request
|
||||||
|
return new HttpRequest<T>(this.method.toUpperCase(), url, this._bodyContent, {
|
||||||
|
params: httpParams,
|
||||||
|
headers: httpHeaders,
|
||||||
|
responseType: options.responseType,
|
||||||
|
reportProgress: options.reportProgress,
|
||||||
|
context: options.context
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
export { AuthService } from './services/auth.service';
|
||||||
|
export { GamesService } from './services/games.service';
|
||||||
|
export { PlatformsService } from './services/platforms.service';
|
||||||
|
export { MiscService } from './services/misc.service';
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext } from '@angular/common/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { BaseService } from '../base-service';
|
||||||
|
import { ApiConfiguration } from '../api-configuration';
|
||||||
|
import { StrictHttpResponse } from '../strict-http-response';
|
||||||
|
|
||||||
|
import { AuthResponse } from '../models/auth-response';
|
||||||
|
import { getMe } from '../fn/auth/get-me';
|
||||||
|
import { GetMe$Params } from '../fn/auth/get-me';
|
||||||
|
import { login } from '../fn/auth/login';
|
||||||
|
import { Login$Params } from '../fn/auth/login';
|
||||||
|
import { logout } from '../fn/auth/logout';
|
||||||
|
import { Logout$Params } from '../fn/auth/logout';
|
||||||
|
import { register } from '../fn/auth/register';
|
||||||
|
import { Register$Params } from '../fn/auth/register';
|
||||||
|
import { User } from '../models/user';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AuthService extends BaseService {
|
||||||
|
constructor(config: ApiConfiguration, http: HttpClient) {
|
||||||
|
super(config, http);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `login()` */
|
||||||
|
static readonly LoginPath = '/api/auth/login';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log in to an existing account.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `login()` instead.
|
||||||
|
*
|
||||||
|
* This method sends `application/json` and handles request body of type `application/json`.
|
||||||
|
*/
|
||||||
|
login$Response(params: Login$Params, context?: HttpContext): Promise<StrictHttpResponse<AuthResponse>> {
|
||||||
|
const obs = login(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log in to an existing account.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `login$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method sends `application/json` and handles request body of type `application/json`.
|
||||||
|
*/
|
||||||
|
login(params: Login$Params, context?: HttpContext): Promise<AuthResponse> {
|
||||||
|
const resp = this.login$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<AuthResponse>): AuthResponse => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `logout()` */
|
||||||
|
static readonly LogoutPath = '/api/auth/logout';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log out of the current session.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `logout()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
logout$Response(params?: Logout$Params, context?: HttpContext): Promise<StrictHttpResponse<void>> {
|
||||||
|
const obs = logout(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log out of the current session.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `logout$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
logout(params?: Logout$Params, context?: HttpContext): Promise<void> {
|
||||||
|
const resp = this.logout$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<void>): void => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `getMe()` */
|
||||||
|
static readonly GetMePath = '/api/auth/me';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current user.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `getMe()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
getMe$Response(params?: GetMe$Params, context?: HttpContext): Promise<StrictHttpResponse<User>> {
|
||||||
|
const obs = getMe(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current user.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `getMe$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
getMe(params?: GetMe$Params, context?: HttpContext): Promise<User> {
|
||||||
|
const resp = this.getMe$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<User>): User => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `register()` */
|
||||||
|
static readonly RegisterPath = '/api/auth/register';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new account.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `register()` instead.
|
||||||
|
*
|
||||||
|
* This method sends `application/json` and handles request body of type `application/json`.
|
||||||
|
*/
|
||||||
|
register$Response(params: Register$Params, context?: HttpContext): Promise<StrictHttpResponse<AuthResponse>> {
|
||||||
|
const obs = register(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new account.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `register$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method sends `application/json` and handles request body of type `application/json`.
|
||||||
|
*/
|
||||||
|
register(params: Register$Params, context?: HttpContext): Promise<AuthResponse> {
|
||||||
|
const resp = this.register$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<AuthResponse>): AuthResponse => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext } from '@angular/common/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { BaseService } from '../base-service';
|
||||||
|
import { ApiConfiguration } from '../api-configuration';
|
||||||
|
import { StrictHttpResponse } from '../strict-http-response';
|
||||||
|
|
||||||
|
import { createGame } from '../fn/games/create-game';
|
||||||
|
import { CreateGame$Params } from '../fn/games/create-game';
|
||||||
|
import { deleteGame } from '../fn/games/delete-game';
|
||||||
|
import { DeleteGame$Params } from '../fn/games/delete-game';
|
||||||
|
import { Game } from '../models/game';
|
||||||
|
import { getGame } from '../fn/games/get-game';
|
||||||
|
import { GetGame$Params } from '../fn/games/get-game';
|
||||||
|
import { listGames } from '../fn/games/list-games';
|
||||||
|
import { ListGames$Params } from '../fn/games/list-games';
|
||||||
|
import { updateGame } from '../fn/games/update-game';
|
||||||
|
import { UpdateGame$Params } from '../fn/games/update-game';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class GamesService extends BaseService {
|
||||||
|
constructor(config: ApiConfiguration, http: HttpClient) {
|
||||||
|
super(config, http);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `listGames()` */
|
||||||
|
static readonly ListGamesPath = '/api/games';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Games.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `listGames()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
listGames$Response(params?: ListGames$Params, context?: HttpContext): Promise<StrictHttpResponse<(Array<Game> | null)>> {
|
||||||
|
const obs = listGames(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Games.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `listGames$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
listGames(params?: ListGames$Params, context?: HttpContext): Promise<(Array<Game> | null)> {
|
||||||
|
const resp = this.listGames$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<(Array<Game> | null)>): (Array<Game> | null) => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `createGame()` */
|
||||||
|
static readonly CreateGamePath = '/api/games';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Game.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `createGame()` instead.
|
||||||
|
*
|
||||||
|
* This method sends `application/json` and handles request body of type `application/json`.
|
||||||
|
*/
|
||||||
|
createGame$Response(params: CreateGame$Params, context?: HttpContext): Promise<StrictHttpResponse<Game>> {
|
||||||
|
const obs = createGame(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Game.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `createGame$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method sends `application/json` and handles request body of type `application/json`.
|
||||||
|
*/
|
||||||
|
createGame(params: CreateGame$Params, context?: HttpContext): Promise<Game> {
|
||||||
|
const resp = this.createGame$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<Game>): Game => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `getGame()` */
|
||||||
|
static readonly GetGamePath = '/api/games/{id}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Game.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `getGame()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
getGame$Response(params: GetGame$Params, context?: HttpContext): Promise<StrictHttpResponse<Game>> {
|
||||||
|
const obs = getGame(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Game.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `getGame$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
getGame(params: GetGame$Params, context?: HttpContext): Promise<Game> {
|
||||||
|
const resp = this.getGame$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<Game>): Game => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `updateGame()` */
|
||||||
|
static readonly UpdateGamePath = '/api/games/{id}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Game.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `updateGame()` instead.
|
||||||
|
*
|
||||||
|
* This method sends `application/json` and handles request body of type `application/json`.
|
||||||
|
*/
|
||||||
|
updateGame$Response(params: UpdateGame$Params, context?: HttpContext): Promise<StrictHttpResponse<Game>> {
|
||||||
|
const obs = updateGame(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Game.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `updateGame$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method sends `application/json` and handles request body of type `application/json`.
|
||||||
|
*/
|
||||||
|
updateGame(params: UpdateGame$Params, context?: HttpContext): Promise<Game> {
|
||||||
|
const resp = this.updateGame$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<Game>): Game => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `deleteGame()` */
|
||||||
|
static readonly DeleteGamePath = '/api/games/{id}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Game.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `deleteGame()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
deleteGame$Response(params: DeleteGame$Params, context?: HttpContext): Promise<StrictHttpResponse<void>> {
|
||||||
|
const obs = deleteGame(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Game.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `deleteGame$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
deleteGame(params: DeleteGame$Params, context?: HttpContext): Promise<void> {
|
||||||
|
const resp = this.deleteGame$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<void>): void => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext } from '@angular/common/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { BaseService } from '../base-service';
|
||||||
|
import { ApiConfiguration } from '../api-configuration';
|
||||||
|
import { StrictHttpResponse } from '../strict-http-response';
|
||||||
|
|
||||||
|
import { GetHelloResponse } from '../models/get-hello-response';
|
||||||
|
import { getHello } from '../fn/misc/get-hello';
|
||||||
|
import { GetHello$Params } from '../fn/misc/get-hello';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MiscService extends BaseService {
|
||||||
|
constructor(config: ApiConfiguration, http: HttpClient) {
|
||||||
|
super(config, http);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `getHello()` */
|
||||||
|
static readonly GetHelloPath = '/hello';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Say Hello.
|
||||||
|
*
|
||||||
|
* Returns a friendly hello message.
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `getHello()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
getHello$Response(params?: GetHello$Params, context?: HttpContext): Promise<StrictHttpResponse<GetHelloResponse>> {
|
||||||
|
const obs = getHello(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Say Hello.
|
||||||
|
*
|
||||||
|
* Returns a friendly hello message.
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `getHello$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
getHello(params?: GetHello$Params, context?: HttpContext): Promise<GetHelloResponse> {
|
||||||
|
const resp = this.getHello$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<GetHelloResponse>): GetHelloResponse => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpClient, HttpContext } from '@angular/common/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { BaseService } from '../base-service';
|
||||||
|
import { ApiConfiguration } from '../api-configuration';
|
||||||
|
import { StrictHttpResponse } from '../strict-http-response';
|
||||||
|
|
||||||
|
import { createPlatform } from '../fn/platforms/create-platform';
|
||||||
|
import { CreatePlatform$Params } from '../fn/platforms/create-platform';
|
||||||
|
import { deletePlatform } from '../fn/platforms/delete-platform';
|
||||||
|
import { DeletePlatform$Params } from '../fn/platforms/delete-platform';
|
||||||
|
import { getPlatform } from '../fn/platforms/get-platform';
|
||||||
|
import { GetPlatform$Params } from '../fn/platforms/get-platform';
|
||||||
|
import { listPlatforms } from '../fn/platforms/list-platforms';
|
||||||
|
import { ListPlatforms$Params } from '../fn/platforms/list-platforms';
|
||||||
|
import { Platform } from '../models/platform';
|
||||||
|
import { updatePlatform } from '../fn/platforms/update-platform';
|
||||||
|
import { UpdatePlatform$Params } from '../fn/platforms/update-platform';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class PlatformsService extends BaseService {
|
||||||
|
constructor(config: ApiConfiguration, http: HttpClient) {
|
||||||
|
super(config, http);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `listPlatforms()` */
|
||||||
|
static readonly ListPlatformsPath = '/api/platforms';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Platforms.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `listPlatforms()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
listPlatforms$Response(params?: ListPlatforms$Params, context?: HttpContext): Promise<StrictHttpResponse<(Array<Platform> | null)>> {
|
||||||
|
const obs = listPlatforms(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Platforms.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `listPlatforms$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
listPlatforms(params?: ListPlatforms$Params, context?: HttpContext): Promise<(Array<Platform> | null)> {
|
||||||
|
const resp = this.listPlatforms$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<(Array<Platform> | null)>): (Array<Platform> | null) => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `createPlatform()` */
|
||||||
|
static readonly CreatePlatformPath = '/api/platforms';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Platform.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `createPlatform()` instead.
|
||||||
|
*
|
||||||
|
* This method sends `application/json` and handles request body of type `application/json`.
|
||||||
|
*/
|
||||||
|
createPlatform$Response(params: CreatePlatform$Params, context?: HttpContext): Promise<StrictHttpResponse<Platform>> {
|
||||||
|
const obs = createPlatform(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Platform.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `createPlatform$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method sends `application/json` and handles request body of type `application/json`.
|
||||||
|
*/
|
||||||
|
createPlatform(params: CreatePlatform$Params, context?: HttpContext): Promise<Platform> {
|
||||||
|
const resp = this.createPlatform$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<Platform>): Platform => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `getPlatform()` */
|
||||||
|
static readonly GetPlatformPath = '/api/platforms/{id}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Platform.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `getPlatform()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
getPlatform$Response(params: GetPlatform$Params, context?: HttpContext): Promise<StrictHttpResponse<Platform>> {
|
||||||
|
const obs = getPlatform(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Platform.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `getPlatform$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
getPlatform(params: GetPlatform$Params, context?: HttpContext): Promise<Platform> {
|
||||||
|
const resp = this.getPlatform$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<Platform>): Platform => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `updatePlatform()` */
|
||||||
|
static readonly UpdatePlatformPath = '/api/platforms/{id}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Platform.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `updatePlatform()` instead.
|
||||||
|
*
|
||||||
|
* This method sends `application/json` and handles request body of type `application/json`.
|
||||||
|
*/
|
||||||
|
updatePlatform$Response(params: UpdatePlatform$Params, context?: HttpContext): Promise<StrictHttpResponse<Platform>> {
|
||||||
|
const obs = updatePlatform(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Platform.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `updatePlatform$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method sends `application/json` and handles request body of type `application/json`.
|
||||||
|
*/
|
||||||
|
updatePlatform(params: UpdatePlatform$Params, context?: HttpContext): Promise<Platform> {
|
||||||
|
const resp = this.updatePlatform$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<Platform>): Platform => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path part for operation `deletePlatform()` */
|
||||||
|
static readonly DeletePlatformPath = '/api/platforms/{id}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Platform.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access to the full `HttpResponse`, allowing access to response headers.
|
||||||
|
* To access only the response body, use `deletePlatform()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
deletePlatform$Response(params: DeletePlatform$Params, context?: HttpContext): Promise<StrictHttpResponse<void>> {
|
||||||
|
const obs = deletePlatform(this.http, this.rootUrl, params, context);
|
||||||
|
return firstValueFrom(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Platform.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method provides access only to the response body.
|
||||||
|
* To access the full response (for headers, for example), `deletePlatform$Response()` instead.
|
||||||
|
*
|
||||||
|
* This method doesn't expect any request body.
|
||||||
|
*/
|
||||||
|
deletePlatform(params: DeletePlatform$Params, context?: HttpContext): Promise<void> {
|
||||||
|
const resp = this.deletePlatform$Response(params, context);
|
||||||
|
return resp.then((r: StrictHttpResponse<void>): void => r.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Code generated by ng-openapi-gen DO NOT EDIT. */
|
||||||
|
|
||||||
|
import { HttpResponse } from '@angular/common/http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constrains the http response to not have the body defined as `T | null`, but `T` only.
|
||||||
|
*/
|
||||||
|
export type StrictHttpResponse<T> = HttpResponse<T> & {
|
||||||
|
readonly body: T;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
import { authInterceptor } from './core/auth-interceptor';
|
||||||
|
import { provideApiConfiguration } from './api/api-configuration';
|
||||||
|
|
||||||
|
// The API's base URL. There's no server-side config to read it from at
|
||||||
|
// runtime in this small base (a plain SPA build), so it's a build-time
|
||||||
|
// constant; swap to Angular's fileReplacements if a later build needs a
|
||||||
|
// different URL per environment.
|
||||||
|
const API_BASE_URL = 'http://localhost:8080';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideAnimationsAsync(),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
|
provideApiConfiguration(API_BASE_URL),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
import { authGuard } from './core/auth-guard';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{ path: '', pathMatch: 'full', redirectTo: 'games' },
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
loadComponent: () => import('./features/login/login').then((m) => m.Login),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'games',
|
||||||
|
loadComponent: () => import('./features/games/games').then((m) => m.Games),
|
||||||
|
canActivate: [authGuard],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [App],
|
||||||
|
providers: [provideRouter([])],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
expect(fixture.componentInstance).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user