From cbbb135a72039360ea9b1abb1ee05b7af6249fa4 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Thu, 9 Oct 2025 22:27:54 +0300 Subject: [PATCH 001/224] feat: first sql-scheme added --- modules/sql/scheme.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 modules/sql/scheme.sql diff --git a/modules/sql/scheme.sql b/modules/sql/scheme.sql new file mode 100644 index 0000000..8e10425 --- /dev/null +++ b/modules/sql/scheme.sql @@ -0,0 +1,10 @@ +CREATE TYPE anime_status AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); + +CREATE TABLE usertitles ( + usertitle_id serial PRIMARY KEY, + user_id int NOT NULL, + title_id int NOT NULL, + status anime_status NOT NULL, + rate int CHECK (rate BETWEEN 0 AND 10), + review_id int, +); From 8b317ae6550be44b373aabb990d5257953b2684c Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 10 Oct 2025 01:12:35 +0300 Subject: [PATCH 002/224] feat: added all table to sql scheme --- modules/sql/scheme.sql | 99 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 7 deletions(-) diff --git a/modules/sql/scheme.sql b/modules/sql/scheme.sql index 8e10425..7ae4ca1 100644 --- a/modules/sql/scheme.sql +++ b/modules/sql/scheme.sql @@ -1,10 +1,95 @@ -CREATE TYPE anime_status AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); +-- TODO: +-- title table triggers +-- maybe jsonb constraints +-- actions (delete) +BEGIN; + +CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); +CREATE TYPE storage_type_t AS ENUM ('local', 's3'); +CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned'); +CREATE TYPE release_season_t AS ENUM ('winter', 'spring', 'summer', 'fall'); CREATE TABLE usertitles ( - usertitle_id serial PRIMARY KEY, - user_id int NOT NULL, - title_id int NOT NULL, - status anime_status NOT NULL, - rate int CHECK (rate BETWEEN 0 AND 10), - review_id int, + usertitle_id serial PRIMARY KEY, + user_id int NOT NULL REFERENCES users, + title_id int NOT NULL REFERENCES titles, + status usertitle_status_t NOT NULL, + rate int CHECK (rate > 0 AND rate <= 10), + review_id int REFERENCES reviews ); + +CREATE TABLE users ( + user_id serial PRIMARY KEY, + avatar_id int REFERENCES images (image_id), + nickname varchar(16) NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'), + disp_name varchar(32), + user_desc varchar(512), + creation_date timestamp NOT NULL +); + +CREATE TABLE images ( + image_id serial PRIMARY KEY, + storage_type storage_type_t NOT NULL, + image_path varchar(256) UNIQUE NOT NULL +); + +CREATE TABLE reviews ( + review_id serial PRIMARY KEY, + user_id int NOT NULL REFERENCES users, + title_id int NOT NULL REFERENCES titles, + image_ids int[], + review_text text NOT NULL, + creation_date timestamp NOT NULL +); + +CREATE TABLE tags ( + tag_id serial PRIMARY KEY, + tag_names jsonb NOT NULL --mb constraints +); + +CREATE TABLE titles ( + title_id serial PRIMARY KEY, + title_names jsonb NOT NULL, + studio_id int NOT NULL REFERENCES studios, + poster_id int REFERENCES images (image_id), + signal_ids int[] NOT NULL, + title_status title_status_t NOT NULL, + rating float CHECK (rating > 0 AND rating <= 10), --by trigger + rating_count int CHECK (rating_count >= 0), --by trigger + release_year int CHECK (release_year >= 1900), + release_season release_season_t, + season int CHECK (season >= 0), + episodes_aired int CHECK (episodes_aired >= 0), + episodes_all int CHECK (episodes_all >= 0), + episodes_len jsonb, + CHECK ((episodes_aired IS NULL AND episodes_all IS NULL) + OR (episodes_aired IS NOT NULL AND episodes_all IS NOT NULL + AND episodes_aired <= episodes_all)) +); + +CREATE TABLE title_tags ( + PRIMARY KEY (title_id, tag_id), + title_id int NOT NULL REFERENCES titles, + tag_id int NOT NULL REFERENCES tags +); + +CREATE TABLE studios ( + studio_id serial PRIMARY KEY, + studio_name varchar(64) UNIQUE, + illust_id int REFERENCES images (image_id), + studio_desc text +); + +CREATE TABLE signals ( + signal_id serial PRIMARY KEY, + raw_data jsonb NOT NULL, + provider_id int NOT NULL REFERENCES providers, + dirty bool NOT NULL +); + +CREATE TABLE providers ( + provider_id serial PRIMARY KEY, + provider_name varchar(64) NOT NULL +); + +COMMIT; \ No newline at end of file From 18722db340368f9999d4aba769c4f854ff9725f7 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 10 Oct 2025 01:23:09 +0300 Subject: [PATCH 003/224] fix: topology sort for tables (references) --- modules/sql/scheme.sql | 74 +++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/modules/sql/scheme.sql b/modules/sql/scheme.sql index 7ae4ca1..bf946a5 100644 --- a/modules/sql/scheme.sql +++ b/modules/sql/scheme.sql @@ -9,13 +9,20 @@ CREATE TYPE storage_type_t AS ENUM ('local', 's3'); CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned'); CREATE TYPE release_season_t AS ENUM ('winter', 'spring', 'summer', 'fall'); -CREATE TABLE usertitles ( - usertitle_id serial PRIMARY KEY, - user_id int NOT NULL REFERENCES users, - title_id int NOT NULL REFERENCES titles, - status usertitle_status_t NOT NULL, - rate int CHECK (rate > 0 AND rate <= 10), - review_id int REFERENCES reviews +CREATE TABLE providers ( + provider_id serial PRIMARY KEY, + provider_name varchar(64) NOT NULL +); + +CREATE TABLE tags ( + tag_id serial PRIMARY KEY, + tag_names jsonb NOT NULL --mb constraints +); + +CREATE TABLE images ( + image_id serial PRIMARY KEY, + storage_type storage_type_t NOT NULL, + image_path varchar(256) UNIQUE NOT NULL ); CREATE TABLE users ( @@ -27,24 +34,11 @@ CREATE TABLE users ( creation_date timestamp NOT NULL ); -CREATE TABLE images ( - image_id serial PRIMARY KEY, - storage_type storage_type_t NOT NULL, - image_path varchar(256) UNIQUE NOT NULL -); - -CREATE TABLE reviews ( - review_id serial PRIMARY KEY, - user_id int NOT NULL REFERENCES users, - title_id int NOT NULL REFERENCES titles, - image_ids int[], - review_text text NOT NULL, - creation_date timestamp NOT NULL -); - -CREATE TABLE tags ( - tag_id serial PRIMARY KEY, - tag_names jsonb NOT NULL --mb constraints +CREATE TABLE studios ( + studio_id serial PRIMARY KEY, + studio_name varchar(64) UNIQUE, + illust_id int REFERENCES images (image_id), + studio_desc text ); CREATE TABLE titles ( @@ -67,19 +61,30 @@ CREATE TABLE titles ( AND episodes_aired <= episodes_all)) ); +CREATE TABLE reviews ( + review_id serial PRIMARY KEY, + user_id int NOT NULL REFERENCES users, + title_id int NOT NULL REFERENCES titles, + image_ids int[], + review_text text NOT NULL, + creation_date timestamp NOT NULL +); + +CREATE TABLE usertitles ( + usertitle_id serial PRIMARY KEY, + user_id int NOT NULL REFERENCES users, + title_id int NOT NULL REFERENCES titles, + status usertitle_status_t NOT NULL, + rate int CHECK (rate > 0 AND rate <= 10), + review_id int REFERENCES reviews +); + CREATE TABLE title_tags ( PRIMARY KEY (title_id, tag_id), title_id int NOT NULL REFERENCES titles, tag_id int NOT NULL REFERENCES tags ); -CREATE TABLE studios ( - studio_id serial PRIMARY KEY, - studio_name varchar(64) UNIQUE, - illust_id int REFERENCES images (image_id), - studio_desc text -); - CREATE TABLE signals ( signal_id serial PRIMARY KEY, raw_data jsonb NOT NULL, @@ -87,9 +92,4 @@ CREATE TABLE signals ( dirty bool NOT NULL ); -CREATE TABLE providers ( - provider_id serial PRIMARY KEY, - provider_name varchar(64) NOT NULL -); - COMMIT; \ No newline at end of file From de1479c995ce3e3e12bed50dee8e568b1105afbd Mon Sep 17 00:00:00 2001 From: nihonium Date: Fri, 10 Oct 2025 13:51:40 +0300 Subject: [PATCH 004/224] feat: run cicd on dev branch push --- .forgejo/workflows/build-and-deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 84c69c3..2bc0106 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -5,6 +5,7 @@ on: branches: - master - cicd + - dev jobs: build: From 29c034bd1aed7e8a36541f809f1e77bdc2798c60 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 10 Oct 2025 21:36:53 +0300 Subject: [PATCH 005/224] feat: passhash field added to users table --- modules/sql/scheme.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/sql/scheme.sql b/modules/sql/scheme.sql index bf946a5..a3e4e5c 100644 --- a/modules/sql/scheme.sql +++ b/modules/sql/scheme.sql @@ -28,6 +28,7 @@ CREATE TABLE images ( CREATE TABLE users ( user_id serial PRIMARY KEY, avatar_id int REFERENCES images (image_id), + passhash text NOT NULL, nickname varchar(16) NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'), disp_name varchar(32), user_desc varchar(512), From 2d2d97ec277319527fe1f5b4749f9ea7f82e220b Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 10 Oct 2025 22:03:10 +0300 Subject: [PATCH 006/224] feat: field mail added in user table --- modules/sql/scheme.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/sql/scheme.sql b/modules/sql/scheme.sql index a3e4e5c..4712075 100644 --- a/modules/sql/scheme.sql +++ b/modules/sql/scheme.sql @@ -29,6 +29,7 @@ CREATE TABLE users ( user_id serial PRIMARY KEY, avatar_id int REFERENCES images (image_id), passhash text NOT NULL, + mail varchar(64) CHECK ((mail ~ '[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+'), nickname varchar(16) NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'), disp_name varchar(32), user_desc varchar(512), From 31a09037cb1e8a53766deeef924b6e9e91358f43 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 11 Oct 2025 02:11:06 +0300 Subject: [PATCH 007/224] feat: sql queries were written and used to generate queries.go --- modules/sql/go.mod | 3 + modules/sql/query.sql | 142 +++++++ modules/sql/scheme.sql | 2 +- modules/sql/sqlc-generate.yaml | 13 + modules/sql/sqlc.yaml | 14 + modules/sql/sqlc/db.go | 32 ++ modules/sql/sqlc/models.go | 266 ++++++++++++ modules/sql/sqlc/query.sql.go | 712 +++++++++++++++++++++++++++++++++ 8 files changed, 1183 insertions(+), 1 deletion(-) create mode 100644 modules/sql/go.mod create mode 100644 modules/sql/query.sql create mode 100644 modules/sql/sqlc-generate.yaml create mode 100644 modules/sql/sqlc.yaml create mode 100644 modules/sql/sqlc/db.go create mode 100644 modules/sql/sqlc/models.go create mode 100644 modules/sql/sqlc/query.sql.go diff --git a/modules/sql/go.mod b/modules/sql/go.mod new file mode 100644 index 0000000..9508b28 --- /dev/null +++ b/modules/sql/go.mod @@ -0,0 +1,3 @@ +module tutorial.sqlc.dev/app + +go 1.25.2 diff --git a/modules/sql/query.sql b/modules/sql/query.sql new file mode 100644 index 0000000..4b85e7e --- /dev/null +++ b/modules/sql/query.sql @@ -0,0 +1,142 @@ +-- name: GetImageByID :one +SELECT image_id, storage_type, image_path +FROM images +WHERE image_id = $1; + +-- name: CreateImage :one +INSERT INTO images (storage_type, image_path) +VALUES ($1, $2) +RETURNING image_id, storage_type, image_path; + +-- name: GetUserByID :one +SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +FROM users +WHERE user_id = $1; + +-- name: ListUsers :many +SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +FROM users +ORDER BY user_id +LIMIT $1 OFFSET $2; + +-- name: CreateUser :one +INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; + +-- name: UpdateUser :one +UPDATE users +SET + avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), + disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), + user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), + passhash = COALESCE(sqlc.narg('passhash'), passhash), +WHERE user_id = sqlc.arg('user_id') +RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; + +-- name: DeleteUser :exec +DELETE FROM users +WHERE user_id = $1; + +-- name: GetTitleByID :one +SELECT title_id, title_names, studio_id, poster_id, signal_ids, + title_status, rating, rating_count, release_year, release_season, + season, episodes_aired, episodes_all, episodes_len +FROM titles +WHERE title_id = $1; + +-- name: ListTitles :many +SELECT title_id, title_names, studio_id, poster_id, signal_ids, + title_status, rating, rating_count, release_year, release_season, + season, episodes_aired, episodes_all, episodes_len +FROM titles +ORDER BY title_id +LIMIT $1 OFFSET $2; + +-- name: UpdateTitle :one +UPDATE titles +SET + title_names = COALESCE(sqlc.narg('title_names'), title_names), + studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), + poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), + signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), + title_status = COALESCE(sqlc.narg('title_status'), title_status), + release_year = COALESCE(sqlc.narg('release_year'), release_year), + release_season = COALESCE(sqlc.narg('release_season'), release_season), + episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), + episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), + episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) +WHERE title_id = sqlc.arg('title_id') +RETURNING *; + +-- name: GetReviewByID :one +SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +FROM reviews +WHERE review_id = $1; + +-- name: CreateReview :one +INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) +VALUES ($1, $2, $3, $4, $5) +RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date; + +-- name: UpdateReview :one +UPDATE reviews +SET + image_ids = COALESCE(sqlc.narg('image_ids'), image_ids), + review_text = COALESCE(sqlc.narg('review_text'), review_text) +WHERE review_id = sqlc.arg('review_id') +RETURNING *; + +-- name: DeleteReview :exec +DELETE FROM reviews +WHERE review_id = $1; + +-- name: ListReviewsByTitle :many +SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +FROM reviews +WHERE title_id = $1 +ORDER BY creation_date DESC +LIMIT $2 OFFSET $3; + +-- name: ListReviewsByUser :many +SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +FROM reviews +WHERE user_id = $1 +ORDER BY creation_date DESC +LIMIT $2 OFFSET $3; + +-- name: GetUserTitle :one +SELECT usertitle_id, user_id, title_id, status, rate, review_id +FROM usertitles +WHERE user_id = $1 AND title_id = $2; + +-- name: ListUserTitles :many +SELECT usertitle_id, user_id, title_id, status, rate, review_id +FROM usertitles +WHERE user_id = $1 +ORDER BY usertitle_id +LIMIT $2 OFFSET $3; + +-- name: CreateUserTitle :one +INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +VALUES ($1, $2, $3, $4, $5) +RETURNING usertitle_id, user_id, title_id, status, rate, review_id; + +-- name: UpdateUserTitle :one +UPDATE usertitles +SET + status = COALESCE(sqlc.narg('status'), status), + rate = COALESCE(sqlc.narg('rate'), rate), + review_id = COALESCE(sqlc.narg('review_id'), review_id) +WHERE user_id = $1 AND title_id = $2 +RETURNING *; + +-- name: DeleteUserTitle :exec +DELETE FROM usertitles +WHERE user_id = $1 AND ($2::int IS NULL OR title_id = $2); + +-- name: ListTags :many +SELECT tag_id, tag_names +FROM tags +ORDER BY tag_id +LIMIT $1 OFFSET $2; \ No newline at end of file diff --git a/modules/sql/scheme.sql b/modules/sql/scheme.sql index 4712075..df84550 100644 --- a/modules/sql/scheme.sql +++ b/modules/sql/scheme.sql @@ -29,7 +29,7 @@ CREATE TABLE users ( user_id serial PRIMARY KEY, avatar_id int REFERENCES images (image_id), passhash text NOT NULL, - mail varchar(64) CHECK ((mail ~ '[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+'), + mail varchar(64) CHECK (mail ~ '[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+'), nickname varchar(16) NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'), disp_name varchar(32), user_desc varchar(512), diff --git a/modules/sql/sqlc-generate.yaml b/modules/sql/sqlc-generate.yaml new file mode 100644 index 0000000..8a625de --- /dev/null +++ b/modules/sql/sqlc-generate.yaml @@ -0,0 +1,13 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "query.sql" + schema: "scheme.sql" + gen: + go: + package: "nyanimedb.backend" + out: "sqlc" + sql_package: "pgx/v5" + sql_driver: "github.com/lib/pq" + emit_db_tags: true + emit_exact_table_names: true \ No newline at end of file diff --git a/modules/sql/sqlc.yaml b/modules/sql/sqlc.yaml new file mode 100644 index 0000000..079626a --- /dev/null +++ b/modules/sql/sqlc.yaml @@ -0,0 +1,14 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "query.sql" + schema: "scheme.sql" + gen: + go: + package: "nyanimedb_backend" + out: "sqlc" + sql_package: "pgx/v5" + sql_driver: "github.com/lib/pq" + emit_db_tags: true + emit_exact_table_names: true + emit_json_tags: true \ No newline at end of file diff --git a/modules/sql/sqlc/db.go b/modules/sql/sqlc/db.go new file mode 100644 index 0000000..cdbc865 --- /dev/null +++ b/modules/sql/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package nyanimedb_backend + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/modules/sql/sqlc/models.go b/modules/sql/sqlc/models.go new file mode 100644 index 0000000..e7aeb3c --- /dev/null +++ b/modules/sql/sqlc/models.go @@ -0,0 +1,266 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package nyanimedb_backend + +import ( + "database/sql/driver" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" +) + +type ReleaseSeasonT string + +const ( + ReleaseSeasonTWinter ReleaseSeasonT = "winter" + ReleaseSeasonTSpring ReleaseSeasonT = "spring" + ReleaseSeasonTSummer ReleaseSeasonT = "summer" + ReleaseSeasonTFall ReleaseSeasonT = "fall" +) + +func (e *ReleaseSeasonT) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ReleaseSeasonT(s) + case string: + *e = ReleaseSeasonT(s) + default: + return fmt.Errorf("unsupported scan type for ReleaseSeasonT: %T", src) + } + return nil +} + +type NullReleaseSeasonT struct { + ReleaseSeasonT ReleaseSeasonT `json:"release_season_t"` + Valid bool `json:"valid"` // Valid is true if ReleaseSeasonT is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullReleaseSeasonT) Scan(value interface{}) error { + if value == nil { + ns.ReleaseSeasonT, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ReleaseSeasonT.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullReleaseSeasonT) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ReleaseSeasonT), nil +} + +type StorageTypeT string + +const ( + StorageTypeTLocal StorageTypeT = "local" + StorageTypeTS3 StorageTypeT = "s3" +) + +func (e *StorageTypeT) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = StorageTypeT(s) + case string: + *e = StorageTypeT(s) + default: + return fmt.Errorf("unsupported scan type for StorageTypeT: %T", src) + } + return nil +} + +type NullStorageTypeT struct { + StorageTypeT StorageTypeT `json:"storage_type_t"` + Valid bool `json:"valid"` // Valid is true if StorageTypeT is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullStorageTypeT) Scan(value interface{}) error { + if value == nil { + ns.StorageTypeT, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.StorageTypeT.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullStorageTypeT) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.StorageTypeT), nil +} + +type TitleStatusT string + +const ( + TitleStatusTFinished TitleStatusT = "finished" + TitleStatusTOngoing TitleStatusT = "ongoing" + TitleStatusTPlanned TitleStatusT = "planned" +) + +func (e *TitleStatusT) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = TitleStatusT(s) + case string: + *e = TitleStatusT(s) + default: + return fmt.Errorf("unsupported scan type for TitleStatusT: %T", src) + } + return nil +} + +type NullTitleStatusT struct { + TitleStatusT TitleStatusT `json:"title_status_t"` + Valid bool `json:"valid"` // Valid is true if TitleStatusT is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullTitleStatusT) Scan(value interface{}) error { + if value == nil { + ns.TitleStatusT, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.TitleStatusT.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullTitleStatusT) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.TitleStatusT), nil +} + +type UsertitleStatusT string + +const ( + UsertitleStatusTFinished UsertitleStatusT = "finished" + UsertitleStatusTPlanned UsertitleStatusT = "planned" + UsertitleStatusTDropped UsertitleStatusT = "dropped" + UsertitleStatusTInProgress UsertitleStatusT = "in-progress" +) + +func (e *UsertitleStatusT) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = UsertitleStatusT(s) + case string: + *e = UsertitleStatusT(s) + default: + return fmt.Errorf("unsupported scan type for UsertitleStatusT: %T", src) + } + return nil +} + +type NullUsertitleStatusT struct { + UsertitleStatusT UsertitleStatusT `json:"usertitle_status_t"` + Valid bool `json:"valid"` // Valid is true if UsertitleStatusT is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullUsertitleStatusT) Scan(value interface{}) error { + if value == nil { + ns.UsertitleStatusT, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.UsertitleStatusT.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullUsertitleStatusT) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.UsertitleStatusT), nil +} + +type Images struct { + ImageID int32 `db:"image_id" json:"image_id"` + StorageType StorageTypeT `db:"storage_type" json:"storage_type"` + ImagePath string `db:"image_path" json:"image_path"` +} + +type Providers struct { + ProviderID int32 `db:"provider_id" json:"provider_id"` + ProviderName string `db:"provider_name" json:"provider_name"` +} + +type Reviews struct { + ReviewID int32 `db:"review_id" json:"review_id"` + UserID int32 `db:"user_id" json:"user_id"` + TitleID int32 `db:"title_id" json:"title_id"` + ImageIds []int32 `db:"image_ids" json:"image_ids"` + ReviewText string `db:"review_text" json:"review_text"` + CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` +} + +type Signals struct { + SignalID int32 `db:"signal_id" json:"signal_id"` + RawData []byte `db:"raw_data" json:"raw_data"` + ProviderID int32 `db:"provider_id" json:"provider_id"` + Dirty bool `db:"dirty" json:"dirty"` +} + +type Studios struct { + StudioID int32 `db:"studio_id" json:"studio_id"` + StudioName pgtype.Text `db:"studio_name" json:"studio_name"` + IllustID pgtype.Int4 `db:"illust_id" json:"illust_id"` + StudioDesc pgtype.Text `db:"studio_desc" json:"studio_desc"` +} + +type Tags struct { + TagID int32 `db:"tag_id" json:"tag_id"` + TagNames []byte `db:"tag_names" json:"tag_names"` +} + +type TitleTags struct { + TitleID int32 `db:"title_id" json:"title_id"` + TagID int32 `db:"tag_id" json:"tag_id"` +} + +type Titles struct { + TitleID int32 `db:"title_id" json:"title_id"` + TitleNames []byte `db:"title_names" json:"title_names"` + StudioID int32 `db:"studio_id" json:"studio_id"` + PosterID pgtype.Int4 `db:"poster_id" json:"poster_id"` + SignalIds []int32 `db:"signal_ids" json:"signal_ids"` + TitleStatus TitleStatusT `db:"title_status" json:"title_status"` + Rating pgtype.Float8 `db:"rating" json:"rating"` + RatingCount pgtype.Int4 `db:"rating_count" json:"rating_count"` + ReleaseYear pgtype.Int4 `db:"release_year" json:"release_year"` + ReleaseSeason NullReleaseSeasonT `db:"release_season" json:"release_season"` + Season pgtype.Int4 `db:"season" json:"season"` + EpisodesAired pgtype.Int4 `db:"episodes_aired" json:"episodes_aired"` + EpisodesAll pgtype.Int4 `db:"episodes_all" json:"episodes_all"` + EpisodesLen []byte `db:"episodes_len" json:"episodes_len"` +} + +type Users struct { + UserID int32 `db:"user_id" json:"user_id"` + AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` + Passhash string `db:"passhash" json:"passhash"` + Mail pgtype.Text `db:"mail" json:"mail"` + Nickname string `db:"nickname" json:"nickname"` + DispName pgtype.Text `db:"disp_name" json:"disp_name"` + UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` + CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` +} + +type Usertitles struct { + UsertitleID int32 `db:"usertitle_id" json:"usertitle_id"` + UserID int32 `db:"user_id" json:"user_id"` + TitleID int32 `db:"title_id" json:"title_id"` + Status UsertitleStatusT `db:"status" json:"status"` + Rate pgtype.Int4 `db:"rate" json:"rate"` + ReviewID pgtype.Int4 `db:"review_id" json:"review_id"` +} diff --git a/modules/sql/sqlc/query.sql.go b/modules/sql/sqlc/query.sql.go new file mode 100644 index 0000000..1fc06ac --- /dev/null +++ b/modules/sql/sqlc/query.sql.go @@ -0,0 +1,712 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: query.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createImage = `-- name: CreateImage :one +INSERT INTO images (storage_type, image_path) +VALUES ($1, $2) +RETURNING image_id, storage_type, image_path +` + +type CreateImageParams struct { + StorageType StorageTypeT `db:"storage_type" json:"storage_type"` + ImagePath string `db:"image_path" json:"image_path"` +} + +func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Images, error) { + row := q.db.QueryRow(ctx, createImage, arg.StorageType, arg.ImagePath) + var i Images + err := row.Scan(&i.ImageID, &i.StorageType, &i.ImagePath) + return i, err +} + +const createReview = `-- name: CreateReview :one +INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) +VALUES ($1, $2, $3, $4, $5) +RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date +` + +type CreateReviewParams struct { + UserID int32 `db:"user_id" json:"user_id"` + TitleID int32 `db:"title_id" json:"title_id"` + ImageIds []int32 `db:"image_ids" json:"image_ids"` + ReviewText string `db:"review_text" json:"review_text"` + CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` +} + +func (q *Queries) CreateReview(ctx context.Context, arg CreateReviewParams) (Reviews, error) { + row := q.db.QueryRow(ctx, createReview, + arg.UserID, + arg.TitleID, + arg.ImageIds, + arg.ReviewText, + arg.CreationDate, + ) + var i Reviews + err := row.Scan( + &i.ReviewID, + &i.UserID, + &i.TitleID, + &i.ImageIds, + &i.ReviewText, + &i.CreationDate, + ) + return i, err +} + +const createUser = `-- name: CreateUser :one +INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date +` + +type CreateUserParams struct { + AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` + Passhash string `db:"passhash" json:"passhash"` + Mail pgtype.Text `db:"mail" json:"mail"` + Nickname string `db:"nickname" json:"nickname"` + DispName pgtype.Text `db:"disp_name" json:"disp_name"` + UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` + CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` +} + +type CreateUserRow struct { + UserID int32 `db:"user_id" json:"user_id"` + AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` + Nickname string `db:"nickname" json:"nickname"` + DispName pgtype.Text `db:"disp_name" json:"disp_name"` + UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` + CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) { + row := q.db.QueryRow(ctx, createUser, + arg.AvatarID, + arg.Passhash, + arg.Mail, + arg.Nickname, + arg.DispName, + arg.UserDesc, + arg.CreationDate, + ) + var i CreateUserRow + err := row.Scan( + &i.UserID, + &i.AvatarID, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + ) + return i, err +} + +const createUserTitle = `-- name: CreateUserTitle :one +INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +VALUES ($1, $2, $3, $4, $5) +RETURNING usertitle_id, user_id, title_id, status, rate, review_id +` + +type CreateUserTitleParams struct { + UserID int32 `db:"user_id" json:"user_id"` + TitleID int32 `db:"title_id" json:"title_id"` + Status UsertitleStatusT `db:"status" json:"status"` + Rate pgtype.Int4 `db:"rate" json:"rate"` + ReviewID pgtype.Int4 `db:"review_id" json:"review_id"` +} + +func (q *Queries) CreateUserTitle(ctx context.Context, arg CreateUserTitleParams) (Usertitles, error) { + row := q.db.QueryRow(ctx, createUserTitle, + arg.UserID, + arg.TitleID, + arg.Status, + arg.Rate, + arg.ReviewID, + ) + var i Usertitles + err := row.Scan( + &i.UsertitleID, + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + ) + return i, err +} + +const deleteReview = `-- name: DeleteReview :exec +DELETE FROM reviews +WHERE review_id = $1 +` + +func (q *Queries) DeleteReview(ctx context.Context, reviewID int32) error { + _, err := q.db.Exec(ctx, deleteReview, reviewID) + return err +} + +const deleteUser = `-- name: DeleteUser :exec +DELETE FROM users +WHERE user_id = $1 +` + +func (q *Queries) DeleteUser(ctx context.Context, userID int32) error { + _, err := q.db.Exec(ctx, deleteUser, userID) + return err +} + +const deleteUserTitle = `-- name: DeleteUserTitle :exec +DELETE FROM usertitles +WHERE user_id = $1 AND ($2::int IS NULL OR title_id = $2) +` + +type DeleteUserTitleParams struct { + UserID int32 `db:"user_id" json:"user_id"` + Column2 int32 `db:"column_2" json:"column_2"` +} + +func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams) error { + _, err := q.db.Exec(ctx, deleteUserTitle, arg.UserID, arg.Column2) + return err +} + +const getImageByID = `-- name: GetImageByID :one +SELECT image_id, storage_type, image_path +FROM images +WHERE image_id = $1 +` + +func (q *Queries) GetImageByID(ctx context.Context, imageID int32) (Images, error) { + row := q.db.QueryRow(ctx, getImageByID, imageID) + var i Images + err := row.Scan(&i.ImageID, &i.StorageType, &i.ImagePath) + return i, err +} + +const getReviewByID = `-- name: GetReviewByID :one +SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +FROM reviews +WHERE review_id = $1 +` + +func (q *Queries) GetReviewByID(ctx context.Context, reviewID int32) (Reviews, error) { + row := q.db.QueryRow(ctx, getReviewByID, reviewID) + var i Reviews + err := row.Scan( + &i.ReviewID, + &i.UserID, + &i.TitleID, + &i.ImageIds, + &i.ReviewText, + &i.CreationDate, + ) + return i, err +} + +const getTitleByID = `-- name: GetTitleByID :one +SELECT title_id, title_names, studio_id, poster_id, signal_ids, + title_status, rating, rating_count, release_year, release_season, + season, episodes_aired, episodes_all, episodes_len +FROM titles +WHERE title_id = $1 +` + +func (q *Queries) GetTitleByID(ctx context.Context, titleID int32) (Titles, error) { + row := q.db.QueryRow(ctx, getTitleByID, titleID) + var i Titles + err := row.Scan( + &i.TitleID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.SignalIds, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +FROM users +WHERE user_id = $1 +` + +func (q *Queries) GetUserByID(ctx context.Context, userID int32) (Users, error) { + row := q.db.QueryRow(ctx, getUserByID, userID) + var i Users + err := row.Scan( + &i.UserID, + &i.AvatarID, + &i.Passhash, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + ) + return i, err +} + +const getUserTitle = `-- name: GetUserTitle :one +SELECT usertitle_id, user_id, title_id, status, rate, review_id +FROM usertitles +WHERE user_id = $1 AND title_id = $2 +` + +type GetUserTitleParams struct { + UserID int32 `db:"user_id" json:"user_id"` + TitleID int32 `db:"title_id" json:"title_id"` +} + +func (q *Queries) GetUserTitle(ctx context.Context, arg GetUserTitleParams) (Usertitles, error) { + row := q.db.QueryRow(ctx, getUserTitle, arg.UserID, arg.TitleID) + var i Usertitles + err := row.Scan( + &i.UsertitleID, + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + ) + return i, err +} + +const listReviewsByTitle = `-- name: ListReviewsByTitle :many +SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +FROM reviews +WHERE title_id = $1 +ORDER BY creation_date DESC +LIMIT $2 OFFSET $3 +` + +type ListReviewsByTitleParams struct { + TitleID int32 `db:"title_id" json:"title_id"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +func (q *Queries) ListReviewsByTitle(ctx context.Context, arg ListReviewsByTitleParams) ([]Reviews, error) { + rows, err := q.db.Query(ctx, listReviewsByTitle, arg.TitleID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Reviews + for rows.Next() { + var i Reviews + if err := rows.Scan( + &i.ReviewID, + &i.UserID, + &i.TitleID, + &i.ImageIds, + &i.ReviewText, + &i.CreationDate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listReviewsByUser = `-- name: ListReviewsByUser :many +SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +FROM reviews +WHERE user_id = $1 +ORDER BY creation_date DESC +LIMIT $2 OFFSET $3 +` + +type ListReviewsByUserParams struct { + UserID int32 `db:"user_id" json:"user_id"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +func (q *Queries) ListReviewsByUser(ctx context.Context, arg ListReviewsByUserParams) ([]Reviews, error) { + rows, err := q.db.Query(ctx, listReviewsByUser, arg.UserID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Reviews + for rows.Next() { + var i Reviews + if err := rows.Scan( + &i.ReviewID, + &i.UserID, + &i.TitleID, + &i.ImageIds, + &i.ReviewText, + &i.CreationDate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTags = `-- name: ListTags :many +SELECT tag_id, tag_names +FROM tags +ORDER BY tag_id +LIMIT $1 OFFSET $2 +` + +type ListTagsParams struct { + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +func (q *Queries) ListTags(ctx context.Context, arg ListTagsParams) ([]Tags, error) { + rows, err := q.db.Query(ctx, listTags, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Tags + for rows.Next() { + var i Tags + if err := rows.Scan(&i.TagID, &i.TagNames); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTitles = `-- name: ListTitles :many +SELECT title_id, title_names, studio_id, poster_id, signal_ids, + title_status, rating, rating_count, release_year, release_season, + season, episodes_aired, episodes_all, episodes_len +FROM titles +ORDER BY title_id +LIMIT $1 OFFSET $2 +` + +type ListTitlesParams struct { + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +func (q *Queries) ListTitles(ctx context.Context, arg ListTitlesParams) ([]Titles, error) { + rows, err := q.db.Query(ctx, listTitles, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Titles + for rows.Next() { + var i Titles + if err := rows.Scan( + &i.TitleID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.SignalIds, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUserTitles = `-- name: ListUserTitles :many +SELECT usertitle_id, user_id, title_id, status, rate, review_id +FROM usertitles +WHERE user_id = $1 +ORDER BY usertitle_id +LIMIT $2 OFFSET $3 +` + +type ListUserTitlesParams struct { + UserID int32 `db:"user_id" json:"user_id"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +func (q *Queries) ListUserTitles(ctx context.Context, arg ListUserTitlesParams) ([]Usertitles, error) { + rows, err := q.db.Query(ctx, listUserTitles, arg.UserID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Usertitles + for rows.Next() { + var i Usertitles + if err := rows.Scan( + &i.UsertitleID, + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUsers = `-- name: ListUsers :many +SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +FROM users +ORDER BY user_id +LIMIT $1 OFFSET $2 +` + +type ListUsersParams struct { + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]Users, error) { + rows, err := q.db.Query(ctx, listUsers, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Users + for rows.Next() { + var i Users + if err := rows.Scan( + &i.UserID, + &i.AvatarID, + &i.Passhash, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateReview = `-- name: UpdateReview :one +UPDATE reviews +SET + image_ids = COALESCE($1, image_ids), + review_text = COALESCE($2, review_text) +WHERE review_id = $3 +RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date +` + +type UpdateReviewParams struct { + ImageIds []int32 `db:"image_ids" json:"image_ids"` + ReviewText pgtype.Text `db:"review_text" json:"review_text"` + ReviewID int32 `db:"review_id" json:"review_id"` +} + +func (q *Queries) UpdateReview(ctx context.Context, arg UpdateReviewParams) (Reviews, error) { + row := q.db.QueryRow(ctx, updateReview, arg.ImageIds, arg.ReviewText, arg.ReviewID) + var i Reviews + err := row.Scan( + &i.ReviewID, + &i.UserID, + &i.TitleID, + &i.ImageIds, + &i.ReviewText, + &i.CreationDate, + ) + return i, err +} + +const updateTitle = `-- name: UpdateTitle :one +UPDATE titles +SET + title_names = COALESCE($1, title_names), + studio_id = COALESCE($2, studio_id), + poster_id = COALESCE($3, poster_id), + signal_ids = COALESCE($4, signal_ids), + title_status = COALESCE($5, title_status), + release_year = COALESCE($6, release_year), + release_season = COALESCE($7, release_season), + episodes_aired = COALESCE($8, episodes_aired), + episodes_all = COALESCE($9, episodes_all), + episodes_len = COALESCE($10, episodes_len) +WHERE title_id = $11 +RETURNING title_id, title_names, studio_id, poster_id, signal_ids, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len +` + +type UpdateTitleParams struct { + TitleNames []byte `db:"title_names" json:"title_names"` + StudioID pgtype.Int4 `db:"studio_id" json:"studio_id"` + PosterID pgtype.Int4 `db:"poster_id" json:"poster_id"` + SignalIds []int32 `db:"signal_ids" json:"signal_ids"` + TitleStatus NullTitleStatusT `db:"title_status" json:"title_status"` + ReleaseYear pgtype.Int4 `db:"release_year" json:"release_year"` + ReleaseSeason NullReleaseSeasonT `db:"release_season" json:"release_season"` + EpisodesAired pgtype.Int4 `db:"episodes_aired" json:"episodes_aired"` + EpisodesAll pgtype.Int4 `db:"episodes_all" json:"episodes_all"` + EpisodesLen []byte `db:"episodes_len" json:"episodes_len"` + TitleID int32 `db:"title_id" json:"title_id"` +} + +func (q *Queries) UpdateTitle(ctx context.Context, arg UpdateTitleParams) (Titles, error) { + row := q.db.QueryRow(ctx, updateTitle, + arg.TitleNames, + arg.StudioID, + arg.PosterID, + arg.SignalIds, + arg.TitleStatus, + arg.ReleaseYear, + arg.ReleaseSeason, + arg.EpisodesAired, + arg.EpisodesAll, + arg.EpisodesLen, + arg.TitleID, + ) + var i Titles + err := row.Scan( + &i.TitleID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.SignalIds, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ) + return i, err +} + +const updateUser = `-- name: UpdateUser :one +UPDATE users +SET + avatar_id = COALESCE($1, avatar_id), + disp_name = COALESCE($2, disp_name), + user_desc = COALESCE($3, user_desc) +WHERE user_id = $4 +RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date +` + +type UpdateUserParams struct { + AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` + DispName pgtype.Text `db:"disp_name" json:"disp_name"` + UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` + UserID int32 `db:"user_id" json:"user_id"` +} + +type UpdateUserRow struct { + UserID int32 `db:"user_id" json:"user_id"` + AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` + Nickname string `db:"nickname" json:"nickname"` + DispName pgtype.Text `db:"disp_name" json:"disp_name"` + UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` + CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` +} + +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) { + row := q.db.QueryRow(ctx, updateUser, + arg.AvatarID, + arg.DispName, + arg.UserDesc, + arg.UserID, + ) + var i UpdateUserRow + err := row.Scan( + &i.UserID, + &i.AvatarID, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + ) + return i, err +} + +const updateUserTitle = `-- name: UpdateUserTitle :one +UPDATE usertitles +SET + status = COALESCE($3, status), + rate = COALESCE($4, rate), + review_id = COALESCE($5, review_id) +WHERE user_id = $1 AND title_id = $2 +RETURNING usertitle_id, user_id, title_id, status, rate, review_id +` + +type UpdateUserTitleParams struct { + UserID int32 `db:"user_id" json:"user_id"` + TitleID int32 `db:"title_id" json:"title_id"` + Status NullUsertitleStatusT `db:"status" json:"status"` + Rate pgtype.Int4 `db:"rate" json:"rate"` + ReviewID pgtype.Int4 `db:"review_id" json:"review_id"` +} + +func (q *Queries) UpdateUserTitle(ctx context.Context, arg UpdateUserTitleParams) (Usertitles, error) { + row := q.db.QueryRow(ctx, updateUserTitle, + arg.UserID, + arg.TitleID, + arg.Status, + arg.Rate, + arg.ReviewID, + ) + var i Usertitles + err := row.Scan( + &i.UsertitleID, + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + ) + return i, err +} From 80285ba7da1102c27daf4bcc9ebd99097ac266c2 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 11 Oct 2025 02:13:08 +0300 Subject: [PATCH 008/224] feat: implementation for handlers were written --- modules/backend/api/gen.go | 1958 +++++++++++++++++++++++++++++++++++ modules/backend/api/impl.go | 718 +++++++++++++ 2 files changed, 2676 insertions(+) create mode 100644 modules/backend/api/gen.go create mode 100644 modules/backend/api/impl.go diff --git a/modules/backend/api/gen.go b/modules/backend/api/gen.go new file mode 100644 index 0000000..2dcb886 --- /dev/null +++ b/modules/backend/api/gen.go @@ -0,0 +1,1958 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/oapi-codegen/runtime" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" +) + +// Review defines model for Review. +type Review map[string]interface{} + +// Tag defines model for Tag. +type Tag map[string]interface{} + +// Title defines model for Title. +type Title map[string]interface{} + +// User defines model for User. +type User map[string]interface{} + +// UserTitle defines model for UserTitle. +type UserTitle map[string]interface{} + +// GetMediaParams defines parameters for GetMedia. +type GetMediaParams struct { + ImageId string `form:"image_id" json:"image_id"` +} + +// GetTagsParams defines parameters for GetTags. +type GetTagsParams struct { + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetTitleParams defines parameters for GetTitle. +type GetTitleParams struct { + Query *string `form:"query,omitempty" json:"query,omitempty"` + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetTitleTitleIdParams defines parameters for GetTitleTitleId. +type GetTitleTitleIdParams struct { + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetTitleTitleIdReviewsParams defines parameters for GetTitleTitleIdReviews. +type GetTitleTitleIdReviewsParams struct { + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` +} + +// GetUsersParams defines parameters for GetUsers. +type GetUsersParams struct { + Query *string `form:"query,omitempty" json:"query,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetUsersUserIdParams defines parameters for GetUsersUserId. +type GetUsersUserIdParams struct { + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetUsersUserIdReviewsParams defines parameters for GetUsersUserIdReviews. +type GetUsersUserIdReviewsParams struct { + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` +} + +// DeleteUsersUserIdTitlesParams defines parameters for DeleteUsersUserIdTitles. +type DeleteUsersUserIdTitlesParams struct { + TitleId *string `form:"title_id,omitempty" json:"title_id,omitempty"` +} + +// GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. +type GetUsersUserIdTitlesParams struct { + Query *string `form:"query,omitempty" json:"query,omitempty"` + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// PostUsersUserIdTitlesJSONBody defines parameters for PostUsersUserIdTitles. +type PostUsersUserIdTitlesJSONBody struct { + Status *string `json:"status,omitempty"` + TitleId *string `json:"title_id,omitempty"` +} + +// PostReviewsJSONRequestBody defines body for PostReviews for application/json ContentType. +type PostReviewsJSONRequestBody = Review + +// PatchReviewsReviewIdJSONRequestBody defines body for PatchReviewsReviewId for application/json ContentType. +type PatchReviewsReviewIdJSONRequestBody = Review + +// PatchTitleTitleIdJSONRequestBody defines body for PatchTitleTitleId for application/json ContentType. +type PatchTitleTitleIdJSONRequestBody = Title + +// PostUsersJSONRequestBody defines body for PostUsers for application/json ContentType. +type PostUsersJSONRequestBody = User + +// PatchUsersUserIdJSONRequestBody defines body for PatchUsersUserId for application/json ContentType. +type PatchUsersUserIdJSONRequestBody = User + +// PatchUsersUserIdTitlesJSONRequestBody defines body for PatchUsersUserIdTitles for application/json ContentType. +type PatchUsersUserIdTitlesJSONRequestBody = UserTitle + +// PostUsersUserIdTitlesJSONRequestBody defines body for PostUsersUserIdTitles for application/json ContentType. +type PostUsersUserIdTitlesJSONRequestBody PostUsersUserIdTitlesJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Get image path + // (GET /media) + GetMedia(c *gin.Context, params GetMediaParams) + // Upload image + // (POST /media) + PostMedia(c *gin.Context) + // Add review + // (POST /reviews) + PostReviews(c *gin.Context) + // Delete review + // (DELETE /reviews/{review_id}) + DeleteReviewsReviewId(c *gin.Context, reviewId string) + // Update review + // (PATCH /reviews/{review_id}) + PatchReviewsReviewId(c *gin.Context, reviewId string) + // Get tags + // (GET /tags) + GetTags(c *gin.Context, params GetTagsParams) + // Get titles + // (GET /title) + GetTitle(c *gin.Context, params GetTitleParams) + // Get title description + // (GET /title/{title_id}) + GetTitleTitleId(c *gin.Context, titleId string, params GetTitleTitleIdParams) + // Update title info + // (PATCH /title/{title_id}) + PatchTitleTitleId(c *gin.Context, titleId string) + // Get title reviews + // (GET /title/{title_id}/reviews) + GetTitleTitleIdReviews(c *gin.Context, titleId string, params GetTitleTitleIdReviewsParams) + // Search user + // (GET /users) + GetUsers(c *gin.Context, params GetUsersParams) + // Add new user + // (POST /users) + PostUsers(c *gin.Context) + // Delete user + // (DELETE /users/{user_id}) + DeleteUsersUserId(c *gin.Context, userId string) + // Get user info + // (GET /users/{user_id}) + GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) + // Update user + // (PATCH /users/{user_id}) + PatchUsersUserId(c *gin.Context, userId string) + // Get user reviews + // (GET /users/{user_id}/reviews) + GetUsersUserIdReviews(c *gin.Context, userId string, params GetUsersUserIdReviewsParams) + // Delete user title + // (DELETE /users/{user_id}/titles) + DeleteUsersUserIdTitles(c *gin.Context, userId string, params DeleteUsersUserIdTitlesParams) + // Get user titles + // (GET /users/{user_id}/titles) + GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) + // Update user title + // (PATCH /users/{user_id}/titles) + PatchUsersUserIdTitles(c *gin.Context, userId string) + // Add user title + // (POST /users/{user_id}/titles) + PostUsersUserIdTitles(c *gin.Context, userId string) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// GetMedia operation middleware +func (siw *ServerInterfaceWrapper) GetMedia(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetMediaParams + + // ------------- Required query parameter "image_id" ------------- + + if paramValue := c.Query("image_id"); paramValue != "" { + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument image_id is required, but not found"), http.StatusBadRequest) + return + } + + err = runtime.BindQueryParameter("form", true, true, "image_id", c.Request.URL.Query(), ¶ms.ImageId) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter image_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetMedia(c, params) +} + +// PostMedia operation middleware +func (siw *ServerInterfaceWrapper) PostMedia(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostMedia(c) +} + +// PostReviews operation middleware +func (siw *ServerInterfaceWrapper) PostReviews(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostReviews(c) +} + +// DeleteReviewsReviewId operation middleware +func (siw *ServerInterfaceWrapper) DeleteReviewsReviewId(c *gin.Context) { + + var err error + + // ------------- Path parameter "review_id" ------------- + var reviewId string + + err = runtime.BindStyledParameterWithOptions("simple", "review_id", c.Param("review_id"), &reviewId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter review_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteReviewsReviewId(c, reviewId) +} + +// PatchReviewsReviewId operation middleware +func (siw *ServerInterfaceWrapper) PatchReviewsReviewId(c *gin.Context) { + + var err error + + // ------------- Path parameter "review_id" ------------- + var reviewId string + + err = runtime.BindStyledParameterWithOptions("simple", "review_id", c.Param("review_id"), &reviewId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter review_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PatchReviewsReviewId(c, reviewId) +} + +// GetTags operation middleware +func (siw *ServerInterfaceWrapper) GetTags(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetTagsParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTags(c, params) +} + +// GetTitle operation middleware +func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitleParams + + // ------------- Optional query parameter "query" ------------- + + err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitle(c, params) +} + +// GetTitleTitleId operation middleware +func (siw *ServerInterfaceWrapper) GetTitleTitleId(c *gin.Context) { + + var err error + + // ------------- Path parameter "title_id" ------------- + var titleId string + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitleTitleIdParams + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitleTitleId(c, titleId, params) +} + +// PatchTitleTitleId operation middleware +func (siw *ServerInterfaceWrapper) PatchTitleTitleId(c *gin.Context) { + + var err error + + // ------------- Path parameter "title_id" ------------- + var titleId string + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PatchTitleTitleId(c, titleId) +} + +// GetTitleTitleIdReviews operation middleware +func (siw *ServerInterfaceWrapper) GetTitleTitleIdReviews(c *gin.Context) { + + var err error + + // ------------- Path parameter "title_id" ------------- + var titleId string + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitleTitleIdReviewsParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitleTitleIdReviews(c, titleId, params) +} + +// GetUsers operation middleware +func (siw *ServerInterfaceWrapper) GetUsers(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersParams + + // ------------- Optional query parameter "query" ------------- + + err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsers(c, params) +} + +// PostUsers operation middleware +func (siw *ServerInterfaceWrapper) PostUsers(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostUsers(c) +} + +// DeleteUsersUserId operation middleware +func (siw *ServerInterfaceWrapper) DeleteUsersUserId(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteUsersUserId(c, userId) +} + +// GetUsersUserId operation middleware +func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersUserIdParams + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsersUserId(c, userId, params) +} + +// PatchUsersUserId operation middleware +func (siw *ServerInterfaceWrapper) PatchUsersUserId(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PatchUsersUserId(c, userId) +} + +// GetUsersUserIdReviews operation middleware +func (siw *ServerInterfaceWrapper) GetUsersUserIdReviews(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersUserIdReviewsParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsersUserIdReviews(c, userId, params) +} + +// DeleteUsersUserIdTitles operation middleware +func (siw *ServerInterfaceWrapper) DeleteUsersUserIdTitles(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params DeleteUsersUserIdTitlesParams + + // ------------- Optional query parameter "title_id" ------------- + + err = runtime.BindQueryParameter("form", true, false, "title_id", c.Request.URL.Query(), ¶ms.TitleId) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteUsersUserIdTitles(c, userId, params) +} + +// GetUsersUserIdTitles operation middleware +func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersUserIdTitlesParams + + // ------------- Optional query parameter "query" ------------- + + err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsersUserIdTitles(c, userId, params) +} + +// PatchUsersUserIdTitles operation middleware +func (siw *ServerInterfaceWrapper) PatchUsersUserIdTitles(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PatchUsersUserIdTitles(c, userId) +} + +// PostUsersUserIdTitles operation middleware +func (siw *ServerInterfaceWrapper) PostUsersUserIdTitles(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostUsersUserIdTitles(c, userId) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.GET(options.BaseURL+"/media", wrapper.GetMedia) + router.POST(options.BaseURL+"/media", wrapper.PostMedia) + router.POST(options.BaseURL+"/reviews", wrapper.PostReviews) + router.DELETE(options.BaseURL+"/reviews/:review_id", wrapper.DeleteReviewsReviewId) + router.PATCH(options.BaseURL+"/reviews/:review_id", wrapper.PatchReviewsReviewId) + router.GET(options.BaseURL+"/tags", wrapper.GetTags) + router.GET(options.BaseURL+"/title", wrapper.GetTitle) + router.GET(options.BaseURL+"/title/:title_id", wrapper.GetTitleTitleId) + router.PATCH(options.BaseURL+"/title/:title_id", wrapper.PatchTitleTitleId) + router.GET(options.BaseURL+"/title/:title_id/reviews", wrapper.GetTitleTitleIdReviews) + router.GET(options.BaseURL+"/users", wrapper.GetUsers) + router.POST(options.BaseURL+"/users", wrapper.PostUsers) + router.DELETE(options.BaseURL+"/users/:user_id", wrapper.DeleteUsersUserId) + router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) + router.PATCH(options.BaseURL+"/users/:user_id", wrapper.PatchUsersUserId) + router.GET(options.BaseURL+"/users/:user_id/reviews", wrapper.GetUsersUserIdReviews) + router.DELETE(options.BaseURL+"/users/:user_id/titles", wrapper.DeleteUsersUserIdTitles) + router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) + router.PATCH(options.BaseURL+"/users/:user_id/titles", wrapper.PatchUsersUserIdTitles) + router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.PostUsersUserIdTitles) +} + +type GetMediaRequestObject struct { + Params GetMediaParams +} + +type GetMediaResponseObject interface { + VisitGetMediaResponse(w http.ResponseWriter) error +} + +type GetMedia200JSONResponse struct { + Error *string `json:"error,omitempty"` + ImagePath *string `json:"image_path,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response GetMedia200JSONResponse) VisitGetMediaResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostMediaRequestObject struct { +} + +type PostMediaResponseObject interface { + VisitPostMediaResponse(w http.ResponseWriter) error +} + +type PostMedia200JSONResponse struct { + Error *string `json:"error,omitempty"` + ImageId *string `json:"image_id,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response PostMedia200JSONResponse) VisitPostMediaResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostReviewsRequestObject struct { + Body *PostReviewsJSONRequestBody +} + +type PostReviewsResponseObject interface { + VisitPostReviewsResponse(w http.ResponseWriter) error +} + +type PostReviews200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response PostReviews200JSONResponse) VisitPostReviewsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteReviewsReviewIdRequestObject struct { + ReviewId string `json:"review_id"` +} + +type DeleteReviewsReviewIdResponseObject interface { + VisitDeleteReviewsReviewIdResponse(w http.ResponseWriter) error +} + +type DeleteReviewsReviewId200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response DeleteReviewsReviewId200JSONResponse) VisitDeleteReviewsReviewIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PatchReviewsReviewIdRequestObject struct { + ReviewId string `json:"review_id"` + Body *PatchReviewsReviewIdJSONRequestBody +} + +type PatchReviewsReviewIdResponseObject interface { + VisitPatchReviewsReviewIdResponse(w http.ResponseWriter) error +} + +type PatchReviewsReviewId200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response PatchReviewsReviewId200JSONResponse) VisitPatchReviewsReviewIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTagsRequestObject struct { + Params GetTagsParams +} + +type GetTagsResponseObject interface { + VisitGetTagsResponse(w http.ResponseWriter) error +} + +type GetTags200JSONResponse []Tag + +func (response GetTags200JSONResponse) VisitGetTagsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitleRequestObject struct { + Params GetTitleParams +} + +type GetTitleResponseObject interface { + VisitGetTitleResponse(w http.ResponseWriter) error +} + +type GetTitle200JSONResponse []Title + +func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitle204Response struct { +} + +func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetTitleTitleIdRequestObject struct { + TitleId string `json:"title_id"` + Params GetTitleTitleIdParams +} + +type GetTitleTitleIdResponseObject interface { + VisitGetTitleTitleIdResponse(w http.ResponseWriter) error +} + +type GetTitleTitleId200JSONResponse Title + +func (response GetTitleTitleId200JSONResponse) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitleTitleId404Response struct { +} + +func (response GetTitleTitleId404Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type PatchTitleTitleIdRequestObject struct { + TitleId string `json:"title_id"` + Body *PatchTitleTitleIdJSONRequestBody +} + +type PatchTitleTitleIdResponseObject interface { + VisitPatchTitleTitleIdResponse(w http.ResponseWriter) error +} + +type PatchTitleTitleId200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` + UserJson *User `json:"user_json,omitempty"` +} + +func (response PatchTitleTitleId200JSONResponse) VisitPatchTitleTitleIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitleTitleIdReviewsRequestObject struct { + TitleId string `json:"title_id"` + Params GetTitleTitleIdReviewsParams +} + +type GetTitleTitleIdReviewsResponseObject interface { + VisitGetTitleTitleIdReviewsResponse(w http.ResponseWriter) error +} + +type GetTitleTitleIdReviews200JSONResponse []Review + +func (response GetTitleTitleIdReviews200JSONResponse) VisitGetTitleTitleIdReviewsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitleTitleIdReviews204Response struct { +} + +func (response GetTitleTitleIdReviews204Response) VisitGetTitleTitleIdReviewsResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetUsersRequestObject struct { + Params GetUsersParams +} + +type GetUsersResponseObject interface { + VisitGetUsersResponse(w http.ResponseWriter) error +} + +type GetUsers200JSONResponse []User + +func (response GetUsers200JSONResponse) VisitGetUsersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostUsersRequestObject struct { + Body *PostUsersJSONRequestBody +} + +type PostUsersResponseObject interface { + VisitPostUsersResponse(w http.ResponseWriter) error +} + +type PostUsers200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` + UserJson *User `json:"user_json,omitempty"` +} + +func (response PostUsers200JSONResponse) VisitPostUsersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteUsersUserIdRequestObject struct { + UserId string `json:"user_id"` +} + +type DeleteUsersUserIdResponseObject interface { + VisitDeleteUsersUserIdResponse(w http.ResponseWriter) error +} + +type DeleteUsersUserId200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response DeleteUsersUserId200JSONResponse) VisitDeleteUsersUserIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserIdRequestObject struct { + UserId string `json:"user_id"` + Params GetUsersUserIdParams +} + +type GetUsersUserIdResponseObject interface { + VisitGetUsersUserIdResponse(w http.ResponseWriter) error +} + +type GetUsersUserId200JSONResponse User + +func (response GetUsersUserId200JSONResponse) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserId404Response struct { +} + +func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type PatchUsersUserIdRequestObject struct { + UserId string `json:"user_id"` + Body *PatchUsersUserIdJSONRequestBody +} + +type PatchUsersUserIdResponseObject interface { + VisitPatchUsersUserIdResponse(w http.ResponseWriter) error +} + +type PatchUsersUserId200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response PatchUsersUserId200JSONResponse) VisitPatchUsersUserIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserIdReviewsRequestObject struct { + UserId string `json:"user_id"` + Params GetUsersUserIdReviewsParams +} + +type GetUsersUserIdReviewsResponseObject interface { + VisitGetUsersUserIdReviewsResponse(w http.ResponseWriter) error +} + +type GetUsersUserIdReviews200JSONResponse []Review + +func (response GetUsersUserIdReviews200JSONResponse) VisitGetUsersUserIdReviewsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteUsersUserIdTitlesRequestObject struct { + UserId string `json:"user_id"` + Params DeleteUsersUserIdTitlesParams +} + +type DeleteUsersUserIdTitlesResponseObject interface { + VisitDeleteUsersUserIdTitlesResponse(w http.ResponseWriter) error +} + +type DeleteUsersUserIdTitles200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response DeleteUsersUserIdTitles200JSONResponse) VisitDeleteUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserIdTitlesRequestObject struct { + UserId string `json:"user_id"` + Params GetUsersUserIdTitlesParams +} + +type GetUsersUserIdTitlesResponseObject interface { + VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error +} + +type GetUsersUserIdTitles200JSONResponse []UserTitle + +func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserIdTitles204Response struct { +} + +func (response GetUsersUserIdTitles204Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type PatchUsersUserIdTitlesRequestObject struct { + UserId string `json:"user_id"` + Body *PatchUsersUserIdTitlesJSONRequestBody +} + +type PatchUsersUserIdTitlesResponseObject interface { + VisitPatchUsersUserIdTitlesResponse(w http.ResponseWriter) error +} + +type PatchUsersUserIdTitles200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response PatchUsersUserIdTitles200JSONResponse) VisitPatchUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostUsersUserIdTitlesRequestObject struct { + UserId string `json:"user_id"` + Body *PostUsersUserIdTitlesJSONRequestBody +} + +type PostUsersUserIdTitlesResponseObject interface { + VisitPostUsersUserIdTitlesResponse(w http.ResponseWriter) error +} + +type PostUsersUserIdTitles200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response PostUsersUserIdTitles200JSONResponse) VisitPostUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Get image path + // (GET /media) + GetMedia(ctx context.Context, request GetMediaRequestObject) (GetMediaResponseObject, error) + // Upload image + // (POST /media) + PostMedia(ctx context.Context, request PostMediaRequestObject) (PostMediaResponseObject, error) + // Add review + // (POST /reviews) + PostReviews(ctx context.Context, request PostReviewsRequestObject) (PostReviewsResponseObject, error) + // Delete review + // (DELETE /reviews/{review_id}) + DeleteReviewsReviewId(ctx context.Context, request DeleteReviewsReviewIdRequestObject) (DeleteReviewsReviewIdResponseObject, error) + // Update review + // (PATCH /reviews/{review_id}) + PatchReviewsReviewId(ctx context.Context, request PatchReviewsReviewIdRequestObject) (PatchReviewsReviewIdResponseObject, error) + // Get tags + // (GET /tags) + GetTags(ctx context.Context, request GetTagsRequestObject) (GetTagsResponseObject, error) + // Get titles + // (GET /title) + GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) + // Get title description + // (GET /title/{title_id}) + GetTitleTitleId(ctx context.Context, request GetTitleTitleIdRequestObject) (GetTitleTitleIdResponseObject, error) + // Update title info + // (PATCH /title/{title_id}) + PatchTitleTitleId(ctx context.Context, request PatchTitleTitleIdRequestObject) (PatchTitleTitleIdResponseObject, error) + // Get title reviews + // (GET /title/{title_id}/reviews) + GetTitleTitleIdReviews(ctx context.Context, request GetTitleTitleIdReviewsRequestObject) (GetTitleTitleIdReviewsResponseObject, error) + // Search user + // (GET /users) + GetUsers(ctx context.Context, request GetUsersRequestObject) (GetUsersResponseObject, error) + // Add new user + // (POST /users) + PostUsers(ctx context.Context, request PostUsersRequestObject) (PostUsersResponseObject, error) + // Delete user + // (DELETE /users/{user_id}) + DeleteUsersUserId(ctx context.Context, request DeleteUsersUserIdRequestObject) (DeleteUsersUserIdResponseObject, error) + // Get user info + // (GET /users/{user_id}) + GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) + // Update user + // (PATCH /users/{user_id}) + PatchUsersUserId(ctx context.Context, request PatchUsersUserIdRequestObject) (PatchUsersUserIdResponseObject, error) + // Get user reviews + // (GET /users/{user_id}/reviews) + GetUsersUserIdReviews(ctx context.Context, request GetUsersUserIdReviewsRequestObject) (GetUsersUserIdReviewsResponseObject, error) + // Delete user title + // (DELETE /users/{user_id}/titles) + DeleteUsersUserIdTitles(ctx context.Context, request DeleteUsersUserIdTitlesRequestObject) (DeleteUsersUserIdTitlesResponseObject, error) + // Get user titles + // (GET /users/{user_id}/titles) + GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) + // Update user title + // (PATCH /users/{user_id}/titles) + PatchUsersUserIdTitles(ctx context.Context, request PatchUsersUserIdTitlesRequestObject) (PatchUsersUserIdTitlesResponseObject, error) + // Add user title + // (POST /users/{user_id}/titles) + PostUsersUserIdTitles(ctx context.Context, request PostUsersUserIdTitlesRequestObject) (PostUsersUserIdTitlesResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// GetMedia operation middleware +func (sh *strictHandler) GetMedia(ctx *gin.Context, params GetMediaParams) { + var request GetMediaRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetMedia(ctx, request.(GetMediaRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetMedia") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetMediaResponseObject); ok { + if err := validResponse.VisitGetMediaResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostMedia operation middleware +func (sh *strictHandler) PostMedia(ctx *gin.Context) { + var request PostMediaRequestObject + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostMedia(ctx, request.(PostMediaRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostMedia") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostMediaResponseObject); ok { + if err := validResponse.VisitPostMediaResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostReviews operation middleware +func (sh *strictHandler) PostReviews(ctx *gin.Context) { + var request PostReviewsRequestObject + + var body PostReviewsJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostReviews(ctx, request.(PostReviewsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostReviews") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostReviewsResponseObject); ok { + if err := validResponse.VisitPostReviewsResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// DeleteReviewsReviewId operation middleware +func (sh *strictHandler) DeleteReviewsReviewId(ctx *gin.Context, reviewId string) { + var request DeleteReviewsReviewIdRequestObject + + request.ReviewId = reviewId + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteReviewsReviewId(ctx, request.(DeleteReviewsReviewIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteReviewsReviewId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(DeleteReviewsReviewIdResponseObject); ok { + if err := validResponse.VisitDeleteReviewsReviewIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PatchReviewsReviewId operation middleware +func (sh *strictHandler) PatchReviewsReviewId(ctx *gin.Context, reviewId string) { + var request PatchReviewsReviewIdRequestObject + + request.ReviewId = reviewId + + var body PatchReviewsReviewIdJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PatchReviewsReviewId(ctx, request.(PatchReviewsReviewIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PatchReviewsReviewId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PatchReviewsReviewIdResponseObject); ok { + if err := validResponse.VisitPatchReviewsReviewIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetTags operation middleware +func (sh *strictHandler) GetTags(ctx *gin.Context, params GetTagsParams) { + var request GetTagsRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTags(ctx, request.(GetTagsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTags") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTagsResponseObject); ok { + if err := validResponse.VisitGetTagsResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetTitle operation middleware +func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { + var request GetTitleRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitleResponseObject); ok { + if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetTitleTitleId operation middleware +func (sh *strictHandler) GetTitleTitleId(ctx *gin.Context, titleId string, params GetTitleTitleIdParams) { + var request GetTitleTitleIdRequestObject + + request.TitleId = titleId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitleTitleId(ctx, request.(GetTitleTitleIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitleTitleId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitleTitleIdResponseObject); ok { + if err := validResponse.VisitGetTitleTitleIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PatchTitleTitleId operation middleware +func (sh *strictHandler) PatchTitleTitleId(ctx *gin.Context, titleId string) { + var request PatchTitleTitleIdRequestObject + + request.TitleId = titleId + + var body PatchTitleTitleIdJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PatchTitleTitleId(ctx, request.(PatchTitleTitleIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PatchTitleTitleId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PatchTitleTitleIdResponseObject); ok { + if err := validResponse.VisitPatchTitleTitleIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetTitleTitleIdReviews operation middleware +func (sh *strictHandler) GetTitleTitleIdReviews(ctx *gin.Context, titleId string, params GetTitleTitleIdReviewsParams) { + var request GetTitleTitleIdReviewsRequestObject + + request.TitleId = titleId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitleTitleIdReviews(ctx, request.(GetTitleTitleIdReviewsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitleTitleIdReviews") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitleTitleIdReviewsResponseObject); ok { + if err := validResponse.VisitGetTitleTitleIdReviewsResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUsers operation middleware +func (sh *strictHandler) GetUsers(ctx *gin.Context, params GetUsersParams) { + var request GetUsersRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsers(ctx, request.(GetUsersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsers") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersResponseObject); ok { + if err := validResponse.VisitGetUsersResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostUsers operation middleware +func (sh *strictHandler) PostUsers(ctx *gin.Context) { + var request PostUsersRequestObject + + var body PostUsersJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostUsers(ctx, request.(PostUsersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostUsers") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostUsersResponseObject); ok { + if err := validResponse.VisitPostUsersResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// DeleteUsersUserId operation middleware +func (sh *strictHandler) DeleteUsersUserId(ctx *gin.Context, userId string) { + var request DeleteUsersUserIdRequestObject + + request.UserId = userId + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteUsersUserId(ctx, request.(DeleteUsersUserIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteUsersUserId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(DeleteUsersUserIdResponseObject); ok { + if err := validResponse.VisitDeleteUsersUserIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUsersUserId operation middleware +func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { + var request GetUsersUserIdRequestObject + + request.UserId = userId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsersUserId(ctx, request.(GetUsersUserIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsersUserId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersUserIdResponseObject); ok { + if err := validResponse.VisitGetUsersUserIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PatchUsersUserId operation middleware +func (sh *strictHandler) PatchUsersUserId(ctx *gin.Context, userId string) { + var request PatchUsersUserIdRequestObject + + request.UserId = userId + + var body PatchUsersUserIdJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PatchUsersUserId(ctx, request.(PatchUsersUserIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PatchUsersUserId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PatchUsersUserIdResponseObject); ok { + if err := validResponse.VisitPatchUsersUserIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUsersUserIdReviews operation middleware +func (sh *strictHandler) GetUsersUserIdReviews(ctx *gin.Context, userId string, params GetUsersUserIdReviewsParams) { + var request GetUsersUserIdReviewsRequestObject + + request.UserId = userId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsersUserIdReviews(ctx, request.(GetUsersUserIdReviewsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsersUserIdReviews") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersUserIdReviewsResponseObject); ok { + if err := validResponse.VisitGetUsersUserIdReviewsResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// DeleteUsersUserIdTitles operation middleware +func (sh *strictHandler) DeleteUsersUserIdTitles(ctx *gin.Context, userId string, params DeleteUsersUserIdTitlesParams) { + var request DeleteUsersUserIdTitlesRequestObject + + request.UserId = userId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteUsersUserIdTitles(ctx, request.(DeleteUsersUserIdTitlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteUsersUserIdTitles") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(DeleteUsersUserIdTitlesResponseObject); ok { + if err := validResponse.VisitDeleteUsersUserIdTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUsersUserIdTitles operation middleware +func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { + var request GetUsersUserIdTitlesRequestObject + + request.UserId = userId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsersUserIdTitles(ctx, request.(GetUsersUserIdTitlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsersUserIdTitles") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersUserIdTitlesResponseObject); ok { + if err := validResponse.VisitGetUsersUserIdTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PatchUsersUserIdTitles operation middleware +func (sh *strictHandler) PatchUsersUserIdTitles(ctx *gin.Context, userId string) { + var request PatchUsersUserIdTitlesRequestObject + + request.UserId = userId + + var body PatchUsersUserIdTitlesJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PatchUsersUserIdTitles(ctx, request.(PatchUsersUserIdTitlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PatchUsersUserIdTitles") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PatchUsersUserIdTitlesResponseObject); ok { + if err := validResponse.VisitPatchUsersUserIdTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostUsersUserIdTitles operation middleware +func (sh *strictHandler) PostUsersUserIdTitles(ctx *gin.Context, userId string) { + var request PostUsersUserIdTitlesRequestObject + + request.UserId = userId + + var body PostUsersUserIdTitlesJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostUsersUserIdTitles(ctx, request.(PostUsersUserIdTitlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostUsersUserIdTitles") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostUsersUserIdTitlesResponseObject); ok { + if err := validResponse.VisitPostUsersUserIdTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/modules/backend/api/impl.go b/modules/backend/api/impl.go new file mode 100644 index 0000000..5783f47 --- /dev/null +++ b/modules/backend/api/impl.go @@ -0,0 +1,718 @@ +package api + +import ( + "context" + "encoding/json" + "nyanimedb-server/db" + "strconv" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "golang.org/x/crypto/bcrypt" +) + +type Server struct { + db *db.Queries +} + +func NewServer(db *db.Queries) Server { + return Server{db: db} +} + +// ————————————————————————————————————————————— +// ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ +// ————————————————————————————————————————————— + +func parseInt32(s string) (int32, error) { + i, err := strconv.ParseInt(s, 10, 32) + return int32(i), err +} + +func ptr[T any](v T) *T { return &v } + +func pgInt4ToPtr(v pgtype.Int4) *int32 { + if v.Valid { + return &v.Int32 + } + return nil +} + +func pgTextToPtr(v pgtype.Text) *string { + if v.Valid { + return &v.String + } + return nil +} + +func pgFloat8ToPtr(v pgtype.Float8) *float64 { + if v.Valid { + return &v.Float64 + } + return nil +} + +func jsonbToInterface(data []byte) interface{} { + if data == nil { + return nil + } + var out interface{} + if err := json.Unmarshal(data, &out); err != nil { + return string(data) // fallback + } + return out +} + +// ————————————————————————————————————————————— +// ХЕНДЛЕРЫ +// ————————————————————————————————————————————— + +func (s Server) GetMedia(ctx context.Context, req GetMediaRequestObject) (GetMediaResponseObject, error) { + id, err := parseInt32(req.Params.ImageId) + if err != nil { + return GetMedia200JSONResponse{Success: ptr(false), Error: ptr("invalid image_id")}, nil + } + img, err := s.db.GetImageByID(ctx, id) + if err != nil { + if err == pgx.ErrNoRows { + return GetMedia200JSONResponse{Success: ptr(false), Error: ptr("image not found")}, nil + } + return nil, err + } + return GetMedia200JSONResponse{ + Success: ptr(true), + ImagePath: ptr(img.ImagePath), + }, nil +} + +func (s Server) PostMedia(ctx context.Context, req PostMediaRequestObject) (PostMediaResponseObject, error) { + // ❗ Не реализовано: OpenAPI не определяет тело запроса для загрузки + return PostMedia200JSONResponse{ + Success: ptr(false), + Error: ptr("upload not implemented: request body not defined in spec"), + }, nil +} + +func (s Server) GetUsers(ctx context.Context, req GetUsersRequestObject) (GetUsersResponseObject, error) { + users, err := s.db.ListUsers(ctx, db.ListUsersParams{}) + if err != nil { + return nil, err + } + var resp []User + for _, u := range users { + resp = append(resp, mapUser(u)) + } + return GetUsers200JSONResponse(resp), nil +} + +func (s Server) PostUsers(ctx context.Context, req PostUsersRequestObject) (PostUsersResponseObject, error) { + if req.Body == nil { + return PostUsers200JSONResponse{ + Success: ptr(false), + Error: ptr("request body is required"), + }, nil + } + + body := *req.Body + + // Обязательные поля + nickname, ok := body["nickname"].(string) + if !ok || nickname == "" { + return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("nickname is required")}, nil + } + + mail, ok := body["mail"].(string) + if !ok || mail == "" { + return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("mail is required")}, nil + } + + password, ok := body["password"].(string) + if !ok || password == "" { + return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("password is required")}, nil + } + + // Опциональные поля + var avatarID *int32 + if v, ok := body["avatar_id"].(float64); ok { + id := int32(v) + avatarID = &id + } + + dispName, _ := body["disp_name"].(string) + userDesc, _ := body["user_desc"].(string) + + // 🔐 Хешируем пароль + passhashBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("failed to hash password")}, nil + } + passhash := string(passhashBytes) + + // Сохраняем в БД + _, err = s.db.CreateUser(ctx, db.CreateUserParams{ + AvatarID: pgtype.Int4{ + Int32: 0, + Valid: avatarID != nil, + }, + Passhash: passhash, + Mail: pgtype.Text{ + String: mail, + Valid: true, + }, + Nickname: nickname, + DispName: pgtype.Text{ + String: dispName, + Valid: dispName != "", + }, + UserDesc: pgtype.Text{ + String: userDesc, + Valid: userDesc != "", + }, + CreationDate: pgtype.Timestamp{ + Time: time.Now(), + Valid: true, + }, + }) + if err != nil { + // Проверяем нарушение уникальности (например, дубль mail или nickname) + if err.Error() == "ERROR: duplicate key value violates unique constraint \"users_mail_key\"" || + err.Error() == "ERROR: duplicate key value violates unique constraint \"users_nickname_key\"" { + return PostUsers200JSONResponse{ + Success: ptr(false), + Error: ptr("user with this email or nickname already exists"), + }, nil + } + return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("database error")}, nil + } + + // Получаем созданного пользователя (без passhash и mail!) + // Предположим, что у вас есть запрос GetUserByNickname или аналогичный + // Но проще — вернуть только ID и nickname + + // ⚠️ Поскольку мы не знаем user_id, можно: + // а) добавить RETURNING в CreateUser (рекомендуется), + // б) сделать отдельный SELECT. + + // Пока вернём минимальный ответ + userResp := User{ + "nickname": nickname, + // "user_id" можно добавить, если обновите query.sql + } + + return PostUsers200JSONResponse{ + Success: ptr(true), + UserJson: &userResp, + }, nil +} +func (s Server) DeleteUsersUserId(ctx context.Context, req DeleteUsersUserIdRequestObject) (DeleteUsersUserIdResponseObject, error) { + userID, err := parseInt32(req.UserId) + if err != nil { + return DeleteUsersUserId200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil + } + err = s.db.DeleteUser(ctx, userID) + if err != nil { + if err == pgx.ErrNoRows { + return DeleteUsersUserId200JSONResponse{Success: ptr(false), Error: ptr("user not found")}, nil + } + return nil, err + } + return DeleteUsersUserId200JSONResponse{Success: ptr(true)}, nil +} + +func (s Server) GetUsersUserId(ctx context.Context, req GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) { + userID, err := parseInt32(req.UserId) + if err != nil { + return GetUsersUserId404Response{}, nil + } + user, err := s.db.GetUserByID(ctx, userID) + if err != nil { + if err == pgx.ErrNoRows { + return GetUsersUserId404Response{}, nil + } + return nil, err + } + return GetUsersUserId200JSONResponse(mapUser(user)), nil +} + +func (s Server) PatchUsersUserId(ctx context.Context, req PatchUsersUserIdRequestObject) (PatchUsersUserIdResponseObject, error) { + userID, err := parseInt32(req.UserId) + if err != nil { + return PatchUsersUserId200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil + } + if req.Body == nil { + return PatchUsersUserId200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil + } + + body := *req.Body + args := db.UpdateUserParams{ + UserID: userID, + } + + if v, ok := body["avatar_id"].(float64); ok { + args.AvatarID = pgtype.Int4{Int32: int32(v), Valid: true} + // args.AvatarIDValid = true + } + if v, ok := body["disp_name"].(string); ok { + args.DispName = pgtype.Text{String: v, Valid: true} + // args.DispNameValid = true + } + if v, ok := body["user_desc"].(string); ok { + args.UserDesc = pgtype.Text{String: v, Valid: true} + // args.UserDescValid = true + } + + _, err = s.db.UpdateUser(ctx, args) + if err != nil { + return PatchUsersUserId200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil + } + return PatchUsersUserId200JSONResponse{Success: ptr(true)}, nil +} + +func (s Server) GetUsersUserIdReviews(ctx context.Context, req GetUsersUserIdReviewsRequestObject) (GetUsersUserIdReviewsResponseObject, error) { + userID, err := parseInt32(req.UserId) + if err != nil { + return GetUsersUserIdReviews200JSONResponse{}, nil + } + limit := int32(20) + offset := int32(0) + // if req.Params.Limit != nil { + // limit = int32(*req.Params.Limit) + // } + // if req.Params.Offset != nil { + // offset = int32(*req.Params.Offset) + // } + + reviews, err := s.db.ListReviewsByUser(ctx, db.ListReviewsByUserParams{ + UserID: userID, + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, err + } + + var resp []Review + for _, r := range reviews { + resp = append(resp, mapReview(r)) + } + return GetUsersUserIdReviews200JSONResponse(resp), nil +} + +func (s Server) DeleteUsersUserIdTitles(ctx context.Context, req DeleteUsersUserIdTitlesRequestObject) (DeleteUsersUserIdTitlesResponseObject, error) { + userID, err := parseInt32(req.UserId) + if err != nil { + return DeleteUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil + } + + if req.Params.TitleId != nil { + titleID, err := parseInt32(*req.Params.TitleId) + if err != nil { + return DeleteUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid title_id")}, nil + } + err = s.db.DeleteUserTitle(ctx, db.DeleteUserTitleParams{ + UserID: userID, + Column2: titleID, + }) + if err != nil && err != pgx.ErrNoRows { + return nil, err + } + } + // else { + // err = s.db.DeleteAllUserTitles(ctx, userID) + // if err != nil { + // return nil, err + // } + // } + return DeleteUsersUserIdTitles200JSONResponse{Success: ptr(true)}, nil +} + +func (s Server) GetUsersUserIdTitles(ctx context.Context, req GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) { + userID, err := parseInt32(req.UserId) + if err != nil { + return GetUsersUserIdTitles200JSONResponse{}, nil + } + limit := int32(100) + offset := int32(0) + if req.Params.Limit != nil { + limit = int32(*req.Params.Limit) + } + if req.Params.Offset != nil { + offset = int32(*req.Params.Offset) + } + + titles, err := s.db.ListUserTitles(ctx, db.ListUserTitlesParams{ + UserID: userID, + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, err + } + + var resp []UserTitle + for _, t := range titles { + resp = append(resp, mapUserTitle(t)) + } + return GetUsersUserIdTitles200JSONResponse(resp), nil +} + +func (s Server) PatchUsersUserIdTitles(ctx context.Context, req PatchUsersUserIdTitlesRequestObject) (PatchUsersUserIdTitlesResponseObject, error) { + // userID, err := parseInt32(req.UserId) + // if err != nil { + // return PatchUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil + // } + // if req.Body == nil { + // return PatchUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil + // } + + // body := *req.Body + // titleID, ok := body["title_id"].(float64) + // if !ok { + // return PatchUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("title_id required")}, nil + // } + + // args := db.UpdateUserTitleParams{ + // UserID: userID, + // TitleID: int32(titleID), + // } + + // if v, ok := body["status"].(string); ok { + // args.Status = db.UsertitleStatusT(v) + // // args.StatusValid = true + // } + // if v, ok := body["rate"].(float64); ok { + // args.Rate = pgtype.Int4{Int32: int32(v), Valid: true} + // // args.RateValid = true + // } + // if v, ok := body["review_id"].(float64); ok { + // args.ReviewID = pgtype.Int4{Int32: int32(v), Valid: true} + // // args.ReviewIDValid = true + // } + + // _, err = s.db.UpdateUserTitle(ctx, args) + // if err != nil { + // return PatchUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil + // } + return PatchUsersUserIdTitles200JSONResponse{Success: ptr(true)}, nil +} + +func (s Server) PostUsersUserIdTitles(ctx context.Context, req PostUsersUserIdTitlesRequestObject) (PostUsersUserIdTitlesResponseObject, error) { + userID, err := parseInt32(req.UserId) + if err != nil { + return PostUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil + } + if req.Body == nil { + return PostUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil + } + + body := req.Body + titleID, err := parseInt32(*body.TitleId) + if err != nil { + return PostUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid title_id")}, nil + } + + status := db.UsertitleStatusT("planned") + if body.Status != nil { + status = db.UsertitleStatusT(*body.Status) + } + + _, err = s.db.CreateUserTitle(ctx, db.CreateUserTitleParams{ + UserID: userID, + TitleID: titleID, + Status: status, + Rate: pgtype.Int4{Valid: false}, + ReviewID: pgtype.Int4{Valid: false}, + }) + if err != nil { + return PostUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil + } + return PostUsersUserIdTitles200JSONResponse{Success: ptr(true)}, nil +} + +func (s Server) GetTags(ctx context.Context, req GetTagsRequestObject) (GetTagsResponseObject, error) { + limit := int32(100) + offset := int32(0) + if req.Params.Limit != nil { + limit = int32(*req.Params.Limit) + } + if req.Params.Offset != nil { + offset = int32(*req.Params.Offset) + } + tags, err := s.db.ListTags(ctx, db.ListTagsParams{Limit: limit, Offset: offset}) + if err != nil { + return nil, err + } + var resp []Tag + for _, t := range tags { + resp = append(resp, Tag{ + "tag_id": t.TagID, + "tag_names": jsonbToInterface(t.TagNames), + }) + } + return GetTags200JSONResponse(resp), nil +} + +func (s Server) GetTitle(ctx context.Context, req GetTitleRequestObject) (GetTitleResponseObject, error) { + limit := int32(50) + offset := int32(0) + if req.Params.Limit != nil { + limit = int32(*req.Params.Limit) + } + if req.Params.Offset != nil { + offset = int32(*req.Params.Offset) + } + titles, err := s.db.ListTitles(ctx, db.ListTitlesParams{Limit: limit, Offset: offset}) + if err != nil { + return nil, err + } + var resp []Title + for _, t := range titles { + resp = append(resp, mapTitle(t)) + } + return GetTitle200JSONResponse(resp), nil +} + +func (s Server) GetTitleTitleId(ctx context.Context, req GetTitleTitleIdRequestObject) (GetTitleTitleIdResponseObject, error) { + titleID, err := parseInt32(req.TitleId) + if err != nil { + return GetTitleTitleId404Response{}, nil + } + title, err := s.db.GetTitleByID(ctx, titleID) + if err != nil { + if err == pgx.ErrNoRows { + return GetTitleTitleId404Response{}, nil + } + return nil, err + } + return GetTitleTitleId200JSONResponse(mapTitle(title)), nil +} + +func (s Server) PatchTitleTitleId(ctx context.Context, req PatchTitleTitleIdRequestObject) (PatchTitleTitleIdResponseObject, error) { + titleID, err := parseInt32(req.TitleId) + if err != nil { + return PatchTitleTitleId200JSONResponse{Success: ptr(false), Error: ptr("invalid title_id")}, nil + } + if req.Body == nil { + return PatchTitleTitleId200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil + } + + body := *req.Body + args := db.UpdateTitleParams{ + TitleID: titleID, + } + + if v, ok := body["title_names"].(map[string]interface{}); ok { + data, _ := json.Marshal(v) + args.TitleNames = data + // args.TitleNamesValid = true + } + if v, ok := body["studio_id"].(float64); ok { + args.StudioID = pgtype.Int4{Int32: int32(v), Valid: true} + // args.StudioIDValid = true + } + if v, ok := body["poster_id"].(float64); ok { + args.PosterID = pgtype.Int4{Int32: int32(v), Valid: true} + // args.PosterIDValid = true + } + // if v, ok := body["title_status"].(string); ok { + // args.TitleStatus = db.NullTitleStatusT(v) + // // args.TitleStatusValid = true + // } + if v, ok := body["release_year"].(float64); ok { + args.ReleaseYear = pgtype.Int4{Int32: int32(v), Valid: true} + // args.ReleaseYearValid = true + } + if v, ok := body["episodes_aired"].(float64); ok { + args.EpisodesAired = pgtype.Int4{Int32: int32(v), Valid: true} + // args.EpisodesAiredValid = true + } + if v, ok := body["episodes_all"].(float64); ok { + args.EpisodesAll = pgtype.Int4{Int32: int32(v), Valid: true} + // args.EpisodesAllValid = true + } + + _, err = s.db.UpdateTitle(ctx, args) + if err != nil { + return PatchTitleTitleId200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil + } + return PatchTitleTitleId200JSONResponse{Success: ptr(true)}, nil +} + +func (s Server) GetTitleTitleIdReviews(ctx context.Context, req GetTitleTitleIdReviewsRequestObject) (GetTitleTitleIdReviewsResponseObject, error) { + titleID, err := parseInt32(req.TitleId) + if err != nil { + return GetTitleTitleIdReviews200JSONResponse{}, nil + } + limit := int32(20) + offset := int32(0) + if req.Params.Limit != nil { + limit = int32(*req.Params.Limit) + } + if req.Params.Offset != nil { + offset = int32(*req.Params.Offset) + } + + reviews, err := s.db.ListReviewsByTitle(ctx, db.ListReviewsByTitleParams{ + TitleID: titleID, + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, err + } + + var resp []Review + for _, r := range reviews { + resp = append(resp, mapReview(r)) + } + return GetTitleTitleIdReviews200JSONResponse(resp), nil +} + +func (s Server) PostReviews(ctx context.Context, req PostReviewsRequestObject) (PostReviewsResponseObject, error) { + if req.Body == nil { + return PostReviews200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil + } + + body := *req.Body + userID, ok1 := body["user_id"].(float64) + titleID, ok2 := body["title_id"].(float64) + reviewText, ok3 := body["review_text"].(string) + if !ok1 || !ok2 || !ok3 { + return PostReviews200JSONResponse{Success: ptr(false), Error: ptr("user_id, title_id, review_text required")}, nil + } + + var imageIDs []int32 + if ids, ok := body["image_ids"].([]interface{}); ok { + for _, id := range ids { + if f, ok := id.(float64); ok { + imageIDs = append(imageIDs, int32(f)) + } + } + } + + _, err := s.db.CreateReview(ctx, db.CreateReviewParams{ + UserID: int32(userID), + TitleID: int32(titleID), + ImageIds: imageIDs, + ReviewText: reviewText, + CreationDate: pgtype.Timestamp{Time: time.Now(), Valid: true}, + }) + if err != nil { + return PostReviews200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil + } + + return PostReviews200JSONResponse{Success: ptr(true)}, nil +} + +func (s Server) DeleteReviewsReviewId(ctx context.Context, req DeleteReviewsReviewIdRequestObject) (DeleteReviewsReviewIdResponseObject, error) { + reviewID, err := parseInt32(req.ReviewId) + if err != nil { + return DeleteReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr("invalid review_id")}, nil + } + err = s.db.DeleteReview(ctx, reviewID) + if err != nil { + if err == pgx.ErrNoRows { + return DeleteReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr("review not found")}, nil + } + return nil, err + } + return DeleteReviewsReviewId200JSONResponse{Success: ptr(true)}, nil +} + +func (s Server) PatchReviewsReviewId(ctx context.Context, req PatchReviewsReviewIdRequestObject) (PatchReviewsReviewIdResponseObject, error) { + reviewID, err := parseInt32(req.ReviewId) + if err != nil { + return PatchReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr("invalid review_id")}, nil + } + if req.Body == nil { + return PatchReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil + } + + body := *req.Body + args := db.UpdateReviewParams{ + ReviewID: reviewID, + } + + if v, ok := body["review_text"].(string); ok { + args.ReviewText = pgtype.Text{String: v, Valid: true} + // args.ReviewTextValid = true + } + if ids, ok := body["image_ids"].([]interface{}); ok { + var imageIDs []int32 + for _, id := range ids { + if f, ok := id.(float64); ok { + imageIDs = append(imageIDs, int32(f)) + } + } + args.ImageIds = imageIDs + // args.ImageIdsValid = true + } + + _, err = s.db.UpdateReview(ctx, args) + if err != nil { + return PatchReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil + } + return PatchReviewsReviewId200JSONResponse{Success: ptr(true)}, nil +} + +// ————————————————————————————————————————————— +// МАППИНГИ +// ————————————————————————————————————————————— + +func mapUser(u db.Users) User { + return User{ + "user_id": u.UserID, + "avatar_id": pgInt4ToPtr(u.AvatarID), + "nickname": u.Nickname, + "disp_name": pgTextToPtr(u.DispName), + "user_desc": pgTextToPtr(u.UserDesc), + "creation_date": u.CreationDate.Time, + // mail и passhash НЕ возвращаем! + } +} + +func mapTitle(t db.Titles) Title { + var releaseSeason interface{} + if t.ReleaseSeason.Valid { + releaseSeason = string(t.ReleaseSeason.ReleaseSeasonT) + } + + return Title{ + "title_id": t.TitleID, + "title_names": jsonbToInterface(t.TitleNames), + "studio_id": t.StudioID, + "poster_id": pgInt4ToPtr(t.PosterID), + "signal_ids": t.SignalIds, + "title_status": string(t.TitleStatus), + "rating": pgFloat8ToPtr(t.Rating), + "rating_count": pgInt4ToPtr(t.RatingCount), + "release_year": pgInt4ToPtr(t.ReleaseYear), + "release_season": releaseSeason, + "season": pgInt4ToPtr(t.Season), + "episodes_aired": pgInt4ToPtr(t.EpisodesAired), + "episodes_all": pgInt4ToPtr(t.EpisodesAll), + "episodes_len": jsonbToInterface(t.EpisodesLen), + } +} + +func mapReview(r db.Reviews) Review { + return Review{ + "review_id": r.ReviewID, + "user_id": r.UserID, + "title_id": r.TitleID, + "image_ids": r.ImageIds, + "review_text": r.ReviewText, + "creation_date": r.CreationDate.Time, + } +} + +func mapUserTitle(ut db.Usertitles) UserTitle { + return UserTitle{ + "usertitle_id": ut.UsertitleID, + "user_id": ut.UserID, + "title_id": ut.TitleID, + "status": string(ut.Status), + "rate": pgInt4ToPtr(ut.Rate), + "review_id": pgInt4ToPtr(ut.ReviewID), + } +} From e239c0ca0484c19443eb950a6dd8c3cd262e34d7 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 11 Oct 2025 02:16:48 +0300 Subject: [PATCH 009/224] fix: now use strictgin --- api/gen.go | 1958 +++++++++++++++++++++++++++++++++++++++++ api/oapi-codegen.yaml | 6 + 2 files changed, 1964 insertions(+) create mode 100644 api/gen.go create mode 100644 api/oapi-codegen.yaml diff --git a/api/gen.go b/api/gen.go new file mode 100644 index 0000000..2dcb886 --- /dev/null +++ b/api/gen.go @@ -0,0 +1,1958 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/oapi-codegen/runtime" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" +) + +// Review defines model for Review. +type Review map[string]interface{} + +// Tag defines model for Tag. +type Tag map[string]interface{} + +// Title defines model for Title. +type Title map[string]interface{} + +// User defines model for User. +type User map[string]interface{} + +// UserTitle defines model for UserTitle. +type UserTitle map[string]interface{} + +// GetMediaParams defines parameters for GetMedia. +type GetMediaParams struct { + ImageId string `form:"image_id" json:"image_id"` +} + +// GetTagsParams defines parameters for GetTags. +type GetTagsParams struct { + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetTitleParams defines parameters for GetTitle. +type GetTitleParams struct { + Query *string `form:"query,omitempty" json:"query,omitempty"` + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetTitleTitleIdParams defines parameters for GetTitleTitleId. +type GetTitleTitleIdParams struct { + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetTitleTitleIdReviewsParams defines parameters for GetTitleTitleIdReviews. +type GetTitleTitleIdReviewsParams struct { + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` +} + +// GetUsersParams defines parameters for GetUsers. +type GetUsersParams struct { + Query *string `form:"query,omitempty" json:"query,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetUsersUserIdParams defines parameters for GetUsersUserId. +type GetUsersUserIdParams struct { + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetUsersUserIdReviewsParams defines parameters for GetUsersUserIdReviews. +type GetUsersUserIdReviewsParams struct { + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` +} + +// DeleteUsersUserIdTitlesParams defines parameters for DeleteUsersUserIdTitles. +type DeleteUsersUserIdTitlesParams struct { + TitleId *string `form:"title_id,omitempty" json:"title_id,omitempty"` +} + +// GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. +type GetUsersUserIdTitlesParams struct { + Query *string `form:"query,omitempty" json:"query,omitempty"` + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// PostUsersUserIdTitlesJSONBody defines parameters for PostUsersUserIdTitles. +type PostUsersUserIdTitlesJSONBody struct { + Status *string `json:"status,omitempty"` + TitleId *string `json:"title_id,omitempty"` +} + +// PostReviewsJSONRequestBody defines body for PostReviews for application/json ContentType. +type PostReviewsJSONRequestBody = Review + +// PatchReviewsReviewIdJSONRequestBody defines body for PatchReviewsReviewId for application/json ContentType. +type PatchReviewsReviewIdJSONRequestBody = Review + +// PatchTitleTitleIdJSONRequestBody defines body for PatchTitleTitleId for application/json ContentType. +type PatchTitleTitleIdJSONRequestBody = Title + +// PostUsersJSONRequestBody defines body for PostUsers for application/json ContentType. +type PostUsersJSONRequestBody = User + +// PatchUsersUserIdJSONRequestBody defines body for PatchUsersUserId for application/json ContentType. +type PatchUsersUserIdJSONRequestBody = User + +// PatchUsersUserIdTitlesJSONRequestBody defines body for PatchUsersUserIdTitles for application/json ContentType. +type PatchUsersUserIdTitlesJSONRequestBody = UserTitle + +// PostUsersUserIdTitlesJSONRequestBody defines body for PostUsersUserIdTitles for application/json ContentType. +type PostUsersUserIdTitlesJSONRequestBody PostUsersUserIdTitlesJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Get image path + // (GET /media) + GetMedia(c *gin.Context, params GetMediaParams) + // Upload image + // (POST /media) + PostMedia(c *gin.Context) + // Add review + // (POST /reviews) + PostReviews(c *gin.Context) + // Delete review + // (DELETE /reviews/{review_id}) + DeleteReviewsReviewId(c *gin.Context, reviewId string) + // Update review + // (PATCH /reviews/{review_id}) + PatchReviewsReviewId(c *gin.Context, reviewId string) + // Get tags + // (GET /tags) + GetTags(c *gin.Context, params GetTagsParams) + // Get titles + // (GET /title) + GetTitle(c *gin.Context, params GetTitleParams) + // Get title description + // (GET /title/{title_id}) + GetTitleTitleId(c *gin.Context, titleId string, params GetTitleTitleIdParams) + // Update title info + // (PATCH /title/{title_id}) + PatchTitleTitleId(c *gin.Context, titleId string) + // Get title reviews + // (GET /title/{title_id}/reviews) + GetTitleTitleIdReviews(c *gin.Context, titleId string, params GetTitleTitleIdReviewsParams) + // Search user + // (GET /users) + GetUsers(c *gin.Context, params GetUsersParams) + // Add new user + // (POST /users) + PostUsers(c *gin.Context) + // Delete user + // (DELETE /users/{user_id}) + DeleteUsersUserId(c *gin.Context, userId string) + // Get user info + // (GET /users/{user_id}) + GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) + // Update user + // (PATCH /users/{user_id}) + PatchUsersUserId(c *gin.Context, userId string) + // Get user reviews + // (GET /users/{user_id}/reviews) + GetUsersUserIdReviews(c *gin.Context, userId string, params GetUsersUserIdReviewsParams) + // Delete user title + // (DELETE /users/{user_id}/titles) + DeleteUsersUserIdTitles(c *gin.Context, userId string, params DeleteUsersUserIdTitlesParams) + // Get user titles + // (GET /users/{user_id}/titles) + GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) + // Update user title + // (PATCH /users/{user_id}/titles) + PatchUsersUserIdTitles(c *gin.Context, userId string) + // Add user title + // (POST /users/{user_id}/titles) + PostUsersUserIdTitles(c *gin.Context, userId string) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// GetMedia operation middleware +func (siw *ServerInterfaceWrapper) GetMedia(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetMediaParams + + // ------------- Required query parameter "image_id" ------------- + + if paramValue := c.Query("image_id"); paramValue != "" { + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument image_id is required, but not found"), http.StatusBadRequest) + return + } + + err = runtime.BindQueryParameter("form", true, true, "image_id", c.Request.URL.Query(), ¶ms.ImageId) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter image_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetMedia(c, params) +} + +// PostMedia operation middleware +func (siw *ServerInterfaceWrapper) PostMedia(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostMedia(c) +} + +// PostReviews operation middleware +func (siw *ServerInterfaceWrapper) PostReviews(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostReviews(c) +} + +// DeleteReviewsReviewId operation middleware +func (siw *ServerInterfaceWrapper) DeleteReviewsReviewId(c *gin.Context) { + + var err error + + // ------------- Path parameter "review_id" ------------- + var reviewId string + + err = runtime.BindStyledParameterWithOptions("simple", "review_id", c.Param("review_id"), &reviewId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter review_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteReviewsReviewId(c, reviewId) +} + +// PatchReviewsReviewId operation middleware +func (siw *ServerInterfaceWrapper) PatchReviewsReviewId(c *gin.Context) { + + var err error + + // ------------- Path parameter "review_id" ------------- + var reviewId string + + err = runtime.BindStyledParameterWithOptions("simple", "review_id", c.Param("review_id"), &reviewId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter review_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PatchReviewsReviewId(c, reviewId) +} + +// GetTags operation middleware +func (siw *ServerInterfaceWrapper) GetTags(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetTagsParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTags(c, params) +} + +// GetTitle operation middleware +func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitleParams + + // ------------- Optional query parameter "query" ------------- + + err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitle(c, params) +} + +// GetTitleTitleId operation middleware +func (siw *ServerInterfaceWrapper) GetTitleTitleId(c *gin.Context) { + + var err error + + // ------------- Path parameter "title_id" ------------- + var titleId string + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitleTitleIdParams + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitleTitleId(c, titleId, params) +} + +// PatchTitleTitleId operation middleware +func (siw *ServerInterfaceWrapper) PatchTitleTitleId(c *gin.Context) { + + var err error + + // ------------- Path parameter "title_id" ------------- + var titleId string + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PatchTitleTitleId(c, titleId) +} + +// GetTitleTitleIdReviews operation middleware +func (siw *ServerInterfaceWrapper) GetTitleTitleIdReviews(c *gin.Context) { + + var err error + + // ------------- Path parameter "title_id" ------------- + var titleId string + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitleTitleIdReviewsParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitleTitleIdReviews(c, titleId, params) +} + +// GetUsers operation middleware +func (siw *ServerInterfaceWrapper) GetUsers(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersParams + + // ------------- Optional query parameter "query" ------------- + + err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsers(c, params) +} + +// PostUsers operation middleware +func (siw *ServerInterfaceWrapper) PostUsers(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostUsers(c) +} + +// DeleteUsersUserId operation middleware +func (siw *ServerInterfaceWrapper) DeleteUsersUserId(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteUsersUserId(c, userId) +} + +// GetUsersUserId operation middleware +func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersUserIdParams + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsersUserId(c, userId, params) +} + +// PatchUsersUserId operation middleware +func (siw *ServerInterfaceWrapper) PatchUsersUserId(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PatchUsersUserId(c, userId) +} + +// GetUsersUserIdReviews operation middleware +func (siw *ServerInterfaceWrapper) GetUsersUserIdReviews(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersUserIdReviewsParams + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsersUserIdReviews(c, userId, params) +} + +// DeleteUsersUserIdTitles operation middleware +func (siw *ServerInterfaceWrapper) DeleteUsersUserIdTitles(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params DeleteUsersUserIdTitlesParams + + // ------------- Optional query parameter "title_id" ------------- + + err = runtime.BindQueryParameter("form", true, false, "title_id", c.Request.URL.Query(), ¶ms.TitleId) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteUsersUserIdTitles(c, userId, params) +} + +// GetUsersUserIdTitles operation middleware +func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersUserIdTitlesParams + + // ------------- Optional query parameter "query" ------------- + + err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsersUserIdTitles(c, userId, params) +} + +// PatchUsersUserIdTitles operation middleware +func (siw *ServerInterfaceWrapper) PatchUsersUserIdTitles(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PatchUsersUserIdTitles(c, userId) +} + +// PostUsersUserIdTitles operation middleware +func (siw *ServerInterfaceWrapper) PostUsersUserIdTitles(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostUsersUserIdTitles(c, userId) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.GET(options.BaseURL+"/media", wrapper.GetMedia) + router.POST(options.BaseURL+"/media", wrapper.PostMedia) + router.POST(options.BaseURL+"/reviews", wrapper.PostReviews) + router.DELETE(options.BaseURL+"/reviews/:review_id", wrapper.DeleteReviewsReviewId) + router.PATCH(options.BaseURL+"/reviews/:review_id", wrapper.PatchReviewsReviewId) + router.GET(options.BaseURL+"/tags", wrapper.GetTags) + router.GET(options.BaseURL+"/title", wrapper.GetTitle) + router.GET(options.BaseURL+"/title/:title_id", wrapper.GetTitleTitleId) + router.PATCH(options.BaseURL+"/title/:title_id", wrapper.PatchTitleTitleId) + router.GET(options.BaseURL+"/title/:title_id/reviews", wrapper.GetTitleTitleIdReviews) + router.GET(options.BaseURL+"/users", wrapper.GetUsers) + router.POST(options.BaseURL+"/users", wrapper.PostUsers) + router.DELETE(options.BaseURL+"/users/:user_id", wrapper.DeleteUsersUserId) + router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) + router.PATCH(options.BaseURL+"/users/:user_id", wrapper.PatchUsersUserId) + router.GET(options.BaseURL+"/users/:user_id/reviews", wrapper.GetUsersUserIdReviews) + router.DELETE(options.BaseURL+"/users/:user_id/titles", wrapper.DeleteUsersUserIdTitles) + router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) + router.PATCH(options.BaseURL+"/users/:user_id/titles", wrapper.PatchUsersUserIdTitles) + router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.PostUsersUserIdTitles) +} + +type GetMediaRequestObject struct { + Params GetMediaParams +} + +type GetMediaResponseObject interface { + VisitGetMediaResponse(w http.ResponseWriter) error +} + +type GetMedia200JSONResponse struct { + Error *string `json:"error,omitempty"` + ImagePath *string `json:"image_path,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response GetMedia200JSONResponse) VisitGetMediaResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostMediaRequestObject struct { +} + +type PostMediaResponseObject interface { + VisitPostMediaResponse(w http.ResponseWriter) error +} + +type PostMedia200JSONResponse struct { + Error *string `json:"error,omitempty"` + ImageId *string `json:"image_id,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response PostMedia200JSONResponse) VisitPostMediaResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostReviewsRequestObject struct { + Body *PostReviewsJSONRequestBody +} + +type PostReviewsResponseObject interface { + VisitPostReviewsResponse(w http.ResponseWriter) error +} + +type PostReviews200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response PostReviews200JSONResponse) VisitPostReviewsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteReviewsReviewIdRequestObject struct { + ReviewId string `json:"review_id"` +} + +type DeleteReviewsReviewIdResponseObject interface { + VisitDeleteReviewsReviewIdResponse(w http.ResponseWriter) error +} + +type DeleteReviewsReviewId200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response DeleteReviewsReviewId200JSONResponse) VisitDeleteReviewsReviewIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PatchReviewsReviewIdRequestObject struct { + ReviewId string `json:"review_id"` + Body *PatchReviewsReviewIdJSONRequestBody +} + +type PatchReviewsReviewIdResponseObject interface { + VisitPatchReviewsReviewIdResponse(w http.ResponseWriter) error +} + +type PatchReviewsReviewId200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response PatchReviewsReviewId200JSONResponse) VisitPatchReviewsReviewIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTagsRequestObject struct { + Params GetTagsParams +} + +type GetTagsResponseObject interface { + VisitGetTagsResponse(w http.ResponseWriter) error +} + +type GetTags200JSONResponse []Tag + +func (response GetTags200JSONResponse) VisitGetTagsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitleRequestObject struct { + Params GetTitleParams +} + +type GetTitleResponseObject interface { + VisitGetTitleResponse(w http.ResponseWriter) error +} + +type GetTitle200JSONResponse []Title + +func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitle204Response struct { +} + +func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetTitleTitleIdRequestObject struct { + TitleId string `json:"title_id"` + Params GetTitleTitleIdParams +} + +type GetTitleTitleIdResponseObject interface { + VisitGetTitleTitleIdResponse(w http.ResponseWriter) error +} + +type GetTitleTitleId200JSONResponse Title + +func (response GetTitleTitleId200JSONResponse) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitleTitleId404Response struct { +} + +func (response GetTitleTitleId404Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type PatchTitleTitleIdRequestObject struct { + TitleId string `json:"title_id"` + Body *PatchTitleTitleIdJSONRequestBody +} + +type PatchTitleTitleIdResponseObject interface { + VisitPatchTitleTitleIdResponse(w http.ResponseWriter) error +} + +type PatchTitleTitleId200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` + UserJson *User `json:"user_json,omitempty"` +} + +func (response PatchTitleTitleId200JSONResponse) VisitPatchTitleTitleIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitleTitleIdReviewsRequestObject struct { + TitleId string `json:"title_id"` + Params GetTitleTitleIdReviewsParams +} + +type GetTitleTitleIdReviewsResponseObject interface { + VisitGetTitleTitleIdReviewsResponse(w http.ResponseWriter) error +} + +type GetTitleTitleIdReviews200JSONResponse []Review + +func (response GetTitleTitleIdReviews200JSONResponse) VisitGetTitleTitleIdReviewsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitleTitleIdReviews204Response struct { +} + +func (response GetTitleTitleIdReviews204Response) VisitGetTitleTitleIdReviewsResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetUsersRequestObject struct { + Params GetUsersParams +} + +type GetUsersResponseObject interface { + VisitGetUsersResponse(w http.ResponseWriter) error +} + +type GetUsers200JSONResponse []User + +func (response GetUsers200JSONResponse) VisitGetUsersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostUsersRequestObject struct { + Body *PostUsersJSONRequestBody +} + +type PostUsersResponseObject interface { + VisitPostUsersResponse(w http.ResponseWriter) error +} + +type PostUsers200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` + UserJson *User `json:"user_json,omitempty"` +} + +func (response PostUsers200JSONResponse) VisitPostUsersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteUsersUserIdRequestObject struct { + UserId string `json:"user_id"` +} + +type DeleteUsersUserIdResponseObject interface { + VisitDeleteUsersUserIdResponse(w http.ResponseWriter) error +} + +type DeleteUsersUserId200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response DeleteUsersUserId200JSONResponse) VisitDeleteUsersUserIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserIdRequestObject struct { + UserId string `json:"user_id"` + Params GetUsersUserIdParams +} + +type GetUsersUserIdResponseObject interface { + VisitGetUsersUserIdResponse(w http.ResponseWriter) error +} + +type GetUsersUserId200JSONResponse User + +func (response GetUsersUserId200JSONResponse) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserId404Response struct { +} + +func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type PatchUsersUserIdRequestObject struct { + UserId string `json:"user_id"` + Body *PatchUsersUserIdJSONRequestBody +} + +type PatchUsersUserIdResponseObject interface { + VisitPatchUsersUserIdResponse(w http.ResponseWriter) error +} + +type PatchUsersUserId200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response PatchUsersUserId200JSONResponse) VisitPatchUsersUserIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserIdReviewsRequestObject struct { + UserId string `json:"user_id"` + Params GetUsersUserIdReviewsParams +} + +type GetUsersUserIdReviewsResponseObject interface { + VisitGetUsersUserIdReviewsResponse(w http.ResponseWriter) error +} + +type GetUsersUserIdReviews200JSONResponse []Review + +func (response GetUsersUserIdReviews200JSONResponse) VisitGetUsersUserIdReviewsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteUsersUserIdTitlesRequestObject struct { + UserId string `json:"user_id"` + Params DeleteUsersUserIdTitlesParams +} + +type DeleteUsersUserIdTitlesResponseObject interface { + VisitDeleteUsersUserIdTitlesResponse(w http.ResponseWriter) error +} + +type DeleteUsersUserIdTitles200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response DeleteUsersUserIdTitles200JSONResponse) VisitDeleteUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserIdTitlesRequestObject struct { + UserId string `json:"user_id"` + Params GetUsersUserIdTitlesParams +} + +type GetUsersUserIdTitlesResponseObject interface { + VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error +} + +type GetUsersUserIdTitles200JSONResponse []UserTitle + +func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserIdTitles204Response struct { +} + +func (response GetUsersUserIdTitles204Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type PatchUsersUserIdTitlesRequestObject struct { + UserId string `json:"user_id"` + Body *PatchUsersUserIdTitlesJSONRequestBody +} + +type PatchUsersUserIdTitlesResponseObject interface { + VisitPatchUsersUserIdTitlesResponse(w http.ResponseWriter) error +} + +type PatchUsersUserIdTitles200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response PatchUsersUserIdTitles200JSONResponse) VisitPatchUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostUsersUserIdTitlesRequestObject struct { + UserId string `json:"user_id"` + Body *PostUsersUserIdTitlesJSONRequestBody +} + +type PostUsersUserIdTitlesResponseObject interface { + VisitPostUsersUserIdTitlesResponse(w http.ResponseWriter) error +} + +type PostUsersUserIdTitles200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` +} + +func (response PostUsersUserIdTitles200JSONResponse) VisitPostUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Get image path + // (GET /media) + GetMedia(ctx context.Context, request GetMediaRequestObject) (GetMediaResponseObject, error) + // Upload image + // (POST /media) + PostMedia(ctx context.Context, request PostMediaRequestObject) (PostMediaResponseObject, error) + // Add review + // (POST /reviews) + PostReviews(ctx context.Context, request PostReviewsRequestObject) (PostReviewsResponseObject, error) + // Delete review + // (DELETE /reviews/{review_id}) + DeleteReviewsReviewId(ctx context.Context, request DeleteReviewsReviewIdRequestObject) (DeleteReviewsReviewIdResponseObject, error) + // Update review + // (PATCH /reviews/{review_id}) + PatchReviewsReviewId(ctx context.Context, request PatchReviewsReviewIdRequestObject) (PatchReviewsReviewIdResponseObject, error) + // Get tags + // (GET /tags) + GetTags(ctx context.Context, request GetTagsRequestObject) (GetTagsResponseObject, error) + // Get titles + // (GET /title) + GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) + // Get title description + // (GET /title/{title_id}) + GetTitleTitleId(ctx context.Context, request GetTitleTitleIdRequestObject) (GetTitleTitleIdResponseObject, error) + // Update title info + // (PATCH /title/{title_id}) + PatchTitleTitleId(ctx context.Context, request PatchTitleTitleIdRequestObject) (PatchTitleTitleIdResponseObject, error) + // Get title reviews + // (GET /title/{title_id}/reviews) + GetTitleTitleIdReviews(ctx context.Context, request GetTitleTitleIdReviewsRequestObject) (GetTitleTitleIdReviewsResponseObject, error) + // Search user + // (GET /users) + GetUsers(ctx context.Context, request GetUsersRequestObject) (GetUsersResponseObject, error) + // Add new user + // (POST /users) + PostUsers(ctx context.Context, request PostUsersRequestObject) (PostUsersResponseObject, error) + // Delete user + // (DELETE /users/{user_id}) + DeleteUsersUserId(ctx context.Context, request DeleteUsersUserIdRequestObject) (DeleteUsersUserIdResponseObject, error) + // Get user info + // (GET /users/{user_id}) + GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) + // Update user + // (PATCH /users/{user_id}) + PatchUsersUserId(ctx context.Context, request PatchUsersUserIdRequestObject) (PatchUsersUserIdResponseObject, error) + // Get user reviews + // (GET /users/{user_id}/reviews) + GetUsersUserIdReviews(ctx context.Context, request GetUsersUserIdReviewsRequestObject) (GetUsersUserIdReviewsResponseObject, error) + // Delete user title + // (DELETE /users/{user_id}/titles) + DeleteUsersUserIdTitles(ctx context.Context, request DeleteUsersUserIdTitlesRequestObject) (DeleteUsersUserIdTitlesResponseObject, error) + // Get user titles + // (GET /users/{user_id}/titles) + GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) + // Update user title + // (PATCH /users/{user_id}/titles) + PatchUsersUserIdTitles(ctx context.Context, request PatchUsersUserIdTitlesRequestObject) (PatchUsersUserIdTitlesResponseObject, error) + // Add user title + // (POST /users/{user_id}/titles) + PostUsersUserIdTitles(ctx context.Context, request PostUsersUserIdTitlesRequestObject) (PostUsersUserIdTitlesResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// GetMedia operation middleware +func (sh *strictHandler) GetMedia(ctx *gin.Context, params GetMediaParams) { + var request GetMediaRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetMedia(ctx, request.(GetMediaRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetMedia") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetMediaResponseObject); ok { + if err := validResponse.VisitGetMediaResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostMedia operation middleware +func (sh *strictHandler) PostMedia(ctx *gin.Context) { + var request PostMediaRequestObject + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostMedia(ctx, request.(PostMediaRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostMedia") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostMediaResponseObject); ok { + if err := validResponse.VisitPostMediaResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostReviews operation middleware +func (sh *strictHandler) PostReviews(ctx *gin.Context) { + var request PostReviewsRequestObject + + var body PostReviewsJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostReviews(ctx, request.(PostReviewsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostReviews") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostReviewsResponseObject); ok { + if err := validResponse.VisitPostReviewsResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// DeleteReviewsReviewId operation middleware +func (sh *strictHandler) DeleteReviewsReviewId(ctx *gin.Context, reviewId string) { + var request DeleteReviewsReviewIdRequestObject + + request.ReviewId = reviewId + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteReviewsReviewId(ctx, request.(DeleteReviewsReviewIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteReviewsReviewId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(DeleteReviewsReviewIdResponseObject); ok { + if err := validResponse.VisitDeleteReviewsReviewIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PatchReviewsReviewId operation middleware +func (sh *strictHandler) PatchReviewsReviewId(ctx *gin.Context, reviewId string) { + var request PatchReviewsReviewIdRequestObject + + request.ReviewId = reviewId + + var body PatchReviewsReviewIdJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PatchReviewsReviewId(ctx, request.(PatchReviewsReviewIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PatchReviewsReviewId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PatchReviewsReviewIdResponseObject); ok { + if err := validResponse.VisitPatchReviewsReviewIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetTags operation middleware +func (sh *strictHandler) GetTags(ctx *gin.Context, params GetTagsParams) { + var request GetTagsRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTags(ctx, request.(GetTagsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTags") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTagsResponseObject); ok { + if err := validResponse.VisitGetTagsResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetTitle operation middleware +func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { + var request GetTitleRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitleResponseObject); ok { + if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetTitleTitleId operation middleware +func (sh *strictHandler) GetTitleTitleId(ctx *gin.Context, titleId string, params GetTitleTitleIdParams) { + var request GetTitleTitleIdRequestObject + + request.TitleId = titleId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitleTitleId(ctx, request.(GetTitleTitleIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitleTitleId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitleTitleIdResponseObject); ok { + if err := validResponse.VisitGetTitleTitleIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PatchTitleTitleId operation middleware +func (sh *strictHandler) PatchTitleTitleId(ctx *gin.Context, titleId string) { + var request PatchTitleTitleIdRequestObject + + request.TitleId = titleId + + var body PatchTitleTitleIdJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PatchTitleTitleId(ctx, request.(PatchTitleTitleIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PatchTitleTitleId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PatchTitleTitleIdResponseObject); ok { + if err := validResponse.VisitPatchTitleTitleIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetTitleTitleIdReviews operation middleware +func (sh *strictHandler) GetTitleTitleIdReviews(ctx *gin.Context, titleId string, params GetTitleTitleIdReviewsParams) { + var request GetTitleTitleIdReviewsRequestObject + + request.TitleId = titleId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitleTitleIdReviews(ctx, request.(GetTitleTitleIdReviewsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitleTitleIdReviews") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitleTitleIdReviewsResponseObject); ok { + if err := validResponse.VisitGetTitleTitleIdReviewsResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUsers operation middleware +func (sh *strictHandler) GetUsers(ctx *gin.Context, params GetUsersParams) { + var request GetUsersRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsers(ctx, request.(GetUsersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsers") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersResponseObject); ok { + if err := validResponse.VisitGetUsersResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostUsers operation middleware +func (sh *strictHandler) PostUsers(ctx *gin.Context) { + var request PostUsersRequestObject + + var body PostUsersJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostUsers(ctx, request.(PostUsersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostUsers") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostUsersResponseObject); ok { + if err := validResponse.VisitPostUsersResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// DeleteUsersUserId operation middleware +func (sh *strictHandler) DeleteUsersUserId(ctx *gin.Context, userId string) { + var request DeleteUsersUserIdRequestObject + + request.UserId = userId + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteUsersUserId(ctx, request.(DeleteUsersUserIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteUsersUserId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(DeleteUsersUserIdResponseObject); ok { + if err := validResponse.VisitDeleteUsersUserIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUsersUserId operation middleware +func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { + var request GetUsersUserIdRequestObject + + request.UserId = userId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsersUserId(ctx, request.(GetUsersUserIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsersUserId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersUserIdResponseObject); ok { + if err := validResponse.VisitGetUsersUserIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PatchUsersUserId operation middleware +func (sh *strictHandler) PatchUsersUserId(ctx *gin.Context, userId string) { + var request PatchUsersUserIdRequestObject + + request.UserId = userId + + var body PatchUsersUserIdJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PatchUsersUserId(ctx, request.(PatchUsersUserIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PatchUsersUserId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PatchUsersUserIdResponseObject); ok { + if err := validResponse.VisitPatchUsersUserIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUsersUserIdReviews operation middleware +func (sh *strictHandler) GetUsersUserIdReviews(ctx *gin.Context, userId string, params GetUsersUserIdReviewsParams) { + var request GetUsersUserIdReviewsRequestObject + + request.UserId = userId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsersUserIdReviews(ctx, request.(GetUsersUserIdReviewsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsersUserIdReviews") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersUserIdReviewsResponseObject); ok { + if err := validResponse.VisitGetUsersUserIdReviewsResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// DeleteUsersUserIdTitles operation middleware +func (sh *strictHandler) DeleteUsersUserIdTitles(ctx *gin.Context, userId string, params DeleteUsersUserIdTitlesParams) { + var request DeleteUsersUserIdTitlesRequestObject + + request.UserId = userId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteUsersUserIdTitles(ctx, request.(DeleteUsersUserIdTitlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteUsersUserIdTitles") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(DeleteUsersUserIdTitlesResponseObject); ok { + if err := validResponse.VisitDeleteUsersUserIdTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUsersUserIdTitles operation middleware +func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { + var request GetUsersUserIdTitlesRequestObject + + request.UserId = userId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsersUserIdTitles(ctx, request.(GetUsersUserIdTitlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsersUserIdTitles") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersUserIdTitlesResponseObject); ok { + if err := validResponse.VisitGetUsersUserIdTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PatchUsersUserIdTitles operation middleware +func (sh *strictHandler) PatchUsersUserIdTitles(ctx *gin.Context, userId string) { + var request PatchUsersUserIdTitlesRequestObject + + request.UserId = userId + + var body PatchUsersUserIdTitlesJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PatchUsersUserIdTitles(ctx, request.(PatchUsersUserIdTitlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PatchUsersUserIdTitles") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PatchUsersUserIdTitlesResponseObject); ok { + if err := validResponse.VisitPatchUsersUserIdTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostUsersUserIdTitles operation middleware +func (sh *strictHandler) PostUsersUserIdTitles(ctx *gin.Context, userId string) { + var request PostUsersUserIdTitlesRequestObject + + request.UserId = userId + + var body PostUsersUserIdTitlesJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostUsersUserIdTitles(ctx, request.(PostUsersUserIdTitlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostUsersUserIdTitles") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostUsersUserIdTitlesResponseObject); ok { + if err := validResponse.VisitPostUsersUserIdTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/api/oapi-codegen.yaml b/api/oapi-codegen.yaml new file mode 100644 index 0000000..1d470ac --- /dev/null +++ b/api/oapi-codegen.yaml @@ -0,0 +1,6 @@ +package: api +generate: + strict-server: true + gin-server: true + models: true +output: gen.go \ No newline at end of file From eda59c68d3eca76515f2ad661c4d62d35af6d4a7 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 11 Oct 2025 02:18:14 +0300 Subject: [PATCH 010/224] feat: all the work with db now here, for some time it dups ../api dir --- modules/backend/db/db.go | 32 ++ modules/backend/db/models.go | 266 ++++++++++++ modules/backend/db/query.sql.go | 712 ++++++++++++++++++++++++++++++++ 3 files changed, 1010 insertions(+) create mode 100644 modules/backend/db/db.go create mode 100644 modules/backend/db/models.go create mode 100644 modules/backend/db/query.sql.go diff --git a/modules/backend/db/db.go b/modules/backend/db/db.go new file mode 100644 index 0000000..9d485b5 --- /dev/null +++ b/modules/backend/db/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/modules/backend/db/models.go b/modules/backend/db/models.go new file mode 100644 index 0000000..ba76782 --- /dev/null +++ b/modules/backend/db/models.go @@ -0,0 +1,266 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "database/sql/driver" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" +) + +type ReleaseSeasonT string + +const ( + ReleaseSeasonTWinter ReleaseSeasonT = "winter" + ReleaseSeasonTSpring ReleaseSeasonT = "spring" + ReleaseSeasonTSummer ReleaseSeasonT = "summer" + ReleaseSeasonTFall ReleaseSeasonT = "fall" +) + +func (e *ReleaseSeasonT) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ReleaseSeasonT(s) + case string: + *e = ReleaseSeasonT(s) + default: + return fmt.Errorf("unsupported scan type for ReleaseSeasonT: %T", src) + } + return nil +} + +type NullReleaseSeasonT struct { + ReleaseSeasonT ReleaseSeasonT `json:"release_season_t"` + Valid bool `json:"valid"` // Valid is true if ReleaseSeasonT is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullReleaseSeasonT) Scan(value interface{}) error { + if value == nil { + ns.ReleaseSeasonT, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ReleaseSeasonT.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullReleaseSeasonT) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ReleaseSeasonT), nil +} + +type StorageTypeT string + +const ( + StorageTypeTLocal StorageTypeT = "local" + StorageTypeTS3 StorageTypeT = "s3" +) + +func (e *StorageTypeT) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = StorageTypeT(s) + case string: + *e = StorageTypeT(s) + default: + return fmt.Errorf("unsupported scan type for StorageTypeT: %T", src) + } + return nil +} + +type NullStorageTypeT struct { + StorageTypeT StorageTypeT `json:"storage_type_t"` + Valid bool `json:"valid"` // Valid is true if StorageTypeT is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullStorageTypeT) Scan(value interface{}) error { + if value == nil { + ns.StorageTypeT, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.StorageTypeT.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullStorageTypeT) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.StorageTypeT), nil +} + +type TitleStatusT string + +const ( + TitleStatusTFinished TitleStatusT = "finished" + TitleStatusTOngoing TitleStatusT = "ongoing" + TitleStatusTPlanned TitleStatusT = "planned" +) + +func (e *TitleStatusT) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = TitleStatusT(s) + case string: + *e = TitleStatusT(s) + default: + return fmt.Errorf("unsupported scan type for TitleStatusT: %T", src) + } + return nil +} + +type NullTitleStatusT struct { + TitleStatusT TitleStatusT `json:"title_status_t"` + Valid bool `json:"valid"` // Valid is true if TitleStatusT is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullTitleStatusT) Scan(value interface{}) error { + if value == nil { + ns.TitleStatusT, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.TitleStatusT.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullTitleStatusT) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.TitleStatusT), nil +} + +type UsertitleStatusT string + +const ( + UsertitleStatusTFinished UsertitleStatusT = "finished" + UsertitleStatusTPlanned UsertitleStatusT = "planned" + UsertitleStatusTDropped UsertitleStatusT = "dropped" + UsertitleStatusTInProgress UsertitleStatusT = "in-progress" +) + +func (e *UsertitleStatusT) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = UsertitleStatusT(s) + case string: + *e = UsertitleStatusT(s) + default: + return fmt.Errorf("unsupported scan type for UsertitleStatusT: %T", src) + } + return nil +} + +type NullUsertitleStatusT struct { + UsertitleStatusT UsertitleStatusT `json:"usertitle_status_t"` + Valid bool `json:"valid"` // Valid is true if UsertitleStatusT is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullUsertitleStatusT) Scan(value interface{}) error { + if value == nil { + ns.UsertitleStatusT, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.UsertitleStatusT.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullUsertitleStatusT) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.UsertitleStatusT), nil +} + +type Images struct { + ImageID int32 `db:"image_id" json:"image_id"` + StorageType StorageTypeT `db:"storage_type" json:"storage_type"` + ImagePath string `db:"image_path" json:"image_path"` +} + +type Providers struct { + ProviderID int32 `db:"provider_id" json:"provider_id"` + ProviderName string `db:"provider_name" json:"provider_name"` +} + +type Reviews struct { + ReviewID int32 `db:"review_id" json:"review_id"` + UserID int32 `db:"user_id" json:"user_id"` + TitleID int32 `db:"title_id" json:"title_id"` + ImageIds []int32 `db:"image_ids" json:"image_ids"` + ReviewText string `db:"review_text" json:"review_text"` + CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` +} + +type Signals struct { + SignalID int32 `db:"signal_id" json:"signal_id"` + RawData []byte `db:"raw_data" json:"raw_data"` + ProviderID int32 `db:"provider_id" json:"provider_id"` + Dirty bool `db:"dirty" json:"dirty"` +} + +type Studios struct { + StudioID int32 `db:"studio_id" json:"studio_id"` + StudioName pgtype.Text `db:"studio_name" json:"studio_name"` + IllustID pgtype.Int4 `db:"illust_id" json:"illust_id"` + StudioDesc pgtype.Text `db:"studio_desc" json:"studio_desc"` +} + +type Tags struct { + TagID int32 `db:"tag_id" json:"tag_id"` + TagNames []byte `db:"tag_names" json:"tag_names"` +} + +type TitleTags struct { + TitleID int32 `db:"title_id" json:"title_id"` + TagID int32 `db:"tag_id" json:"tag_id"` +} + +type Titles struct { + TitleID int32 `db:"title_id" json:"title_id"` + TitleNames []byte `db:"title_names" json:"title_names"` + StudioID int32 `db:"studio_id" json:"studio_id"` + PosterID pgtype.Int4 `db:"poster_id" json:"poster_id"` + SignalIds []int32 `db:"signal_ids" json:"signal_ids"` + TitleStatus TitleStatusT `db:"title_status" json:"title_status"` + Rating pgtype.Float8 `db:"rating" json:"rating"` + RatingCount pgtype.Int4 `db:"rating_count" json:"rating_count"` + ReleaseYear pgtype.Int4 `db:"release_year" json:"release_year"` + ReleaseSeason NullReleaseSeasonT `db:"release_season" json:"release_season"` + Season pgtype.Int4 `db:"season" json:"season"` + EpisodesAired pgtype.Int4 `db:"episodes_aired" json:"episodes_aired"` + EpisodesAll pgtype.Int4 `db:"episodes_all" json:"episodes_all"` + EpisodesLen []byte `db:"episodes_len" json:"episodes_len"` +} + +type Users struct { + UserID int32 `db:"user_id" json:"user_id"` + AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` + Passhash string `db:"passhash" json:"passhash"` + Mail pgtype.Text `db:"mail" json:"mail"` + Nickname string `db:"nickname" json:"nickname"` + DispName pgtype.Text `db:"disp_name" json:"disp_name"` + UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` + CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` +} + +type Usertitles struct { + UsertitleID int32 `db:"usertitle_id" json:"usertitle_id"` + UserID int32 `db:"user_id" json:"user_id"` + TitleID int32 `db:"title_id" json:"title_id"` + Status UsertitleStatusT `db:"status" json:"status"` + Rate pgtype.Int4 `db:"rate" json:"rate"` + ReviewID pgtype.Int4 `db:"review_id" json:"review_id"` +} diff --git a/modules/backend/db/query.sql.go b/modules/backend/db/query.sql.go new file mode 100644 index 0000000..1fc06ac --- /dev/null +++ b/modules/backend/db/query.sql.go @@ -0,0 +1,712 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: query.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createImage = `-- name: CreateImage :one +INSERT INTO images (storage_type, image_path) +VALUES ($1, $2) +RETURNING image_id, storage_type, image_path +` + +type CreateImageParams struct { + StorageType StorageTypeT `db:"storage_type" json:"storage_type"` + ImagePath string `db:"image_path" json:"image_path"` +} + +func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Images, error) { + row := q.db.QueryRow(ctx, createImage, arg.StorageType, arg.ImagePath) + var i Images + err := row.Scan(&i.ImageID, &i.StorageType, &i.ImagePath) + return i, err +} + +const createReview = `-- name: CreateReview :one +INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) +VALUES ($1, $2, $3, $4, $5) +RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date +` + +type CreateReviewParams struct { + UserID int32 `db:"user_id" json:"user_id"` + TitleID int32 `db:"title_id" json:"title_id"` + ImageIds []int32 `db:"image_ids" json:"image_ids"` + ReviewText string `db:"review_text" json:"review_text"` + CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` +} + +func (q *Queries) CreateReview(ctx context.Context, arg CreateReviewParams) (Reviews, error) { + row := q.db.QueryRow(ctx, createReview, + arg.UserID, + arg.TitleID, + arg.ImageIds, + arg.ReviewText, + arg.CreationDate, + ) + var i Reviews + err := row.Scan( + &i.ReviewID, + &i.UserID, + &i.TitleID, + &i.ImageIds, + &i.ReviewText, + &i.CreationDate, + ) + return i, err +} + +const createUser = `-- name: CreateUser :one +INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date +` + +type CreateUserParams struct { + AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` + Passhash string `db:"passhash" json:"passhash"` + Mail pgtype.Text `db:"mail" json:"mail"` + Nickname string `db:"nickname" json:"nickname"` + DispName pgtype.Text `db:"disp_name" json:"disp_name"` + UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` + CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` +} + +type CreateUserRow struct { + UserID int32 `db:"user_id" json:"user_id"` + AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` + Nickname string `db:"nickname" json:"nickname"` + DispName pgtype.Text `db:"disp_name" json:"disp_name"` + UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` + CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) { + row := q.db.QueryRow(ctx, createUser, + arg.AvatarID, + arg.Passhash, + arg.Mail, + arg.Nickname, + arg.DispName, + arg.UserDesc, + arg.CreationDate, + ) + var i CreateUserRow + err := row.Scan( + &i.UserID, + &i.AvatarID, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + ) + return i, err +} + +const createUserTitle = `-- name: CreateUserTitle :one +INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +VALUES ($1, $2, $3, $4, $5) +RETURNING usertitle_id, user_id, title_id, status, rate, review_id +` + +type CreateUserTitleParams struct { + UserID int32 `db:"user_id" json:"user_id"` + TitleID int32 `db:"title_id" json:"title_id"` + Status UsertitleStatusT `db:"status" json:"status"` + Rate pgtype.Int4 `db:"rate" json:"rate"` + ReviewID pgtype.Int4 `db:"review_id" json:"review_id"` +} + +func (q *Queries) CreateUserTitle(ctx context.Context, arg CreateUserTitleParams) (Usertitles, error) { + row := q.db.QueryRow(ctx, createUserTitle, + arg.UserID, + arg.TitleID, + arg.Status, + arg.Rate, + arg.ReviewID, + ) + var i Usertitles + err := row.Scan( + &i.UsertitleID, + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + ) + return i, err +} + +const deleteReview = `-- name: DeleteReview :exec +DELETE FROM reviews +WHERE review_id = $1 +` + +func (q *Queries) DeleteReview(ctx context.Context, reviewID int32) error { + _, err := q.db.Exec(ctx, deleteReview, reviewID) + return err +} + +const deleteUser = `-- name: DeleteUser :exec +DELETE FROM users +WHERE user_id = $1 +` + +func (q *Queries) DeleteUser(ctx context.Context, userID int32) error { + _, err := q.db.Exec(ctx, deleteUser, userID) + return err +} + +const deleteUserTitle = `-- name: DeleteUserTitle :exec +DELETE FROM usertitles +WHERE user_id = $1 AND ($2::int IS NULL OR title_id = $2) +` + +type DeleteUserTitleParams struct { + UserID int32 `db:"user_id" json:"user_id"` + Column2 int32 `db:"column_2" json:"column_2"` +} + +func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams) error { + _, err := q.db.Exec(ctx, deleteUserTitle, arg.UserID, arg.Column2) + return err +} + +const getImageByID = `-- name: GetImageByID :one +SELECT image_id, storage_type, image_path +FROM images +WHERE image_id = $1 +` + +func (q *Queries) GetImageByID(ctx context.Context, imageID int32) (Images, error) { + row := q.db.QueryRow(ctx, getImageByID, imageID) + var i Images + err := row.Scan(&i.ImageID, &i.StorageType, &i.ImagePath) + return i, err +} + +const getReviewByID = `-- name: GetReviewByID :one +SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +FROM reviews +WHERE review_id = $1 +` + +func (q *Queries) GetReviewByID(ctx context.Context, reviewID int32) (Reviews, error) { + row := q.db.QueryRow(ctx, getReviewByID, reviewID) + var i Reviews + err := row.Scan( + &i.ReviewID, + &i.UserID, + &i.TitleID, + &i.ImageIds, + &i.ReviewText, + &i.CreationDate, + ) + return i, err +} + +const getTitleByID = `-- name: GetTitleByID :one +SELECT title_id, title_names, studio_id, poster_id, signal_ids, + title_status, rating, rating_count, release_year, release_season, + season, episodes_aired, episodes_all, episodes_len +FROM titles +WHERE title_id = $1 +` + +func (q *Queries) GetTitleByID(ctx context.Context, titleID int32) (Titles, error) { + row := q.db.QueryRow(ctx, getTitleByID, titleID) + var i Titles + err := row.Scan( + &i.TitleID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.SignalIds, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +FROM users +WHERE user_id = $1 +` + +func (q *Queries) GetUserByID(ctx context.Context, userID int32) (Users, error) { + row := q.db.QueryRow(ctx, getUserByID, userID) + var i Users + err := row.Scan( + &i.UserID, + &i.AvatarID, + &i.Passhash, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + ) + return i, err +} + +const getUserTitle = `-- name: GetUserTitle :one +SELECT usertitle_id, user_id, title_id, status, rate, review_id +FROM usertitles +WHERE user_id = $1 AND title_id = $2 +` + +type GetUserTitleParams struct { + UserID int32 `db:"user_id" json:"user_id"` + TitleID int32 `db:"title_id" json:"title_id"` +} + +func (q *Queries) GetUserTitle(ctx context.Context, arg GetUserTitleParams) (Usertitles, error) { + row := q.db.QueryRow(ctx, getUserTitle, arg.UserID, arg.TitleID) + var i Usertitles + err := row.Scan( + &i.UsertitleID, + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + ) + return i, err +} + +const listReviewsByTitle = `-- name: ListReviewsByTitle :many +SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +FROM reviews +WHERE title_id = $1 +ORDER BY creation_date DESC +LIMIT $2 OFFSET $3 +` + +type ListReviewsByTitleParams struct { + TitleID int32 `db:"title_id" json:"title_id"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +func (q *Queries) ListReviewsByTitle(ctx context.Context, arg ListReviewsByTitleParams) ([]Reviews, error) { + rows, err := q.db.Query(ctx, listReviewsByTitle, arg.TitleID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Reviews + for rows.Next() { + var i Reviews + if err := rows.Scan( + &i.ReviewID, + &i.UserID, + &i.TitleID, + &i.ImageIds, + &i.ReviewText, + &i.CreationDate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listReviewsByUser = `-- name: ListReviewsByUser :many +SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +FROM reviews +WHERE user_id = $1 +ORDER BY creation_date DESC +LIMIT $2 OFFSET $3 +` + +type ListReviewsByUserParams struct { + UserID int32 `db:"user_id" json:"user_id"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +func (q *Queries) ListReviewsByUser(ctx context.Context, arg ListReviewsByUserParams) ([]Reviews, error) { + rows, err := q.db.Query(ctx, listReviewsByUser, arg.UserID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Reviews + for rows.Next() { + var i Reviews + if err := rows.Scan( + &i.ReviewID, + &i.UserID, + &i.TitleID, + &i.ImageIds, + &i.ReviewText, + &i.CreationDate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTags = `-- name: ListTags :many +SELECT tag_id, tag_names +FROM tags +ORDER BY tag_id +LIMIT $1 OFFSET $2 +` + +type ListTagsParams struct { + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +func (q *Queries) ListTags(ctx context.Context, arg ListTagsParams) ([]Tags, error) { + rows, err := q.db.Query(ctx, listTags, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Tags + for rows.Next() { + var i Tags + if err := rows.Scan(&i.TagID, &i.TagNames); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTitles = `-- name: ListTitles :many +SELECT title_id, title_names, studio_id, poster_id, signal_ids, + title_status, rating, rating_count, release_year, release_season, + season, episodes_aired, episodes_all, episodes_len +FROM titles +ORDER BY title_id +LIMIT $1 OFFSET $2 +` + +type ListTitlesParams struct { + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +func (q *Queries) ListTitles(ctx context.Context, arg ListTitlesParams) ([]Titles, error) { + rows, err := q.db.Query(ctx, listTitles, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Titles + for rows.Next() { + var i Titles + if err := rows.Scan( + &i.TitleID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.SignalIds, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUserTitles = `-- name: ListUserTitles :many +SELECT usertitle_id, user_id, title_id, status, rate, review_id +FROM usertitles +WHERE user_id = $1 +ORDER BY usertitle_id +LIMIT $2 OFFSET $3 +` + +type ListUserTitlesParams struct { + UserID int32 `db:"user_id" json:"user_id"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +func (q *Queries) ListUserTitles(ctx context.Context, arg ListUserTitlesParams) ([]Usertitles, error) { + rows, err := q.db.Query(ctx, listUserTitles, arg.UserID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Usertitles + for rows.Next() { + var i Usertitles + if err := rows.Scan( + &i.UsertitleID, + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUsers = `-- name: ListUsers :many +SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +FROM users +ORDER BY user_id +LIMIT $1 OFFSET $2 +` + +type ListUsersParams struct { + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]Users, error) { + rows, err := q.db.Query(ctx, listUsers, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Users + for rows.Next() { + var i Users + if err := rows.Scan( + &i.UserID, + &i.AvatarID, + &i.Passhash, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateReview = `-- name: UpdateReview :one +UPDATE reviews +SET + image_ids = COALESCE($1, image_ids), + review_text = COALESCE($2, review_text) +WHERE review_id = $3 +RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date +` + +type UpdateReviewParams struct { + ImageIds []int32 `db:"image_ids" json:"image_ids"` + ReviewText pgtype.Text `db:"review_text" json:"review_text"` + ReviewID int32 `db:"review_id" json:"review_id"` +} + +func (q *Queries) UpdateReview(ctx context.Context, arg UpdateReviewParams) (Reviews, error) { + row := q.db.QueryRow(ctx, updateReview, arg.ImageIds, arg.ReviewText, arg.ReviewID) + var i Reviews + err := row.Scan( + &i.ReviewID, + &i.UserID, + &i.TitleID, + &i.ImageIds, + &i.ReviewText, + &i.CreationDate, + ) + return i, err +} + +const updateTitle = `-- name: UpdateTitle :one +UPDATE titles +SET + title_names = COALESCE($1, title_names), + studio_id = COALESCE($2, studio_id), + poster_id = COALESCE($3, poster_id), + signal_ids = COALESCE($4, signal_ids), + title_status = COALESCE($5, title_status), + release_year = COALESCE($6, release_year), + release_season = COALESCE($7, release_season), + episodes_aired = COALESCE($8, episodes_aired), + episodes_all = COALESCE($9, episodes_all), + episodes_len = COALESCE($10, episodes_len) +WHERE title_id = $11 +RETURNING title_id, title_names, studio_id, poster_id, signal_ids, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len +` + +type UpdateTitleParams struct { + TitleNames []byte `db:"title_names" json:"title_names"` + StudioID pgtype.Int4 `db:"studio_id" json:"studio_id"` + PosterID pgtype.Int4 `db:"poster_id" json:"poster_id"` + SignalIds []int32 `db:"signal_ids" json:"signal_ids"` + TitleStatus NullTitleStatusT `db:"title_status" json:"title_status"` + ReleaseYear pgtype.Int4 `db:"release_year" json:"release_year"` + ReleaseSeason NullReleaseSeasonT `db:"release_season" json:"release_season"` + EpisodesAired pgtype.Int4 `db:"episodes_aired" json:"episodes_aired"` + EpisodesAll pgtype.Int4 `db:"episodes_all" json:"episodes_all"` + EpisodesLen []byte `db:"episodes_len" json:"episodes_len"` + TitleID int32 `db:"title_id" json:"title_id"` +} + +func (q *Queries) UpdateTitle(ctx context.Context, arg UpdateTitleParams) (Titles, error) { + row := q.db.QueryRow(ctx, updateTitle, + arg.TitleNames, + arg.StudioID, + arg.PosterID, + arg.SignalIds, + arg.TitleStatus, + arg.ReleaseYear, + arg.ReleaseSeason, + arg.EpisodesAired, + arg.EpisodesAll, + arg.EpisodesLen, + arg.TitleID, + ) + var i Titles + err := row.Scan( + &i.TitleID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.SignalIds, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ) + return i, err +} + +const updateUser = `-- name: UpdateUser :one +UPDATE users +SET + avatar_id = COALESCE($1, avatar_id), + disp_name = COALESCE($2, disp_name), + user_desc = COALESCE($3, user_desc) +WHERE user_id = $4 +RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date +` + +type UpdateUserParams struct { + AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` + DispName pgtype.Text `db:"disp_name" json:"disp_name"` + UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` + UserID int32 `db:"user_id" json:"user_id"` +} + +type UpdateUserRow struct { + UserID int32 `db:"user_id" json:"user_id"` + AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` + Nickname string `db:"nickname" json:"nickname"` + DispName pgtype.Text `db:"disp_name" json:"disp_name"` + UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` + CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` +} + +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) { + row := q.db.QueryRow(ctx, updateUser, + arg.AvatarID, + arg.DispName, + arg.UserDesc, + arg.UserID, + ) + var i UpdateUserRow + err := row.Scan( + &i.UserID, + &i.AvatarID, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + ) + return i, err +} + +const updateUserTitle = `-- name: UpdateUserTitle :one +UPDATE usertitles +SET + status = COALESCE($3, status), + rate = COALESCE($4, rate), + review_id = COALESCE($5, review_id) +WHERE user_id = $1 AND title_id = $2 +RETURNING usertitle_id, user_id, title_id, status, rate, review_id +` + +type UpdateUserTitleParams struct { + UserID int32 `db:"user_id" json:"user_id"` + TitleID int32 `db:"title_id" json:"title_id"` + Status NullUsertitleStatusT `db:"status" json:"status"` + Rate pgtype.Int4 `db:"rate" json:"rate"` + ReviewID pgtype.Int4 `db:"review_id" json:"review_id"` +} + +func (q *Queries) UpdateUserTitle(ctx context.Context, arg UpdateUserTitleParams) (Usertitles, error) { + row := q.db.QueryRow(ctx, updateUserTitle, + arg.UserID, + arg.TitleID, + arg.Status, + arg.Rate, + arg.ReviewID, + ) + var i Usertitles + err := row.Scan( + &i.UsertitleID, + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + ) + return i, err +} From 102d15c5fdb238c29ed842c2eb8652967ec49059 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 11 Oct 2025 02:19:33 +0300 Subject: [PATCH 011/224] feat: connection to db logic added to main --- modules/backend/go.mod | 6 ++++ modules/backend/go.sum | 16 ++++++++++ modules/backend/main.go | 68 ++++++++++++++++++++++++++--------------- 3 files changed, 65 insertions(+), 25 deletions(-) diff --git a/modules/backend/go.mod b/modules/backend/go.mod index 9c9e8fc..d8e0cb3 100644 --- a/modules/backend/go.mod +++ b/modules/backend/go.mod @@ -3,6 +3,7 @@ module nyanimedb-server go 1.25.0 require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect @@ -15,12 +16,17 @@ require ( github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/oapi-codegen/runtime v1.1.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect diff --git a/modules/backend/go.sum b/modules/backend/go.sum index 5cc008d..a08eb1d 100644 --- a/modules/backend/go.sum +++ b/modules/backend/go.sum @@ -1,3 +1,7 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= @@ -29,8 +33,17 @@ github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -43,6 +56,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -52,6 +67,7 @@ github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQB github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/modules/backend/main.go b/modules/backend/main.go index 651d9cc..5a61226 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -1,35 +1,48 @@ package main import ( + "context" "fmt" - "net/http" + "nyanimedb-server/api" + "nyanimedb-server/db" "os" "reflect" "time" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" "github.com/pelletier/go-toml/v2" - log "github.com/sirupsen/logrus" ) var AppConfig Config func main() { - if len(os.Args) != 2 { - AppConfig.Mode = "env" - } else { - AppConfig.Mode = "argv" + // if len(os.Args) != 2 { + // AppConfig.Mode = "env" + // } else { + // AppConfig.Mode = "argv" + // } + + // err := InitConfig() + // if err != nil { + // log.Fatalf("Failed to init config: %v\n", err) + // } + + conn, err := pgx.Connect(context.Background(), os.Getenv("DATABASE_URL")) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) + os.Exit(1) } - err := InitConfig() - if err != nil { - log.Fatalf("Failed to init config: %v\n", err) - } + defer conn.Close(context.Background()) r := gin.Default() - r.LoadHTMLGlob("templates/*") + queries := db.New(conn) + + server := api.NewServer(queries) + // r.LoadHTMLGlob("templates/*") r.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production @@ -40,22 +53,27 @@ func main() { MaxAge: 12 * time.Hour, })) - r.GET("/", func(c *gin.Context) { - c.HTML(http.StatusOK, "index.html", gin.H{ - "title": "Welcome Page", - "message": "Hello, Gin with HTML templates!", - }) - }) + api.RegisterHandlers(r, api.NewStrictHandler( + server, + // сюда можно добавить middlewares, если нужно + []api.StrictMiddlewareFunc{}, + )) + // r.GET("/", func(c *gin.Context) { + // c.HTML(http.StatusOK, "index.html", gin.H{ + // "title": "Welcome Page", + // "message": "Hello, Gin with HTML templates!", + // }) + // }) - r.GET("/api", func(c *gin.Context) { - items := []Item{ - {ID: 1, Title: "First Item", Description: "This is the description of the first item."}, - {ID: 2, Title: "Second Item", Description: "This is the description of the second item."}, - {ID: 3, Title: "Third Item", Description: "This is the description of the third item."}, - } + // r.GET("/api", func(c *gin.Context) { + // items := []Item{ + // {ID: 1, Title: "First Item", Description: "This is the description of the first item."}, + // {ID: 2, Title: "Second Item", Description: "This is the description of the second item."}, + // {ID: 3, Title: "Third Item", Description: "This is the description of the third item."}, + // } - c.JSON(http.StatusOK, items) - }) + // c.JSON(http.StatusOK, items) + // }) r.Run(":8080") } From 28c38ca1a0ef875a9d9f0151b29af798fd5d8651 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 11 Oct 2025 02:27:13 +0300 Subject: [PATCH 012/224] feat: db url env added --- .forgejo/workflows/build-and-deploy.yml | 1 + deploy/docker-compose.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 2bc0106..cd1087b 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -94,6 +94,7 @@ jobs: POSTGRES_PORT: 5432 POSTGRES_VERSION: 18 LOG_LEVEL: ${{ vars.LOG_LEVEL }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} steps: - name: Checkout code diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 266f9d5..a9ca65d 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -32,6 +32,7 @@ services: restart: always environment: LOG_LEVEL: ${LOG_LEVEL} + DATABASE_URL: ${DATABASE_URL} ports: - "8080:8080" depends_on: From 8e44b8b7b45105719fd4695f91670b5d093eb7d7 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 11 Oct 2025 04:25:57 +0300 Subject: [PATCH 013/224] feat: fetch user info --- modules/frontend/package-lock.json | 181 +++++++ modules/frontend/package.json | 1 + modules/frontend/src/App.tsx | 27 +- modules/frontend/src/api/core/ApiError.ts | 25 + .../src/api/core/ApiRequestOptions.ts | 17 + modules/frontend/src/api/core/ApiResult.ts | 11 + .../src/api/core/CancelablePromise.ts | 131 +++++ modules/frontend/src/api/core/OpenAPI.ts | 32 ++ modules/frontend/src/api/core/request.ts | 323 ++++++++++++ modules/frontend/src/api/index.ts | 16 + modules/frontend/src/api/models/Review.ts | 5 + modules/frontend/src/api/models/Tag.ts | 5 + modules/frontend/src/api/models/Title.ts | 5 + modules/frontend/src/api/models/User.ts | 5 + modules/frontend/src/api/models/UserTitle.ts | 5 + .../src/api/services/DefaultService.ts | 478 ++++++++++++++++++ 16 files changed, 1254 insertions(+), 13 deletions(-) create mode 100644 modules/frontend/src/api/core/ApiError.ts create mode 100644 modules/frontend/src/api/core/ApiRequestOptions.ts create mode 100644 modules/frontend/src/api/core/ApiResult.ts create mode 100644 modules/frontend/src/api/core/CancelablePromise.ts create mode 100644 modules/frontend/src/api/core/OpenAPI.ts create mode 100644 modules/frontend/src/api/core/request.ts create mode 100644 modules/frontend/src/api/index.ts create mode 100644 modules/frontend/src/api/models/Review.ts create mode 100644 modules/frontend/src/api/models/Tag.ts create mode 100644 modules/frontend/src/api/models/Title.ts create mode 100644 modules/frontend/src/api/models/User.ts create mode 100644 modules/frontend/src/api/models/UserTitle.ts create mode 100644 modules/frontend/src/api/services/DefaultService.ts diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index a282de5..b1789ed 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -22,11 +22,30 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "openapi-typescript-codegen": "^0.29.0", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1011,6 +1030,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1928,6 +1954,19 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001749", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz", @@ -1998,6 +2037,16 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2539,6 +2588,21 @@ "node": ">= 6" } }, + "node_modules/fs-extra": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2648,6 +2712,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2655,6 +2726,28 @@ "dev": true, "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2848,6 +2941,19 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2972,6 +3078,16 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3005,6 +3121,13 @@ "dev": true, "license": "MIT" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.23", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", @@ -3012,6 +3135,23 @@ "dev": true, "license": "MIT" }, + "node_modules/openapi-typescript-codegen": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-codegen/-/openapi-typescript-codegen-0.29.0.tgz", + "integrity": "sha512-/wC42PkD0LGjDTEULa/XiWQbv4E9NwLjwLjsaJ/62yOsoYhwvmBR31kPttn1DzQ2OlGe5stACcF/EIkZk43M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.4", + "camelcase": "^6.3.0", + "commander": "^12.0.0", + "fs-extra": "^11.2.0", + "handlebars": "^4.7.8" + }, + "bin": { + "openapi": "bin/index.js" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3349,6 +3489,16 @@ "node": ">=8" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3512,6 +3662,20 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", @@ -3519,6 +3683,16 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -3694,6 +3868,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/modules/frontend/package.json b/modules/frontend/package.json index 9fbc7aa..c40ff17 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -24,6 +24,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "openapi-typescript-codegen": "^0.29.0", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index d9e6fbd..f16bb18 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,36 +1,37 @@ import React, { useEffect, useState } from "react"; -import { fetchItems } from "./services/api"; -import type { Item } from "./services/api"; -import ItemTemplate from "./components/ItemTemplate"; +import { DefaultService } from "./api/services/DefaultService"; // adjust path if needed +import type { User } from "./api/models/User"; // adjust path if needed const App: React.FC = () => { - const [items, setItems] = useState([]); + const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - const getData = async () => { + const getUserInfo = async () => { try { - const data = await fetchItems(); - setItems(data); + const userInfo = await DefaultService.getUsers("1", "all"); + setUser(userInfo); } catch (err) { - setError("Failed to fetch items."); + console.error(err); + setError("Failed to fetch user info."); } finally { setLoading(false); } }; - getData(); + getUserInfo(); }, []); if (loading) return
Loading...
; if (error) return
{error}
; + if (!user) return
No user found.
; return (
-

Items List

- {items.map((item) => ( - - ))} +

User Info

+

ID: {user.id}

+

Name: {user.name}

+

Email: {user.email}

); }; diff --git a/modules/frontend/src/api/core/ApiError.ts b/modules/frontend/src/api/core/ApiError.ts new file mode 100644 index 0000000..ec7b16a --- /dev/null +++ b/modules/frontend/src/api/core/ApiError.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: any; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} diff --git a/modules/frontend/src/api/core/ApiRequestOptions.ts b/modules/frontend/src/api/core/ApiRequestOptions.ts new file mode 100644 index 0000000..93143c3 --- /dev/null +++ b/modules/frontend/src/api/core/ApiRequestOptions.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiRequestOptions = { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record; + readonly cookies?: Record; + readonly headers?: Record; + readonly query?: Record; + readonly formData?: Record; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record; +}; diff --git a/modules/frontend/src/api/core/ApiResult.ts b/modules/frontend/src/api/core/ApiResult.ts new file mode 100644 index 0000000..ee1126e --- /dev/null +++ b/modules/frontend/src/api/core/ApiResult.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiResult = { + readonly url: string; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly body: any; +}; diff --git a/modules/frontend/src/api/core/CancelablePromise.ts b/modules/frontend/src/api/core/CancelablePromise.ts new file mode 100644 index 0000000..d70de92 --- /dev/null +++ b/modules/frontend/src/api/core/CancelablePromise.ts @@ -0,0 +1,131 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export class CancelError extends Error { + + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise implements Promise { + #isResolved: boolean; + #isRejected: boolean; + #isCancelled: boolean; + readonly #cancelHandlers: (() => void)[]; + readonly #promise: Promise; + #resolve?: (value: T | PromiseLike) => void; + #reject?: (reason?: any) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: any) => void, + onCancel: OnCancel + ) => void + ) { + this.#isResolved = false; + this.#isRejected = false; + this.#isCancelled = false; + this.#cancelHandlers = []; + this.#promise = new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + + const onResolve = (value: T | PromiseLike): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isResolved = true; + if (this.#resolve) this.#resolve(value); + }; + + const onReject = (reason?: any): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isRejected = true; + if (this.#reject) this.#reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this.#isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this.#isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this.#isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + get [Symbol.toStringTag]() { + return "Cancellable Promise"; + } + + public then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike) | null + ): Promise { + return this.#promise.then(onFulfilled, onRejected); + } + + public catch( + onRejected?: ((reason: any) => TResult | PromiseLike) | null + ): Promise { + return this.#promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise { + return this.#promise.finally(onFinally); + } + + public cancel(): void { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isCancelled = true; + if (this.#cancelHandlers.length) { + try { + for (const cancelHandler of this.#cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this.#cancelHandlers.length = 0; + if (this.#reject) this.#reject(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this.#isCancelled; + } +} diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts new file mode 100644 index 0000000..4fb2299 --- /dev/null +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -0,0 +1,32 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; + +type Resolver = (options: ApiRequestOptions) => Promise; +type Headers = Record; + +export type OpenAPIConfig = { + BASE: string; + VERSION: string; + WITH_CREDENTIALS: boolean; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + TOKEN?: string | Resolver | undefined; + USERNAME?: string | Resolver | undefined; + PASSWORD?: string | Resolver | undefined; + HEADERS?: Headers | Resolver | undefined; + ENCODE_PATH?: ((path: string) => string) | undefined; +}; + +export const OpenAPI: OpenAPIConfig = { + BASE: 'https://api.example.com', + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'include', + TOKEN: undefined, + USERNAME: undefined, + PASSWORD: undefined, + HEADERS: undefined, + ENCODE_PATH: undefined, +}; diff --git a/modules/frontend/src/api/core/request.ts b/modules/frontend/src/api/core/request.ts new file mode 100644 index 0000000..1dc6fef --- /dev/null +++ b/modules/frontend/src/api/core/request.ts @@ -0,0 +1,323 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; +import FormData from 'form-data'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isDefined = (value: T | null | undefined): value is Exclude => { + return value !== undefined && value !== null; +}; + +export const isString = (value: any): value is string => { + return typeof value === 'string'; +}; + +export const isStringWithValue = (value: any): value is string => { + return isString(value) && value !== ''; +}; + +export const isBlob = (value: any): value is Blob => { + return ( + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +export const isFormData = (value: any): value is FormData => { + return value instanceof FormData; +}; + +export const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + +export const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +export const getQueryString = (params: Record): string => { + const qs: string[] = []; + + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach(v => { + process(key, v); + }); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; + + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); + + if (qs.length > 0) { + return `?${qs.join('&')}`; + } + + return ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; +}; + +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver = (options: ApiRequestOptions) => Promise; + +export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { + if (typeof resolver === 'function') { + return (resolver as Resolver)(options); + } + return resolver; +}; + +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) + .filter(([_, value]) => isDefined(value)) + .reduce((headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), {} as Record); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return headers; +}; + +export const getRequestBody = (options: ApiRequestOptions): any => { + if (options.body) { + return options.body; + } + return undefined; +}; + +export const sendRequest = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Record, + onCancel: OnCancel, + axiosClient: AxiosInstance +): Promise> => { + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, + headers, + data: body ?? formData, + method: options.method, + withCredentials: config.WITH_CREDENTIALS, + withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, + cancelToken: source.token, + }; + + onCancel(() => source.cancel('The user aborted a request.')); + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError; + if (axiosError.response) { + return axiosError.response; + } + throw error; + } +}; + +export const getResponseHeader = (response: AxiosResponse, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; + } + } + return undefined; +}; + +export const getResponseBody = (response: AxiosResponse): any => { + if (response.status !== 204) { + return response.data; + } + return undefined; +}; + +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError(options, result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @param axiosClient The axios client instance to use + * @returns CancelablePromise + * @throws ApiError + */ +export const request = (config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + if (!onCancel.isCancelled) { + const response = await sendRequest(config, options, url, body, formData, headers, onCancel, axiosClient); + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts new file mode 100644 index 0000000..e4f4ef4 --- /dev/null +++ b/modules/frontend/src/api/index.ts @@ -0,0 +1,16 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export { ApiError } from './core/ApiError'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI } from './core/OpenAPI'; +export type { OpenAPIConfig } from './core/OpenAPI'; + +export type { Review } from './models/Review'; +export type { Tag } from './models/Tag'; +export type { Title } from './models/Title'; +export type { User } from './models/User'; +export type { UserTitle } from './models/UserTitle'; + +export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api/models/Review.ts b/modules/frontend/src/api/models/Review.ts new file mode 100644 index 0000000..9b453b7 --- /dev/null +++ b/modules/frontend/src/api/models/Review.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Review = Record; diff --git a/modules/frontend/src/api/models/Tag.ts b/modules/frontend/src/api/models/Tag.ts new file mode 100644 index 0000000..9560ea8 --- /dev/null +++ b/modules/frontend/src/api/models/Tag.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Tag = Record; diff --git a/modules/frontend/src/api/models/Title.ts b/modules/frontend/src/api/models/Title.ts new file mode 100644 index 0000000..4da7aa3 --- /dev/null +++ b/modules/frontend/src/api/models/Title.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Title = Record; diff --git a/modules/frontend/src/api/models/User.ts b/modules/frontend/src/api/models/User.ts new file mode 100644 index 0000000..6c47b04 --- /dev/null +++ b/modules/frontend/src/api/models/User.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type User = Record; diff --git a/modules/frontend/src/api/models/UserTitle.ts b/modules/frontend/src/api/models/UserTitle.ts new file mode 100644 index 0000000..26d5ddc --- /dev/null +++ b/modules/frontend/src/api/models/UserTitle.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type UserTitle = Record; diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts new file mode 100644 index 0000000..f000c3f --- /dev/null +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -0,0 +1,478 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Review } from '../models/Review'; +import type { Tag } from '../models/Tag'; +import type { Title } from '../models/Title'; +import type { User } from '../models/User'; +import type { UserTitle } from '../models/UserTitle'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class DefaultService { + /** + * Get titles + * @param query + * @param limit + * @param offset + * @param fields + * @returns Title List of titles + * @throws ApiError + */ + public static getTitle( + query?: string, + limit: number = 10, + offset?: number, + fields: string = 'all', + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/title', + query: { + 'query': query, + 'limit': limit, + 'offset': offset, + 'fields': fields, + }, + }); + } + /** + * Get title description + * @param titleId + * @param fields + * @returns Title Title description + * @throws ApiError + */ + public static getTitle1( + titleId: string, + fields: string = 'all', + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/title/{title_id}', + path: { + 'title_id': titleId, + }, + query: { + 'fields': fields, + }, + errors: { + 404: `Title not found`, + }, + }); + } + /** + * Update title info + * @param titleId + * @param requestBody + * @returns any Update result + * @throws ApiError + */ + public static patchTitle( + titleId: string, + requestBody: Title, + ): CancelablePromise<{ + success?: boolean; + error?: string; + user_json?: User; + }> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/title/{title_id}', + path: { + 'title_id': titleId, + }, + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Get title reviews + * @param titleId + * @param limit + * @param offset + * @returns Review List of reviews + * @throws ApiError + */ + public static getTitleReviews( + titleId: string, + limit: number = 10, + offset?: number, + ): CancelablePromise<Array<Review>> { + return __request(OpenAPI, { + method: 'GET', + url: '/title/{title_id}/reviews', + path: { + 'title_id': titleId, + }, + query: { + 'limit': limit, + 'offset': offset, + }, + }); + } + /** + * Get user info + * @param userId + * @param fields + * @returns User User info + * @throws ApiError + */ + public static getUsers( + userId: string, + fields: string = 'all', + ): CancelablePromise<User> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}', + path: { + 'user_id': userId, + }, + query: { + 'fields': fields, + }, + errors: { + 404: `User not found`, + }, + }); + } + /** + * Update user + * @param userId + * @param requestBody + * @returns any Update result + * @throws ApiError + */ + public static patchUsers( + userId: string, + requestBody: User, + ): CancelablePromise<{ + success?: boolean; + error?: string; + }> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{user_id}', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Delete user + * @param userId + * @returns any Delete result + * @throws ApiError + */ + public static deleteUsers( + userId: string, + ): CancelablePromise<{ + success?: boolean; + error?: string; + }> { + return __request(OpenAPI, { + method: 'DELETE', + url: '/users/{user_id}', + path: { + 'user_id': userId, + }, + }); + } + /** + * Search user + * @param query + * @param fields + * @returns User List of users + * @throws ApiError + */ + public static getUsers1( + query?: string, + fields?: string, + ): CancelablePromise<Array<User>> { + return __request(OpenAPI, { + method: 'GET', + url: '/users', + query: { + 'query': query, + 'fields': fields, + }, + }); + } + /** + * Add new user + * @param requestBody + * @returns any Add result + * @throws ApiError + */ + public static postUsers( + requestBody: User, + ): CancelablePromise<{ + success?: boolean; + error?: string; + user_json?: User; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/users', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Get user titles + * @param userId + * @param query + * @param limit + * @param offset + * @param fields + * @returns UserTitle List of user titles + * @throws ApiError + */ + public static getUsersTitles( + userId: string, + query?: string, + limit: number = 10, + offset?: number, + fields: string = 'all', + ): CancelablePromise<Array<UserTitle>> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + query: { + 'query': query, + 'limit': limit, + 'offset': offset, + 'fields': fields, + }, + }); + } + /** + * Add user title + * @param userId + * @param requestBody + * @returns any Add result + * @throws ApiError + */ + public static postUsersTitles( + userId: string, + requestBody: { + title_id?: string; + status?: string; + }, + ): CancelablePromise<{ + success?: boolean; + error?: string; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Update user title + * @param userId + * @param requestBody + * @returns any Update result + * @throws ApiError + */ + public static patchUsersTitles( + userId: string, + requestBody: UserTitle, + ): CancelablePromise<{ + success?: boolean; + error?: string; + }> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Delete user title + * @param userId + * @param titleId + * @returns any Delete result + * @throws ApiError + */ + public static deleteUsersTitles( + userId: string, + titleId?: string, + ): CancelablePromise<{ + success?: boolean; + error?: string; + }> { + return __request(OpenAPI, { + method: 'DELETE', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + query: { + 'title_id': titleId, + }, + }); + } + /** + * Get user reviews + * @param userId + * @param limit + * @param offset + * @returns Review List of reviews + * @throws ApiError + */ + public static getUsersReviews( + userId: string, + limit: number = 10, + offset?: number, + ): CancelablePromise<Array<Review>> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}/reviews', + path: { + 'user_id': userId, + }, + query: { + 'limit': limit, + 'offset': offset, + }, + }); + } + /** + * Add review + * @param requestBody + * @returns any Add result + * @throws ApiError + */ + public static postReviews( + requestBody: Review, + ): CancelablePromise<{ + success?: boolean; + error?: string; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/reviews', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Update review + * @param reviewId + * @param requestBody + * @returns any Update result + * @throws ApiError + */ + public static patchReviews( + reviewId: string, + requestBody: Review, + ): CancelablePromise<{ + success?: boolean; + error?: string; + }> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/reviews/{review_id}', + path: { + 'review_id': reviewId, + }, + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Delete review + * @param reviewId + * @returns any Delete result + * @throws ApiError + */ + public static deleteReviews( + reviewId: string, + ): CancelablePromise<{ + success?: boolean; + error?: string; + }> { + return __request(OpenAPI, { + method: 'DELETE', + url: '/reviews/{review_id}', + path: { + 'review_id': reviewId, + }, + }); + } + /** + * Get tags + * @param limit + * @param offset + * @param fields + * @returns Tag List of tags + * @throws ApiError + */ + public static getTags( + limit: number = 10, + offset?: number, + fields?: string, + ): CancelablePromise<Array<Tag>> { + return __request(OpenAPI, { + method: 'GET', + url: '/tags', + query: { + 'limit': limit, + 'offset': offset, + 'fields': fields, + }, + }); + } + /** + * Upload image + * @returns any Upload result + * @throws ApiError + */ + public static postMedia(): CancelablePromise<{ + success?: boolean; + error?: string; + image_id?: string; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/media', + }); + } + /** + * Get image path + * @param imageId + * @returns any Image path + * @throws ApiError + */ + public static getMedia( + imageId: string, + ): CancelablePromise<{ + success?: boolean; + error?: string; + image_path?: string; + }> { + return __request(OpenAPI, { + method: 'GET', + url: '/media', + query: { + 'image_id': imageId, + }, + }); + } +} From f2188f636302c932fd5b6c74222dc2057e0f558e Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 11 Oct 2025 04:33:56 +0300 Subject: [PATCH 014/224] fix: temp fix for API endpoint --- modules/frontend/src/api/core/OpenAPI.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 4fb2299..99c577d 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'https://api.example.com', + BASE: 'http://10.1.0.65:8080', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', From a0df0dff00e732b0a7453eb99a9b15c5a9b9f2d1 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 11 Oct 2025 04:51:50 +0300 Subject: [PATCH 015/224] feat: add User scahema to api --- api/openapi.yaml | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 5a35e20..ca6099f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -540,7 +540,47 @@ components: additionalProperties: true User: type: object - additionalProperties: true + properties: + user_id: + type: integer + format: int32 + description: Unique user ID (primary key) + example: 1 + avatar_id: + type: integer + format: int32 + description: ID of the user avatar (references images table) + nullable: true + example: null + mail: + type: string + format: email + description: User email + example: "john.doe@example.com" + nickname: + type: string + description: Username (alphanumeric + _ or -) + maxLength: 16 + example: "john_doe_42" + disp_name: + type: string + description: Display name + maxLength: 32 + example: "John Doe" + user_desc: + type: string + description: User description + maxLength: 512 + example: "Just a regular user." + creation_date: + type: string + format: date-time + description: Timestamp when the user was created + example: "2025-10-10T23:45:47.908073Z" + required: + - user_id + - nickname + - creation_date UserTitle: type: object additionalProperties: true From 0b5ea285de47a51416c7b7ba9884b5910fd191d1 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 11 Oct 2025 04:52:21 +0300 Subject: [PATCH 016/224] fix: regenerated api --- modules/frontend/src/api/models/User.ts | 32 ++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/modules/frontend/src/api/models/User.ts b/modules/frontend/src/api/models/User.ts index 6c47b04..c296445 100644 --- a/modules/frontend/src/api/models/User.ts +++ b/modules/frontend/src/api/models/User.ts @@ -2,4 +2,34 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type User = Record<string, any>; +export type User = { + /** + * Unique user ID (primary key) + */ + user_id: number; + /** + * ID of the user avatar (references images table) + */ + avatar_id?: number | null; + /** + * User email + */ + mail?: string; + /** + * Username (alphanumeric + _ or -) + */ + nickname: string; + /** + * Display name + */ + disp_name?: string; + /** + * User description + */ + user_desc?: string; + /** + * Timestamp when the user was created + */ + creation_date: string; +}; + From 827431bb2fa2d7326203f78d8ee1b32190edfbf0 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 11 Oct 2025 04:54:39 +0300 Subject: [PATCH 017/224] fix: App.tsx user field names --- modules/frontend/src/App.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index f16bb18..2b0ffa7 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -29,9 +29,9 @@ const App: React.FC = () => { return ( <div style={{ padding: "2rem" }}> <h1>User Info</h1> - <p><strong>ID:</strong> {user.id}</p> - <p><strong>Name:</strong> {user.name}</p> - <p><strong>Email:</strong> {user.email}</p> + <p><strong>ID:</strong> {user.user_id}</p> + <p><strong>Name:</strong> {user.nickname}</p> + <p><strong>Email:</strong> {user.mail}</p> </div> ); }; From fd0ca4411bee588f74473427240537acaf70b875 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 11 Oct 2025 05:24:11 +0300 Subject: [PATCH 018/224] feat: created UserPage.tsx --- modules/frontend/src/App.tsx | 37 +------ .../components/UserPage/UserPage.module.css | 97 +++++++++++++++++++ .../src/components/UserPage/UserPage.tsx | 60 ++++++++++++ 3 files changed, 160 insertions(+), 34 deletions(-) create mode 100644 modules/frontend/src/components/UserPage/UserPage.module.css create mode 100644 modules/frontend/src/components/UserPage/UserPage.tsx diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 2b0ffa7..6b2ee5f 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,39 +1,8 @@ -import React, { useEffect, useState } from "react"; -import { DefaultService } from "./api/services/DefaultService"; // adjust path if needed -import type { User } from "./api/models/User"; // adjust path if needed +import React from "react"; +import UserPage from "./components/UserPage/UserPage"; const App: React.FC = () => { - const [user, setUser] = useState<User | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - - useEffect(() => { - const getUserInfo = async () => { - try { - const userInfo = await DefaultService.getUsers("1", "all"); - setUser(userInfo); - } catch (err) { - console.error(err); - setError("Failed to fetch user info."); - } finally { - setLoading(false); - } - }; - getUserInfo(); - }, []); - - if (loading) return <div>Loading...</div>; - if (error) return <div>{error}</div>; - if (!user) return <div>No user found.</div>; - - return ( - <div style={{ padding: "2rem" }}> - <h1>User Info</h1> - <p><strong>ID:</strong> {user.user_id}</p> - <p><strong>Name:</strong> {user.nickname}</p> - <p><strong>Email:</strong> {user.mail}</p> - </div> - ); + return <UserPage />; }; export default App; diff --git a/modules/frontend/src/components/UserPage/UserPage.module.css b/modules/frontend/src/components/UserPage/UserPage.module.css new file mode 100644 index 0000000..75195bf --- /dev/null +++ b/modules/frontend/src/components/UserPage/UserPage.module.css @@ -0,0 +1,97 @@ +.container { + display: flex; + justify-content: center; + align-items: flex-start; + padding: 3rem 1rem; + background-color: #f5f6fa; + min-height: 100vh; + font-family: "Inter", sans-serif; +} + +.card { + background-color: #ffffff; + border-radius: 1rem; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); + padding: 2rem; + max-width: 400px; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.avatar { + margin-bottom: 1.5rem; +} + +.avatarImg { + width: 120px; + height: 120px; + border-radius: 50%; + object-fit: cover; + border: 3px solid #4a90e2; +} + +.avatarPlaceholder { + width: 120px; + height: 120px; + border-radius: 50%; + background-color: #dcdde1; + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + color: #4a4a4a; + font-weight: bold; + border: 3px solid #4a90e2; +} + +.info { + display: flex; + flex-direction: column; + align-items: center; +} + +.name { + font-size: 1.8rem; + font-weight: 700; + margin: 0.25rem 0; + color: #2f3640; +} + +.nickname { + font-size: 1rem; + color: #718093; + margin-bottom: 1rem; +} + +.desc { + font-size: 1rem; + color: #353b48; + margin-bottom: 1rem; +} + +.created { + font-size: 0.9rem; + color: #7f8fa6; +} + +.loader { + display: flex; + justify-content: center; + align-items: center; + height: 80vh; + font-size: 1.5rem; + color: #4a90e2; +} + +.error { + display: flex; + justify-content: center; + align-items: center; + height: 80vh; + color: #e84118; + font-weight: 600; + font-size: 1.2rem; +} diff --git a/modules/frontend/src/components/UserPage/UserPage.tsx b/modules/frontend/src/components/UserPage/UserPage.tsx new file mode 100644 index 0000000..b0db90c --- /dev/null +++ b/modules/frontend/src/components/UserPage/UserPage.tsx @@ -0,0 +1,60 @@ +import React, { useEffect, useState } from "react"; +import { DefaultService } from "../../api/services/DefaultService"; // adjust path +import type { User } from "../../api/models/User"; +import styles from "./UserPage.module.css"; + +const UserPage: React.FC = () => { + const [user, setUser] = useState<User | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + const getUserInfo = async () => { + try { + const userInfo = await DefaultService.getUsers("1", "all"); + setUser(userInfo); + } catch (err) { + console.error(err); + setError("Failed to fetch user info."); + } finally { + setLoading(false); + } + }; + getUserInfo(); + }, []); + + if (loading) return <div className={styles.loader}>Loading...</div>; + if (error) return <div className={styles.error}>{error}</div>; + if (!user) return <div className={styles.error}>User not found.</div>; + + return ( + <div className={styles.container}> + <div className={styles.card}> + <div className={styles.avatar}> + {user.avatar_id ? ( + <img + src={`/images/${user.avatar_id}.png`} + alt="User Avatar" + className={styles.avatarImg} + /> + ) : ( + <div className={styles.avatarPlaceholder}> + {user.disp_name?.[0] || "U"} + </div> + )} + </div> + + <div className={styles.info}> + <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> + <p className={styles.nickname}>@{user.nickname}</p> + {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} + <p className={styles.created}> + Joined: {new Date(user.creation_date).toLocaleDateString()} + </p> + </div> + </div> + </div> + ); +}; + +export default UserPage; From db53ae04e39bff6e625963be87809d798263e827 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 25 Oct 2025 21:05:16 +0300 Subject: [PATCH 019/224] refact!: project structure --- api/api.gen.go | 193 ++ api/gen.go | 1958 ----------------- api/oapi-codegen.yaml | 4 +- api/openapi.yaml | 950 ++++---- deploy/generate.sh | 3 + modules/backend/go.mod => go.mod | 18 +- modules/backend/go.sum => go.sum | 20 +- modules/backend/api/gen.go | 1958 ----------------- modules/backend/db/query.sql.go | 712 ------ modules/backend/{api/impl.go => handlers.go} | 7 +- modules/backend/main.go | 13 +- modules/backend/queries.sql | 142 ++ modules/backend/templates/index.html | 10 - modules/sql/go.mod | 3 - modules/sql/query.sql | 142 -- modules/sql/sqlc-generate.yaml | 13 - modules/sql/sqlc.yaml | 14 - modules/sql/sqlc/db.go | 32 - modules/sql/sqlc/models.go | 266 --- modules/sql/sqlc/query.sql.go | 712 ------ {modules/backend/db => sql}/db.go | 2 +- sql/migrations/000001_init.down.sql | 15 + .../migrations/000001_init.up.sql | 20 +- {modules/backend/db => sql}/models.go | 122 +- sql/queries.sql.go | 23 + sql/sqlc.yaml | 14 + 26 files changed, 971 insertions(+), 6395 deletions(-) create mode 100644 api/api.gen.go delete mode 100644 api/gen.go create mode 100644 deploy/generate.sh rename modules/backend/go.mod => go.mod (82%) rename modules/backend/go.sum => go.sum (90%) delete mode 100644 modules/backend/api/gen.go delete mode 100644 modules/backend/db/query.sql.go rename modules/backend/{api/impl.go => handlers.go} (99%) create mode 100644 modules/backend/queries.sql delete mode 100644 modules/backend/templates/index.html delete mode 100644 modules/sql/go.mod delete mode 100644 modules/sql/query.sql delete mode 100644 modules/sql/sqlc-generate.yaml delete mode 100644 modules/sql/sqlc.yaml delete mode 100644 modules/sql/sqlc/db.go delete mode 100644 modules/sql/sqlc/models.go delete mode 100644 modules/sql/sqlc/query.sql.go rename {modules/backend/db => sql}/db.go (97%) create mode 100644 sql/migrations/000001_init.down.sql rename modules/sql/scheme.sql => sql/migrations/000001_init.up.sql (91%) rename {modules/backend/db => sql}/models.go (56%) create mode 100644 sql/queries.sql.go create mode 100644 sql/sqlc.yaml diff --git a/api/api.gen.go b/api/api.gen.go new file mode 100644 index 0000000..58b5b53 --- /dev/null +++ b/api/api.gen.go @@ -0,0 +1,193 @@ +// Package oapi provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +package oapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/oapi-codegen/runtime" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" +) + +// Title defines model for Title. +type Title map[string]interface{} + +// GetTitleParams defines parameters for GetTitle. +type GetTitleParams struct { + Query *string `form:"query,omitempty" json:"query,omitempty"` + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Get titles + // (GET /title) + GetTitle(c *gin.Context, params GetTitleParams) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// GetTitle operation middleware +func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitleParams + + // ------------- Optional query parameter "query" ------------- + + err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitle(c, params) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.GET(options.BaseURL+"/title", wrapper.GetTitle) +} + +type GetTitleRequestObject struct { + Params GetTitleParams +} + +type GetTitleResponseObject interface { + VisitGetTitleResponse(w http.ResponseWriter) error +} + +type GetTitle200JSONResponse []Title + +func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitle204Response struct { +} + +func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Get titles + // (GET /title) + GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// GetTitle operation middleware +func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { + var request GetTitleRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitleResponseObject); ok { + if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/api/gen.go b/api/gen.go deleted file mode 100644 index 2dcb886..0000000 --- a/api/gen.go +++ /dev/null @@ -1,1958 +0,0 @@ -// Package api provides primitives to interact with the openapi HTTP API. -// -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. -package api - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/oapi-codegen/runtime" - strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" -) - -// Review defines model for Review. -type Review map[string]interface{} - -// Tag defines model for Tag. -type Tag map[string]interface{} - -// Title defines model for Title. -type Title map[string]interface{} - -// User defines model for User. -type User map[string]interface{} - -// UserTitle defines model for UserTitle. -type UserTitle map[string]interface{} - -// GetMediaParams defines parameters for GetMedia. -type GetMediaParams struct { - ImageId string `form:"image_id" json:"image_id"` -} - -// GetTagsParams defines parameters for GetTags. -type GetTagsParams struct { - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` -} - -// GetTitleParams defines parameters for GetTitle. -type GetTitleParams struct { - Query *string `form:"query,omitempty" json:"query,omitempty"` - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` -} - -// GetTitleTitleIdParams defines parameters for GetTitleTitleId. -type GetTitleTitleIdParams struct { - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` -} - -// GetTitleTitleIdReviewsParams defines parameters for GetTitleTitleIdReviews. -type GetTitleTitleIdReviewsParams struct { - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` -} - -// GetUsersParams defines parameters for GetUsers. -type GetUsersParams struct { - Query *string `form:"query,omitempty" json:"query,omitempty"` - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` -} - -// GetUsersUserIdParams defines parameters for GetUsersUserId. -type GetUsersUserIdParams struct { - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` -} - -// GetUsersUserIdReviewsParams defines parameters for GetUsersUserIdReviews. -type GetUsersUserIdReviewsParams struct { - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` -} - -// DeleteUsersUserIdTitlesParams defines parameters for DeleteUsersUserIdTitles. -type DeleteUsersUserIdTitlesParams struct { - TitleId *string `form:"title_id,omitempty" json:"title_id,omitempty"` -} - -// GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. -type GetUsersUserIdTitlesParams struct { - Query *string `form:"query,omitempty" json:"query,omitempty"` - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` -} - -// PostUsersUserIdTitlesJSONBody defines parameters for PostUsersUserIdTitles. -type PostUsersUserIdTitlesJSONBody struct { - Status *string `json:"status,omitempty"` - TitleId *string `json:"title_id,omitempty"` -} - -// PostReviewsJSONRequestBody defines body for PostReviews for application/json ContentType. -type PostReviewsJSONRequestBody = Review - -// PatchReviewsReviewIdJSONRequestBody defines body for PatchReviewsReviewId for application/json ContentType. -type PatchReviewsReviewIdJSONRequestBody = Review - -// PatchTitleTitleIdJSONRequestBody defines body for PatchTitleTitleId for application/json ContentType. -type PatchTitleTitleIdJSONRequestBody = Title - -// PostUsersJSONRequestBody defines body for PostUsers for application/json ContentType. -type PostUsersJSONRequestBody = User - -// PatchUsersUserIdJSONRequestBody defines body for PatchUsersUserId for application/json ContentType. -type PatchUsersUserIdJSONRequestBody = User - -// PatchUsersUserIdTitlesJSONRequestBody defines body for PatchUsersUserIdTitles for application/json ContentType. -type PatchUsersUserIdTitlesJSONRequestBody = UserTitle - -// PostUsersUserIdTitlesJSONRequestBody defines body for PostUsersUserIdTitles for application/json ContentType. -type PostUsersUserIdTitlesJSONRequestBody PostUsersUserIdTitlesJSONBody - -// ServerInterface represents all server handlers. -type ServerInterface interface { - // Get image path - // (GET /media) - GetMedia(c *gin.Context, params GetMediaParams) - // Upload image - // (POST /media) - PostMedia(c *gin.Context) - // Add review - // (POST /reviews) - PostReviews(c *gin.Context) - // Delete review - // (DELETE /reviews/{review_id}) - DeleteReviewsReviewId(c *gin.Context, reviewId string) - // Update review - // (PATCH /reviews/{review_id}) - PatchReviewsReviewId(c *gin.Context, reviewId string) - // Get tags - // (GET /tags) - GetTags(c *gin.Context, params GetTagsParams) - // Get titles - // (GET /title) - GetTitle(c *gin.Context, params GetTitleParams) - // Get title description - // (GET /title/{title_id}) - GetTitleTitleId(c *gin.Context, titleId string, params GetTitleTitleIdParams) - // Update title info - // (PATCH /title/{title_id}) - PatchTitleTitleId(c *gin.Context, titleId string) - // Get title reviews - // (GET /title/{title_id}/reviews) - GetTitleTitleIdReviews(c *gin.Context, titleId string, params GetTitleTitleIdReviewsParams) - // Search user - // (GET /users) - GetUsers(c *gin.Context, params GetUsersParams) - // Add new user - // (POST /users) - PostUsers(c *gin.Context) - // Delete user - // (DELETE /users/{user_id}) - DeleteUsersUserId(c *gin.Context, userId string) - // Get user info - // (GET /users/{user_id}) - GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) - // Update user - // (PATCH /users/{user_id}) - PatchUsersUserId(c *gin.Context, userId string) - // Get user reviews - // (GET /users/{user_id}/reviews) - GetUsersUserIdReviews(c *gin.Context, userId string, params GetUsersUserIdReviewsParams) - // Delete user title - // (DELETE /users/{user_id}/titles) - DeleteUsersUserIdTitles(c *gin.Context, userId string, params DeleteUsersUserIdTitlesParams) - // Get user titles - // (GET /users/{user_id}/titles) - GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) - // Update user title - // (PATCH /users/{user_id}/titles) - PatchUsersUserIdTitles(c *gin.Context, userId string) - // Add user title - // (POST /users/{user_id}/titles) - PostUsersUserIdTitles(c *gin.Context, userId string) -} - -// ServerInterfaceWrapper converts contexts to parameters. -type ServerInterfaceWrapper struct { - Handler ServerInterface - HandlerMiddlewares []MiddlewareFunc - ErrorHandler func(*gin.Context, error, int) -} - -type MiddlewareFunc func(c *gin.Context) - -// GetMedia operation middleware -func (siw *ServerInterfaceWrapper) GetMedia(c *gin.Context) { - - var err error - - // Parameter object where we will unmarshal all parameters from the context - var params GetMediaParams - - // ------------- Required query parameter "image_id" ------------- - - if paramValue := c.Query("image_id"); paramValue != "" { - - } else { - siw.ErrorHandler(c, fmt.Errorf("Query argument image_id is required, but not found"), http.StatusBadRequest) - return - } - - err = runtime.BindQueryParameter("form", true, true, "image_id", c.Request.URL.Query(), ¶ms.ImageId) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter image_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetMedia(c, params) -} - -// PostMedia operation middleware -func (siw *ServerInterfaceWrapper) PostMedia(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostMedia(c) -} - -// PostReviews operation middleware -func (siw *ServerInterfaceWrapper) PostReviews(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostReviews(c) -} - -// DeleteReviewsReviewId operation middleware -func (siw *ServerInterfaceWrapper) DeleteReviewsReviewId(c *gin.Context) { - - var err error - - // ------------- Path parameter "review_id" ------------- - var reviewId string - - err = runtime.BindStyledParameterWithOptions("simple", "review_id", c.Param("review_id"), &reviewId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter review_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.DeleteReviewsReviewId(c, reviewId) -} - -// PatchReviewsReviewId operation middleware -func (siw *ServerInterfaceWrapper) PatchReviewsReviewId(c *gin.Context) { - - var err error - - // ------------- Path parameter "review_id" ------------- - var reviewId string - - err = runtime.BindStyledParameterWithOptions("simple", "review_id", c.Param("review_id"), &reviewId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter review_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PatchReviewsReviewId(c, reviewId) -} - -// GetTags operation middleware -func (siw *ServerInterfaceWrapper) GetTags(c *gin.Context) { - - var err error - - // Parameter object where we will unmarshal all parameters from the context - var params GetTagsParams - - // ------------- Optional query parameter "limit" ------------- - - err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "offset" ------------- - - err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "fields" ------------- - - err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetTags(c, params) -} - -// GetTitle operation middleware -func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { - - var err error - - // Parameter object where we will unmarshal all parameters from the context - var params GetTitleParams - - // ------------- Optional query parameter "query" ------------- - - err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "limit" ------------- - - err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "offset" ------------- - - err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "fields" ------------- - - err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetTitle(c, params) -} - -// GetTitleTitleId operation middleware -func (siw *ServerInterfaceWrapper) GetTitleTitleId(c *gin.Context) { - - var err error - - // ------------- Path parameter "title_id" ------------- - var titleId string - - err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params GetTitleTitleIdParams - - // ------------- Optional query parameter "fields" ------------- - - err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetTitleTitleId(c, titleId, params) -} - -// PatchTitleTitleId operation middleware -func (siw *ServerInterfaceWrapper) PatchTitleTitleId(c *gin.Context) { - - var err error - - // ------------- Path parameter "title_id" ------------- - var titleId string - - err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PatchTitleTitleId(c, titleId) -} - -// GetTitleTitleIdReviews operation middleware -func (siw *ServerInterfaceWrapper) GetTitleTitleIdReviews(c *gin.Context) { - - var err error - - // ------------- Path parameter "title_id" ------------- - var titleId string - - err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params GetTitleTitleIdReviewsParams - - // ------------- Optional query parameter "limit" ------------- - - err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "offset" ------------- - - err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetTitleTitleIdReviews(c, titleId, params) -} - -// GetUsers operation middleware -func (siw *ServerInterfaceWrapper) GetUsers(c *gin.Context) { - - var err error - - // Parameter object where we will unmarshal all parameters from the context - var params GetUsersParams - - // ------------- Optional query parameter "query" ------------- - - err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "fields" ------------- - - err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetUsers(c, params) -} - -// PostUsers operation middleware -func (siw *ServerInterfaceWrapper) PostUsers(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostUsers(c) -} - -// DeleteUsersUserId operation middleware -func (siw *ServerInterfaceWrapper) DeleteUsersUserId(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.DeleteUsersUserId(c, userId) -} - -// GetUsersUserId operation middleware -func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params GetUsersUserIdParams - - // ------------- Optional query parameter "fields" ------------- - - err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetUsersUserId(c, userId, params) -} - -// PatchUsersUserId operation middleware -func (siw *ServerInterfaceWrapper) PatchUsersUserId(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PatchUsersUserId(c, userId) -} - -// GetUsersUserIdReviews operation middleware -func (siw *ServerInterfaceWrapper) GetUsersUserIdReviews(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params GetUsersUserIdReviewsParams - - // ------------- Optional query parameter "limit" ------------- - - err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "offset" ------------- - - err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetUsersUserIdReviews(c, userId, params) -} - -// DeleteUsersUserIdTitles operation middleware -func (siw *ServerInterfaceWrapper) DeleteUsersUserIdTitles(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params DeleteUsersUserIdTitlesParams - - // ------------- Optional query parameter "title_id" ------------- - - err = runtime.BindQueryParameter("form", true, false, "title_id", c.Request.URL.Query(), ¶ms.TitleId) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.DeleteUsersUserIdTitles(c, userId, params) -} - -// GetUsersUserIdTitles operation middleware -func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params GetUsersUserIdTitlesParams - - // ------------- Optional query parameter "query" ------------- - - err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "limit" ------------- - - err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "offset" ------------- - - err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "fields" ------------- - - err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetUsersUserIdTitles(c, userId, params) -} - -// PatchUsersUserIdTitles operation middleware -func (siw *ServerInterfaceWrapper) PatchUsersUserIdTitles(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PatchUsersUserIdTitles(c, userId) -} - -// PostUsersUserIdTitles operation middleware -func (siw *ServerInterfaceWrapper) PostUsersUserIdTitles(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostUsersUserIdTitles(c, userId) -} - -// GinServerOptions provides options for the Gin server. -type GinServerOptions struct { - BaseURL string - Middlewares []MiddlewareFunc - ErrorHandler func(*gin.Context, error, int) -} - -// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. -func RegisterHandlers(router gin.IRouter, si ServerInterface) { - RegisterHandlersWithOptions(router, si, GinServerOptions{}) -} - -// RegisterHandlersWithOptions creates http.Handler with additional options -func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { - errorHandler := options.ErrorHandler - if errorHandler == nil { - errorHandler = func(c *gin.Context, err error, statusCode int) { - c.JSON(statusCode, gin.H{"msg": err.Error()}) - } - } - - wrapper := ServerInterfaceWrapper{ - Handler: si, - HandlerMiddlewares: options.Middlewares, - ErrorHandler: errorHandler, - } - - router.GET(options.BaseURL+"/media", wrapper.GetMedia) - router.POST(options.BaseURL+"/media", wrapper.PostMedia) - router.POST(options.BaseURL+"/reviews", wrapper.PostReviews) - router.DELETE(options.BaseURL+"/reviews/:review_id", wrapper.DeleteReviewsReviewId) - router.PATCH(options.BaseURL+"/reviews/:review_id", wrapper.PatchReviewsReviewId) - router.GET(options.BaseURL+"/tags", wrapper.GetTags) - router.GET(options.BaseURL+"/title", wrapper.GetTitle) - router.GET(options.BaseURL+"/title/:title_id", wrapper.GetTitleTitleId) - router.PATCH(options.BaseURL+"/title/:title_id", wrapper.PatchTitleTitleId) - router.GET(options.BaseURL+"/title/:title_id/reviews", wrapper.GetTitleTitleIdReviews) - router.GET(options.BaseURL+"/users", wrapper.GetUsers) - router.POST(options.BaseURL+"/users", wrapper.PostUsers) - router.DELETE(options.BaseURL+"/users/:user_id", wrapper.DeleteUsersUserId) - router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) - router.PATCH(options.BaseURL+"/users/:user_id", wrapper.PatchUsersUserId) - router.GET(options.BaseURL+"/users/:user_id/reviews", wrapper.GetUsersUserIdReviews) - router.DELETE(options.BaseURL+"/users/:user_id/titles", wrapper.DeleteUsersUserIdTitles) - router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) - router.PATCH(options.BaseURL+"/users/:user_id/titles", wrapper.PatchUsersUserIdTitles) - router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.PostUsersUserIdTitles) -} - -type GetMediaRequestObject struct { - Params GetMediaParams -} - -type GetMediaResponseObject interface { - VisitGetMediaResponse(w http.ResponseWriter) error -} - -type GetMedia200JSONResponse struct { - Error *string `json:"error,omitempty"` - ImagePath *string `json:"image_path,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response GetMedia200JSONResponse) VisitGetMediaResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type PostMediaRequestObject struct { -} - -type PostMediaResponseObject interface { - VisitPostMediaResponse(w http.ResponseWriter) error -} - -type PostMedia200JSONResponse struct { - Error *string `json:"error,omitempty"` - ImageId *string `json:"image_id,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response PostMedia200JSONResponse) VisitPostMediaResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type PostReviewsRequestObject struct { - Body *PostReviewsJSONRequestBody -} - -type PostReviewsResponseObject interface { - VisitPostReviewsResponse(w http.ResponseWriter) error -} - -type PostReviews200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response PostReviews200JSONResponse) VisitPostReviewsResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type DeleteReviewsReviewIdRequestObject struct { - ReviewId string `json:"review_id"` -} - -type DeleteReviewsReviewIdResponseObject interface { - VisitDeleteReviewsReviewIdResponse(w http.ResponseWriter) error -} - -type DeleteReviewsReviewId200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response DeleteReviewsReviewId200JSONResponse) VisitDeleteReviewsReviewIdResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type PatchReviewsReviewIdRequestObject struct { - ReviewId string `json:"review_id"` - Body *PatchReviewsReviewIdJSONRequestBody -} - -type PatchReviewsReviewIdResponseObject interface { - VisitPatchReviewsReviewIdResponse(w http.ResponseWriter) error -} - -type PatchReviewsReviewId200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response PatchReviewsReviewId200JSONResponse) VisitPatchReviewsReviewIdResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetTagsRequestObject struct { - Params GetTagsParams -} - -type GetTagsResponseObject interface { - VisitGetTagsResponse(w http.ResponseWriter) error -} - -type GetTags200JSONResponse []Tag - -func (response GetTags200JSONResponse) VisitGetTagsResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetTitleRequestObject struct { - Params GetTitleParams -} - -type GetTitleResponseObject interface { - VisitGetTitleResponse(w http.ResponseWriter) error -} - -type GetTitle200JSONResponse []Title - -func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetTitle204Response struct { -} - -func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(204) - return nil -} - -type GetTitleTitleIdRequestObject struct { - TitleId string `json:"title_id"` - Params GetTitleTitleIdParams -} - -type GetTitleTitleIdResponseObject interface { - VisitGetTitleTitleIdResponse(w http.ResponseWriter) error -} - -type GetTitleTitleId200JSONResponse Title - -func (response GetTitleTitleId200JSONResponse) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetTitleTitleId404Response struct { -} - -func (response GetTitleTitleId404Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { - w.WriteHeader(404) - return nil -} - -type PatchTitleTitleIdRequestObject struct { - TitleId string `json:"title_id"` - Body *PatchTitleTitleIdJSONRequestBody -} - -type PatchTitleTitleIdResponseObject interface { - VisitPatchTitleTitleIdResponse(w http.ResponseWriter) error -} - -type PatchTitleTitleId200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` - UserJson *User `json:"user_json,omitempty"` -} - -func (response PatchTitleTitleId200JSONResponse) VisitPatchTitleTitleIdResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetTitleTitleIdReviewsRequestObject struct { - TitleId string `json:"title_id"` - Params GetTitleTitleIdReviewsParams -} - -type GetTitleTitleIdReviewsResponseObject interface { - VisitGetTitleTitleIdReviewsResponse(w http.ResponseWriter) error -} - -type GetTitleTitleIdReviews200JSONResponse []Review - -func (response GetTitleTitleIdReviews200JSONResponse) VisitGetTitleTitleIdReviewsResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetTitleTitleIdReviews204Response struct { -} - -func (response GetTitleTitleIdReviews204Response) VisitGetTitleTitleIdReviewsResponse(w http.ResponseWriter) error { - w.WriteHeader(204) - return nil -} - -type GetUsersRequestObject struct { - Params GetUsersParams -} - -type GetUsersResponseObject interface { - VisitGetUsersResponse(w http.ResponseWriter) error -} - -type GetUsers200JSONResponse []User - -func (response GetUsers200JSONResponse) VisitGetUsersResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type PostUsersRequestObject struct { - Body *PostUsersJSONRequestBody -} - -type PostUsersResponseObject interface { - VisitPostUsersResponse(w http.ResponseWriter) error -} - -type PostUsers200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` - UserJson *User `json:"user_json,omitempty"` -} - -func (response PostUsers200JSONResponse) VisitPostUsersResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type DeleteUsersUserIdRequestObject struct { - UserId string `json:"user_id"` -} - -type DeleteUsersUserIdResponseObject interface { - VisitDeleteUsersUserIdResponse(w http.ResponseWriter) error -} - -type DeleteUsersUserId200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response DeleteUsersUserId200JSONResponse) VisitDeleteUsersUserIdResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetUsersUserIdRequestObject struct { - UserId string `json:"user_id"` - Params GetUsersUserIdParams -} - -type GetUsersUserIdResponseObject interface { - VisitGetUsersUserIdResponse(w http.ResponseWriter) error -} - -type GetUsersUserId200JSONResponse User - -func (response GetUsersUserId200JSONResponse) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetUsersUserId404Response struct { -} - -func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { - w.WriteHeader(404) - return nil -} - -type PatchUsersUserIdRequestObject struct { - UserId string `json:"user_id"` - Body *PatchUsersUserIdJSONRequestBody -} - -type PatchUsersUserIdResponseObject interface { - VisitPatchUsersUserIdResponse(w http.ResponseWriter) error -} - -type PatchUsersUserId200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response PatchUsersUserId200JSONResponse) VisitPatchUsersUserIdResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetUsersUserIdReviewsRequestObject struct { - UserId string `json:"user_id"` - Params GetUsersUserIdReviewsParams -} - -type GetUsersUserIdReviewsResponseObject interface { - VisitGetUsersUserIdReviewsResponse(w http.ResponseWriter) error -} - -type GetUsersUserIdReviews200JSONResponse []Review - -func (response GetUsersUserIdReviews200JSONResponse) VisitGetUsersUserIdReviewsResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type DeleteUsersUserIdTitlesRequestObject struct { - UserId string `json:"user_id"` - Params DeleteUsersUserIdTitlesParams -} - -type DeleteUsersUserIdTitlesResponseObject interface { - VisitDeleteUsersUserIdTitlesResponse(w http.ResponseWriter) error -} - -type DeleteUsersUserIdTitles200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response DeleteUsersUserIdTitles200JSONResponse) VisitDeleteUsersUserIdTitlesResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetUsersUserIdTitlesRequestObject struct { - UserId string `json:"user_id"` - Params GetUsersUserIdTitlesParams -} - -type GetUsersUserIdTitlesResponseObject interface { - VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error -} - -type GetUsersUserIdTitles200JSONResponse []UserTitle - -func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetUsersUserIdTitles204Response struct { -} - -func (response GetUsersUserIdTitles204Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { - w.WriteHeader(204) - return nil -} - -type PatchUsersUserIdTitlesRequestObject struct { - UserId string `json:"user_id"` - Body *PatchUsersUserIdTitlesJSONRequestBody -} - -type PatchUsersUserIdTitlesResponseObject interface { - VisitPatchUsersUserIdTitlesResponse(w http.ResponseWriter) error -} - -type PatchUsersUserIdTitles200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response PatchUsersUserIdTitles200JSONResponse) VisitPatchUsersUserIdTitlesResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type PostUsersUserIdTitlesRequestObject struct { - UserId string `json:"user_id"` - Body *PostUsersUserIdTitlesJSONRequestBody -} - -type PostUsersUserIdTitlesResponseObject interface { - VisitPostUsersUserIdTitlesResponse(w http.ResponseWriter) error -} - -type PostUsersUserIdTitles200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response PostUsersUserIdTitles200JSONResponse) VisitPostUsersUserIdTitlesResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -// StrictServerInterface represents all server handlers. -type StrictServerInterface interface { - // Get image path - // (GET /media) - GetMedia(ctx context.Context, request GetMediaRequestObject) (GetMediaResponseObject, error) - // Upload image - // (POST /media) - PostMedia(ctx context.Context, request PostMediaRequestObject) (PostMediaResponseObject, error) - // Add review - // (POST /reviews) - PostReviews(ctx context.Context, request PostReviewsRequestObject) (PostReviewsResponseObject, error) - // Delete review - // (DELETE /reviews/{review_id}) - DeleteReviewsReviewId(ctx context.Context, request DeleteReviewsReviewIdRequestObject) (DeleteReviewsReviewIdResponseObject, error) - // Update review - // (PATCH /reviews/{review_id}) - PatchReviewsReviewId(ctx context.Context, request PatchReviewsReviewIdRequestObject) (PatchReviewsReviewIdResponseObject, error) - // Get tags - // (GET /tags) - GetTags(ctx context.Context, request GetTagsRequestObject) (GetTagsResponseObject, error) - // Get titles - // (GET /title) - GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) - // Get title description - // (GET /title/{title_id}) - GetTitleTitleId(ctx context.Context, request GetTitleTitleIdRequestObject) (GetTitleTitleIdResponseObject, error) - // Update title info - // (PATCH /title/{title_id}) - PatchTitleTitleId(ctx context.Context, request PatchTitleTitleIdRequestObject) (PatchTitleTitleIdResponseObject, error) - // Get title reviews - // (GET /title/{title_id}/reviews) - GetTitleTitleIdReviews(ctx context.Context, request GetTitleTitleIdReviewsRequestObject) (GetTitleTitleIdReviewsResponseObject, error) - // Search user - // (GET /users) - GetUsers(ctx context.Context, request GetUsersRequestObject) (GetUsersResponseObject, error) - // Add new user - // (POST /users) - PostUsers(ctx context.Context, request PostUsersRequestObject) (PostUsersResponseObject, error) - // Delete user - // (DELETE /users/{user_id}) - DeleteUsersUserId(ctx context.Context, request DeleteUsersUserIdRequestObject) (DeleteUsersUserIdResponseObject, error) - // Get user info - // (GET /users/{user_id}) - GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) - // Update user - // (PATCH /users/{user_id}) - PatchUsersUserId(ctx context.Context, request PatchUsersUserIdRequestObject) (PatchUsersUserIdResponseObject, error) - // Get user reviews - // (GET /users/{user_id}/reviews) - GetUsersUserIdReviews(ctx context.Context, request GetUsersUserIdReviewsRequestObject) (GetUsersUserIdReviewsResponseObject, error) - // Delete user title - // (DELETE /users/{user_id}/titles) - DeleteUsersUserIdTitles(ctx context.Context, request DeleteUsersUserIdTitlesRequestObject) (DeleteUsersUserIdTitlesResponseObject, error) - // Get user titles - // (GET /users/{user_id}/titles) - GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) - // Update user title - // (PATCH /users/{user_id}/titles) - PatchUsersUserIdTitles(ctx context.Context, request PatchUsersUserIdTitlesRequestObject) (PatchUsersUserIdTitlesResponseObject, error) - // Add user title - // (POST /users/{user_id}/titles) - PostUsersUserIdTitles(ctx context.Context, request PostUsersUserIdTitlesRequestObject) (PostUsersUserIdTitlesResponseObject, error) -} - -type StrictHandlerFunc = strictgin.StrictGinHandlerFunc -type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc - -func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { - return &strictHandler{ssi: ssi, middlewares: middlewares} -} - -type strictHandler struct { - ssi StrictServerInterface - middlewares []StrictMiddlewareFunc -} - -// GetMedia operation middleware -func (sh *strictHandler) GetMedia(ctx *gin.Context, params GetMediaParams) { - var request GetMediaRequestObject - - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetMedia(ctx, request.(GetMediaRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetMedia") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetMediaResponseObject); ok { - if err := validResponse.VisitGetMediaResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PostMedia operation middleware -func (sh *strictHandler) PostMedia(ctx *gin.Context) { - var request PostMediaRequestObject - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostMedia(ctx, request.(PostMediaRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostMedia") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostMediaResponseObject); ok { - if err := validResponse.VisitPostMediaResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PostReviews operation middleware -func (sh *strictHandler) PostReviews(ctx *gin.Context) { - var request PostReviewsRequestObject - - var body PostReviewsJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostReviews(ctx, request.(PostReviewsRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostReviews") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostReviewsResponseObject); ok { - if err := validResponse.VisitPostReviewsResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// DeleteReviewsReviewId operation middleware -func (sh *strictHandler) DeleteReviewsReviewId(ctx *gin.Context, reviewId string) { - var request DeleteReviewsReviewIdRequestObject - - request.ReviewId = reviewId - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.DeleteReviewsReviewId(ctx, request.(DeleteReviewsReviewIdRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "DeleteReviewsReviewId") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(DeleteReviewsReviewIdResponseObject); ok { - if err := validResponse.VisitDeleteReviewsReviewIdResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PatchReviewsReviewId operation middleware -func (sh *strictHandler) PatchReviewsReviewId(ctx *gin.Context, reviewId string) { - var request PatchReviewsReviewIdRequestObject - - request.ReviewId = reviewId - - var body PatchReviewsReviewIdJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PatchReviewsReviewId(ctx, request.(PatchReviewsReviewIdRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PatchReviewsReviewId") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PatchReviewsReviewIdResponseObject); ok { - if err := validResponse.VisitPatchReviewsReviewIdResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetTags operation middleware -func (sh *strictHandler) GetTags(ctx *gin.Context, params GetTagsParams) { - var request GetTagsRequestObject - - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTags(ctx, request.(GetTagsRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTags") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTagsResponseObject); ok { - if err := validResponse.VisitGetTagsResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetTitle operation middleware -func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { - var request GetTitleRequestObject - - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitle") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitleResponseObject); ok { - if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetTitleTitleId operation middleware -func (sh *strictHandler) GetTitleTitleId(ctx *gin.Context, titleId string, params GetTitleTitleIdParams) { - var request GetTitleTitleIdRequestObject - - request.TitleId = titleId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitleTitleId(ctx, request.(GetTitleTitleIdRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitleTitleId") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitleTitleIdResponseObject); ok { - if err := validResponse.VisitGetTitleTitleIdResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PatchTitleTitleId operation middleware -func (sh *strictHandler) PatchTitleTitleId(ctx *gin.Context, titleId string) { - var request PatchTitleTitleIdRequestObject - - request.TitleId = titleId - - var body PatchTitleTitleIdJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PatchTitleTitleId(ctx, request.(PatchTitleTitleIdRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PatchTitleTitleId") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PatchTitleTitleIdResponseObject); ok { - if err := validResponse.VisitPatchTitleTitleIdResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetTitleTitleIdReviews operation middleware -func (sh *strictHandler) GetTitleTitleIdReviews(ctx *gin.Context, titleId string, params GetTitleTitleIdReviewsParams) { - var request GetTitleTitleIdReviewsRequestObject - - request.TitleId = titleId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitleTitleIdReviews(ctx, request.(GetTitleTitleIdReviewsRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitleTitleIdReviews") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitleTitleIdReviewsResponseObject); ok { - if err := validResponse.VisitGetTitleTitleIdReviewsResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetUsers operation middleware -func (sh *strictHandler) GetUsers(ctx *gin.Context, params GetUsersParams) { - var request GetUsersRequestObject - - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsers(ctx, request.(GetUsersRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsers") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersResponseObject); ok { - if err := validResponse.VisitGetUsersResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PostUsers operation middleware -func (sh *strictHandler) PostUsers(ctx *gin.Context) { - var request PostUsersRequestObject - - var body PostUsersJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostUsers(ctx, request.(PostUsersRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostUsers") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostUsersResponseObject); ok { - if err := validResponse.VisitPostUsersResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// DeleteUsersUserId operation middleware -func (sh *strictHandler) DeleteUsersUserId(ctx *gin.Context, userId string) { - var request DeleteUsersUserIdRequestObject - - request.UserId = userId - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.DeleteUsersUserId(ctx, request.(DeleteUsersUserIdRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "DeleteUsersUserId") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(DeleteUsersUserIdResponseObject); ok { - if err := validResponse.VisitDeleteUsersUserIdResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetUsersUserId operation middleware -func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { - var request GetUsersUserIdRequestObject - - request.UserId = userId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsersUserId(ctx, request.(GetUsersUserIdRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsersUserId") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersUserIdResponseObject); ok { - if err := validResponse.VisitGetUsersUserIdResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PatchUsersUserId operation middleware -func (sh *strictHandler) PatchUsersUserId(ctx *gin.Context, userId string) { - var request PatchUsersUserIdRequestObject - - request.UserId = userId - - var body PatchUsersUserIdJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PatchUsersUserId(ctx, request.(PatchUsersUserIdRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PatchUsersUserId") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PatchUsersUserIdResponseObject); ok { - if err := validResponse.VisitPatchUsersUserIdResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetUsersUserIdReviews operation middleware -func (sh *strictHandler) GetUsersUserIdReviews(ctx *gin.Context, userId string, params GetUsersUserIdReviewsParams) { - var request GetUsersUserIdReviewsRequestObject - - request.UserId = userId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsersUserIdReviews(ctx, request.(GetUsersUserIdReviewsRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsersUserIdReviews") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersUserIdReviewsResponseObject); ok { - if err := validResponse.VisitGetUsersUserIdReviewsResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// DeleteUsersUserIdTitles operation middleware -func (sh *strictHandler) DeleteUsersUserIdTitles(ctx *gin.Context, userId string, params DeleteUsersUserIdTitlesParams) { - var request DeleteUsersUserIdTitlesRequestObject - - request.UserId = userId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.DeleteUsersUserIdTitles(ctx, request.(DeleteUsersUserIdTitlesRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "DeleteUsersUserIdTitles") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(DeleteUsersUserIdTitlesResponseObject); ok { - if err := validResponse.VisitDeleteUsersUserIdTitlesResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetUsersUserIdTitles operation middleware -func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { - var request GetUsersUserIdTitlesRequestObject - - request.UserId = userId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsersUserIdTitles(ctx, request.(GetUsersUserIdTitlesRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsersUserIdTitles") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersUserIdTitlesResponseObject); ok { - if err := validResponse.VisitGetUsersUserIdTitlesResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PatchUsersUserIdTitles operation middleware -func (sh *strictHandler) PatchUsersUserIdTitles(ctx *gin.Context, userId string) { - var request PatchUsersUserIdTitlesRequestObject - - request.UserId = userId - - var body PatchUsersUserIdTitlesJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PatchUsersUserIdTitles(ctx, request.(PatchUsersUserIdTitlesRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PatchUsersUserIdTitles") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PatchUsersUserIdTitlesResponseObject); ok { - if err := validResponse.VisitPatchUsersUserIdTitlesResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PostUsersUserIdTitles operation middleware -func (sh *strictHandler) PostUsersUserIdTitles(ctx *gin.Context, userId string) { - var request PostUsersUserIdTitlesRequestObject - - request.UserId = userId - - var body PostUsersUserIdTitlesJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostUsersUserIdTitles(ctx, request.(PostUsersUserIdTitlesRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostUsersUserIdTitles") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostUsersUserIdTitlesResponseObject); ok { - if err := validResponse.VisitPostUsersUserIdTitlesResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} diff --git a/api/oapi-codegen.yaml b/api/oapi-codegen.yaml index 1d470ac..32e029a 100644 --- a/api/oapi-codegen.yaml +++ b/api/oapi-codegen.yaml @@ -1,6 +1,6 @@ -package: api +package: oapi generate: strict-server: true gin-server: true models: true -output: gen.go \ No newline at end of file +output: api/api.gen.go \ No newline at end of file diff --git a/api/openapi.yaml b/api/openapi.yaml index ca6099f..b2a2df0 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -40,498 +40,498 @@ paths: '204': description: No titles found - /title/{title_id}: - get: - summary: Get title description - parameters: - - in: path - name: title_id - required: true - schema: - type: string - - in: query - name: fields - schema: - type: string - default: all - responses: - '200': - description: Title description - content: - application/json: - schema: - $ref: '#/components/schemas/Title' - '404': - description: Title not found +# /title/{title_id}: +# get: +# summary: Get title description +# parameters: +# - in: path +# name: title_id +# required: true +# schema: +# type: string +# - in: query +# name: fields +# schema: +# type: string +# default: all +# responses: +# '200': +# description: Title description +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/Title' +# '404': +# description: Title not found - patch: - summary: Update title info - parameters: - - in: path - name: title_id - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Title' - responses: - '200': - description: Update result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string - user_json: - $ref: '#/components/schemas/User' +# patch: +# summary: Update title info +# parameters: +# - in: path +# name: title_id +# required: true +# schema: +# type: string +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/Title' +# responses: +# '200': +# description: Update result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string +# user_json: +# $ref: '#/components/schemas/User' - /title/{title_id}/reviews: - get: - summary: Get title reviews - parameters: - - in: path - name: title_id - required: true - schema: - type: string - - in: query - name: limit - schema: - type: integer - default: 10 - - in: query - name: offset - schema: - type: integer - default: 0 - responses: - '200': - description: List of reviews - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Review' - '204': - description: No reviews found +# /title/{title_id}/reviews: +# get: +# summary: Get title reviews +# parameters: +# - in: path +# name: title_id +# required: true +# schema: +# type: string +# - in: query +# name: limit +# schema: +# type: integer +# default: 10 +# - in: query +# name: offset +# schema: +# type: integer +# default: 0 +# responses: +# '200': +# description: List of reviews +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/Review' +# '204': +# description: No reviews found - /users/{user_id}: - get: - summary: Get user info - parameters: - - in: path - name: user_id - required: true - schema: - type: string - - in: query - name: fields - schema: - type: string - default: all - responses: - '200': - description: User info - content: - application/json: - schema: - $ref: '#/components/schemas/User' - '404': - description: User not found +# /users/{user_id}: +# get: +# summary: Get user info +# parameters: +# - in: path +# name: user_id +# required: true +# schema: +# type: string +# - in: query +# name: fields +# schema: +# type: string +# default: all +# responses: +# '200': +# description: User info +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/User' +# '404': +# description: User not found - patch: - summary: Update user - parameters: - - in: path - name: user_id - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/User' - responses: - '200': - description: Update result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string +# patch: +# summary: Update user +# parameters: +# - in: path +# name: user_id +# required: true +# schema: +# type: string +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/User' +# responses: +# '200': +# description: Update result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string - delete: - summary: Delete user - parameters: - - in: path - name: user_id - required: true - schema: - type: string - responses: - '200': - description: Delete result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string +# delete: +# summary: Delete user +# parameters: +# - in: path +# name: user_id +# required: true +# schema: +# type: string +# responses: +# '200': +# description: Delete result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string - /users: - get: - summary: Search user - parameters: - - in: query - name: query - schema: - type: string - - in: query - name: fields - schema: - type: string - responses: - '200': - description: List of users - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/User' +# /users: +# get: +# summary: Search user +# parameters: +# - in: query +# name: query +# schema: +# type: string +# - in: query +# name: fields +# schema: +# type: string +# responses: +# '200': +# description: List of users +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/User' - post: - summary: Add new user - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/User' - responses: - '200': - description: Add result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string - user_json: - $ref: '#/components/schemas/User' +# post: +# summary: Add new user +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/User' +# responses: +# '200': +# description: Add result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string +# user_json: +# $ref: '#/components/schemas/User' - /users/{user_id}/titles: - get: - summary: Get user titles - parameters: - - in: path - name: user_id - required: true - schema: - type: string - - in: query - name: query - schema: - type: string - - in: query - name: limit - schema: - type: integer - default: 10 - - in: query - name: offset - schema: - type: integer - default: 0 - - in: query - name: fields - schema: - type: string - default: all - responses: - '200': - description: List of user titles - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/UserTitle' - '204': - description: No titles found +# /users/{user_id}/titles: +# get: +# summary: Get user titles +# parameters: +# - in: path +# name: user_id +# required: true +# schema: +# type: string +# - in: query +# name: query +# schema: +# type: string +# - in: query +# name: limit +# schema: +# type: integer +# default: 10 +# - in: query +# name: offset +# schema: +# type: integer +# default: 0 +# - in: query +# name: fields +# schema: +# type: string +# default: all +# responses: +# '200': +# description: List of user titles +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/UserTitle' +# '204': +# description: No titles found - post: - summary: Add user title - parameters: - - in: path - name: user_id - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - title_id: - type: string - status: - type: string - responses: - '200': - description: Add result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string +# post: +# summary: Add user title +# parameters: +# - in: path +# name: user_id +# required: true +# schema: +# type: string +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# title_id: +# type: string +# status: +# type: string +# responses: +# '200': +# description: Add result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string - patch: - summary: Update user title - parameters: - - in: path - name: user_id - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UserTitle' - responses: - '200': - description: Update result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string +# patch: +# summary: Update user title +# parameters: +# - in: path +# name: user_id +# required: true +# schema: +# type: string +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/UserTitle' +# responses: +# '200': +# description: Update result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string - delete: - summary: Delete user title - parameters: - - in: path - name: user_id - required: true - schema: - type: string - - in: query - name: title_id - schema: - type: string - responses: - '200': - description: Delete result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string +# delete: +# summary: Delete user title +# parameters: +# - in: path +# name: user_id +# required: true +# schema: +# type: string +# - in: query +# name: title_id +# schema: +# type: string +# responses: +# '200': +# description: Delete result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string - /users/{user_id}/reviews: - get: - summary: Get user reviews - parameters: - - in: path - name: user_id - required: true - schema: - type: string - - in: query - name: limit - schema: - type: integer - default: 10 - - in: query - name: offset - schema: - type: integer - default: 0 - responses: - '200': - description: List of reviews - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Review' +# /users/{user_id}/reviews: +# get: +# summary: Get user reviews +# parameters: +# - in: path +# name: user_id +# required: true +# schema: +# type: string +# - in: query +# name: limit +# schema: +# type: integer +# default: 10 +# - in: query +# name: offset +# schema: +# type: integer +# default: 0 +# responses: +# '200': +# description: List of reviews +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/Review' - /reviews: - post: - summary: Add review - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Review' - responses: - '200': - description: Add result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string +# /reviews: +# post: +# summary: Add review +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/Review' +# responses: +# '200': +# description: Add result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string - /reviews/{review_id}: - patch: - summary: Update review - parameters: - - in: path - name: review_id - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Review' - responses: - '200': - description: Update result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string - delete: - summary: Delete review - parameters: - - in: path - name: review_id - required: true - schema: - type: string - responses: - '200': - description: Delete result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string +# /reviews/{review_id}: +# patch: +# summary: Update review +# parameters: +# - in: path +# name: review_id +# required: true +# schema: +# type: string +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/Review' +# responses: +# '200': +# description: Update result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string +# delete: +# summary: Delete review +# parameters: +# - in: path +# name: review_id +# required: true +# schema: +# type: string +# responses: +# '200': +# description: Delete result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string - /tags: - get: - summary: Get tags - parameters: - - in: query - name: limit - schema: - type: integer - default: 10 - - in: query - name: offset - schema: - type: integer - default: 0 - - in: query - name: fields - schema: - type: string - responses: - '200': - description: List of tags - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Tag' +# /tags: +# get: +# summary: Get tags +# parameters: +# - in: query +# name: limit +# schema: +# type: integer +# default: 10 +# - in: query +# name: offset +# schema: +# type: integer +# default: 0 +# - in: query +# name: fields +# schema: +# type: string +# responses: +# '200': +# description: List of tags +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/Tag' - /media: - post: - summary: Upload image - responses: - '200': - description: Upload result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string - image_id: - type: string +# /media: +# post: +# summary: Upload image +# responses: +# '200': +# description: Upload result +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string +# image_id: +# type: string - get: - summary: Get image path - parameters: - - in: query - name: image_id - required: true - schema: - type: string - responses: - '200': - description: Image path - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string - image_path: - type: string +# get: +# summary: Get image path +# parameters: +# - in: query +# name: image_id +# required: true +# schema: +# type: string +# responses: +# '200': +# description: Image path +# content: +# application/json: +# schema: +# type: object +# properties: +# success: +# type: boolean +# error: +# type: string +# image_path: +# type: string components: schemas: diff --git a/deploy/generate.sh b/deploy/generate.sh new file mode 100644 index 0000000..d7d94a2 --- /dev/null +++ b/deploy/generate.sh @@ -0,0 +1,3 @@ +npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios +oapi-codegen --config=api/oapi-codegen.yaml .\api\openapi.yaml +sqlc generate -f .\sql\sqlc.yaml \ No newline at end of file diff --git a/modules/backend/go.mod b/go.mod similarity index 82% rename from modules/backend/go.mod rename to go.mod index d8e0cb3..b7a66f2 100644 --- a/modules/backend/go.mod +++ b/go.mod @@ -1,16 +1,23 @@ -module nyanimedb-server +module nyanimedb go 1.25.0 +require ( + github.com/gin-contrib/cors v1.7.6 + github.com/gin-gonic/gin v1.11.0 + github.com/jackc/pgx/v5 v5.7.6 + github.com/oapi-codegen/runtime v1.1.2 + github.com/pelletier/go-toml/v2 v2.2.4 + golang.org/x/crypto v0.40.0 +) + require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect - github.com/gin-contrib/cors v1.7.6 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/gin-gonic/gin v1.11.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect @@ -19,23 +26,18 @@ require ( github.com/google/uuid v1.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.7.6 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/oapi-codegen/runtime v1.1.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect diff --git a/modules/backend/go.sum b/go.sum similarity index 90% rename from modules/backend/go.sum rename to go.sum index a08eb1d..1af1a7c 100644 --- a/modules/backend/go.sum +++ b/go.sum @@ -9,9 +9,8 @@ github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= @@ -20,18 +19,20 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -41,6 +42,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 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/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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= @@ -50,7 +53,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -60,13 +62,12 @@ github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQ github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -76,6 +77,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= @@ -92,8 +95,6 @@ golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -105,4 +106,5 @@ google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7I google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/modules/backend/api/gen.go b/modules/backend/api/gen.go deleted file mode 100644 index 2dcb886..0000000 --- a/modules/backend/api/gen.go +++ /dev/null @@ -1,1958 +0,0 @@ -// Package api provides primitives to interact with the openapi HTTP API. -// -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. -package api - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/oapi-codegen/runtime" - strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" -) - -// Review defines model for Review. -type Review map[string]interface{} - -// Tag defines model for Tag. -type Tag map[string]interface{} - -// Title defines model for Title. -type Title map[string]interface{} - -// User defines model for User. -type User map[string]interface{} - -// UserTitle defines model for UserTitle. -type UserTitle map[string]interface{} - -// GetMediaParams defines parameters for GetMedia. -type GetMediaParams struct { - ImageId string `form:"image_id" json:"image_id"` -} - -// GetTagsParams defines parameters for GetTags. -type GetTagsParams struct { - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` -} - -// GetTitleParams defines parameters for GetTitle. -type GetTitleParams struct { - Query *string `form:"query,omitempty" json:"query,omitempty"` - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` -} - -// GetTitleTitleIdParams defines parameters for GetTitleTitleId. -type GetTitleTitleIdParams struct { - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` -} - -// GetTitleTitleIdReviewsParams defines parameters for GetTitleTitleIdReviews. -type GetTitleTitleIdReviewsParams struct { - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` -} - -// GetUsersParams defines parameters for GetUsers. -type GetUsersParams struct { - Query *string `form:"query,omitempty" json:"query,omitempty"` - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` -} - -// GetUsersUserIdParams defines parameters for GetUsersUserId. -type GetUsersUserIdParams struct { - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` -} - -// GetUsersUserIdReviewsParams defines parameters for GetUsersUserIdReviews. -type GetUsersUserIdReviewsParams struct { - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` -} - -// DeleteUsersUserIdTitlesParams defines parameters for DeleteUsersUserIdTitles. -type DeleteUsersUserIdTitlesParams struct { - TitleId *string `form:"title_id,omitempty" json:"title_id,omitempty"` -} - -// GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. -type GetUsersUserIdTitlesParams struct { - Query *string `form:"query,omitempty" json:"query,omitempty"` - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` -} - -// PostUsersUserIdTitlesJSONBody defines parameters for PostUsersUserIdTitles. -type PostUsersUserIdTitlesJSONBody struct { - Status *string `json:"status,omitempty"` - TitleId *string `json:"title_id,omitempty"` -} - -// PostReviewsJSONRequestBody defines body for PostReviews for application/json ContentType. -type PostReviewsJSONRequestBody = Review - -// PatchReviewsReviewIdJSONRequestBody defines body for PatchReviewsReviewId for application/json ContentType. -type PatchReviewsReviewIdJSONRequestBody = Review - -// PatchTitleTitleIdJSONRequestBody defines body for PatchTitleTitleId for application/json ContentType. -type PatchTitleTitleIdJSONRequestBody = Title - -// PostUsersJSONRequestBody defines body for PostUsers for application/json ContentType. -type PostUsersJSONRequestBody = User - -// PatchUsersUserIdJSONRequestBody defines body for PatchUsersUserId for application/json ContentType. -type PatchUsersUserIdJSONRequestBody = User - -// PatchUsersUserIdTitlesJSONRequestBody defines body for PatchUsersUserIdTitles for application/json ContentType. -type PatchUsersUserIdTitlesJSONRequestBody = UserTitle - -// PostUsersUserIdTitlesJSONRequestBody defines body for PostUsersUserIdTitles for application/json ContentType. -type PostUsersUserIdTitlesJSONRequestBody PostUsersUserIdTitlesJSONBody - -// ServerInterface represents all server handlers. -type ServerInterface interface { - // Get image path - // (GET /media) - GetMedia(c *gin.Context, params GetMediaParams) - // Upload image - // (POST /media) - PostMedia(c *gin.Context) - // Add review - // (POST /reviews) - PostReviews(c *gin.Context) - // Delete review - // (DELETE /reviews/{review_id}) - DeleteReviewsReviewId(c *gin.Context, reviewId string) - // Update review - // (PATCH /reviews/{review_id}) - PatchReviewsReviewId(c *gin.Context, reviewId string) - // Get tags - // (GET /tags) - GetTags(c *gin.Context, params GetTagsParams) - // Get titles - // (GET /title) - GetTitle(c *gin.Context, params GetTitleParams) - // Get title description - // (GET /title/{title_id}) - GetTitleTitleId(c *gin.Context, titleId string, params GetTitleTitleIdParams) - // Update title info - // (PATCH /title/{title_id}) - PatchTitleTitleId(c *gin.Context, titleId string) - // Get title reviews - // (GET /title/{title_id}/reviews) - GetTitleTitleIdReviews(c *gin.Context, titleId string, params GetTitleTitleIdReviewsParams) - // Search user - // (GET /users) - GetUsers(c *gin.Context, params GetUsersParams) - // Add new user - // (POST /users) - PostUsers(c *gin.Context) - // Delete user - // (DELETE /users/{user_id}) - DeleteUsersUserId(c *gin.Context, userId string) - // Get user info - // (GET /users/{user_id}) - GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) - // Update user - // (PATCH /users/{user_id}) - PatchUsersUserId(c *gin.Context, userId string) - // Get user reviews - // (GET /users/{user_id}/reviews) - GetUsersUserIdReviews(c *gin.Context, userId string, params GetUsersUserIdReviewsParams) - // Delete user title - // (DELETE /users/{user_id}/titles) - DeleteUsersUserIdTitles(c *gin.Context, userId string, params DeleteUsersUserIdTitlesParams) - // Get user titles - // (GET /users/{user_id}/titles) - GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) - // Update user title - // (PATCH /users/{user_id}/titles) - PatchUsersUserIdTitles(c *gin.Context, userId string) - // Add user title - // (POST /users/{user_id}/titles) - PostUsersUserIdTitles(c *gin.Context, userId string) -} - -// ServerInterfaceWrapper converts contexts to parameters. -type ServerInterfaceWrapper struct { - Handler ServerInterface - HandlerMiddlewares []MiddlewareFunc - ErrorHandler func(*gin.Context, error, int) -} - -type MiddlewareFunc func(c *gin.Context) - -// GetMedia operation middleware -func (siw *ServerInterfaceWrapper) GetMedia(c *gin.Context) { - - var err error - - // Parameter object where we will unmarshal all parameters from the context - var params GetMediaParams - - // ------------- Required query parameter "image_id" ------------- - - if paramValue := c.Query("image_id"); paramValue != "" { - - } else { - siw.ErrorHandler(c, fmt.Errorf("Query argument image_id is required, but not found"), http.StatusBadRequest) - return - } - - err = runtime.BindQueryParameter("form", true, true, "image_id", c.Request.URL.Query(), ¶ms.ImageId) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter image_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetMedia(c, params) -} - -// PostMedia operation middleware -func (siw *ServerInterfaceWrapper) PostMedia(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostMedia(c) -} - -// PostReviews operation middleware -func (siw *ServerInterfaceWrapper) PostReviews(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostReviews(c) -} - -// DeleteReviewsReviewId operation middleware -func (siw *ServerInterfaceWrapper) DeleteReviewsReviewId(c *gin.Context) { - - var err error - - // ------------- Path parameter "review_id" ------------- - var reviewId string - - err = runtime.BindStyledParameterWithOptions("simple", "review_id", c.Param("review_id"), &reviewId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter review_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.DeleteReviewsReviewId(c, reviewId) -} - -// PatchReviewsReviewId operation middleware -func (siw *ServerInterfaceWrapper) PatchReviewsReviewId(c *gin.Context) { - - var err error - - // ------------- Path parameter "review_id" ------------- - var reviewId string - - err = runtime.BindStyledParameterWithOptions("simple", "review_id", c.Param("review_id"), &reviewId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter review_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PatchReviewsReviewId(c, reviewId) -} - -// GetTags operation middleware -func (siw *ServerInterfaceWrapper) GetTags(c *gin.Context) { - - var err error - - // Parameter object where we will unmarshal all parameters from the context - var params GetTagsParams - - // ------------- Optional query parameter "limit" ------------- - - err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "offset" ------------- - - err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "fields" ------------- - - err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetTags(c, params) -} - -// GetTitle operation middleware -func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { - - var err error - - // Parameter object where we will unmarshal all parameters from the context - var params GetTitleParams - - // ------------- Optional query parameter "query" ------------- - - err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "limit" ------------- - - err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "offset" ------------- - - err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "fields" ------------- - - err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetTitle(c, params) -} - -// GetTitleTitleId operation middleware -func (siw *ServerInterfaceWrapper) GetTitleTitleId(c *gin.Context) { - - var err error - - // ------------- Path parameter "title_id" ------------- - var titleId string - - err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params GetTitleTitleIdParams - - // ------------- Optional query parameter "fields" ------------- - - err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetTitleTitleId(c, titleId, params) -} - -// PatchTitleTitleId operation middleware -func (siw *ServerInterfaceWrapper) PatchTitleTitleId(c *gin.Context) { - - var err error - - // ------------- Path parameter "title_id" ------------- - var titleId string - - err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PatchTitleTitleId(c, titleId) -} - -// GetTitleTitleIdReviews operation middleware -func (siw *ServerInterfaceWrapper) GetTitleTitleIdReviews(c *gin.Context) { - - var err error - - // ------------- Path parameter "title_id" ------------- - var titleId string - - err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params GetTitleTitleIdReviewsParams - - // ------------- Optional query parameter "limit" ------------- - - err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "offset" ------------- - - err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetTitleTitleIdReviews(c, titleId, params) -} - -// GetUsers operation middleware -func (siw *ServerInterfaceWrapper) GetUsers(c *gin.Context) { - - var err error - - // Parameter object where we will unmarshal all parameters from the context - var params GetUsersParams - - // ------------- Optional query parameter "query" ------------- - - err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "fields" ------------- - - err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetUsers(c, params) -} - -// PostUsers operation middleware -func (siw *ServerInterfaceWrapper) PostUsers(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostUsers(c) -} - -// DeleteUsersUserId operation middleware -func (siw *ServerInterfaceWrapper) DeleteUsersUserId(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.DeleteUsersUserId(c, userId) -} - -// GetUsersUserId operation middleware -func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params GetUsersUserIdParams - - // ------------- Optional query parameter "fields" ------------- - - err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetUsersUserId(c, userId, params) -} - -// PatchUsersUserId operation middleware -func (siw *ServerInterfaceWrapper) PatchUsersUserId(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PatchUsersUserId(c, userId) -} - -// GetUsersUserIdReviews operation middleware -func (siw *ServerInterfaceWrapper) GetUsersUserIdReviews(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params GetUsersUserIdReviewsParams - - // ------------- Optional query parameter "limit" ------------- - - err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "offset" ------------- - - err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetUsersUserIdReviews(c, userId, params) -} - -// DeleteUsersUserIdTitles operation middleware -func (siw *ServerInterfaceWrapper) DeleteUsersUserIdTitles(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params DeleteUsersUserIdTitlesParams - - // ------------- Optional query parameter "title_id" ------------- - - err = runtime.BindQueryParameter("form", true, false, "title_id", c.Request.URL.Query(), ¶ms.TitleId) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.DeleteUsersUserIdTitles(c, userId, params) -} - -// GetUsersUserIdTitles operation middleware -func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params GetUsersUserIdTitlesParams - - // ------------- Optional query parameter "query" ------------- - - err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "limit" ------------- - - err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "offset" ------------- - - err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "fields" ------------- - - err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.GetUsersUserIdTitles(c, userId, params) -} - -// PatchUsersUserIdTitles operation middleware -func (siw *ServerInterfaceWrapper) PatchUsersUserIdTitles(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PatchUsersUserIdTitles(c, userId) -} - -// PostUsersUserIdTitles operation middleware -func (siw *ServerInterfaceWrapper) PostUsersUserIdTitles(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId string - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostUsersUserIdTitles(c, userId) -} - -// GinServerOptions provides options for the Gin server. -type GinServerOptions struct { - BaseURL string - Middlewares []MiddlewareFunc - ErrorHandler func(*gin.Context, error, int) -} - -// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. -func RegisterHandlers(router gin.IRouter, si ServerInterface) { - RegisterHandlersWithOptions(router, si, GinServerOptions{}) -} - -// RegisterHandlersWithOptions creates http.Handler with additional options -func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { - errorHandler := options.ErrorHandler - if errorHandler == nil { - errorHandler = func(c *gin.Context, err error, statusCode int) { - c.JSON(statusCode, gin.H{"msg": err.Error()}) - } - } - - wrapper := ServerInterfaceWrapper{ - Handler: si, - HandlerMiddlewares: options.Middlewares, - ErrorHandler: errorHandler, - } - - router.GET(options.BaseURL+"/media", wrapper.GetMedia) - router.POST(options.BaseURL+"/media", wrapper.PostMedia) - router.POST(options.BaseURL+"/reviews", wrapper.PostReviews) - router.DELETE(options.BaseURL+"/reviews/:review_id", wrapper.DeleteReviewsReviewId) - router.PATCH(options.BaseURL+"/reviews/:review_id", wrapper.PatchReviewsReviewId) - router.GET(options.BaseURL+"/tags", wrapper.GetTags) - router.GET(options.BaseURL+"/title", wrapper.GetTitle) - router.GET(options.BaseURL+"/title/:title_id", wrapper.GetTitleTitleId) - router.PATCH(options.BaseURL+"/title/:title_id", wrapper.PatchTitleTitleId) - router.GET(options.BaseURL+"/title/:title_id/reviews", wrapper.GetTitleTitleIdReviews) - router.GET(options.BaseURL+"/users", wrapper.GetUsers) - router.POST(options.BaseURL+"/users", wrapper.PostUsers) - router.DELETE(options.BaseURL+"/users/:user_id", wrapper.DeleteUsersUserId) - router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) - router.PATCH(options.BaseURL+"/users/:user_id", wrapper.PatchUsersUserId) - router.GET(options.BaseURL+"/users/:user_id/reviews", wrapper.GetUsersUserIdReviews) - router.DELETE(options.BaseURL+"/users/:user_id/titles", wrapper.DeleteUsersUserIdTitles) - router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) - router.PATCH(options.BaseURL+"/users/:user_id/titles", wrapper.PatchUsersUserIdTitles) - router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.PostUsersUserIdTitles) -} - -type GetMediaRequestObject struct { - Params GetMediaParams -} - -type GetMediaResponseObject interface { - VisitGetMediaResponse(w http.ResponseWriter) error -} - -type GetMedia200JSONResponse struct { - Error *string `json:"error,omitempty"` - ImagePath *string `json:"image_path,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response GetMedia200JSONResponse) VisitGetMediaResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type PostMediaRequestObject struct { -} - -type PostMediaResponseObject interface { - VisitPostMediaResponse(w http.ResponseWriter) error -} - -type PostMedia200JSONResponse struct { - Error *string `json:"error,omitempty"` - ImageId *string `json:"image_id,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response PostMedia200JSONResponse) VisitPostMediaResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type PostReviewsRequestObject struct { - Body *PostReviewsJSONRequestBody -} - -type PostReviewsResponseObject interface { - VisitPostReviewsResponse(w http.ResponseWriter) error -} - -type PostReviews200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response PostReviews200JSONResponse) VisitPostReviewsResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type DeleteReviewsReviewIdRequestObject struct { - ReviewId string `json:"review_id"` -} - -type DeleteReviewsReviewIdResponseObject interface { - VisitDeleteReviewsReviewIdResponse(w http.ResponseWriter) error -} - -type DeleteReviewsReviewId200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response DeleteReviewsReviewId200JSONResponse) VisitDeleteReviewsReviewIdResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type PatchReviewsReviewIdRequestObject struct { - ReviewId string `json:"review_id"` - Body *PatchReviewsReviewIdJSONRequestBody -} - -type PatchReviewsReviewIdResponseObject interface { - VisitPatchReviewsReviewIdResponse(w http.ResponseWriter) error -} - -type PatchReviewsReviewId200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response PatchReviewsReviewId200JSONResponse) VisitPatchReviewsReviewIdResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetTagsRequestObject struct { - Params GetTagsParams -} - -type GetTagsResponseObject interface { - VisitGetTagsResponse(w http.ResponseWriter) error -} - -type GetTags200JSONResponse []Tag - -func (response GetTags200JSONResponse) VisitGetTagsResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetTitleRequestObject struct { - Params GetTitleParams -} - -type GetTitleResponseObject interface { - VisitGetTitleResponse(w http.ResponseWriter) error -} - -type GetTitle200JSONResponse []Title - -func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetTitle204Response struct { -} - -func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(204) - return nil -} - -type GetTitleTitleIdRequestObject struct { - TitleId string `json:"title_id"` - Params GetTitleTitleIdParams -} - -type GetTitleTitleIdResponseObject interface { - VisitGetTitleTitleIdResponse(w http.ResponseWriter) error -} - -type GetTitleTitleId200JSONResponse Title - -func (response GetTitleTitleId200JSONResponse) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetTitleTitleId404Response struct { -} - -func (response GetTitleTitleId404Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { - w.WriteHeader(404) - return nil -} - -type PatchTitleTitleIdRequestObject struct { - TitleId string `json:"title_id"` - Body *PatchTitleTitleIdJSONRequestBody -} - -type PatchTitleTitleIdResponseObject interface { - VisitPatchTitleTitleIdResponse(w http.ResponseWriter) error -} - -type PatchTitleTitleId200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` - UserJson *User `json:"user_json,omitempty"` -} - -func (response PatchTitleTitleId200JSONResponse) VisitPatchTitleTitleIdResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetTitleTitleIdReviewsRequestObject struct { - TitleId string `json:"title_id"` - Params GetTitleTitleIdReviewsParams -} - -type GetTitleTitleIdReviewsResponseObject interface { - VisitGetTitleTitleIdReviewsResponse(w http.ResponseWriter) error -} - -type GetTitleTitleIdReviews200JSONResponse []Review - -func (response GetTitleTitleIdReviews200JSONResponse) VisitGetTitleTitleIdReviewsResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetTitleTitleIdReviews204Response struct { -} - -func (response GetTitleTitleIdReviews204Response) VisitGetTitleTitleIdReviewsResponse(w http.ResponseWriter) error { - w.WriteHeader(204) - return nil -} - -type GetUsersRequestObject struct { - Params GetUsersParams -} - -type GetUsersResponseObject interface { - VisitGetUsersResponse(w http.ResponseWriter) error -} - -type GetUsers200JSONResponse []User - -func (response GetUsers200JSONResponse) VisitGetUsersResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type PostUsersRequestObject struct { - Body *PostUsersJSONRequestBody -} - -type PostUsersResponseObject interface { - VisitPostUsersResponse(w http.ResponseWriter) error -} - -type PostUsers200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` - UserJson *User `json:"user_json,omitempty"` -} - -func (response PostUsers200JSONResponse) VisitPostUsersResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type DeleteUsersUserIdRequestObject struct { - UserId string `json:"user_id"` -} - -type DeleteUsersUserIdResponseObject interface { - VisitDeleteUsersUserIdResponse(w http.ResponseWriter) error -} - -type DeleteUsersUserId200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response DeleteUsersUserId200JSONResponse) VisitDeleteUsersUserIdResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetUsersUserIdRequestObject struct { - UserId string `json:"user_id"` - Params GetUsersUserIdParams -} - -type GetUsersUserIdResponseObject interface { - VisitGetUsersUserIdResponse(w http.ResponseWriter) error -} - -type GetUsersUserId200JSONResponse User - -func (response GetUsersUserId200JSONResponse) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetUsersUserId404Response struct { -} - -func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { - w.WriteHeader(404) - return nil -} - -type PatchUsersUserIdRequestObject struct { - UserId string `json:"user_id"` - Body *PatchUsersUserIdJSONRequestBody -} - -type PatchUsersUserIdResponseObject interface { - VisitPatchUsersUserIdResponse(w http.ResponseWriter) error -} - -type PatchUsersUserId200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response PatchUsersUserId200JSONResponse) VisitPatchUsersUserIdResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetUsersUserIdReviewsRequestObject struct { - UserId string `json:"user_id"` - Params GetUsersUserIdReviewsParams -} - -type GetUsersUserIdReviewsResponseObject interface { - VisitGetUsersUserIdReviewsResponse(w http.ResponseWriter) error -} - -type GetUsersUserIdReviews200JSONResponse []Review - -func (response GetUsersUserIdReviews200JSONResponse) VisitGetUsersUserIdReviewsResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type DeleteUsersUserIdTitlesRequestObject struct { - UserId string `json:"user_id"` - Params DeleteUsersUserIdTitlesParams -} - -type DeleteUsersUserIdTitlesResponseObject interface { - VisitDeleteUsersUserIdTitlesResponse(w http.ResponseWriter) error -} - -type DeleteUsersUserIdTitles200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response DeleteUsersUserIdTitles200JSONResponse) VisitDeleteUsersUserIdTitlesResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetUsersUserIdTitlesRequestObject struct { - UserId string `json:"user_id"` - Params GetUsersUserIdTitlesParams -} - -type GetUsersUserIdTitlesResponseObject interface { - VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error -} - -type GetUsersUserIdTitles200JSONResponse []UserTitle - -func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type GetUsersUserIdTitles204Response struct { -} - -func (response GetUsersUserIdTitles204Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { - w.WriteHeader(204) - return nil -} - -type PatchUsersUserIdTitlesRequestObject struct { - UserId string `json:"user_id"` - Body *PatchUsersUserIdTitlesJSONRequestBody -} - -type PatchUsersUserIdTitlesResponseObject interface { - VisitPatchUsersUserIdTitlesResponse(w http.ResponseWriter) error -} - -type PatchUsersUserIdTitles200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response PatchUsersUserIdTitles200JSONResponse) VisitPatchUsersUserIdTitlesResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type PostUsersUserIdTitlesRequestObject struct { - UserId string `json:"user_id"` - Body *PostUsersUserIdTitlesJSONRequestBody -} - -type PostUsersUserIdTitlesResponseObject interface { - VisitPostUsersUserIdTitlesResponse(w http.ResponseWriter) error -} - -type PostUsersUserIdTitles200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` -} - -func (response PostUsersUserIdTitles200JSONResponse) VisitPostUsersUserIdTitlesResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -// StrictServerInterface represents all server handlers. -type StrictServerInterface interface { - // Get image path - // (GET /media) - GetMedia(ctx context.Context, request GetMediaRequestObject) (GetMediaResponseObject, error) - // Upload image - // (POST /media) - PostMedia(ctx context.Context, request PostMediaRequestObject) (PostMediaResponseObject, error) - // Add review - // (POST /reviews) - PostReviews(ctx context.Context, request PostReviewsRequestObject) (PostReviewsResponseObject, error) - // Delete review - // (DELETE /reviews/{review_id}) - DeleteReviewsReviewId(ctx context.Context, request DeleteReviewsReviewIdRequestObject) (DeleteReviewsReviewIdResponseObject, error) - // Update review - // (PATCH /reviews/{review_id}) - PatchReviewsReviewId(ctx context.Context, request PatchReviewsReviewIdRequestObject) (PatchReviewsReviewIdResponseObject, error) - // Get tags - // (GET /tags) - GetTags(ctx context.Context, request GetTagsRequestObject) (GetTagsResponseObject, error) - // Get titles - // (GET /title) - GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) - // Get title description - // (GET /title/{title_id}) - GetTitleTitleId(ctx context.Context, request GetTitleTitleIdRequestObject) (GetTitleTitleIdResponseObject, error) - // Update title info - // (PATCH /title/{title_id}) - PatchTitleTitleId(ctx context.Context, request PatchTitleTitleIdRequestObject) (PatchTitleTitleIdResponseObject, error) - // Get title reviews - // (GET /title/{title_id}/reviews) - GetTitleTitleIdReviews(ctx context.Context, request GetTitleTitleIdReviewsRequestObject) (GetTitleTitleIdReviewsResponseObject, error) - // Search user - // (GET /users) - GetUsers(ctx context.Context, request GetUsersRequestObject) (GetUsersResponseObject, error) - // Add new user - // (POST /users) - PostUsers(ctx context.Context, request PostUsersRequestObject) (PostUsersResponseObject, error) - // Delete user - // (DELETE /users/{user_id}) - DeleteUsersUserId(ctx context.Context, request DeleteUsersUserIdRequestObject) (DeleteUsersUserIdResponseObject, error) - // Get user info - // (GET /users/{user_id}) - GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) - // Update user - // (PATCH /users/{user_id}) - PatchUsersUserId(ctx context.Context, request PatchUsersUserIdRequestObject) (PatchUsersUserIdResponseObject, error) - // Get user reviews - // (GET /users/{user_id}/reviews) - GetUsersUserIdReviews(ctx context.Context, request GetUsersUserIdReviewsRequestObject) (GetUsersUserIdReviewsResponseObject, error) - // Delete user title - // (DELETE /users/{user_id}/titles) - DeleteUsersUserIdTitles(ctx context.Context, request DeleteUsersUserIdTitlesRequestObject) (DeleteUsersUserIdTitlesResponseObject, error) - // Get user titles - // (GET /users/{user_id}/titles) - GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) - // Update user title - // (PATCH /users/{user_id}/titles) - PatchUsersUserIdTitles(ctx context.Context, request PatchUsersUserIdTitlesRequestObject) (PatchUsersUserIdTitlesResponseObject, error) - // Add user title - // (POST /users/{user_id}/titles) - PostUsersUserIdTitles(ctx context.Context, request PostUsersUserIdTitlesRequestObject) (PostUsersUserIdTitlesResponseObject, error) -} - -type StrictHandlerFunc = strictgin.StrictGinHandlerFunc -type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc - -func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { - return &strictHandler{ssi: ssi, middlewares: middlewares} -} - -type strictHandler struct { - ssi StrictServerInterface - middlewares []StrictMiddlewareFunc -} - -// GetMedia operation middleware -func (sh *strictHandler) GetMedia(ctx *gin.Context, params GetMediaParams) { - var request GetMediaRequestObject - - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetMedia(ctx, request.(GetMediaRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetMedia") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetMediaResponseObject); ok { - if err := validResponse.VisitGetMediaResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PostMedia operation middleware -func (sh *strictHandler) PostMedia(ctx *gin.Context) { - var request PostMediaRequestObject - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostMedia(ctx, request.(PostMediaRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostMedia") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostMediaResponseObject); ok { - if err := validResponse.VisitPostMediaResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PostReviews operation middleware -func (sh *strictHandler) PostReviews(ctx *gin.Context) { - var request PostReviewsRequestObject - - var body PostReviewsJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostReviews(ctx, request.(PostReviewsRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostReviews") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostReviewsResponseObject); ok { - if err := validResponse.VisitPostReviewsResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// DeleteReviewsReviewId operation middleware -func (sh *strictHandler) DeleteReviewsReviewId(ctx *gin.Context, reviewId string) { - var request DeleteReviewsReviewIdRequestObject - - request.ReviewId = reviewId - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.DeleteReviewsReviewId(ctx, request.(DeleteReviewsReviewIdRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "DeleteReviewsReviewId") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(DeleteReviewsReviewIdResponseObject); ok { - if err := validResponse.VisitDeleteReviewsReviewIdResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PatchReviewsReviewId operation middleware -func (sh *strictHandler) PatchReviewsReviewId(ctx *gin.Context, reviewId string) { - var request PatchReviewsReviewIdRequestObject - - request.ReviewId = reviewId - - var body PatchReviewsReviewIdJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PatchReviewsReviewId(ctx, request.(PatchReviewsReviewIdRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PatchReviewsReviewId") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PatchReviewsReviewIdResponseObject); ok { - if err := validResponse.VisitPatchReviewsReviewIdResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetTags operation middleware -func (sh *strictHandler) GetTags(ctx *gin.Context, params GetTagsParams) { - var request GetTagsRequestObject - - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTags(ctx, request.(GetTagsRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTags") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTagsResponseObject); ok { - if err := validResponse.VisitGetTagsResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetTitle operation middleware -func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { - var request GetTitleRequestObject - - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitle") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitleResponseObject); ok { - if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetTitleTitleId operation middleware -func (sh *strictHandler) GetTitleTitleId(ctx *gin.Context, titleId string, params GetTitleTitleIdParams) { - var request GetTitleTitleIdRequestObject - - request.TitleId = titleId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitleTitleId(ctx, request.(GetTitleTitleIdRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitleTitleId") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitleTitleIdResponseObject); ok { - if err := validResponse.VisitGetTitleTitleIdResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PatchTitleTitleId operation middleware -func (sh *strictHandler) PatchTitleTitleId(ctx *gin.Context, titleId string) { - var request PatchTitleTitleIdRequestObject - - request.TitleId = titleId - - var body PatchTitleTitleIdJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PatchTitleTitleId(ctx, request.(PatchTitleTitleIdRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PatchTitleTitleId") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PatchTitleTitleIdResponseObject); ok { - if err := validResponse.VisitPatchTitleTitleIdResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetTitleTitleIdReviews operation middleware -func (sh *strictHandler) GetTitleTitleIdReviews(ctx *gin.Context, titleId string, params GetTitleTitleIdReviewsParams) { - var request GetTitleTitleIdReviewsRequestObject - - request.TitleId = titleId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitleTitleIdReviews(ctx, request.(GetTitleTitleIdReviewsRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitleTitleIdReviews") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitleTitleIdReviewsResponseObject); ok { - if err := validResponse.VisitGetTitleTitleIdReviewsResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetUsers operation middleware -func (sh *strictHandler) GetUsers(ctx *gin.Context, params GetUsersParams) { - var request GetUsersRequestObject - - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsers(ctx, request.(GetUsersRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsers") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersResponseObject); ok { - if err := validResponse.VisitGetUsersResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PostUsers operation middleware -func (sh *strictHandler) PostUsers(ctx *gin.Context) { - var request PostUsersRequestObject - - var body PostUsersJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostUsers(ctx, request.(PostUsersRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostUsers") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostUsersResponseObject); ok { - if err := validResponse.VisitPostUsersResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// DeleteUsersUserId operation middleware -func (sh *strictHandler) DeleteUsersUserId(ctx *gin.Context, userId string) { - var request DeleteUsersUserIdRequestObject - - request.UserId = userId - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.DeleteUsersUserId(ctx, request.(DeleteUsersUserIdRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "DeleteUsersUserId") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(DeleteUsersUserIdResponseObject); ok { - if err := validResponse.VisitDeleteUsersUserIdResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetUsersUserId operation middleware -func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { - var request GetUsersUserIdRequestObject - - request.UserId = userId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsersUserId(ctx, request.(GetUsersUserIdRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsersUserId") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersUserIdResponseObject); ok { - if err := validResponse.VisitGetUsersUserIdResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PatchUsersUserId operation middleware -func (sh *strictHandler) PatchUsersUserId(ctx *gin.Context, userId string) { - var request PatchUsersUserIdRequestObject - - request.UserId = userId - - var body PatchUsersUserIdJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PatchUsersUserId(ctx, request.(PatchUsersUserIdRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PatchUsersUserId") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PatchUsersUserIdResponseObject); ok { - if err := validResponse.VisitPatchUsersUserIdResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetUsersUserIdReviews operation middleware -func (sh *strictHandler) GetUsersUserIdReviews(ctx *gin.Context, userId string, params GetUsersUserIdReviewsParams) { - var request GetUsersUserIdReviewsRequestObject - - request.UserId = userId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsersUserIdReviews(ctx, request.(GetUsersUserIdReviewsRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsersUserIdReviews") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersUserIdReviewsResponseObject); ok { - if err := validResponse.VisitGetUsersUserIdReviewsResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// DeleteUsersUserIdTitles operation middleware -func (sh *strictHandler) DeleteUsersUserIdTitles(ctx *gin.Context, userId string, params DeleteUsersUserIdTitlesParams) { - var request DeleteUsersUserIdTitlesRequestObject - - request.UserId = userId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.DeleteUsersUserIdTitles(ctx, request.(DeleteUsersUserIdTitlesRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "DeleteUsersUserIdTitles") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(DeleteUsersUserIdTitlesResponseObject); ok { - if err := validResponse.VisitDeleteUsersUserIdTitlesResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetUsersUserIdTitles operation middleware -func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { - var request GetUsersUserIdTitlesRequestObject - - request.UserId = userId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsersUserIdTitles(ctx, request.(GetUsersUserIdTitlesRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsersUserIdTitles") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersUserIdTitlesResponseObject); ok { - if err := validResponse.VisitGetUsersUserIdTitlesResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PatchUsersUserIdTitles operation middleware -func (sh *strictHandler) PatchUsersUserIdTitles(ctx *gin.Context, userId string) { - var request PatchUsersUserIdTitlesRequestObject - - request.UserId = userId - - var body PatchUsersUserIdTitlesJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PatchUsersUserIdTitles(ctx, request.(PatchUsersUserIdTitlesRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PatchUsersUserIdTitles") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PatchUsersUserIdTitlesResponseObject); ok { - if err := validResponse.VisitPatchUsersUserIdTitlesResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// PostUsersUserIdTitles operation middleware -func (sh *strictHandler) PostUsersUserIdTitles(ctx *gin.Context, userId string) { - var request PostUsersUserIdTitlesRequestObject - - request.UserId = userId - - var body PostUsersUserIdTitlesJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostUsersUserIdTitles(ctx, request.(PostUsersUserIdTitlesRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostUsersUserIdTitles") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostUsersUserIdTitlesResponseObject); ok { - if err := validResponse.VisitPostUsersUserIdTitlesResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} diff --git a/modules/backend/db/query.sql.go b/modules/backend/db/query.sql.go deleted file mode 100644 index 1fc06ac..0000000 --- a/modules/backend/db/query.sql.go +++ /dev/null @@ -1,712 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: query.sql - -package db - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createImage = `-- name: CreateImage :one -INSERT INTO images (storage_type, image_path) -VALUES ($1, $2) -RETURNING image_id, storage_type, image_path -` - -type CreateImageParams struct { - StorageType StorageTypeT `db:"storage_type" json:"storage_type"` - ImagePath string `db:"image_path" json:"image_path"` -} - -func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Images, error) { - row := q.db.QueryRow(ctx, createImage, arg.StorageType, arg.ImagePath) - var i Images - err := row.Scan(&i.ImageID, &i.StorageType, &i.ImagePath) - return i, err -} - -const createReview = `-- name: CreateReview :one -INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) -VALUES ($1, $2, $3, $4, $5) -RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date -` - -type CreateReviewParams struct { - UserID int32 `db:"user_id" json:"user_id"` - TitleID int32 `db:"title_id" json:"title_id"` - ImageIds []int32 `db:"image_ids" json:"image_ids"` - ReviewText string `db:"review_text" json:"review_text"` - CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` -} - -func (q *Queries) CreateReview(ctx context.Context, arg CreateReviewParams) (Reviews, error) { - row := q.db.QueryRow(ctx, createReview, - arg.UserID, - arg.TitleID, - arg.ImageIds, - arg.ReviewText, - arg.CreationDate, - ) - var i Reviews - err := row.Scan( - &i.ReviewID, - &i.UserID, - &i.TitleID, - &i.ImageIds, - &i.ReviewText, - &i.CreationDate, - ) - return i, err -} - -const createUser = `-- name: CreateUser :one -INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date -` - -type CreateUserParams struct { - AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` - Passhash string `db:"passhash" json:"passhash"` - Mail pgtype.Text `db:"mail" json:"mail"` - Nickname string `db:"nickname" json:"nickname"` - DispName pgtype.Text `db:"disp_name" json:"disp_name"` - UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` - CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` -} - -type CreateUserRow struct { - UserID int32 `db:"user_id" json:"user_id"` - AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` - Nickname string `db:"nickname" json:"nickname"` - DispName pgtype.Text `db:"disp_name" json:"disp_name"` - UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` - CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` -} - -func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) { - row := q.db.QueryRow(ctx, createUser, - arg.AvatarID, - arg.Passhash, - arg.Mail, - arg.Nickname, - arg.DispName, - arg.UserDesc, - arg.CreationDate, - ) - var i CreateUserRow - err := row.Scan( - &i.UserID, - &i.AvatarID, - &i.Nickname, - &i.DispName, - &i.UserDesc, - &i.CreationDate, - ) - return i, err -} - -const createUserTitle = `-- name: CreateUserTitle :one -INSERT INTO usertitles (user_id, title_id, status, rate, review_id) -VALUES ($1, $2, $3, $4, $5) -RETURNING usertitle_id, user_id, title_id, status, rate, review_id -` - -type CreateUserTitleParams struct { - UserID int32 `db:"user_id" json:"user_id"` - TitleID int32 `db:"title_id" json:"title_id"` - Status UsertitleStatusT `db:"status" json:"status"` - Rate pgtype.Int4 `db:"rate" json:"rate"` - ReviewID pgtype.Int4 `db:"review_id" json:"review_id"` -} - -func (q *Queries) CreateUserTitle(ctx context.Context, arg CreateUserTitleParams) (Usertitles, error) { - row := q.db.QueryRow(ctx, createUserTitle, - arg.UserID, - arg.TitleID, - arg.Status, - arg.Rate, - arg.ReviewID, - ) - var i Usertitles - err := row.Scan( - &i.UsertitleID, - &i.UserID, - &i.TitleID, - &i.Status, - &i.Rate, - &i.ReviewID, - ) - return i, err -} - -const deleteReview = `-- name: DeleteReview :exec -DELETE FROM reviews -WHERE review_id = $1 -` - -func (q *Queries) DeleteReview(ctx context.Context, reviewID int32) error { - _, err := q.db.Exec(ctx, deleteReview, reviewID) - return err -} - -const deleteUser = `-- name: DeleteUser :exec -DELETE FROM users -WHERE user_id = $1 -` - -func (q *Queries) DeleteUser(ctx context.Context, userID int32) error { - _, err := q.db.Exec(ctx, deleteUser, userID) - return err -} - -const deleteUserTitle = `-- name: DeleteUserTitle :exec -DELETE FROM usertitles -WHERE user_id = $1 AND ($2::int IS NULL OR title_id = $2) -` - -type DeleteUserTitleParams struct { - UserID int32 `db:"user_id" json:"user_id"` - Column2 int32 `db:"column_2" json:"column_2"` -} - -func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams) error { - _, err := q.db.Exec(ctx, deleteUserTitle, arg.UserID, arg.Column2) - return err -} - -const getImageByID = `-- name: GetImageByID :one -SELECT image_id, storage_type, image_path -FROM images -WHERE image_id = $1 -` - -func (q *Queries) GetImageByID(ctx context.Context, imageID int32) (Images, error) { - row := q.db.QueryRow(ctx, getImageByID, imageID) - var i Images - err := row.Scan(&i.ImageID, &i.StorageType, &i.ImagePath) - return i, err -} - -const getReviewByID = `-- name: GetReviewByID :one -SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -FROM reviews -WHERE review_id = $1 -` - -func (q *Queries) GetReviewByID(ctx context.Context, reviewID int32) (Reviews, error) { - row := q.db.QueryRow(ctx, getReviewByID, reviewID) - var i Reviews - err := row.Scan( - &i.ReviewID, - &i.UserID, - &i.TitleID, - &i.ImageIds, - &i.ReviewText, - &i.CreationDate, - ) - return i, err -} - -const getTitleByID = `-- name: GetTitleByID :one -SELECT title_id, title_names, studio_id, poster_id, signal_ids, - title_status, rating, rating_count, release_year, release_season, - season, episodes_aired, episodes_all, episodes_len -FROM titles -WHERE title_id = $1 -` - -func (q *Queries) GetTitleByID(ctx context.Context, titleID int32) (Titles, error) { - row := q.db.QueryRow(ctx, getTitleByID, titleID) - var i Titles - err := row.Scan( - &i.TitleID, - &i.TitleNames, - &i.StudioID, - &i.PosterID, - &i.SignalIds, - &i.TitleStatus, - &i.Rating, - &i.RatingCount, - &i.ReleaseYear, - &i.ReleaseSeason, - &i.Season, - &i.EpisodesAired, - &i.EpisodesAll, - &i.EpisodesLen, - ) - return i, err -} - -const getUserByID = `-- name: GetUserByID :one -SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -FROM users -WHERE user_id = $1 -` - -func (q *Queries) GetUserByID(ctx context.Context, userID int32) (Users, error) { - row := q.db.QueryRow(ctx, getUserByID, userID) - var i Users - err := row.Scan( - &i.UserID, - &i.AvatarID, - &i.Passhash, - &i.Mail, - &i.Nickname, - &i.DispName, - &i.UserDesc, - &i.CreationDate, - ) - return i, err -} - -const getUserTitle = `-- name: GetUserTitle :one -SELECT usertitle_id, user_id, title_id, status, rate, review_id -FROM usertitles -WHERE user_id = $1 AND title_id = $2 -` - -type GetUserTitleParams struct { - UserID int32 `db:"user_id" json:"user_id"` - TitleID int32 `db:"title_id" json:"title_id"` -} - -func (q *Queries) GetUserTitle(ctx context.Context, arg GetUserTitleParams) (Usertitles, error) { - row := q.db.QueryRow(ctx, getUserTitle, arg.UserID, arg.TitleID) - var i Usertitles - err := row.Scan( - &i.UsertitleID, - &i.UserID, - &i.TitleID, - &i.Status, - &i.Rate, - &i.ReviewID, - ) - return i, err -} - -const listReviewsByTitle = `-- name: ListReviewsByTitle :many -SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -FROM reviews -WHERE title_id = $1 -ORDER BY creation_date DESC -LIMIT $2 OFFSET $3 -` - -type ListReviewsByTitleParams struct { - TitleID int32 `db:"title_id" json:"title_id"` - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` -} - -func (q *Queries) ListReviewsByTitle(ctx context.Context, arg ListReviewsByTitleParams) ([]Reviews, error) { - rows, err := q.db.Query(ctx, listReviewsByTitle, arg.TitleID, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Reviews - for rows.Next() { - var i Reviews - if err := rows.Scan( - &i.ReviewID, - &i.UserID, - &i.TitleID, - &i.ImageIds, - &i.ReviewText, - &i.CreationDate, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listReviewsByUser = `-- name: ListReviewsByUser :many -SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -FROM reviews -WHERE user_id = $1 -ORDER BY creation_date DESC -LIMIT $2 OFFSET $3 -` - -type ListReviewsByUserParams struct { - UserID int32 `db:"user_id" json:"user_id"` - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` -} - -func (q *Queries) ListReviewsByUser(ctx context.Context, arg ListReviewsByUserParams) ([]Reviews, error) { - rows, err := q.db.Query(ctx, listReviewsByUser, arg.UserID, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Reviews - for rows.Next() { - var i Reviews - if err := rows.Scan( - &i.ReviewID, - &i.UserID, - &i.TitleID, - &i.ImageIds, - &i.ReviewText, - &i.CreationDate, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listTags = `-- name: ListTags :many -SELECT tag_id, tag_names -FROM tags -ORDER BY tag_id -LIMIT $1 OFFSET $2 -` - -type ListTagsParams struct { - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` -} - -func (q *Queries) ListTags(ctx context.Context, arg ListTagsParams) ([]Tags, error) { - rows, err := q.db.Query(ctx, listTags, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Tags - for rows.Next() { - var i Tags - if err := rows.Scan(&i.TagID, &i.TagNames); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listTitles = `-- name: ListTitles :many -SELECT title_id, title_names, studio_id, poster_id, signal_ids, - title_status, rating, rating_count, release_year, release_season, - season, episodes_aired, episodes_all, episodes_len -FROM titles -ORDER BY title_id -LIMIT $1 OFFSET $2 -` - -type ListTitlesParams struct { - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` -} - -func (q *Queries) ListTitles(ctx context.Context, arg ListTitlesParams) ([]Titles, error) { - rows, err := q.db.Query(ctx, listTitles, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Titles - for rows.Next() { - var i Titles - if err := rows.Scan( - &i.TitleID, - &i.TitleNames, - &i.StudioID, - &i.PosterID, - &i.SignalIds, - &i.TitleStatus, - &i.Rating, - &i.RatingCount, - &i.ReleaseYear, - &i.ReleaseSeason, - &i.Season, - &i.EpisodesAired, - &i.EpisodesAll, - &i.EpisodesLen, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listUserTitles = `-- name: ListUserTitles :many -SELECT usertitle_id, user_id, title_id, status, rate, review_id -FROM usertitles -WHERE user_id = $1 -ORDER BY usertitle_id -LIMIT $2 OFFSET $3 -` - -type ListUserTitlesParams struct { - UserID int32 `db:"user_id" json:"user_id"` - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` -} - -func (q *Queries) ListUserTitles(ctx context.Context, arg ListUserTitlesParams) ([]Usertitles, error) { - rows, err := q.db.Query(ctx, listUserTitles, arg.UserID, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Usertitles - for rows.Next() { - var i Usertitles - if err := rows.Scan( - &i.UsertitleID, - &i.UserID, - &i.TitleID, - &i.Status, - &i.Rate, - &i.ReviewID, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listUsers = `-- name: ListUsers :many -SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -FROM users -ORDER BY user_id -LIMIT $1 OFFSET $2 -` - -type ListUsersParams struct { - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` -} - -func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]Users, error) { - rows, err := q.db.Query(ctx, listUsers, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Users - for rows.Next() { - var i Users - if err := rows.Scan( - &i.UserID, - &i.AvatarID, - &i.Passhash, - &i.Mail, - &i.Nickname, - &i.DispName, - &i.UserDesc, - &i.CreationDate, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const updateReview = `-- name: UpdateReview :one -UPDATE reviews -SET - image_ids = COALESCE($1, image_ids), - review_text = COALESCE($2, review_text) -WHERE review_id = $3 -RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date -` - -type UpdateReviewParams struct { - ImageIds []int32 `db:"image_ids" json:"image_ids"` - ReviewText pgtype.Text `db:"review_text" json:"review_text"` - ReviewID int32 `db:"review_id" json:"review_id"` -} - -func (q *Queries) UpdateReview(ctx context.Context, arg UpdateReviewParams) (Reviews, error) { - row := q.db.QueryRow(ctx, updateReview, arg.ImageIds, arg.ReviewText, arg.ReviewID) - var i Reviews - err := row.Scan( - &i.ReviewID, - &i.UserID, - &i.TitleID, - &i.ImageIds, - &i.ReviewText, - &i.CreationDate, - ) - return i, err -} - -const updateTitle = `-- name: UpdateTitle :one -UPDATE titles -SET - title_names = COALESCE($1, title_names), - studio_id = COALESCE($2, studio_id), - poster_id = COALESCE($3, poster_id), - signal_ids = COALESCE($4, signal_ids), - title_status = COALESCE($5, title_status), - release_year = COALESCE($6, release_year), - release_season = COALESCE($7, release_season), - episodes_aired = COALESCE($8, episodes_aired), - episodes_all = COALESCE($9, episodes_all), - episodes_len = COALESCE($10, episodes_len) -WHERE title_id = $11 -RETURNING title_id, title_names, studio_id, poster_id, signal_ids, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len -` - -type UpdateTitleParams struct { - TitleNames []byte `db:"title_names" json:"title_names"` - StudioID pgtype.Int4 `db:"studio_id" json:"studio_id"` - PosterID pgtype.Int4 `db:"poster_id" json:"poster_id"` - SignalIds []int32 `db:"signal_ids" json:"signal_ids"` - TitleStatus NullTitleStatusT `db:"title_status" json:"title_status"` - ReleaseYear pgtype.Int4 `db:"release_year" json:"release_year"` - ReleaseSeason NullReleaseSeasonT `db:"release_season" json:"release_season"` - EpisodesAired pgtype.Int4 `db:"episodes_aired" json:"episodes_aired"` - EpisodesAll pgtype.Int4 `db:"episodes_all" json:"episodes_all"` - EpisodesLen []byte `db:"episodes_len" json:"episodes_len"` - TitleID int32 `db:"title_id" json:"title_id"` -} - -func (q *Queries) UpdateTitle(ctx context.Context, arg UpdateTitleParams) (Titles, error) { - row := q.db.QueryRow(ctx, updateTitle, - arg.TitleNames, - arg.StudioID, - arg.PosterID, - arg.SignalIds, - arg.TitleStatus, - arg.ReleaseYear, - arg.ReleaseSeason, - arg.EpisodesAired, - arg.EpisodesAll, - arg.EpisodesLen, - arg.TitleID, - ) - var i Titles - err := row.Scan( - &i.TitleID, - &i.TitleNames, - &i.StudioID, - &i.PosterID, - &i.SignalIds, - &i.TitleStatus, - &i.Rating, - &i.RatingCount, - &i.ReleaseYear, - &i.ReleaseSeason, - &i.Season, - &i.EpisodesAired, - &i.EpisodesAll, - &i.EpisodesLen, - ) - return i, err -} - -const updateUser = `-- name: UpdateUser :one -UPDATE users -SET - avatar_id = COALESCE($1, avatar_id), - disp_name = COALESCE($2, disp_name), - user_desc = COALESCE($3, user_desc) -WHERE user_id = $4 -RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date -` - -type UpdateUserParams struct { - AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` - DispName pgtype.Text `db:"disp_name" json:"disp_name"` - UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` - UserID int32 `db:"user_id" json:"user_id"` -} - -type UpdateUserRow struct { - UserID int32 `db:"user_id" json:"user_id"` - AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` - Nickname string `db:"nickname" json:"nickname"` - DispName pgtype.Text `db:"disp_name" json:"disp_name"` - UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` - CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` -} - -func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) { - row := q.db.QueryRow(ctx, updateUser, - arg.AvatarID, - arg.DispName, - arg.UserDesc, - arg.UserID, - ) - var i UpdateUserRow - err := row.Scan( - &i.UserID, - &i.AvatarID, - &i.Nickname, - &i.DispName, - &i.UserDesc, - &i.CreationDate, - ) - return i, err -} - -const updateUserTitle = `-- name: UpdateUserTitle :one -UPDATE usertitles -SET - status = COALESCE($3, status), - rate = COALESCE($4, rate), - review_id = COALESCE($5, review_id) -WHERE user_id = $1 AND title_id = $2 -RETURNING usertitle_id, user_id, title_id, status, rate, review_id -` - -type UpdateUserTitleParams struct { - UserID int32 `db:"user_id" json:"user_id"` - TitleID int32 `db:"title_id" json:"title_id"` - Status NullUsertitleStatusT `db:"status" json:"status"` - Rate pgtype.Int4 `db:"rate" json:"rate"` - ReviewID pgtype.Int4 `db:"review_id" json:"review_id"` -} - -func (q *Queries) UpdateUserTitle(ctx context.Context, arg UpdateUserTitleParams) (Usertitles, error) { - row := q.db.QueryRow(ctx, updateUserTitle, - arg.UserID, - arg.TitleID, - arg.Status, - arg.Rate, - arg.ReviewID, - ) - var i Usertitles - err := row.Scan( - &i.UsertitleID, - &i.UserID, - &i.TitleID, - &i.Status, - &i.Rate, - &i.ReviewID, - ) - return i, err -} diff --git a/modules/backend/api/impl.go b/modules/backend/handlers.go similarity index 99% rename from modules/backend/api/impl.go rename to modules/backend/handlers.go index 5783f47..366f298 100644 --- a/modules/backend/api/impl.go +++ b/modules/backend/handlers.go @@ -1,9 +1,10 @@ -package api +package main import ( "context" "encoding/json" - "nyanimedb-server/db" + "nyanimedb/modules/backend/db" + sqlc "nyanimedb/sql" "strconv" "time" @@ -13,7 +14,7 @@ import ( ) type Server struct { - db *db.Queries + db *sqlc.Queries } func NewServer(db *db.Queries) Server { diff --git a/modules/backend/main.go b/modules/backend/main.go index 5a61226..a9e8db0 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -3,12 +3,13 @@ package main import ( "context" "fmt" - "nyanimedb-server/api" - "nyanimedb-server/db" + sqlc "nyanimedb/sql" "os" "reflect" "time" + oapi "nyanimedb/api" + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5" @@ -39,9 +40,9 @@ func main() { r := gin.Default() - queries := db.New(conn) + queries := sqlc.New(conn) - server := api.NewServer(queries) + server := NewServer(queries) // r.LoadHTMLGlob("templates/*") r.Use(cors.New(cors.Config{ @@ -53,10 +54,10 @@ func main() { MaxAge: 12 * time.Hour, })) - api.RegisterHandlers(r, api.NewStrictHandler( + oapi.RegisterHandlers(r, oapi.NewStrictHandler( server, // сюда можно добавить middlewares, если нужно - []api.StrictMiddlewareFunc{}, + []oapi.StrictMiddlewareFunc{}, )) // r.GET("/", func(c *gin.Context) { // c.HTML(http.StatusOK, "index.html", gin.H{ diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql new file mode 100644 index 0000000..7c9c197 --- /dev/null +++ b/modules/backend/queries.sql @@ -0,0 +1,142 @@ +-- name: GetImageByID :one +SELECT image_id, storage_type, image_path +FROM images +WHERE image_id = $1; + +-- -- name: CreateImage :one +-- INSERT INTO images (storage_type, image_path) +-- VALUES ($1, $2) +-- RETURNING image_id, storage_type, image_path; + +-- -- name: GetUserByID :one +-- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +-- FROM users +-- WHERE user_id = $1; + +-- -- name: ListUsers :many +-- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +-- FROM users +-- ORDER BY user_id +-- LIMIT $1 OFFSET $2; + +-- -- name: CreateUser :one +-- INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) +-- VALUES ($1, $2, $3, $4, $5, $6, $7) +-- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; + +-- -- name: UpdateUser :one +-- UPDATE users +-- SET +-- avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), +-- disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), +-- user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), +-- passhash = COALESCE(sqlc.narg('passhash'), passhash) +-- WHERE user_id = sqlc.arg('user_id') +-- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; + +-- -- name: DeleteUser :exec +-- DELETE FROM users +-- WHERE user_id = $1; + +-- -- name: GetTitleByID :one +-- SELECT title_id, title_names, studio_id, poster_id, signal_ids, +-- title_status, rating, rating_count, release_year, release_season, +-- season, episodes_aired, episodes_all, episodes_len +-- FROM titles +-- WHERE title_id = $1; + +-- -- name: ListTitles :many +-- SELECT title_id, title_names, studio_id, poster_id, signal_ids, +-- title_status, rating, rating_count, release_year, release_season, +-- season, episodes_aired, episodes_all, episodes_len +-- FROM titles +-- ORDER BY title_id +-- LIMIT $1 OFFSET $2; + +-- -- name: UpdateTitle :one +-- UPDATE titles +-- SET +-- title_names = COALESCE(sqlc.narg('title_names'), title_names), +-- studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), +-- poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), +-- signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), +-- title_status = COALESCE(sqlc.narg('title_status'), title_status), +-- release_year = COALESCE(sqlc.narg('release_year'), release_year), +-- release_season = COALESCE(sqlc.narg('release_season'), release_season), +-- episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), +-- episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), +-- episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) +-- WHERE title_id = sqlc.arg('title_id') +-- RETURNING *; + +-- -- name: GetReviewByID :one +-- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +-- FROM reviews +-- WHERE review_id = $1; + +-- -- name: CreateReview :one +-- INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) +-- VALUES ($1, $2, $3, $4, $5) +-- RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date; + +-- -- name: UpdateReview :one +-- UPDATE reviews +-- SET +-- image_ids = COALESCE(sqlc.narg('image_ids'), image_ids), +-- review_text = COALESCE(sqlc.narg('review_text'), review_text) +-- WHERE review_id = sqlc.arg('review_id') +-- RETURNING *; + +-- -- name: DeleteReview :exec +-- DELETE FROM reviews +-- WHERE review_id = $1; + +-- -- name: ListReviewsByTitle :many +-- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +-- FROM reviews +-- WHERE title_id = $1 +-- ORDER BY creation_date DESC +-- LIMIT $2 OFFSET $3; + +-- -- name: ListReviewsByUser :many +-- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +-- FROM reviews +-- WHERE user_id = $1 +-- ORDER BY creation_date DESC +-- LIMIT $2 OFFSET $3; + +-- -- name: GetUserTitle :one +-- SELECT usertitle_id, user_id, title_id, status, rate, review_id +-- FROM usertitles +-- WHERE user_id = $1 AND title_id = $2; + +-- -- name: ListUserTitles :many +-- SELECT usertitle_id, user_id, title_id, status, rate, review_id +-- FROM usertitles +-- WHERE user_id = $1 +-- ORDER BY usertitle_id +-- LIMIT $2 OFFSET $3; + +-- -- name: CreateUserTitle :one +-- INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +-- VALUES ($1, $2, $3, $4, $5) +-- RETURNING usertitle_id, user_id, title_id, status, rate, review_id; + +-- -- name: UpdateUserTitle :one +-- UPDATE usertitles +-- SET +-- status = COALESCE(sqlc.narg('status'), status), +-- rate = COALESCE(sqlc.narg('rate'), rate), +-- review_id = COALESCE(sqlc.narg('review_id'), review_id) +-- WHERE user_id = $1 AND title_id = $2 +-- RETURNING *; + +-- -- name: DeleteUserTitle :exec +-- DELETE FROM usertitles +-- WHERE user_id = $1 AND ($2::int IS NULL OR title_id = $2); + +-- -- name: ListTags :many +-- SELECT tag_id, tag_names +-- FROM tags +-- ORDER BY tag_id +-- LIMIT $1 OFFSET $2; \ No newline at end of file diff --git a/modules/backend/templates/index.html b/modules/backend/templates/index.html deleted file mode 100644 index 9f7bd75..0000000 --- a/modules/backend/templates/index.html +++ /dev/null @@ -1,10 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <title>{{ .title }} - - -

{{ .message }}

- - \ No newline at end of file diff --git a/modules/sql/go.mod b/modules/sql/go.mod deleted file mode 100644 index 9508b28..0000000 --- a/modules/sql/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module tutorial.sqlc.dev/app - -go 1.25.2 diff --git a/modules/sql/query.sql b/modules/sql/query.sql deleted file mode 100644 index 4b85e7e..0000000 --- a/modules/sql/query.sql +++ /dev/null @@ -1,142 +0,0 @@ --- name: GetImageByID :one -SELECT image_id, storage_type, image_path -FROM images -WHERE image_id = $1; - --- name: CreateImage :one -INSERT INTO images (storage_type, image_path) -VALUES ($1, $2) -RETURNING image_id, storage_type, image_path; - --- name: GetUserByID :one -SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -FROM users -WHERE user_id = $1; - --- name: ListUsers :many -SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -FROM users -ORDER BY user_id -LIMIT $1 OFFSET $2; - --- name: CreateUser :one -INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; - --- name: UpdateUser :one -UPDATE users -SET - avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), - disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), - user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), - passhash = COALESCE(sqlc.narg('passhash'), passhash), -WHERE user_id = sqlc.arg('user_id') -RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; - --- name: DeleteUser :exec -DELETE FROM users -WHERE user_id = $1; - --- name: GetTitleByID :one -SELECT title_id, title_names, studio_id, poster_id, signal_ids, - title_status, rating, rating_count, release_year, release_season, - season, episodes_aired, episodes_all, episodes_len -FROM titles -WHERE title_id = $1; - --- name: ListTitles :many -SELECT title_id, title_names, studio_id, poster_id, signal_ids, - title_status, rating, rating_count, release_year, release_season, - season, episodes_aired, episodes_all, episodes_len -FROM titles -ORDER BY title_id -LIMIT $1 OFFSET $2; - --- name: UpdateTitle :one -UPDATE titles -SET - title_names = COALESCE(sqlc.narg('title_names'), title_names), - studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), - poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), - signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), - title_status = COALESCE(sqlc.narg('title_status'), title_status), - release_year = COALESCE(sqlc.narg('release_year'), release_year), - release_season = COALESCE(sqlc.narg('release_season'), release_season), - episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), - episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), - episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) -WHERE title_id = sqlc.arg('title_id') -RETURNING *; - --- name: GetReviewByID :one -SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -FROM reviews -WHERE review_id = $1; - --- name: CreateReview :one -INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) -VALUES ($1, $2, $3, $4, $5) -RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date; - --- name: UpdateReview :one -UPDATE reviews -SET - image_ids = COALESCE(sqlc.narg('image_ids'), image_ids), - review_text = COALESCE(sqlc.narg('review_text'), review_text) -WHERE review_id = sqlc.arg('review_id') -RETURNING *; - --- name: DeleteReview :exec -DELETE FROM reviews -WHERE review_id = $1; - --- name: ListReviewsByTitle :many -SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -FROM reviews -WHERE title_id = $1 -ORDER BY creation_date DESC -LIMIT $2 OFFSET $3; - --- name: ListReviewsByUser :many -SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -FROM reviews -WHERE user_id = $1 -ORDER BY creation_date DESC -LIMIT $2 OFFSET $3; - --- name: GetUserTitle :one -SELECT usertitle_id, user_id, title_id, status, rate, review_id -FROM usertitles -WHERE user_id = $1 AND title_id = $2; - --- name: ListUserTitles :many -SELECT usertitle_id, user_id, title_id, status, rate, review_id -FROM usertitles -WHERE user_id = $1 -ORDER BY usertitle_id -LIMIT $2 OFFSET $3; - --- name: CreateUserTitle :one -INSERT INTO usertitles (user_id, title_id, status, rate, review_id) -VALUES ($1, $2, $3, $4, $5) -RETURNING usertitle_id, user_id, title_id, status, rate, review_id; - --- name: UpdateUserTitle :one -UPDATE usertitles -SET - status = COALESCE(sqlc.narg('status'), status), - rate = COALESCE(sqlc.narg('rate'), rate), - review_id = COALESCE(sqlc.narg('review_id'), review_id) -WHERE user_id = $1 AND title_id = $2 -RETURNING *; - --- name: DeleteUserTitle :exec -DELETE FROM usertitles -WHERE user_id = $1 AND ($2::int IS NULL OR title_id = $2); - --- name: ListTags :many -SELECT tag_id, tag_names -FROM tags -ORDER BY tag_id -LIMIT $1 OFFSET $2; \ No newline at end of file diff --git a/modules/sql/sqlc-generate.yaml b/modules/sql/sqlc-generate.yaml deleted file mode 100644 index 8a625de..0000000 --- a/modules/sql/sqlc-generate.yaml +++ /dev/null @@ -1,13 +0,0 @@ -version: "2" -sql: - - engine: "postgresql" - queries: "query.sql" - schema: "scheme.sql" - gen: - go: - package: "nyanimedb.backend" - out: "sqlc" - sql_package: "pgx/v5" - sql_driver: "github.com/lib/pq" - emit_db_tags: true - emit_exact_table_names: true \ No newline at end of file diff --git a/modules/sql/sqlc.yaml b/modules/sql/sqlc.yaml deleted file mode 100644 index 079626a..0000000 --- a/modules/sql/sqlc.yaml +++ /dev/null @@ -1,14 +0,0 @@ -version: "2" -sql: - - engine: "postgresql" - queries: "query.sql" - schema: "scheme.sql" - gen: - go: - package: "nyanimedb_backend" - out: "sqlc" - sql_package: "pgx/v5" - sql_driver: "github.com/lib/pq" - emit_db_tags: true - emit_exact_table_names: true - emit_json_tags: true \ No newline at end of file diff --git a/modules/sql/sqlc/db.go b/modules/sql/sqlc/db.go deleted file mode 100644 index cdbc865..0000000 --- a/modules/sql/sqlc/db.go +++ /dev/null @@ -1,32 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package nyanimedb_backend - -import ( - "context" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" -) - -type DBTX interface { - Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) - Query(context.Context, string, ...interface{}) (pgx.Rows, error) - QueryRow(context.Context, string, ...interface{}) pgx.Row -} - -func New(db DBTX) *Queries { - return &Queries{db: db} -} - -type Queries struct { - db DBTX -} - -func (q *Queries) WithTx(tx pgx.Tx) *Queries { - return &Queries{ - db: tx, - } -} diff --git a/modules/sql/sqlc/models.go b/modules/sql/sqlc/models.go deleted file mode 100644 index e7aeb3c..0000000 --- a/modules/sql/sqlc/models.go +++ /dev/null @@ -1,266 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package nyanimedb_backend - -import ( - "database/sql/driver" - "fmt" - - "github.com/jackc/pgx/v5/pgtype" -) - -type ReleaseSeasonT string - -const ( - ReleaseSeasonTWinter ReleaseSeasonT = "winter" - ReleaseSeasonTSpring ReleaseSeasonT = "spring" - ReleaseSeasonTSummer ReleaseSeasonT = "summer" - ReleaseSeasonTFall ReleaseSeasonT = "fall" -) - -func (e *ReleaseSeasonT) Scan(src interface{}) error { - switch s := src.(type) { - case []byte: - *e = ReleaseSeasonT(s) - case string: - *e = ReleaseSeasonT(s) - default: - return fmt.Errorf("unsupported scan type for ReleaseSeasonT: %T", src) - } - return nil -} - -type NullReleaseSeasonT struct { - ReleaseSeasonT ReleaseSeasonT `json:"release_season_t"` - Valid bool `json:"valid"` // Valid is true if ReleaseSeasonT is not NULL -} - -// Scan implements the Scanner interface. -func (ns *NullReleaseSeasonT) Scan(value interface{}) error { - if value == nil { - ns.ReleaseSeasonT, ns.Valid = "", false - return nil - } - ns.Valid = true - return ns.ReleaseSeasonT.Scan(value) -} - -// Value implements the driver Valuer interface. -func (ns NullReleaseSeasonT) Value() (driver.Value, error) { - if !ns.Valid { - return nil, nil - } - return string(ns.ReleaseSeasonT), nil -} - -type StorageTypeT string - -const ( - StorageTypeTLocal StorageTypeT = "local" - StorageTypeTS3 StorageTypeT = "s3" -) - -func (e *StorageTypeT) Scan(src interface{}) error { - switch s := src.(type) { - case []byte: - *e = StorageTypeT(s) - case string: - *e = StorageTypeT(s) - default: - return fmt.Errorf("unsupported scan type for StorageTypeT: %T", src) - } - return nil -} - -type NullStorageTypeT struct { - StorageTypeT StorageTypeT `json:"storage_type_t"` - Valid bool `json:"valid"` // Valid is true if StorageTypeT is not NULL -} - -// Scan implements the Scanner interface. -func (ns *NullStorageTypeT) Scan(value interface{}) error { - if value == nil { - ns.StorageTypeT, ns.Valid = "", false - return nil - } - ns.Valid = true - return ns.StorageTypeT.Scan(value) -} - -// Value implements the driver Valuer interface. -func (ns NullStorageTypeT) Value() (driver.Value, error) { - if !ns.Valid { - return nil, nil - } - return string(ns.StorageTypeT), nil -} - -type TitleStatusT string - -const ( - TitleStatusTFinished TitleStatusT = "finished" - TitleStatusTOngoing TitleStatusT = "ongoing" - TitleStatusTPlanned TitleStatusT = "planned" -) - -func (e *TitleStatusT) Scan(src interface{}) error { - switch s := src.(type) { - case []byte: - *e = TitleStatusT(s) - case string: - *e = TitleStatusT(s) - default: - return fmt.Errorf("unsupported scan type for TitleStatusT: %T", src) - } - return nil -} - -type NullTitleStatusT struct { - TitleStatusT TitleStatusT `json:"title_status_t"` - Valid bool `json:"valid"` // Valid is true if TitleStatusT is not NULL -} - -// Scan implements the Scanner interface. -func (ns *NullTitleStatusT) Scan(value interface{}) error { - if value == nil { - ns.TitleStatusT, ns.Valid = "", false - return nil - } - ns.Valid = true - return ns.TitleStatusT.Scan(value) -} - -// Value implements the driver Valuer interface. -func (ns NullTitleStatusT) Value() (driver.Value, error) { - if !ns.Valid { - return nil, nil - } - return string(ns.TitleStatusT), nil -} - -type UsertitleStatusT string - -const ( - UsertitleStatusTFinished UsertitleStatusT = "finished" - UsertitleStatusTPlanned UsertitleStatusT = "planned" - UsertitleStatusTDropped UsertitleStatusT = "dropped" - UsertitleStatusTInProgress UsertitleStatusT = "in-progress" -) - -func (e *UsertitleStatusT) Scan(src interface{}) error { - switch s := src.(type) { - case []byte: - *e = UsertitleStatusT(s) - case string: - *e = UsertitleStatusT(s) - default: - return fmt.Errorf("unsupported scan type for UsertitleStatusT: %T", src) - } - return nil -} - -type NullUsertitleStatusT struct { - UsertitleStatusT UsertitleStatusT `json:"usertitle_status_t"` - Valid bool `json:"valid"` // Valid is true if UsertitleStatusT is not NULL -} - -// Scan implements the Scanner interface. -func (ns *NullUsertitleStatusT) Scan(value interface{}) error { - if value == nil { - ns.UsertitleStatusT, ns.Valid = "", false - return nil - } - ns.Valid = true - return ns.UsertitleStatusT.Scan(value) -} - -// Value implements the driver Valuer interface. -func (ns NullUsertitleStatusT) Value() (driver.Value, error) { - if !ns.Valid { - return nil, nil - } - return string(ns.UsertitleStatusT), nil -} - -type Images struct { - ImageID int32 `db:"image_id" json:"image_id"` - StorageType StorageTypeT `db:"storage_type" json:"storage_type"` - ImagePath string `db:"image_path" json:"image_path"` -} - -type Providers struct { - ProviderID int32 `db:"provider_id" json:"provider_id"` - ProviderName string `db:"provider_name" json:"provider_name"` -} - -type Reviews struct { - ReviewID int32 `db:"review_id" json:"review_id"` - UserID int32 `db:"user_id" json:"user_id"` - TitleID int32 `db:"title_id" json:"title_id"` - ImageIds []int32 `db:"image_ids" json:"image_ids"` - ReviewText string `db:"review_text" json:"review_text"` - CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` -} - -type Signals struct { - SignalID int32 `db:"signal_id" json:"signal_id"` - RawData []byte `db:"raw_data" json:"raw_data"` - ProviderID int32 `db:"provider_id" json:"provider_id"` - Dirty bool `db:"dirty" json:"dirty"` -} - -type Studios struct { - StudioID int32 `db:"studio_id" json:"studio_id"` - StudioName pgtype.Text `db:"studio_name" json:"studio_name"` - IllustID pgtype.Int4 `db:"illust_id" json:"illust_id"` - StudioDesc pgtype.Text `db:"studio_desc" json:"studio_desc"` -} - -type Tags struct { - TagID int32 `db:"tag_id" json:"tag_id"` - TagNames []byte `db:"tag_names" json:"tag_names"` -} - -type TitleTags struct { - TitleID int32 `db:"title_id" json:"title_id"` - TagID int32 `db:"tag_id" json:"tag_id"` -} - -type Titles struct { - TitleID int32 `db:"title_id" json:"title_id"` - TitleNames []byte `db:"title_names" json:"title_names"` - StudioID int32 `db:"studio_id" json:"studio_id"` - PosterID pgtype.Int4 `db:"poster_id" json:"poster_id"` - SignalIds []int32 `db:"signal_ids" json:"signal_ids"` - TitleStatus TitleStatusT `db:"title_status" json:"title_status"` - Rating pgtype.Float8 `db:"rating" json:"rating"` - RatingCount pgtype.Int4 `db:"rating_count" json:"rating_count"` - ReleaseYear pgtype.Int4 `db:"release_year" json:"release_year"` - ReleaseSeason NullReleaseSeasonT `db:"release_season" json:"release_season"` - Season pgtype.Int4 `db:"season" json:"season"` - EpisodesAired pgtype.Int4 `db:"episodes_aired" json:"episodes_aired"` - EpisodesAll pgtype.Int4 `db:"episodes_all" json:"episodes_all"` - EpisodesLen []byte `db:"episodes_len" json:"episodes_len"` -} - -type Users struct { - UserID int32 `db:"user_id" json:"user_id"` - AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` - Passhash string `db:"passhash" json:"passhash"` - Mail pgtype.Text `db:"mail" json:"mail"` - Nickname string `db:"nickname" json:"nickname"` - DispName pgtype.Text `db:"disp_name" json:"disp_name"` - UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` - CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` -} - -type Usertitles struct { - UsertitleID int32 `db:"usertitle_id" json:"usertitle_id"` - UserID int32 `db:"user_id" json:"user_id"` - TitleID int32 `db:"title_id" json:"title_id"` - Status UsertitleStatusT `db:"status" json:"status"` - Rate pgtype.Int4 `db:"rate" json:"rate"` - ReviewID pgtype.Int4 `db:"review_id" json:"review_id"` -} diff --git a/modules/sql/sqlc/query.sql.go b/modules/sql/sqlc/query.sql.go deleted file mode 100644 index 1fc06ac..0000000 --- a/modules/sql/sqlc/query.sql.go +++ /dev/null @@ -1,712 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: query.sql - -package db - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createImage = `-- name: CreateImage :one -INSERT INTO images (storage_type, image_path) -VALUES ($1, $2) -RETURNING image_id, storage_type, image_path -` - -type CreateImageParams struct { - StorageType StorageTypeT `db:"storage_type" json:"storage_type"` - ImagePath string `db:"image_path" json:"image_path"` -} - -func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Images, error) { - row := q.db.QueryRow(ctx, createImage, arg.StorageType, arg.ImagePath) - var i Images - err := row.Scan(&i.ImageID, &i.StorageType, &i.ImagePath) - return i, err -} - -const createReview = `-- name: CreateReview :one -INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) -VALUES ($1, $2, $3, $4, $5) -RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date -` - -type CreateReviewParams struct { - UserID int32 `db:"user_id" json:"user_id"` - TitleID int32 `db:"title_id" json:"title_id"` - ImageIds []int32 `db:"image_ids" json:"image_ids"` - ReviewText string `db:"review_text" json:"review_text"` - CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` -} - -func (q *Queries) CreateReview(ctx context.Context, arg CreateReviewParams) (Reviews, error) { - row := q.db.QueryRow(ctx, createReview, - arg.UserID, - arg.TitleID, - arg.ImageIds, - arg.ReviewText, - arg.CreationDate, - ) - var i Reviews - err := row.Scan( - &i.ReviewID, - &i.UserID, - &i.TitleID, - &i.ImageIds, - &i.ReviewText, - &i.CreationDate, - ) - return i, err -} - -const createUser = `-- name: CreateUser :one -INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date -` - -type CreateUserParams struct { - AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` - Passhash string `db:"passhash" json:"passhash"` - Mail pgtype.Text `db:"mail" json:"mail"` - Nickname string `db:"nickname" json:"nickname"` - DispName pgtype.Text `db:"disp_name" json:"disp_name"` - UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` - CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` -} - -type CreateUserRow struct { - UserID int32 `db:"user_id" json:"user_id"` - AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` - Nickname string `db:"nickname" json:"nickname"` - DispName pgtype.Text `db:"disp_name" json:"disp_name"` - UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` - CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` -} - -func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) { - row := q.db.QueryRow(ctx, createUser, - arg.AvatarID, - arg.Passhash, - arg.Mail, - arg.Nickname, - arg.DispName, - arg.UserDesc, - arg.CreationDate, - ) - var i CreateUserRow - err := row.Scan( - &i.UserID, - &i.AvatarID, - &i.Nickname, - &i.DispName, - &i.UserDesc, - &i.CreationDate, - ) - return i, err -} - -const createUserTitle = `-- name: CreateUserTitle :one -INSERT INTO usertitles (user_id, title_id, status, rate, review_id) -VALUES ($1, $2, $3, $4, $5) -RETURNING usertitle_id, user_id, title_id, status, rate, review_id -` - -type CreateUserTitleParams struct { - UserID int32 `db:"user_id" json:"user_id"` - TitleID int32 `db:"title_id" json:"title_id"` - Status UsertitleStatusT `db:"status" json:"status"` - Rate pgtype.Int4 `db:"rate" json:"rate"` - ReviewID pgtype.Int4 `db:"review_id" json:"review_id"` -} - -func (q *Queries) CreateUserTitle(ctx context.Context, arg CreateUserTitleParams) (Usertitles, error) { - row := q.db.QueryRow(ctx, createUserTitle, - arg.UserID, - arg.TitleID, - arg.Status, - arg.Rate, - arg.ReviewID, - ) - var i Usertitles - err := row.Scan( - &i.UsertitleID, - &i.UserID, - &i.TitleID, - &i.Status, - &i.Rate, - &i.ReviewID, - ) - return i, err -} - -const deleteReview = `-- name: DeleteReview :exec -DELETE FROM reviews -WHERE review_id = $1 -` - -func (q *Queries) DeleteReview(ctx context.Context, reviewID int32) error { - _, err := q.db.Exec(ctx, deleteReview, reviewID) - return err -} - -const deleteUser = `-- name: DeleteUser :exec -DELETE FROM users -WHERE user_id = $1 -` - -func (q *Queries) DeleteUser(ctx context.Context, userID int32) error { - _, err := q.db.Exec(ctx, deleteUser, userID) - return err -} - -const deleteUserTitle = `-- name: DeleteUserTitle :exec -DELETE FROM usertitles -WHERE user_id = $1 AND ($2::int IS NULL OR title_id = $2) -` - -type DeleteUserTitleParams struct { - UserID int32 `db:"user_id" json:"user_id"` - Column2 int32 `db:"column_2" json:"column_2"` -} - -func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams) error { - _, err := q.db.Exec(ctx, deleteUserTitle, arg.UserID, arg.Column2) - return err -} - -const getImageByID = `-- name: GetImageByID :one -SELECT image_id, storage_type, image_path -FROM images -WHERE image_id = $1 -` - -func (q *Queries) GetImageByID(ctx context.Context, imageID int32) (Images, error) { - row := q.db.QueryRow(ctx, getImageByID, imageID) - var i Images - err := row.Scan(&i.ImageID, &i.StorageType, &i.ImagePath) - return i, err -} - -const getReviewByID = `-- name: GetReviewByID :one -SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -FROM reviews -WHERE review_id = $1 -` - -func (q *Queries) GetReviewByID(ctx context.Context, reviewID int32) (Reviews, error) { - row := q.db.QueryRow(ctx, getReviewByID, reviewID) - var i Reviews - err := row.Scan( - &i.ReviewID, - &i.UserID, - &i.TitleID, - &i.ImageIds, - &i.ReviewText, - &i.CreationDate, - ) - return i, err -} - -const getTitleByID = `-- name: GetTitleByID :one -SELECT title_id, title_names, studio_id, poster_id, signal_ids, - title_status, rating, rating_count, release_year, release_season, - season, episodes_aired, episodes_all, episodes_len -FROM titles -WHERE title_id = $1 -` - -func (q *Queries) GetTitleByID(ctx context.Context, titleID int32) (Titles, error) { - row := q.db.QueryRow(ctx, getTitleByID, titleID) - var i Titles - err := row.Scan( - &i.TitleID, - &i.TitleNames, - &i.StudioID, - &i.PosterID, - &i.SignalIds, - &i.TitleStatus, - &i.Rating, - &i.RatingCount, - &i.ReleaseYear, - &i.ReleaseSeason, - &i.Season, - &i.EpisodesAired, - &i.EpisodesAll, - &i.EpisodesLen, - ) - return i, err -} - -const getUserByID = `-- name: GetUserByID :one -SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -FROM users -WHERE user_id = $1 -` - -func (q *Queries) GetUserByID(ctx context.Context, userID int32) (Users, error) { - row := q.db.QueryRow(ctx, getUserByID, userID) - var i Users - err := row.Scan( - &i.UserID, - &i.AvatarID, - &i.Passhash, - &i.Mail, - &i.Nickname, - &i.DispName, - &i.UserDesc, - &i.CreationDate, - ) - return i, err -} - -const getUserTitle = `-- name: GetUserTitle :one -SELECT usertitle_id, user_id, title_id, status, rate, review_id -FROM usertitles -WHERE user_id = $1 AND title_id = $2 -` - -type GetUserTitleParams struct { - UserID int32 `db:"user_id" json:"user_id"` - TitleID int32 `db:"title_id" json:"title_id"` -} - -func (q *Queries) GetUserTitle(ctx context.Context, arg GetUserTitleParams) (Usertitles, error) { - row := q.db.QueryRow(ctx, getUserTitle, arg.UserID, arg.TitleID) - var i Usertitles - err := row.Scan( - &i.UsertitleID, - &i.UserID, - &i.TitleID, - &i.Status, - &i.Rate, - &i.ReviewID, - ) - return i, err -} - -const listReviewsByTitle = `-- name: ListReviewsByTitle :many -SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -FROM reviews -WHERE title_id = $1 -ORDER BY creation_date DESC -LIMIT $2 OFFSET $3 -` - -type ListReviewsByTitleParams struct { - TitleID int32 `db:"title_id" json:"title_id"` - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` -} - -func (q *Queries) ListReviewsByTitle(ctx context.Context, arg ListReviewsByTitleParams) ([]Reviews, error) { - rows, err := q.db.Query(ctx, listReviewsByTitle, arg.TitleID, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Reviews - for rows.Next() { - var i Reviews - if err := rows.Scan( - &i.ReviewID, - &i.UserID, - &i.TitleID, - &i.ImageIds, - &i.ReviewText, - &i.CreationDate, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listReviewsByUser = `-- name: ListReviewsByUser :many -SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -FROM reviews -WHERE user_id = $1 -ORDER BY creation_date DESC -LIMIT $2 OFFSET $3 -` - -type ListReviewsByUserParams struct { - UserID int32 `db:"user_id" json:"user_id"` - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` -} - -func (q *Queries) ListReviewsByUser(ctx context.Context, arg ListReviewsByUserParams) ([]Reviews, error) { - rows, err := q.db.Query(ctx, listReviewsByUser, arg.UserID, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Reviews - for rows.Next() { - var i Reviews - if err := rows.Scan( - &i.ReviewID, - &i.UserID, - &i.TitleID, - &i.ImageIds, - &i.ReviewText, - &i.CreationDate, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listTags = `-- name: ListTags :many -SELECT tag_id, tag_names -FROM tags -ORDER BY tag_id -LIMIT $1 OFFSET $2 -` - -type ListTagsParams struct { - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` -} - -func (q *Queries) ListTags(ctx context.Context, arg ListTagsParams) ([]Tags, error) { - rows, err := q.db.Query(ctx, listTags, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Tags - for rows.Next() { - var i Tags - if err := rows.Scan(&i.TagID, &i.TagNames); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listTitles = `-- name: ListTitles :many -SELECT title_id, title_names, studio_id, poster_id, signal_ids, - title_status, rating, rating_count, release_year, release_season, - season, episodes_aired, episodes_all, episodes_len -FROM titles -ORDER BY title_id -LIMIT $1 OFFSET $2 -` - -type ListTitlesParams struct { - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` -} - -func (q *Queries) ListTitles(ctx context.Context, arg ListTitlesParams) ([]Titles, error) { - rows, err := q.db.Query(ctx, listTitles, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Titles - for rows.Next() { - var i Titles - if err := rows.Scan( - &i.TitleID, - &i.TitleNames, - &i.StudioID, - &i.PosterID, - &i.SignalIds, - &i.TitleStatus, - &i.Rating, - &i.RatingCount, - &i.ReleaseYear, - &i.ReleaseSeason, - &i.Season, - &i.EpisodesAired, - &i.EpisodesAll, - &i.EpisodesLen, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listUserTitles = `-- name: ListUserTitles :many -SELECT usertitle_id, user_id, title_id, status, rate, review_id -FROM usertitles -WHERE user_id = $1 -ORDER BY usertitle_id -LIMIT $2 OFFSET $3 -` - -type ListUserTitlesParams struct { - UserID int32 `db:"user_id" json:"user_id"` - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` -} - -func (q *Queries) ListUserTitles(ctx context.Context, arg ListUserTitlesParams) ([]Usertitles, error) { - rows, err := q.db.Query(ctx, listUserTitles, arg.UserID, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Usertitles - for rows.Next() { - var i Usertitles - if err := rows.Scan( - &i.UsertitleID, - &i.UserID, - &i.TitleID, - &i.Status, - &i.Rate, - &i.ReviewID, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listUsers = `-- name: ListUsers :many -SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -FROM users -ORDER BY user_id -LIMIT $1 OFFSET $2 -` - -type ListUsersParams struct { - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` -} - -func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]Users, error) { - rows, err := q.db.Query(ctx, listUsers, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Users - for rows.Next() { - var i Users - if err := rows.Scan( - &i.UserID, - &i.AvatarID, - &i.Passhash, - &i.Mail, - &i.Nickname, - &i.DispName, - &i.UserDesc, - &i.CreationDate, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const updateReview = `-- name: UpdateReview :one -UPDATE reviews -SET - image_ids = COALESCE($1, image_ids), - review_text = COALESCE($2, review_text) -WHERE review_id = $3 -RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date -` - -type UpdateReviewParams struct { - ImageIds []int32 `db:"image_ids" json:"image_ids"` - ReviewText pgtype.Text `db:"review_text" json:"review_text"` - ReviewID int32 `db:"review_id" json:"review_id"` -} - -func (q *Queries) UpdateReview(ctx context.Context, arg UpdateReviewParams) (Reviews, error) { - row := q.db.QueryRow(ctx, updateReview, arg.ImageIds, arg.ReviewText, arg.ReviewID) - var i Reviews - err := row.Scan( - &i.ReviewID, - &i.UserID, - &i.TitleID, - &i.ImageIds, - &i.ReviewText, - &i.CreationDate, - ) - return i, err -} - -const updateTitle = `-- name: UpdateTitle :one -UPDATE titles -SET - title_names = COALESCE($1, title_names), - studio_id = COALESCE($2, studio_id), - poster_id = COALESCE($3, poster_id), - signal_ids = COALESCE($4, signal_ids), - title_status = COALESCE($5, title_status), - release_year = COALESCE($6, release_year), - release_season = COALESCE($7, release_season), - episodes_aired = COALESCE($8, episodes_aired), - episodes_all = COALESCE($9, episodes_all), - episodes_len = COALESCE($10, episodes_len) -WHERE title_id = $11 -RETURNING title_id, title_names, studio_id, poster_id, signal_ids, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len -` - -type UpdateTitleParams struct { - TitleNames []byte `db:"title_names" json:"title_names"` - StudioID pgtype.Int4 `db:"studio_id" json:"studio_id"` - PosterID pgtype.Int4 `db:"poster_id" json:"poster_id"` - SignalIds []int32 `db:"signal_ids" json:"signal_ids"` - TitleStatus NullTitleStatusT `db:"title_status" json:"title_status"` - ReleaseYear pgtype.Int4 `db:"release_year" json:"release_year"` - ReleaseSeason NullReleaseSeasonT `db:"release_season" json:"release_season"` - EpisodesAired pgtype.Int4 `db:"episodes_aired" json:"episodes_aired"` - EpisodesAll pgtype.Int4 `db:"episodes_all" json:"episodes_all"` - EpisodesLen []byte `db:"episodes_len" json:"episodes_len"` - TitleID int32 `db:"title_id" json:"title_id"` -} - -func (q *Queries) UpdateTitle(ctx context.Context, arg UpdateTitleParams) (Titles, error) { - row := q.db.QueryRow(ctx, updateTitle, - arg.TitleNames, - arg.StudioID, - arg.PosterID, - arg.SignalIds, - arg.TitleStatus, - arg.ReleaseYear, - arg.ReleaseSeason, - arg.EpisodesAired, - arg.EpisodesAll, - arg.EpisodesLen, - arg.TitleID, - ) - var i Titles - err := row.Scan( - &i.TitleID, - &i.TitleNames, - &i.StudioID, - &i.PosterID, - &i.SignalIds, - &i.TitleStatus, - &i.Rating, - &i.RatingCount, - &i.ReleaseYear, - &i.ReleaseSeason, - &i.Season, - &i.EpisodesAired, - &i.EpisodesAll, - &i.EpisodesLen, - ) - return i, err -} - -const updateUser = `-- name: UpdateUser :one -UPDATE users -SET - avatar_id = COALESCE($1, avatar_id), - disp_name = COALESCE($2, disp_name), - user_desc = COALESCE($3, user_desc) -WHERE user_id = $4 -RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date -` - -type UpdateUserParams struct { - AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` - DispName pgtype.Text `db:"disp_name" json:"disp_name"` - UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` - UserID int32 `db:"user_id" json:"user_id"` -} - -type UpdateUserRow struct { - UserID int32 `db:"user_id" json:"user_id"` - AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` - Nickname string `db:"nickname" json:"nickname"` - DispName pgtype.Text `db:"disp_name" json:"disp_name"` - UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` - CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` -} - -func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) { - row := q.db.QueryRow(ctx, updateUser, - arg.AvatarID, - arg.DispName, - arg.UserDesc, - arg.UserID, - ) - var i UpdateUserRow - err := row.Scan( - &i.UserID, - &i.AvatarID, - &i.Nickname, - &i.DispName, - &i.UserDesc, - &i.CreationDate, - ) - return i, err -} - -const updateUserTitle = `-- name: UpdateUserTitle :one -UPDATE usertitles -SET - status = COALESCE($3, status), - rate = COALESCE($4, rate), - review_id = COALESCE($5, review_id) -WHERE user_id = $1 AND title_id = $2 -RETURNING usertitle_id, user_id, title_id, status, rate, review_id -` - -type UpdateUserTitleParams struct { - UserID int32 `db:"user_id" json:"user_id"` - TitleID int32 `db:"title_id" json:"title_id"` - Status NullUsertitleStatusT `db:"status" json:"status"` - Rate pgtype.Int4 `db:"rate" json:"rate"` - ReviewID pgtype.Int4 `db:"review_id" json:"review_id"` -} - -func (q *Queries) UpdateUserTitle(ctx context.Context, arg UpdateUserTitleParams) (Usertitles, error) { - row := q.db.QueryRow(ctx, updateUserTitle, - arg.UserID, - arg.TitleID, - arg.Status, - arg.Rate, - arg.ReviewID, - ) - var i Usertitles - err := row.Scan( - &i.UsertitleID, - &i.UserID, - &i.TitleID, - &i.Status, - &i.Rate, - &i.ReviewID, - ) - return i, err -} diff --git a/modules/backend/db/db.go b/sql/db.go similarity index 97% rename from modules/backend/db/db.go rename to sql/db.go index 9d485b5..7a56507 100644 --- a/modules/backend/db/db.go +++ b/sql/db.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package db +package sqlc import ( "context" diff --git a/sql/migrations/000001_init.down.sql b/sql/migrations/000001_init.down.sql new file mode 100644 index 0000000..d9f23c0 --- /dev/null +++ b/sql/migrations/000001_init.down.sql @@ -0,0 +1,15 @@ +DROP TABLE IF EXISTS signals; +DROP TABLE IF EXISTS title_tags; +DROP TABLE IF EXISTS usertitles; +DROP TABLE IF EXISTS reviews; +DROP TABLE IF EXISTS titles; +DROP TABLE IF EXISTS studios; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS images; +DROP TABLE IF EXISTS tags; +DROP TABLE IF EXISTS providers; + +DROP TYPE IF EXISTS release_season_t; +DROP TYPE IF EXISTS title_status_t; +DROP TYPE IF EXISTS storage_type_t; +DROP TYPE IF EXISTS usertitle_status_t; \ No newline at end of file diff --git a/modules/sql/scheme.sql b/sql/migrations/000001_init.up.sql similarity index 91% rename from modules/sql/scheme.sql rename to sql/migrations/000001_init.up.sql index df84550..93ce071 100644 --- a/modules/sql/scheme.sql +++ b/sql/migrations/000001_init.up.sql @@ -2,8 +2,6 @@ -- title table triggers -- maybe jsonb constraints -- actions (delete) -BEGIN; - CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); CREATE TYPE storage_type_t AS ENUM ('local', 's3'); CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned'); @@ -12,6 +10,7 @@ CREATE TYPE release_season_t AS ENUM ('winter', 'spring', 'summer', 'fall'); CREATE TABLE providers ( provider_id serial PRIMARY KEY, provider_name varchar(64) NOT NULL + -- token ); CREATE TABLE tags ( @@ -19,6 +18,8 @@ CREATE TABLE tags ( tag_names jsonb NOT NULL --mb constraints ); + +-- clean unused images CREATE TABLE images ( image_id serial PRIMARY KEY, storage_type storage_type_t NOT NULL, @@ -33,6 +34,7 @@ CREATE TABLE users ( nickname varchar(16) NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'), disp_name varchar(32), user_desc varchar(512), + -- timestamp tl dr, also add access ts creation_date timestamp NOT NULL ); @@ -48,7 +50,7 @@ CREATE TABLE titles ( title_names jsonb NOT NULL, studio_id int NOT NULL REFERENCES studios, poster_id int REFERENCES images (image_id), - signal_ids int[] NOT NULL, + --signal_ids int[] NOT NULL, title_status title_status_t NOT NULL, rating float CHECK (rating > 0 AND rating <= 10), --by trigger rating_count int CHECK (rating_count >= 0), --by trigger @@ -64,16 +66,17 @@ CREATE TABLE titles ( ); CREATE TABLE reviews ( - review_id serial PRIMARY KEY, + review_id serial PRIMARY KEY, --??? user_id int NOT NULL REFERENCES users, title_id int NOT NULL REFERENCES titles, - image_ids int[], + --image_ids int[], move somewhere review_text text NOT NULL, creation_date timestamp NOT NULL + -- constrai (title, user) ); CREATE TABLE usertitles ( - usertitle_id serial PRIMARY KEY, + usertitle_id serial PRIMARY KEY, -- bigserial, replace by (,) user_id int NOT NULL REFERENCES users, title_id int NOT NULL REFERENCES titles, status usertitle_status_t NOT NULL, @@ -89,9 +92,8 @@ CREATE TABLE title_tags ( CREATE TABLE signals ( signal_id serial PRIMARY KEY, + -- title_id raw_data jsonb NOT NULL, provider_id int NOT NULL REFERENCES providers, dirty bool NOT NULL -); - -COMMIT; \ No newline at end of file +); \ No newline at end of file diff --git a/modules/backend/db/models.go b/sql/models.go similarity index 56% rename from modules/backend/db/models.go rename to sql/models.go index ba76782..4bed173 100644 --- a/modules/backend/db/models.go +++ b/sql/models.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package db +package sqlc import ( "database/sql/driver" @@ -184,83 +184,81 @@ func (ns NullUsertitleStatusT) Value() (driver.Value, error) { return string(ns.UsertitleStatusT), nil } -type Images struct { - ImageID int32 `db:"image_id" json:"image_id"` - StorageType StorageTypeT `db:"storage_type" json:"storage_type"` - ImagePath string `db:"image_path" json:"image_path"` +type Image struct { + ImageID int32 `json:"image_id"` + StorageType StorageTypeT `json:"storage_type"` + ImagePath string `json:"image_path"` } -type Providers struct { - ProviderID int32 `db:"provider_id" json:"provider_id"` - ProviderName string `db:"provider_name" json:"provider_name"` +type Provider struct { + ProviderID int32 `json:"provider_id"` + ProviderName string `json:"provider_name"` } -type Reviews struct { - ReviewID int32 `db:"review_id" json:"review_id"` - UserID int32 `db:"user_id" json:"user_id"` - TitleID int32 `db:"title_id" json:"title_id"` - ImageIds []int32 `db:"image_ids" json:"image_ids"` - ReviewText string `db:"review_text" json:"review_text"` - CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` +type Review struct { + ReviewID int32 `json:"review_id"` + UserID int32 `json:"user_id"` + TitleID int32 `json:"title_id"` + ReviewText string `json:"review_text"` + CreationDate pgtype.Timestamp `json:"creation_date"` } -type Signals struct { - SignalID int32 `db:"signal_id" json:"signal_id"` - RawData []byte `db:"raw_data" json:"raw_data"` - ProviderID int32 `db:"provider_id" json:"provider_id"` - Dirty bool `db:"dirty" json:"dirty"` +type Signal struct { + SignalID int32 `json:"signal_id"` + RawData []byte `json:"raw_data"` + ProviderID int32 `json:"provider_id"` + Dirty bool `json:"dirty"` } -type Studios struct { - StudioID int32 `db:"studio_id" json:"studio_id"` - StudioName pgtype.Text `db:"studio_name" json:"studio_name"` - IllustID pgtype.Int4 `db:"illust_id" json:"illust_id"` - StudioDesc pgtype.Text `db:"studio_desc" json:"studio_desc"` +type Studio struct { + StudioID int32 `json:"studio_id"` + StudioName *string `json:"studio_name"` + IllustID *int32 `json:"illust_id"` + StudioDesc *string `json:"studio_desc"` } -type Tags struct { - TagID int32 `db:"tag_id" json:"tag_id"` - TagNames []byte `db:"tag_names" json:"tag_names"` +type Tag struct { + TagID int32 `json:"tag_id"` + TagNames []byte `json:"tag_names"` } -type TitleTags struct { - TitleID int32 `db:"title_id" json:"title_id"` - TagID int32 `db:"tag_id" json:"tag_id"` +type Title struct { + TitleID int32 `json:"title_id"` + TitleNames []byte `json:"title_names"` + StudioID int32 `json:"studio_id"` + PosterID *int32 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason NullReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` } -type Titles struct { - TitleID int32 `db:"title_id" json:"title_id"` - TitleNames []byte `db:"title_names" json:"title_names"` - StudioID int32 `db:"studio_id" json:"studio_id"` - PosterID pgtype.Int4 `db:"poster_id" json:"poster_id"` - SignalIds []int32 `db:"signal_ids" json:"signal_ids"` - TitleStatus TitleStatusT `db:"title_status" json:"title_status"` - Rating pgtype.Float8 `db:"rating" json:"rating"` - RatingCount pgtype.Int4 `db:"rating_count" json:"rating_count"` - ReleaseYear pgtype.Int4 `db:"release_year" json:"release_year"` - ReleaseSeason NullReleaseSeasonT `db:"release_season" json:"release_season"` - Season pgtype.Int4 `db:"season" json:"season"` - EpisodesAired pgtype.Int4 `db:"episodes_aired" json:"episodes_aired"` - EpisodesAll pgtype.Int4 `db:"episodes_all" json:"episodes_all"` - EpisodesLen []byte `db:"episodes_len" json:"episodes_len"` +type TitleTag struct { + TitleID int32 `json:"title_id"` + TagID int32 `json:"tag_id"` } -type Users struct { - UserID int32 `db:"user_id" json:"user_id"` - AvatarID pgtype.Int4 `db:"avatar_id" json:"avatar_id"` - Passhash string `db:"passhash" json:"passhash"` - Mail pgtype.Text `db:"mail" json:"mail"` - Nickname string `db:"nickname" json:"nickname"` - DispName pgtype.Text `db:"disp_name" json:"disp_name"` - UserDesc pgtype.Text `db:"user_desc" json:"user_desc"` - CreationDate pgtype.Timestamp `db:"creation_date" json:"creation_date"` +type User struct { + UserID int32 `json:"user_id"` + AvatarID *int32 `json:"avatar_id"` + Passhash string `json:"passhash"` + Mail *string `json:"mail"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate pgtype.Timestamp `json:"creation_date"` } -type Usertitles struct { - UsertitleID int32 `db:"usertitle_id" json:"usertitle_id"` - UserID int32 `db:"user_id" json:"user_id"` - TitleID int32 `db:"title_id" json:"title_id"` - Status UsertitleStatusT `db:"status" json:"status"` - Rate pgtype.Int4 `db:"rate" json:"rate"` - ReviewID pgtype.Int4 `db:"review_id" json:"review_id"` +type Usertitle struct { + UsertitleID int32 `json:"usertitle_id"` + UserID int32 `json:"user_id"` + TitleID int32 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int32 `json:"review_id"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go new file mode 100644 index 0000000..2b00bef --- /dev/null +++ b/sql/queries.sql.go @@ -0,0 +1,23 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: queries.sql + +package sqlc + +import ( + "context" +) + +const getImageByID = `-- name: GetImageByID :one +SELECT image_id, storage_type, image_path +FROM images +WHERE image_id = $1 +` + +func (q *Queries) GetImageByID(ctx context.Context, imageID int32) (Image, error) { + row := q.db.QueryRow(ctx, getImageByID, imageID) + var i Image + err := row.Scan(&i.ImageID, &i.StorageType, &i.ImagePath) + return i, err +} diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml new file mode 100644 index 0000000..23b22f9 --- /dev/null +++ b/sql/sqlc.yaml @@ -0,0 +1,14 @@ +version: "2" +sql: + - engine: "postgresql" + queries: + - "../modules/backend/queries.sql" + schema: "migrations" + gen: + go: + package: "sqlc" + out: "." + sql_package: "pgx/v5" + sql_driver: "github.com/jackc/pgx/v5" + emit_json_tags: true + emit_pointers_for_null_types: true \ No newline at end of file From 71e2661fb9afd59af0f06ca4e3432f392da465d4 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sun, 26 Oct 2025 02:09:44 +0300 Subject: [PATCH 020/224] feat!: rewritten db scheme --- sql/migrations/000001_init.down.sql | 7 +- sql/migrations/000001_init.up.sql | 170 +++++++++++++++++----------- 2 files changed, 112 insertions(+), 65 deletions(-) diff --git a/sql/migrations/000001_init.down.sql b/sql/migrations/000001_init.down.sql index d9f23c0..dc52d23 100644 --- a/sql/migrations/000001_init.down.sql +++ b/sql/migrations/000001_init.down.sql @@ -1,7 +1,12 @@ +DROP TRIGGER IF EXISTS trg_update_title_rating ON usertitles; +DROP TRIGGER IF EXISTS trg_notify_new_signal ON signals; + +DROP FUNCTION IF EXISTS update_title_rating(); +DROP FUNCTION IF EXISTS notify_new_signal(); + DROP TABLE IF EXISTS signals; DROP TABLE IF EXISTS title_tags; DROP TABLE IF EXISTS usertitles; -DROP TABLE IF EXISTS reviews; DROP TABLE IF EXISTS titles; DROP TABLE IF EXISTS studios; DROP TABLE IF EXISTS users; diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 93ce071..00114a3 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -1,99 +1,141 @@ -- TODO: --- title table triggers -- maybe jsonb constraints --- actions (delete) +-- clean unused images CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); CREATE TYPE storage_type_t AS ENUM ('local', 's3'); CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned'); CREATE TYPE release_season_t AS ENUM ('winter', 'spring', 'summer', 'fall'); CREATE TABLE providers ( - provider_id serial PRIMARY KEY, - provider_name varchar(64) NOT NULL - -- token + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + provider_name text NOT NULL, + credentials jsonb ); CREATE TABLE tags ( - tag_id serial PRIMARY KEY, - tag_names jsonb NOT NULL --mb constraints + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tag_names jsonb NOT NULL ); - --- clean unused images CREATE TABLE images ( - image_id serial PRIMARY KEY, - storage_type storage_type_t NOT NULL, - image_path varchar(256) UNIQUE NOT NULL + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + storage_type storage_type_t NOT NULL, + image_path text UNIQUE NOT NULL ); CREATE TABLE users ( - user_id serial PRIMARY KEY, - avatar_id int REFERENCES images (image_id), - passhash text NOT NULL, - mail varchar(64) CHECK (mail ~ '[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+'), - nickname varchar(16) NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'), - disp_name varchar(32), - user_desc varchar(512), - -- timestamp tl dr, also add access ts - creation_date timestamp NOT NULL + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + avatar_id int REFERENCES images (id), + passhash text NOT NULL, + mail text CHECK (mail ~ '[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+'), + nickname text NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'), + disp_name text, + user_desc text, + creation_date timestamptz NOT NULL, + last_login timestamptz ); CREATE TABLE studios ( - studio_id serial PRIMARY KEY, - studio_name varchar(64) UNIQUE, - illust_id int REFERENCES images (image_id), - studio_desc text + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + studio_name text UNIQUE, + illust_id int REFERENCES images (id), + studio_desc text ); CREATE TABLE titles ( - title_id serial PRIMARY KEY, - title_names jsonb NOT NULL, - studio_id int NOT NULL REFERENCES studios, - poster_id int REFERENCES images (image_id), - --signal_ids int[] NOT NULL, - title_status title_status_t NOT NULL, - rating float CHECK (rating > 0 AND rating <= 10), --by trigger - rating_count int CHECK (rating_count >= 0), --by trigger - release_year int CHECK (release_year >= 1900), - release_season release_season_t, - season int CHECK (season >= 0), - episodes_aired int CHECK (episodes_aired >= 0), - episodes_all int CHECK (episodes_all >= 0), - episodes_len jsonb, + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title_names jsonb NOT NULL, + studio_id bigint NOT NULL REFERENCES studios (id), + poster_id bigint REFERENCES images (id), + title_status title_status_t NOT NULL, + rating float CHECK (rating >= 0 AND rating <= 10), + rating_count int CHECK (rating_count >= 0), + release_year int CHECK (release_year >= 1900), + release_season release_season_t, + season int CHECK (season >= 0), + episodes_aired int CHECK (episodes_aired >= 0), + episodes_all int CHECK (episodes_all >= 0), + episodes_len jsonb, CHECK ((episodes_aired IS NULL AND episodes_all IS NULL) OR (episodes_aired IS NOT NULL AND episodes_all IS NOT NULL AND episodes_aired <= episodes_all)) ); -CREATE TABLE reviews ( - review_id serial PRIMARY KEY, --??? - user_id int NOT NULL REFERENCES users, - title_id int NOT NULL REFERENCES titles, - --image_ids int[], move somewhere - review_text text NOT NULL, - creation_date timestamp NOT NULL - -- constrai (title, user) -); - CREATE TABLE usertitles ( - usertitle_id serial PRIMARY KEY, -- bigserial, replace by (,) - user_id int NOT NULL REFERENCES users, - title_id int NOT NULL REFERENCES titles, - status usertitle_status_t NOT NULL, - rate int CHECK (rate > 0 AND rate <= 10), - review_id int REFERENCES reviews + PRIMARY KEY (user_id, title_id), + user_id bigint NOT NULL REFERENCES users (id), + title_id bigint NOT NULL REFERENCES titles (id), + status usertitle_status_t NOT NULL, + rate int CHECK (rate > 0 AND rate <= 10), + review_text text, + review_date timestamptz ); CREATE TABLE title_tags ( - PRIMARY KEY (title_id, tag_id), - title_id int NOT NULL REFERENCES titles, - tag_id int NOT NULL REFERENCES tags + PRIMARY KEY (title_id, tag_id), + title_id bigint NOT NULL REFERENCES titles (id), + tag_id bigint NOT NULL REFERENCES tags (id) ); CREATE TABLE signals ( - signal_id serial PRIMARY KEY, - -- title_id - raw_data jsonb NOT NULL, - provider_id int NOT NULL REFERENCES providers, - dirty bool NOT NULL -); \ No newline at end of file + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title_id bigint REFERENCES titles (id), + raw_data jsonb NOT NULL, + provider_id bigint NOT NULL REFERENCES providers (id), + pending boolean NOT NULL +); + +-- Functions +CREATE OR REPLACE FUNCTION update_title_rating() +RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'INSERT') OR (TG_OP = 'UPDATE' AND NEW.rate IS DISTINCT FROM OLD.rate) THEN + UPDATE titles + SET + rating = sub.avg_rating, + rating_count = sub.rating_count + FROM ( + SELECT + title_id, + AVG(rate)::float AS avg_rating, + COUNT(rate) AS rating_count + FROM usertitles + WHERE title_id = NEW.title_id AND rate IS NOT NULL + GROUP BY title_id + ) AS sub + WHERE titles.id = sub.title_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION notify_new_signal() +RETURNS TRIGGER AS $$ +DECLARE + payload JSON; +BEGIN + payload := json_build_object( + 'signal_id', NEW.id, + 'title_id', NEW.title_id, + 'provider_id', NEW.provider_id, + 'pending', NEW.pending, + 'timestamp', NOW() + ); + PERFORM pg_notify('new_signal', payload::text); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Triggers + +CREATE TRIGGER trg_update_title_rating +AFTER INSERT OR UPDATE OF rate ON usertitles +FOR EACH ROW +EXECUTE FUNCTION update_title_rating(); + +CREATE TRIGGER trg_notify_new_signal +AFTER INSERT ON signals +FOR EACH ROW +EXECUTE FUNCTION notify_new_signal(); \ No newline at end of file From 948e036e8cbe5f42939782d65c6a3c0839c2a7d4 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sun, 26 Oct 2025 02:34:45 +0300 Subject: [PATCH 021/224] feat: implemented /users/{id} api route --- api/api.gen.go | 123 ++--- api/openapi.yaml | 298 ++++++------- modules/backend/handlers.go | 719 ------------------------------ modules/backend/handlers/users.go | 51 +++ modules/backend/main.go | 3 +- modules/backend/queries.sql | 20 +- sql/migrations/000001_init.up.sql | 4 +- sql/models.go | 66 ++- sql/queries.sql.go | 64 ++- sql/sqlc.yaml | 15 +- 10 files changed, 381 insertions(+), 982 deletions(-) delete mode 100644 modules/backend/handlers.go create mode 100644 modules/backend/handlers/users.go diff --git a/api/api.gen.go b/api/api.gen.go index 58b5b53..24aebd3 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -8,28 +8,48 @@ import ( "encoding/json" "fmt" "net/http" + "time" "github.com/gin-gonic/gin" "github.com/oapi-codegen/runtime" strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" + openapi_types "github.com/oapi-codegen/runtime/types" ) -// Title defines model for Title. -type Title map[string]interface{} +// User defines model for User. +type User struct { + // AvatarId ID of the user avatar (references images table) + AvatarId *int64 `json:"avatar_id"` -// GetTitleParams defines parameters for GetTitle. -type GetTitleParams struct { - Query *string `form:"query,omitempty" json:"query,omitempty"` - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + // CreationDate Timestamp when the user was created + CreationDate time.Time `json:"creation_date"` + + // DispName Display name + DispName *string `json:"disp_name,omitempty"` + + // Id Unique user ID (primary key) + Id *int64 `json:"id,omitempty"` + + // Mail User email + Mail *openapi_types.Email `json:"mail,omitempty"` + + // Nickname Username (alphanumeric + _ or -) + Nickname string `json:"nickname"` + + // UserDesc User description + UserDesc *string `json:"user_desc,omitempty"` +} + +// GetUsersUserIdParams defines parameters for GetUsersUserId. +type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } // ServerInterface represents all server handlers. type ServerInterface interface { - // Get titles - // (GET /title) - GetTitle(c *gin.Context, params GetTitleParams) + // Get user info + // (GET /users/{user_id}) + GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) } // ServerInterfaceWrapper converts contexts to parameters. @@ -41,37 +61,22 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) -// GetTitle operation middleware -func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { +// GetUsersUserId operation middleware +func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { var err error + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + // Parameter object where we will unmarshal all parameters from the context - var params GetTitleParams - - // ------------- Optional query parameter "query" ------------- - - err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "limit" ------------- - - err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "offset" ------------- - - err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) - return - } + var params GetUsersUserIdParams // ------------- Optional query parameter "fields" ------------- @@ -88,7 +93,7 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { } } - siw.Handler.GetTitle(c, params) + siw.Handler.GetUsersUserId(c, userId, params) } // GinServerOptions provides options for the Gin server. @@ -118,39 +123,40 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } - router.GET(options.BaseURL+"/title", wrapper.GetTitle) + router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) } -type GetTitleRequestObject struct { - Params GetTitleParams +type GetUsersUserIdRequestObject struct { + UserId string `json:"user_id"` + Params GetUsersUserIdParams } -type GetTitleResponseObject interface { - VisitGetTitleResponse(w http.ResponseWriter) error +type GetUsersUserIdResponseObject interface { + VisitGetUsersUserIdResponse(w http.ResponseWriter) error } -type GetTitle200JSONResponse []Title +type GetUsersUserId200JSONResponse User -func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error { +func (response GetUsersUserId200JSONResponse) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetTitle204Response struct { +type GetUsersUserId404Response struct { } -func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(204) +func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.WriteHeader(404) return nil } // StrictServerInterface represents all server handlers. type StrictServerInterface interface { - // Get titles - // (GET /title) - GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) + // Get user info + // (GET /users/{user_id}) + GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -165,17 +171,18 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } -// GetTitle operation middleware -func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { - var request GetTitleRequestObject +// GetUsersUserId operation middleware +func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { + var request GetUsersUserIdRequestObject + request.UserId = userId request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject)) + return sh.ssi.GetUsersUserId(ctx, request.(GetUsersUserIdRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitle") + handler = middleware(handler, "GetUsersUserId") } response, err := handler(ctx, request) @@ -183,8 +190,8 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitleResponseObject); ok { - if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetUsersUserIdResponseObject); ok { + if err := validResponse.VisitGetUsersUserIdResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { diff --git a/api/openapi.yaml b/api/openapi.yaml index b2a2df0..97fa3a4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -5,40 +5,40 @@ info: servers: - url: https://api.example.com paths: - /title: - get: - summary: Get titles - parameters: - - in: query - name: query - schema: - type: string - - in: query - name: limit - schema: - type: integer - default: 10 - - in: query - name: offset - schema: - type: integer - default: 0 - - in: query - name: fields - schema: - type: string - default: all - responses: - '200': - description: List of titles - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Title' - '204': - description: No titles found + # /title: + # get: + # summary: Get titles + # parameters: + # - in: query + # name: query + # schema: + # type: string + # - in: query + # name: limit + # schema: + # type: integer + # default: 10 + # - in: query + # name: offset + # schema: + # type: integer + # default: 0 + # - in: query + # name: fields + # schema: + # type: string + # default: all + # responses: + # '200': + # description: List of titles + # content: + # application/json: + # schema: + # type: array + # items: + # $ref: '#/components/schemas/Title' + # '204': + # description: No titles found # /title/{title_id}: # get: @@ -124,122 +124,122 @@ paths: # '204': # description: No reviews found -# /users/{user_id}: -# get: -# summary: Get user info -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# - in: query -# name: fields -# schema: -# type: string -# default: all -# responses: -# '200': -# description: User info -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/User' -# '404': -# description: User not found + /users/{user_id}: + get: + summary: Get user info + parameters: + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: User info + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: User not found -# patch: -# summary: Update user -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/User' -# responses: -# '200': -# description: Update result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string + # patch: + # summary: Update user + # parameters: + # - in: path + # name: user_id + # required: true + # schema: + # type: string + # requestBody: + # required: true + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/User' + # responses: + # '200': + # description: Update result + # content: + # application/json: + # schema: + # type: object + # properties: + # success: + # type: boolean + # error: + # type: string -# delete: -# summary: Delete user -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# responses: -# '200': -# description: Delete result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string + # delete: + # summary: Delete user + # parameters: + # - in: path + # name: user_id + # required: true + # schema: + # type: string + # responses: + # '200': + # description: Delete result + # content: + # application/json: + # schema: + # type: object + # properties: + # success: + # type: boolean + # error: + # type: string -# /users: -# get: -# summary: Search user -# parameters: -# - in: query -# name: query -# schema: -# type: string -# - in: query -# name: fields -# schema: -# type: string -# responses: -# '200': -# description: List of users -# content: -# application/json: -# schema: -# type: array -# items: -# $ref: '#/components/schemas/User' + # /users: + # get: + # summary: Search user + # parameters: + # - in: query + # name: query + # schema: + # type: string + # - in: query + # name: fields + # schema: + # type: string + # responses: + # '200': + # description: List of users + # content: + # application/json: + # schema: + # type: array + # items: + # $ref: '#/components/schemas/User' -# post: -# summary: Add new user -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/User' -# responses: -# '200': -# description: Add result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string -# user_json: -# $ref: '#/components/schemas/User' + # post: + # summary: Add new user + # requestBody: + # required: true + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/User' + # responses: + # '200': + # description: Add result + # content: + # application/json: + # schema: + # type: object + # properties: + # success: + # type: boolean + # error: + # type: string + # user_json: + # $ref: '#/components/schemas/User' # /users/{user_id}/titles: # get: @@ -541,14 +541,14 @@ components: User: type: object properties: - user_id: + id: type: integer - format: int32 + format: int64 description: Unique user ID (primary key) example: 1 avatar_id: type: integer - format: int32 + format: int64 description: ID of the user avatar (references images table) nullable: true example: null diff --git a/modules/backend/handlers.go b/modules/backend/handlers.go deleted file mode 100644 index 366f298..0000000 --- a/modules/backend/handlers.go +++ /dev/null @@ -1,719 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "nyanimedb/modules/backend/db" - sqlc "nyanimedb/sql" - "strconv" - "time" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "golang.org/x/crypto/bcrypt" -) - -type Server struct { - db *sqlc.Queries -} - -func NewServer(db *db.Queries) Server { - return Server{db: db} -} - -// ————————————————————————————————————————————— -// ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ -// ————————————————————————————————————————————— - -func parseInt32(s string) (int32, error) { - i, err := strconv.ParseInt(s, 10, 32) - return int32(i), err -} - -func ptr[T any](v T) *T { return &v } - -func pgInt4ToPtr(v pgtype.Int4) *int32 { - if v.Valid { - return &v.Int32 - } - return nil -} - -func pgTextToPtr(v pgtype.Text) *string { - if v.Valid { - return &v.String - } - return nil -} - -func pgFloat8ToPtr(v pgtype.Float8) *float64 { - if v.Valid { - return &v.Float64 - } - return nil -} - -func jsonbToInterface(data []byte) interface{} { - if data == nil { - return nil - } - var out interface{} - if err := json.Unmarshal(data, &out); err != nil { - return string(data) // fallback - } - return out -} - -// ————————————————————————————————————————————— -// ХЕНДЛЕРЫ -// ————————————————————————————————————————————— - -func (s Server) GetMedia(ctx context.Context, req GetMediaRequestObject) (GetMediaResponseObject, error) { - id, err := parseInt32(req.Params.ImageId) - if err != nil { - return GetMedia200JSONResponse{Success: ptr(false), Error: ptr("invalid image_id")}, nil - } - img, err := s.db.GetImageByID(ctx, id) - if err != nil { - if err == pgx.ErrNoRows { - return GetMedia200JSONResponse{Success: ptr(false), Error: ptr("image not found")}, nil - } - return nil, err - } - return GetMedia200JSONResponse{ - Success: ptr(true), - ImagePath: ptr(img.ImagePath), - }, nil -} - -func (s Server) PostMedia(ctx context.Context, req PostMediaRequestObject) (PostMediaResponseObject, error) { - // ❗ Не реализовано: OpenAPI не определяет тело запроса для загрузки - return PostMedia200JSONResponse{ - Success: ptr(false), - Error: ptr("upload not implemented: request body not defined in spec"), - }, nil -} - -func (s Server) GetUsers(ctx context.Context, req GetUsersRequestObject) (GetUsersResponseObject, error) { - users, err := s.db.ListUsers(ctx, db.ListUsersParams{}) - if err != nil { - return nil, err - } - var resp []User - for _, u := range users { - resp = append(resp, mapUser(u)) - } - return GetUsers200JSONResponse(resp), nil -} - -func (s Server) PostUsers(ctx context.Context, req PostUsersRequestObject) (PostUsersResponseObject, error) { - if req.Body == nil { - return PostUsers200JSONResponse{ - Success: ptr(false), - Error: ptr("request body is required"), - }, nil - } - - body := *req.Body - - // Обязательные поля - nickname, ok := body["nickname"].(string) - if !ok || nickname == "" { - return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("nickname is required")}, nil - } - - mail, ok := body["mail"].(string) - if !ok || mail == "" { - return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("mail is required")}, nil - } - - password, ok := body["password"].(string) - if !ok || password == "" { - return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("password is required")}, nil - } - - // Опциональные поля - var avatarID *int32 - if v, ok := body["avatar_id"].(float64); ok { - id := int32(v) - avatarID = &id - } - - dispName, _ := body["disp_name"].(string) - userDesc, _ := body["user_desc"].(string) - - // 🔐 Хешируем пароль - passhashBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("failed to hash password")}, nil - } - passhash := string(passhashBytes) - - // Сохраняем в БД - _, err = s.db.CreateUser(ctx, db.CreateUserParams{ - AvatarID: pgtype.Int4{ - Int32: 0, - Valid: avatarID != nil, - }, - Passhash: passhash, - Mail: pgtype.Text{ - String: mail, - Valid: true, - }, - Nickname: nickname, - DispName: pgtype.Text{ - String: dispName, - Valid: dispName != "", - }, - UserDesc: pgtype.Text{ - String: userDesc, - Valid: userDesc != "", - }, - CreationDate: pgtype.Timestamp{ - Time: time.Now(), - Valid: true, - }, - }) - if err != nil { - // Проверяем нарушение уникальности (например, дубль mail или nickname) - if err.Error() == "ERROR: duplicate key value violates unique constraint \"users_mail_key\"" || - err.Error() == "ERROR: duplicate key value violates unique constraint \"users_nickname_key\"" { - return PostUsers200JSONResponse{ - Success: ptr(false), - Error: ptr("user with this email or nickname already exists"), - }, nil - } - return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("database error")}, nil - } - - // Получаем созданного пользователя (без passhash и mail!) - // Предположим, что у вас есть запрос GetUserByNickname или аналогичный - // Но проще — вернуть только ID и nickname - - // ⚠️ Поскольку мы не знаем user_id, можно: - // а) добавить RETURNING в CreateUser (рекомендуется), - // б) сделать отдельный SELECT. - - // Пока вернём минимальный ответ - userResp := User{ - "nickname": nickname, - // "user_id" можно добавить, если обновите query.sql - } - - return PostUsers200JSONResponse{ - Success: ptr(true), - UserJson: &userResp, - }, nil -} -func (s Server) DeleteUsersUserId(ctx context.Context, req DeleteUsersUserIdRequestObject) (DeleteUsersUserIdResponseObject, error) { - userID, err := parseInt32(req.UserId) - if err != nil { - return DeleteUsersUserId200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil - } - err = s.db.DeleteUser(ctx, userID) - if err != nil { - if err == pgx.ErrNoRows { - return DeleteUsersUserId200JSONResponse{Success: ptr(false), Error: ptr("user not found")}, nil - } - return nil, err - } - return DeleteUsersUserId200JSONResponse{Success: ptr(true)}, nil -} - -func (s Server) GetUsersUserId(ctx context.Context, req GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) { - userID, err := parseInt32(req.UserId) - if err != nil { - return GetUsersUserId404Response{}, nil - } - user, err := s.db.GetUserByID(ctx, userID) - if err != nil { - if err == pgx.ErrNoRows { - return GetUsersUserId404Response{}, nil - } - return nil, err - } - return GetUsersUserId200JSONResponse(mapUser(user)), nil -} - -func (s Server) PatchUsersUserId(ctx context.Context, req PatchUsersUserIdRequestObject) (PatchUsersUserIdResponseObject, error) { - userID, err := parseInt32(req.UserId) - if err != nil { - return PatchUsersUserId200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil - } - if req.Body == nil { - return PatchUsersUserId200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil - } - - body := *req.Body - args := db.UpdateUserParams{ - UserID: userID, - } - - if v, ok := body["avatar_id"].(float64); ok { - args.AvatarID = pgtype.Int4{Int32: int32(v), Valid: true} - // args.AvatarIDValid = true - } - if v, ok := body["disp_name"].(string); ok { - args.DispName = pgtype.Text{String: v, Valid: true} - // args.DispNameValid = true - } - if v, ok := body["user_desc"].(string); ok { - args.UserDesc = pgtype.Text{String: v, Valid: true} - // args.UserDescValid = true - } - - _, err = s.db.UpdateUser(ctx, args) - if err != nil { - return PatchUsersUserId200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil - } - return PatchUsersUserId200JSONResponse{Success: ptr(true)}, nil -} - -func (s Server) GetUsersUserIdReviews(ctx context.Context, req GetUsersUserIdReviewsRequestObject) (GetUsersUserIdReviewsResponseObject, error) { - userID, err := parseInt32(req.UserId) - if err != nil { - return GetUsersUserIdReviews200JSONResponse{}, nil - } - limit := int32(20) - offset := int32(0) - // if req.Params.Limit != nil { - // limit = int32(*req.Params.Limit) - // } - // if req.Params.Offset != nil { - // offset = int32(*req.Params.Offset) - // } - - reviews, err := s.db.ListReviewsByUser(ctx, db.ListReviewsByUserParams{ - UserID: userID, - Limit: limit, - Offset: offset, - }) - if err != nil { - return nil, err - } - - var resp []Review - for _, r := range reviews { - resp = append(resp, mapReview(r)) - } - return GetUsersUserIdReviews200JSONResponse(resp), nil -} - -func (s Server) DeleteUsersUserIdTitles(ctx context.Context, req DeleteUsersUserIdTitlesRequestObject) (DeleteUsersUserIdTitlesResponseObject, error) { - userID, err := parseInt32(req.UserId) - if err != nil { - return DeleteUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil - } - - if req.Params.TitleId != nil { - titleID, err := parseInt32(*req.Params.TitleId) - if err != nil { - return DeleteUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid title_id")}, nil - } - err = s.db.DeleteUserTitle(ctx, db.DeleteUserTitleParams{ - UserID: userID, - Column2: titleID, - }) - if err != nil && err != pgx.ErrNoRows { - return nil, err - } - } - // else { - // err = s.db.DeleteAllUserTitles(ctx, userID) - // if err != nil { - // return nil, err - // } - // } - return DeleteUsersUserIdTitles200JSONResponse{Success: ptr(true)}, nil -} - -func (s Server) GetUsersUserIdTitles(ctx context.Context, req GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) { - userID, err := parseInt32(req.UserId) - if err != nil { - return GetUsersUserIdTitles200JSONResponse{}, nil - } - limit := int32(100) - offset := int32(0) - if req.Params.Limit != nil { - limit = int32(*req.Params.Limit) - } - if req.Params.Offset != nil { - offset = int32(*req.Params.Offset) - } - - titles, err := s.db.ListUserTitles(ctx, db.ListUserTitlesParams{ - UserID: userID, - Limit: limit, - Offset: offset, - }) - if err != nil { - return nil, err - } - - var resp []UserTitle - for _, t := range titles { - resp = append(resp, mapUserTitle(t)) - } - return GetUsersUserIdTitles200JSONResponse(resp), nil -} - -func (s Server) PatchUsersUserIdTitles(ctx context.Context, req PatchUsersUserIdTitlesRequestObject) (PatchUsersUserIdTitlesResponseObject, error) { - // userID, err := parseInt32(req.UserId) - // if err != nil { - // return PatchUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil - // } - // if req.Body == nil { - // return PatchUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil - // } - - // body := *req.Body - // titleID, ok := body["title_id"].(float64) - // if !ok { - // return PatchUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("title_id required")}, nil - // } - - // args := db.UpdateUserTitleParams{ - // UserID: userID, - // TitleID: int32(titleID), - // } - - // if v, ok := body["status"].(string); ok { - // args.Status = db.UsertitleStatusT(v) - // // args.StatusValid = true - // } - // if v, ok := body["rate"].(float64); ok { - // args.Rate = pgtype.Int4{Int32: int32(v), Valid: true} - // // args.RateValid = true - // } - // if v, ok := body["review_id"].(float64); ok { - // args.ReviewID = pgtype.Int4{Int32: int32(v), Valid: true} - // // args.ReviewIDValid = true - // } - - // _, err = s.db.UpdateUserTitle(ctx, args) - // if err != nil { - // return PatchUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil - // } - return PatchUsersUserIdTitles200JSONResponse{Success: ptr(true)}, nil -} - -func (s Server) PostUsersUserIdTitles(ctx context.Context, req PostUsersUserIdTitlesRequestObject) (PostUsersUserIdTitlesResponseObject, error) { - userID, err := parseInt32(req.UserId) - if err != nil { - return PostUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil - } - if req.Body == nil { - return PostUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil - } - - body := req.Body - titleID, err := parseInt32(*body.TitleId) - if err != nil { - return PostUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid title_id")}, nil - } - - status := db.UsertitleStatusT("planned") - if body.Status != nil { - status = db.UsertitleStatusT(*body.Status) - } - - _, err = s.db.CreateUserTitle(ctx, db.CreateUserTitleParams{ - UserID: userID, - TitleID: titleID, - Status: status, - Rate: pgtype.Int4{Valid: false}, - ReviewID: pgtype.Int4{Valid: false}, - }) - if err != nil { - return PostUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil - } - return PostUsersUserIdTitles200JSONResponse{Success: ptr(true)}, nil -} - -func (s Server) GetTags(ctx context.Context, req GetTagsRequestObject) (GetTagsResponseObject, error) { - limit := int32(100) - offset := int32(0) - if req.Params.Limit != nil { - limit = int32(*req.Params.Limit) - } - if req.Params.Offset != nil { - offset = int32(*req.Params.Offset) - } - tags, err := s.db.ListTags(ctx, db.ListTagsParams{Limit: limit, Offset: offset}) - if err != nil { - return nil, err - } - var resp []Tag - for _, t := range tags { - resp = append(resp, Tag{ - "tag_id": t.TagID, - "tag_names": jsonbToInterface(t.TagNames), - }) - } - return GetTags200JSONResponse(resp), nil -} - -func (s Server) GetTitle(ctx context.Context, req GetTitleRequestObject) (GetTitleResponseObject, error) { - limit := int32(50) - offset := int32(0) - if req.Params.Limit != nil { - limit = int32(*req.Params.Limit) - } - if req.Params.Offset != nil { - offset = int32(*req.Params.Offset) - } - titles, err := s.db.ListTitles(ctx, db.ListTitlesParams{Limit: limit, Offset: offset}) - if err != nil { - return nil, err - } - var resp []Title - for _, t := range titles { - resp = append(resp, mapTitle(t)) - } - return GetTitle200JSONResponse(resp), nil -} - -func (s Server) GetTitleTitleId(ctx context.Context, req GetTitleTitleIdRequestObject) (GetTitleTitleIdResponseObject, error) { - titleID, err := parseInt32(req.TitleId) - if err != nil { - return GetTitleTitleId404Response{}, nil - } - title, err := s.db.GetTitleByID(ctx, titleID) - if err != nil { - if err == pgx.ErrNoRows { - return GetTitleTitleId404Response{}, nil - } - return nil, err - } - return GetTitleTitleId200JSONResponse(mapTitle(title)), nil -} - -func (s Server) PatchTitleTitleId(ctx context.Context, req PatchTitleTitleIdRequestObject) (PatchTitleTitleIdResponseObject, error) { - titleID, err := parseInt32(req.TitleId) - if err != nil { - return PatchTitleTitleId200JSONResponse{Success: ptr(false), Error: ptr("invalid title_id")}, nil - } - if req.Body == nil { - return PatchTitleTitleId200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil - } - - body := *req.Body - args := db.UpdateTitleParams{ - TitleID: titleID, - } - - if v, ok := body["title_names"].(map[string]interface{}); ok { - data, _ := json.Marshal(v) - args.TitleNames = data - // args.TitleNamesValid = true - } - if v, ok := body["studio_id"].(float64); ok { - args.StudioID = pgtype.Int4{Int32: int32(v), Valid: true} - // args.StudioIDValid = true - } - if v, ok := body["poster_id"].(float64); ok { - args.PosterID = pgtype.Int4{Int32: int32(v), Valid: true} - // args.PosterIDValid = true - } - // if v, ok := body["title_status"].(string); ok { - // args.TitleStatus = db.NullTitleStatusT(v) - // // args.TitleStatusValid = true - // } - if v, ok := body["release_year"].(float64); ok { - args.ReleaseYear = pgtype.Int4{Int32: int32(v), Valid: true} - // args.ReleaseYearValid = true - } - if v, ok := body["episodes_aired"].(float64); ok { - args.EpisodesAired = pgtype.Int4{Int32: int32(v), Valid: true} - // args.EpisodesAiredValid = true - } - if v, ok := body["episodes_all"].(float64); ok { - args.EpisodesAll = pgtype.Int4{Int32: int32(v), Valid: true} - // args.EpisodesAllValid = true - } - - _, err = s.db.UpdateTitle(ctx, args) - if err != nil { - return PatchTitleTitleId200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil - } - return PatchTitleTitleId200JSONResponse{Success: ptr(true)}, nil -} - -func (s Server) GetTitleTitleIdReviews(ctx context.Context, req GetTitleTitleIdReviewsRequestObject) (GetTitleTitleIdReviewsResponseObject, error) { - titleID, err := parseInt32(req.TitleId) - if err != nil { - return GetTitleTitleIdReviews200JSONResponse{}, nil - } - limit := int32(20) - offset := int32(0) - if req.Params.Limit != nil { - limit = int32(*req.Params.Limit) - } - if req.Params.Offset != nil { - offset = int32(*req.Params.Offset) - } - - reviews, err := s.db.ListReviewsByTitle(ctx, db.ListReviewsByTitleParams{ - TitleID: titleID, - Limit: limit, - Offset: offset, - }) - if err != nil { - return nil, err - } - - var resp []Review - for _, r := range reviews { - resp = append(resp, mapReview(r)) - } - return GetTitleTitleIdReviews200JSONResponse(resp), nil -} - -func (s Server) PostReviews(ctx context.Context, req PostReviewsRequestObject) (PostReviewsResponseObject, error) { - if req.Body == nil { - return PostReviews200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil - } - - body := *req.Body - userID, ok1 := body["user_id"].(float64) - titleID, ok2 := body["title_id"].(float64) - reviewText, ok3 := body["review_text"].(string) - if !ok1 || !ok2 || !ok3 { - return PostReviews200JSONResponse{Success: ptr(false), Error: ptr("user_id, title_id, review_text required")}, nil - } - - var imageIDs []int32 - if ids, ok := body["image_ids"].([]interface{}); ok { - for _, id := range ids { - if f, ok := id.(float64); ok { - imageIDs = append(imageIDs, int32(f)) - } - } - } - - _, err := s.db.CreateReview(ctx, db.CreateReviewParams{ - UserID: int32(userID), - TitleID: int32(titleID), - ImageIds: imageIDs, - ReviewText: reviewText, - CreationDate: pgtype.Timestamp{Time: time.Now(), Valid: true}, - }) - if err != nil { - return PostReviews200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil - } - - return PostReviews200JSONResponse{Success: ptr(true)}, nil -} - -func (s Server) DeleteReviewsReviewId(ctx context.Context, req DeleteReviewsReviewIdRequestObject) (DeleteReviewsReviewIdResponseObject, error) { - reviewID, err := parseInt32(req.ReviewId) - if err != nil { - return DeleteReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr("invalid review_id")}, nil - } - err = s.db.DeleteReview(ctx, reviewID) - if err != nil { - if err == pgx.ErrNoRows { - return DeleteReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr("review not found")}, nil - } - return nil, err - } - return DeleteReviewsReviewId200JSONResponse{Success: ptr(true)}, nil -} - -func (s Server) PatchReviewsReviewId(ctx context.Context, req PatchReviewsReviewIdRequestObject) (PatchReviewsReviewIdResponseObject, error) { - reviewID, err := parseInt32(req.ReviewId) - if err != nil { - return PatchReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr("invalid review_id")}, nil - } - if req.Body == nil { - return PatchReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil - } - - body := *req.Body - args := db.UpdateReviewParams{ - ReviewID: reviewID, - } - - if v, ok := body["review_text"].(string); ok { - args.ReviewText = pgtype.Text{String: v, Valid: true} - // args.ReviewTextValid = true - } - if ids, ok := body["image_ids"].([]interface{}); ok { - var imageIDs []int32 - for _, id := range ids { - if f, ok := id.(float64); ok { - imageIDs = append(imageIDs, int32(f)) - } - } - args.ImageIds = imageIDs - // args.ImageIdsValid = true - } - - _, err = s.db.UpdateReview(ctx, args) - if err != nil { - return PatchReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil - } - return PatchReviewsReviewId200JSONResponse{Success: ptr(true)}, nil -} - -// ————————————————————————————————————————————— -// МАППИНГИ -// ————————————————————————————————————————————— - -func mapUser(u db.Users) User { - return User{ - "user_id": u.UserID, - "avatar_id": pgInt4ToPtr(u.AvatarID), - "nickname": u.Nickname, - "disp_name": pgTextToPtr(u.DispName), - "user_desc": pgTextToPtr(u.UserDesc), - "creation_date": u.CreationDate.Time, - // mail и passhash НЕ возвращаем! - } -} - -func mapTitle(t db.Titles) Title { - var releaseSeason interface{} - if t.ReleaseSeason.Valid { - releaseSeason = string(t.ReleaseSeason.ReleaseSeasonT) - } - - return Title{ - "title_id": t.TitleID, - "title_names": jsonbToInterface(t.TitleNames), - "studio_id": t.StudioID, - "poster_id": pgInt4ToPtr(t.PosterID), - "signal_ids": t.SignalIds, - "title_status": string(t.TitleStatus), - "rating": pgFloat8ToPtr(t.Rating), - "rating_count": pgInt4ToPtr(t.RatingCount), - "release_year": pgInt4ToPtr(t.ReleaseYear), - "release_season": releaseSeason, - "season": pgInt4ToPtr(t.Season), - "episodes_aired": pgInt4ToPtr(t.EpisodesAired), - "episodes_all": pgInt4ToPtr(t.EpisodesAll), - "episodes_len": jsonbToInterface(t.EpisodesLen), - } -} - -func mapReview(r db.Reviews) Review { - return Review{ - "review_id": r.ReviewID, - "user_id": r.UserID, - "title_id": r.TitleID, - "image_ids": r.ImageIds, - "review_text": r.ReviewText, - "creation_date": r.CreationDate.Time, - } -} - -func mapUserTitle(ut db.Usertitles) UserTitle { - return UserTitle{ - "usertitle_id": ut.UsertitleID, - "user_id": ut.UserID, - "title_id": ut.TitleID, - "status": string(ut.Status), - "rate": pgInt4ToPtr(ut.Rate), - "review_id": pgInt4ToPtr(ut.ReviewID), - } -} diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go new file mode 100644 index 0000000..b67153d --- /dev/null +++ b/modules/backend/handlers/users.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "context" + oapi "nyanimedb/api" + sqlc "nyanimedb/sql" + "strconv" + + "github.com/jackc/pgx/v5" + "github.com/oapi-codegen/runtime/types" +) + +type Server struct { + db *sqlc.Queries +} + +func NewServer(db *sqlc.Queries) Server { + return Server{db: db} +} + +func parseInt64(s string) (int32, error) { + i, err := strconv.ParseInt(s, 10, 64) + return int32(i), err +} + +func mapUser(u sqlc.GetUserByIDRow) oapi.User { + return oapi.User{ + AvatarId: u.AvatarID, + CreationDate: u.CreationDate, + DispName: u.DispName, + Id: &u.ID, + Mail: (*types.Email)(u.Mail), + Nickname: u.Nickname, + UserDesc: u.UserDesc, + } +} + +func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdRequestObject) (oapi.GetUsersUserIdResponseObject, error) { + userID, err := parseInt64(req.UserId) + if err != nil { + return oapi.GetUsersUserId404Response{}, nil + } + user, err := s.db.GetUserByID(context.TODO(), int64(userID)) + if err != nil { + if err == pgx.ErrNoRows { + return oapi.GetUsersUserId404Response{}, nil + } + return nil, err + } + return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil +} diff --git a/modules/backend/main.go b/modules/backend/main.go index a9e8db0..42a66d3 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -9,6 +9,7 @@ import ( "time" oapi "nyanimedb/api" + handlers "nyanimedb/modules/backend/handlers" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" @@ -42,7 +43,7 @@ func main() { queries := sqlc.New(conn) - server := NewServer(queries) + server := handlers.NewServer(queries) // r.LoadHTMLGlob("templates/*") r.Use(cors.New(cors.Config{ diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 7c9c197..b1dd8af 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -1,17 +1,17 @@ -- name: GetImageByID :one -SELECT image_id, storage_type, image_path +SELECT id, storage_type, image_path FROM images -WHERE image_id = $1; +WHERE id = $1; --- -- name: CreateImage :one --- INSERT INTO images (storage_type, image_path) --- VALUES ($1, $2) --- RETURNING image_id, storage_type, image_path; +-- name: CreateImage :one +INSERT INTO images (storage_type, image_path) +VALUES ($1, $2) +RETURNING id, storage_type, image_path; --- -- name: GetUserByID :one --- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date --- FROM users --- WHERE user_id = $1; +-- name: GetUserByID :one +SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date +FROM users +WHERE id = $1; -- -- name: ListUsers :many -- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 00114a3..0b7fa33 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -25,7 +25,7 @@ CREATE TABLE images ( CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - avatar_id int REFERENCES images (id), + avatar_id bigint REFERENCES images (id), passhash text NOT NULL, mail text CHECK (mail ~ '[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+'), nickname text NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'), @@ -38,7 +38,7 @@ CREATE TABLE users ( CREATE TABLE studios ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, studio_name text UNIQUE, - illust_id int REFERENCES images (id), + illust_id bigint REFERENCES images (id), studio_desc text ); diff --git a/sql/models.go b/sql/models.go index 4bed173..928d5ac 100644 --- a/sql/models.go +++ b/sql/models.go @@ -7,6 +7,7 @@ package sqlc import ( "database/sql/driver" "fmt" + "time" "github.com/jackc/pgx/v5/pgtype" ) @@ -185,48 +186,42 @@ func (ns NullUsertitleStatusT) Value() (driver.Value, error) { } type Image struct { - ImageID int32 `json:"image_id"` + ID int64 `json:"id"` StorageType StorageTypeT `json:"storage_type"` ImagePath string `json:"image_path"` } type Provider struct { - ProviderID int32 `json:"provider_id"` + ID int64 `json:"id"` ProviderName string `json:"provider_name"` -} - -type Review struct { - ReviewID int32 `json:"review_id"` - UserID int32 `json:"user_id"` - TitleID int32 `json:"title_id"` - ReviewText string `json:"review_text"` - CreationDate pgtype.Timestamp `json:"creation_date"` + Credentials []byte `json:"credentials"` } type Signal struct { - SignalID int32 `json:"signal_id"` + ID int64 `json:"id"` + TitleID *int64 `json:"title_id"` RawData []byte `json:"raw_data"` - ProviderID int32 `json:"provider_id"` - Dirty bool `json:"dirty"` + ProviderID int64 `json:"provider_id"` + Pending bool `json:"pending"` } type Studio struct { - StudioID int32 `json:"studio_id"` + ID int64 `json:"id"` StudioName *string `json:"studio_name"` - IllustID *int32 `json:"illust_id"` + IllustID *int64 `json:"illust_id"` StudioDesc *string `json:"studio_desc"` } type Tag struct { - TagID int32 `json:"tag_id"` + ID int64 `json:"id"` TagNames []byte `json:"tag_names"` } type Title struct { - TitleID int32 `json:"title_id"` + ID int64 `json:"id"` TitleNames []byte `json:"title_names"` - StudioID int32 `json:"studio_id"` - PosterID *int32 `json:"poster_id"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` Rating *float64 `json:"rating"` RatingCount *int32 `json:"rating_count"` @@ -239,26 +234,27 @@ type Title struct { } type TitleTag struct { - TitleID int32 `json:"title_id"` - TagID int32 `json:"tag_id"` + TitleID int64 `json:"title_id"` + TagID int64 `json:"tag_id"` } type User struct { - UserID int32 `json:"user_id"` - AvatarID *int32 `json:"avatar_id"` - Passhash string `json:"passhash"` - Mail *string `json:"mail"` - Nickname string `json:"nickname"` - DispName *string `json:"disp_name"` - UserDesc *string `json:"user_desc"` - CreationDate pgtype.Timestamp `json:"creation_date"` + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Passhash string `json:"passhash"` + Mail *string `json:"mail"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + LastLogin pgtype.Timestamptz `json:"last_login"` } type Usertitle struct { - UsertitleID int32 `json:"usertitle_id"` - UserID int32 `json:"user_id"` - TitleID int32 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int32 `json:"review_id"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewText *string `json:"review_text"` + ReviewDate pgtype.Timestamptz `json:"review_date"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 2b00bef..8f92c2a 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -7,17 +7,67 @@ package sqlc import ( "context" + "time" ) -const getImageByID = `-- name: GetImageByID :one -SELECT image_id, storage_type, image_path -FROM images -WHERE image_id = $1 +const createImage = `-- name: CreateImage :one +INSERT INTO images (storage_type, image_path) +VALUES ($1, $2) +RETURNING id, storage_type, image_path ` -func (q *Queries) GetImageByID(ctx context.Context, imageID int32) (Image, error) { - row := q.db.QueryRow(ctx, getImageByID, imageID) +type CreateImageParams struct { + StorageType StorageTypeT `json:"storage_type"` + ImagePath string `json:"image_path"` +} + +func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image, error) { + row := q.db.QueryRow(ctx, createImage, arg.StorageType, arg.ImagePath) var i Image - err := row.Scan(&i.ImageID, &i.StorageType, &i.ImagePath) + err := row.Scan(&i.ID, &i.StorageType, &i.ImagePath) + return i, err +} + +const getImageByID = `-- name: GetImageByID :one +SELECT id, storage_type, image_path +FROM images +WHERE id = $1 +` + +func (q *Queries) GetImageByID(ctx context.Context, id int64) (Image, error) { + row := q.db.QueryRow(ctx, getImageByID, id) + var i Image + err := row.Scan(&i.ID, &i.StorageType, &i.ImagePath) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date +FROM users +WHERE id = $1 +` + +type GetUserByIDRow struct { + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Mail *string `json:"mail"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` +} + +func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) { + row := q.db.QueryRow(ctx, getUserByID, id) + var i GetUserByIDRow + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + ) return i, err } diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index 23b22f9..f44761e 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -11,4 +11,17 @@ sql: sql_package: "pgx/v5" sql_driver: "github.com/jackc/pgx/v5" emit_json_tags: true - emit_pointers_for_null_types: true \ No newline at end of file + emit_pointers_for_null_types: true + overrides: + - db_type: "uuid" + nullable: false + go_type: + import: "github.com/gofrs/uuid" + package: "gofrsuuid" + type: UUID + pointer: true + - db_type: "timestamptz" + nullable: false + go_type: + import: "time" + type: "Time" \ No newline at end of file From e12812d202aa31ba565100400f2a14312a302e86 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sun, 26 Oct 2025 02:39:54 +0300 Subject: [PATCH 022/224] feat: frontend first routing implementation --- modules/frontend/package-lock.json | 57 ++++++++++++++++++- modules/frontend/package.json | 3 +- modules/frontend/src/App.tsx | 11 +++- .../src/components/UserPage/UserPage.tsx | 10 +++- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index b1789ed..6a06afb 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "axios": "^1.12.2", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.4" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -2061,6 +2062,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3346,6 +3356,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3363,6 +3374,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz", + "integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.4.tgz", + "integrity": "sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3466,6 +3515,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/modules/frontend/package.json b/modules/frontend/package.json index c40ff17..b4977aa 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -12,7 +12,8 @@ "dependencies": { "axios": "^1.12.2", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.4" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 6b2ee5f..a88ad57 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,8 +1,15 @@ import React from "react"; +import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./components/UserPage/UserPage"; const App: React.FC = () => { - return ; + return ( + + + } /> + + + ); }; -export default App; +export default App; \ No newline at end of file diff --git a/modules/frontend/src/components/UserPage/UserPage.tsx b/modules/frontend/src/components/UserPage/UserPage.tsx index b0db90c..0a83679 100644 --- a/modules/frontend/src/components/UserPage/UserPage.tsx +++ b/modules/frontend/src/components/UserPage/UserPage.tsx @@ -1,17 +1,21 @@ import React, { useEffect, useState } from "react"; -import { DefaultService } from "../../api/services/DefaultService"; // adjust path +import { useParams } from "react-router-dom"; // <-- import +import { DefaultService } from "../../api/services/DefaultService"; import type { User } from "../../api/models/User"; import styles from "./UserPage.module.css"; const UserPage: React.FC = () => { + const { id } = useParams<{ id: string }>(); // <-- get user id from URL const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { + if (!id) return; + const getUserInfo = async () => { try { - const userInfo = await DefaultService.getUsers("1", "all"); + const userInfo = await DefaultService.getUsers(id, "all"); // <-- use dynamic id setUser(userInfo); } catch (err) { console.error(err); @@ -21,7 +25,7 @@ const UserPage: React.FC = () => { } }; getUserInfo(); - }, []); + }, [id]); if (loading) return
Loading...
; if (error) return
{error}
; From 92c06bbb507ac35086acf1c8e8add4200491ea9b Mon Sep 17 00:00:00 2001 From: nihonium Date: Sun, 26 Oct 2025 02:44:28 +0300 Subject: [PATCH 023/224] fix: remove templates from backend Dockerfile --- Dockerfiles/Dockerfile_backend | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfiles/Dockerfile_backend b/Dockerfiles/Dockerfile_backend index 27c9212..68c2414 100644 --- a/Dockerfiles/Dockerfile_backend +++ b/Dockerfiles/Dockerfile_backend @@ -2,6 +2,5 @@ FROM ubuntu:22.04 WORKDIR /app COPY --chmod=755 modules/backend/nyanimedb /app -COPY modules/backend/templates /app/templates EXPOSE 8080 ENTRYPOINT ["/app/nyanimedb"] \ No newline at end of file From fd1e129a5ff2eda351645798dd0a4372736e7fd3 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sun, 26 Oct 2025 02:49:50 +0300 Subject: [PATCH 024/224] fix: remove templates from cicd --- .forgejo/workflows/build-and-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index cd1087b..e7d0a83 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -27,7 +27,7 @@ jobs: cd modules/backend go mod tidy go build -o nyanimedb . - tar -czvf nyanimedb-backend.tar.gz nyanimedb templates/ + tar -czvf nyanimedb-backend.tar.gz nyanimedb - name: Upload built backend to artifactory uses: actions/upload-artifact@v3 From feb763509e56744434459265b2b53bc6e6221ef1 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sun, 26 Oct 2025 02:51:53 +0300 Subject: [PATCH 025/224] fix: regenerated frontend openapi functions --- modules/frontend/src/api/core/OpenAPI.ts | 2 +- modules/frontend/src/api/models/User.ts | 2 +- .../src/api/services/DefaultService.ts | 443 ------------------ 3 files changed, 2 insertions(+), 445 deletions(-) diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 99c577d..4fb2299 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://10.1.0.65:8080', + BASE: 'https://api.example.com', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/api/models/User.ts b/modules/frontend/src/api/models/User.ts index c296445..b03d22f 100644 --- a/modules/frontend/src/api/models/User.ts +++ b/modules/frontend/src/api/models/User.ts @@ -6,7 +6,7 @@ export type User = { /** * Unique user ID (primary key) */ - user_id: number; + id?: number; /** * ID of the user avatar (references images table) */ diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index f000c3f..7ebd129 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -2,116 +2,11 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { Review } from '../models/Review'; -import type { Tag } from '../models/Tag'; -import type { Title } from '../models/Title'; import type { User } from '../models/User'; -import type { UserTitle } from '../models/UserTitle'; import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; import { request as __request } from '../core/request'; export class DefaultService { - /** - * Get titles - * @param query - * @param limit - * @param offset - * @param fields - * @returns Title List of titles - * @throws ApiError - */ - public static getTitle( - query?: string, - limit: number = 10, - offset?: number, - fields: string = 'all', - ): CancelablePromise> { - return __request(OpenAPI, { - method: 'GET', - url: '/title', - query: { - 'query': query, - 'limit': limit, - 'offset': offset, - 'fields': fields, - }, - }); - } - /** - * Get title description - * @param titleId - * @param fields - * @returns Title Title description - * @throws ApiError - */ - public static getTitle1( - titleId: string, - fields: string = 'all', - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/title/{title_id}', - path: { - 'title_id': titleId, - }, - query: { - 'fields': fields, - }, - errors: { - 404: `Title not found`, - }, - }); - } - /** - * Update title info - * @param titleId - * @param requestBody - * @returns any Update result - * @throws ApiError - */ - public static patchTitle( - titleId: string, - requestBody: Title, - ): CancelablePromise<{ - success?: boolean; - error?: string; - user_json?: User; - }> { - return __request(OpenAPI, { - method: 'PATCH', - url: '/title/{title_id}', - path: { - 'title_id': titleId, - }, - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Get title reviews - * @param titleId - * @param limit - * @param offset - * @returns Review List of reviews - * @throws ApiError - */ - public static getTitleReviews( - titleId: string, - limit: number = 10, - offset?: number, - ): CancelablePromise<Array<Review>> { - return __request(OpenAPI, { - method: 'GET', - url: '/title/{title_id}/reviews', - path: { - 'title_id': titleId, - }, - query: { - 'limit': limit, - 'offset': offset, - }, - }); - } /** * Get user info * @param userId @@ -137,342 +32,4 @@ export class DefaultService { }, }); } - /** - * Update user - * @param userId - * @param requestBody - * @returns any Update result - * @throws ApiError - */ - public static patchUsers( - userId: string, - requestBody: User, - ): CancelablePromise<{ - success?: boolean; - error?: string; - }> { - return __request(OpenAPI, { - method: 'PATCH', - url: '/users/{user_id}', - path: { - 'user_id': userId, - }, - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Delete user - * @param userId - * @returns any Delete result - * @throws ApiError - */ - public static deleteUsers( - userId: string, - ): CancelablePromise<{ - success?: boolean; - error?: string; - }> { - return __request(OpenAPI, { - method: 'DELETE', - url: '/users/{user_id}', - path: { - 'user_id': userId, - }, - }); - } - /** - * Search user - * @param query - * @param fields - * @returns User List of users - * @throws ApiError - */ - public static getUsers1( - query?: string, - fields?: string, - ): CancelablePromise<Array<User>> { - return __request(OpenAPI, { - method: 'GET', - url: '/users', - query: { - 'query': query, - 'fields': fields, - }, - }); - } - /** - * Add new user - * @param requestBody - * @returns any Add result - * @throws ApiError - */ - public static postUsers( - requestBody: User, - ): CancelablePromise<{ - success?: boolean; - error?: string; - user_json?: User; - }> { - return __request(OpenAPI, { - method: 'POST', - url: '/users', - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Get user titles - * @param userId - * @param query - * @param limit - * @param offset - * @param fields - * @returns UserTitle List of user titles - * @throws ApiError - */ - public static getUsersTitles( - userId: string, - query?: string, - limit: number = 10, - offset?: number, - fields: string = 'all', - ): CancelablePromise<Array<UserTitle>> { - return __request(OpenAPI, { - method: 'GET', - url: '/users/{user_id}/titles', - path: { - 'user_id': userId, - }, - query: { - 'query': query, - 'limit': limit, - 'offset': offset, - 'fields': fields, - }, - }); - } - /** - * Add user title - * @param userId - * @param requestBody - * @returns any Add result - * @throws ApiError - */ - public static postUsersTitles( - userId: string, - requestBody: { - title_id?: string; - status?: string; - }, - ): CancelablePromise<{ - success?: boolean; - error?: string; - }> { - return __request(OpenAPI, { - method: 'POST', - url: '/users/{user_id}/titles', - path: { - 'user_id': userId, - }, - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Update user title - * @param userId - * @param requestBody - * @returns any Update result - * @throws ApiError - */ - public static patchUsersTitles( - userId: string, - requestBody: UserTitle, - ): CancelablePromise<{ - success?: boolean; - error?: string; - }> { - return __request(OpenAPI, { - method: 'PATCH', - url: '/users/{user_id}/titles', - path: { - 'user_id': userId, - }, - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Delete user title - * @param userId - * @param titleId - * @returns any Delete result - * @throws ApiError - */ - public static deleteUsersTitles( - userId: string, - titleId?: string, - ): CancelablePromise<{ - success?: boolean; - error?: string; - }> { - return __request(OpenAPI, { - method: 'DELETE', - url: '/users/{user_id}/titles', - path: { - 'user_id': userId, - }, - query: { - 'title_id': titleId, - }, - }); - } - /** - * Get user reviews - * @param userId - * @param limit - * @param offset - * @returns Review List of reviews - * @throws ApiError - */ - public static getUsersReviews( - userId: string, - limit: number = 10, - offset?: number, - ): CancelablePromise<Array<Review>> { - return __request(OpenAPI, { - method: 'GET', - url: '/users/{user_id}/reviews', - path: { - 'user_id': userId, - }, - query: { - 'limit': limit, - 'offset': offset, - }, - }); - } - /** - * Add review - * @param requestBody - * @returns any Add result - * @throws ApiError - */ - public static postReviews( - requestBody: Review, - ): CancelablePromise<{ - success?: boolean; - error?: string; - }> { - return __request(OpenAPI, { - method: 'POST', - url: '/reviews', - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Update review - * @param reviewId - * @param requestBody - * @returns any Update result - * @throws ApiError - */ - public static patchReviews( - reviewId: string, - requestBody: Review, - ): CancelablePromise<{ - success?: boolean; - error?: string; - }> { - return __request(OpenAPI, { - method: 'PATCH', - url: '/reviews/{review_id}', - path: { - 'review_id': reviewId, - }, - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Delete review - * @param reviewId - * @returns any Delete result - * @throws ApiError - */ - public static deleteReviews( - reviewId: string, - ): CancelablePromise<{ - success?: boolean; - error?: string; - }> { - return __request(OpenAPI, { - method: 'DELETE', - url: '/reviews/{review_id}', - path: { - 'review_id': reviewId, - }, - }); - } - /** - * Get tags - * @param limit - * @param offset - * @param fields - * @returns Tag List of tags - * @throws ApiError - */ - public static getTags( - limit: number = 10, - offset?: number, - fields?: string, - ): CancelablePromise<Array<Tag>> { - return __request(OpenAPI, { - method: 'GET', - url: '/tags', - query: { - 'limit': limit, - 'offset': offset, - 'fields': fields, - }, - }); - } - /** - * Upload image - * @returns any Upload result - * @throws ApiError - */ - public static postMedia(): CancelablePromise<{ - success?: boolean; - error?: string; - image_id?: string; - }> { - return __request(OpenAPI, { - method: 'POST', - url: '/media', - }); - } - /** - * Get image path - * @param imageId - * @returns any Image path - * @throws ApiError - */ - public static getMedia( - imageId: string, - ): CancelablePromise<{ - success?: boolean; - error?: string; - image_path?: string; - }> { - return __request(OpenAPI, { - method: 'GET', - url: '/media', - query: { - 'image_id': imageId, - }, - }); - } } From 66281838b226d6584622990bda1c76298f640389 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sun, 26 Oct 2025 03:18:54 +0300 Subject: [PATCH 026/224] fix: new psql feature --- deploy/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index a9ca65d..1a96253 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -10,7 +10,7 @@ services: ports: - "${POSTGRES_PORT}:5432" volumes: - - postgres_data:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql # pgadmin: # image: dpage/pgadmin4:${PGADMIN_VERSION} From 6ed47b667ca61f87bfec88cf4801a8c1e2148b9f Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sun, 26 Oct 2025 04:06:38 +0300 Subject: [PATCH 027/224] feat: proxy api requests via frontend --- Dockerfiles/Dockerfile_frontend | 1 + api/openapi.yaml | 2 +- modules/frontend/nginx-default.conf | 29 ++++++++++++++++++++++++ modules/frontend/src/api/core/OpenAPI.ts | 2 +- 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 modules/frontend/nginx-default.conf diff --git a/Dockerfiles/Dockerfile_frontend b/Dockerfiles/Dockerfile_frontend index a7ec893..18bc6d7 100644 --- a/Dockerfiles/Dockerfile_frontend +++ b/Dockerfiles/Dockerfile_frontend @@ -1,4 +1,5 @@ FROM nginx:alpine COPY modules/frontend/dist /usr/share/nginx/html +COPY modules/frontend/nginx-default.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/api/openapi.yaml b/api/openapi.yaml index 97fa3a4..b20f677 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -3,7 +3,7 @@ info: title: Titles, Users, Reviews, Tags, and Media API version: 1.0.0 servers: - - url: https://api.example.com + - url: /api/v1 paths: # /title: # get: diff --git a/modules/frontend/nginx-default.conf b/modules/frontend/nginx-default.conf new file mode 100644 index 0000000..a538968 --- /dev/null +++ b/modules/frontend/nginx-default.conf @@ -0,0 +1,29 @@ +server { + listen 80; + listen [::]:80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/v1/ { + rewrite ^/api/v1/(.*)$ /$1 break; + proxy_pass http://nyanimedb-backend:8080/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + #error_page 404 /404.html; + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + +} \ No newline at end of file diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 4fb2299..185e5c3 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'https://api.example.com', + BASE: '/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', From 6d538ed15419d898482f94da4de7da1067974817 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 1 Nov 2025 21:17:42 +0300 Subject: [PATCH 028/224] feat: common code for back functions are now in handlers/common.go --- modules/backend/handlers/common.go | 19 +++++++++++++++++++ modules/backend/handlers/users.go | 21 ++++++++++----------- 2 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 modules/backend/handlers/common.go diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go new file mode 100644 index 0000000..b85d022 --- /dev/null +++ b/modules/backend/handlers/common.go @@ -0,0 +1,19 @@ +package handlers + +import ( + sqlc "nyanimedb/sql" + "strconv" +) + +type Server struct { + db *sqlc.Queries +} + +func NewServer(db *sqlc.Queries) Server { + return Server{db: db} +} + +func parseInt64(s string) (int32, error) { + i, err := strconv.ParseInt(s, 10, 64) + return int32(i), err +} diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index b67153d..3da61de 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -4,24 +4,23 @@ import ( "context" oapi "nyanimedb/api" sqlc "nyanimedb/sql" - "strconv" "github.com/jackc/pgx/v5" "github.com/oapi-codegen/runtime/types" ) -type Server struct { - db *sqlc.Queries -} +// type Server struct { +// db *sqlc.Queries +// } -func NewServer(db *sqlc.Queries) Server { - return Server{db: db} -} +// func NewServer(db *sqlc.Queries) Server { +// return Server{db: db} +// } -func parseInt64(s string) (int32, error) { - i, err := strconv.ParseInt(s, 10, 64) - return int32(i), err -} +// func parseInt64(s string) (int32, error) { +// i, err := strconv.ParseInt(s, 10, 64) +// return int32(i), err +// } func mapUser(u sqlc.GetUserByIDRow) oapi.User { return oapi.User{ From 4ffa7dc93e7a06f39260a27b1ee5b0d574d4e5be Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Wed, 5 Nov 2025 17:11:43 +0300 Subject: [PATCH 029/224] feat: add new user handler was uncommented --- api/openapi.yaml | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index b20f677..6439c86 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -196,7 +196,7 @@ paths: # error: # type: string - # /users: + /users: # get: # summary: Search user # parameters: @@ -218,28 +218,28 @@ paths: # items: # $ref: '#/components/schemas/User' - # post: - # summary: Add new user - # requestBody: - # required: true - # content: - # application/json: - # schema: - # $ref: '#/components/schemas/User' - # responses: - # '200': - # description: Add result - # content: - # application/json: - # schema: - # type: object - # properties: - # success: - # type: boolean - # error: - # type: string - # user_json: - # $ref: '#/components/schemas/User' + post: + summary: Add new user + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: Add result + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + error: + type: string + user_json: + $ref: '#/components/schemas/User' # /users/{user_id}/titles: # get: @@ -580,7 +580,7 @@ components: required: - user_id - nickname - - creation_date + # - creation_date UserTitle: type: object additionalProperties: true From bac889b6276c6cb5a9f6a8b136922ab434a2e442 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Wed, 5 Nov 2025 17:20:09 +0300 Subject: [PATCH 030/224] fix: regexps for mail and nickname were corrected --- sql/migrations/000001_init.up.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 0b7fa33..eba22e2 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -27,8 +27,8 @@ CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, avatar_id bigint REFERENCES images (id), passhash text NOT NULL, - mail text CHECK (mail ~ '[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+'), - nickname text NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'), + mail text CHECK (mail ~ '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+$'), + nickname text UNIQUE NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]{3,}$'), disp_name text, user_desc text, creation_date timestamptz NOT NULL, From 83fee98059ced5aaee9a1109ccb5129cf47fc360 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 14 Nov 2025 15:22:14 +0300 Subject: [PATCH 031/224] feat: external_ids table is added for binding user sessions in tg and other services --- sql/migrations/000001_init.up.sql | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index eba22e2..abecd32 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -85,6 +85,12 @@ CREATE TABLE signals ( pending boolean NOT NULL ); +CREATE TABLE external_ids ( + user_id NOT NULL REFERENCES users (id), + service_id text NOT NULL, + external_ids text NOT NULL +); + -- Functions CREATE OR REPLACE FUNCTION update_title_rating() RETURNS TRIGGER AS $$ From f24edc5dd76f1bedfece5c173203d461d9971e91 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 14 Nov 2025 15:34:48 +0300 Subject: [PATCH 032/224] feat: external_services table create --- sql/migrations/000001_init.up.sql | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index abecd32..a5e89b8 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -87,8 +87,13 @@ CREATE TABLE signals ( CREATE TABLE external_ids ( user_id NOT NULL REFERENCES users (id), - service_id text NOT NULL, - external_ids text NOT NULL + service_id bigint REFERENCES external_services (id), + external_id text NOT NULL +); + +CREATE TABLE external_services ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text UNIQUE NOT NULL ); -- Functions From 7fed5ed53665466a8da575a0a9b83978ecc85c7b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 14 Nov 2025 23:57:34 +0300 Subject: [PATCH 033/224] feat: get titles added with all components needed --- api/openapi.yaml | 151 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 117 insertions(+), 34 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 6439c86..c1aed30 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -5,40 +5,60 @@ info: servers: - url: /api/v1 paths: - # /title: - # get: - # summary: Get titles - # parameters: - # - in: query - # name: query - # schema: - # type: string - # - in: query - # name: limit - # schema: - # type: integer - # default: 10 - # - in: query - # name: offset - # schema: - # type: integer - # default: 0 - # - in: query - # name: fields - # schema: - # type: string - # default: all - # responses: - # '200': - # description: List of titles - # content: - # application/json: - # schema: - # type: array - # items: - # $ref: '#/components/schemas/Title' - # '204': - # description: No titles found + /title: + get: + summary: Get titles + parameters: + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + $ref: '#/components/schemas/TitleStatus' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '#/components/schemas/ReleaseSeason' + - in: query + name: limit + schema: + type: integer + default: 10 + - in: query + name: offset + schema: + type: integer + default: 0 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of titles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Title' + '204': + description: No titles found + '500': + description: Unknown server error # /title/{title_id}: # get: @@ -535,8 +555,71 @@ paths: components: schemas: + TitleStatus: + type: string + description: Title status + enum: + - finished + - ongoing + - planned + ReleaseSeason: + type: string + description: Title release season + enum: + - winter + - spring + - summer + - fall + UserTitleStatus: + type: string + description: User's title status + enum: + - finished + - planned + - dropped + - in-progress Title: type: object + properties: + id: + type: integer + format: int64 + description: Unique title ID (primary key) + example: 1 + title_names: + type: array + items: + type: string + studio_id: + type: integer + format: int64 + poster_id: + type: integer + format: int64 + title_status: + $ref: '#/components/schemas/TitleStatus' + rating: + type: number + format: double + rating_count: + type: integer + format: int32 + release_year: + type: integer + format: int32 + release_season: + $ref: '#/components/schemas/ReleaseSeason' + episodes_aired: + type: integer + format: int32 + episodes_all: + type: integer + format: int32 + episodes_len: + type: array + items: + type: number + format: double additionalProperties: true User: type: object From 765e75e8bba8808abb08dc7f9209a06771d00249 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 15 Nov 2025 00:04:43 +0300 Subject: [PATCH 034/224] feat: new responses added --- api/openapi.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/openapi.yaml b/api/openapi.yaml index c1aed30..b17b539 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -57,6 +57,8 @@ paths: $ref: '#/components/schemas/Title' '204': description: No titles found + '400': + description: Request params are not correct '500': description: Unknown server error @@ -167,6 +169,10 @@ paths: $ref: '#/components/schemas/User' '404': description: User not found + '400': + description: Request params are not correct + '500': + description: Unknown server error # patch: # summary: Update user From c2dc7627002b56e1ad92f6aff41b26e0ea63608b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 15 Nov 2025 00:46:47 +0300 Subject: [PATCH 035/224] feat: openapi changes --- api/api.gen.go | 517 +++++++++++++++++++++++++++++++++++- go.mod | 1 + go.sum | 3 + modules/backend/queries.sql | 39 ++- 4 files changed, 553 insertions(+), 7 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index 24aebd3..40c0fa2 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -16,13 +16,57 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +// Defines values for ReleaseSeason. +const ( + Fall ReleaseSeason = "fall" + Spring ReleaseSeason = "spring" + Summer ReleaseSeason = "summer" + Winter ReleaseSeason = "winter" +) + +// Defines values for TitleStatus. +const ( + Finished TitleStatus = "finished" + Ongoing TitleStatus = "ongoing" + Planned TitleStatus = "planned" +) + +// ReleaseSeason Title release season +type ReleaseSeason string + +// Title defines model for Title. +type Title struct { + EpisodesAired *int32 `json:"episodes_aired,omitempty"` + EpisodesAll *int32 `json:"episodes_all,omitempty"` + EpisodesLen *[]float64 `json:"episodes_len,omitempty"` + + // Id Unique title ID (primary key) + Id *int64 `json:"id,omitempty"` + PosterId *int64 `json:"poster_id,omitempty"` + Rating *float64 `json:"rating,omitempty"` + RatingCount *int32 `json:"rating_count,omitempty"` + + // ReleaseSeason Title release season + ReleaseSeason *ReleaseSeason `json:"release_season,omitempty"` + ReleaseYear *int32 `json:"release_year,omitempty"` + StudioId *int64 `json:"studio_id,omitempty"` + TitleNames *[]string `json:"title_names,omitempty"` + + // TitleStatus Title status + TitleStatus *TitleStatus `json:"title_status,omitempty"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// TitleStatus Title status +type TitleStatus string + // User defines model for User. type User struct { // AvatarId ID of the user avatar (references images table) AvatarId *int64 `json:"avatar_id"` // CreationDate Timestamp when the user was created - CreationDate time.Time `json:"creation_date"` + CreationDate *time.Time `json:"creation_date,omitempty"` // DispName Display name DispName *string `json:"disp_name,omitempty"` @@ -40,13 +84,267 @@ type User struct { UserDesc *string `json:"user_desc,omitempty"` } +// GetTitleParams defines parameters for GetTitle. +type GetTitleParams struct { + Word *string `form:"word,omitempty" json:"word,omitempty"` + Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` + Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` + ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` + ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + // GetUsersUserIdParams defines parameters for GetUsersUserId. type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// PostUsersJSONRequestBody defines body for PostUsers for application/json ContentType. +type PostUsersJSONRequestBody = User + +// Getter for additional properties for Title. Returns the specified +// element and whether it was found +func (a Title) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for Title +func (a *Title) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for Title to handle AdditionalProperties +func (a *Title) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["episodes_aired"]; found { + err = json.Unmarshal(raw, &a.EpisodesAired) + if err != nil { + return fmt.Errorf("error reading 'episodes_aired': %w", err) + } + delete(object, "episodes_aired") + } + + if raw, found := object["episodes_all"]; found { + err = json.Unmarshal(raw, &a.EpisodesAll) + if err != nil { + return fmt.Errorf("error reading 'episodes_all': %w", err) + } + delete(object, "episodes_all") + } + + if raw, found := object["episodes_len"]; found { + err = json.Unmarshal(raw, &a.EpisodesLen) + if err != nil { + return fmt.Errorf("error reading 'episodes_len': %w", err) + } + delete(object, "episodes_len") + } + + if raw, found := object["id"]; found { + err = json.Unmarshal(raw, &a.Id) + if err != nil { + return fmt.Errorf("error reading 'id': %w", err) + } + delete(object, "id") + } + + if raw, found := object["poster_id"]; found { + err = json.Unmarshal(raw, &a.PosterId) + if err != nil { + return fmt.Errorf("error reading 'poster_id': %w", err) + } + delete(object, "poster_id") + } + + if raw, found := object["rating"]; found { + err = json.Unmarshal(raw, &a.Rating) + if err != nil { + return fmt.Errorf("error reading 'rating': %w", err) + } + delete(object, "rating") + } + + if raw, found := object["rating_count"]; found { + err = json.Unmarshal(raw, &a.RatingCount) + if err != nil { + return fmt.Errorf("error reading 'rating_count': %w", err) + } + delete(object, "rating_count") + } + + if raw, found := object["release_season"]; found { + err = json.Unmarshal(raw, &a.ReleaseSeason) + if err != nil { + return fmt.Errorf("error reading 'release_season': %w", err) + } + delete(object, "release_season") + } + + if raw, found := object["release_year"]; found { + err = json.Unmarshal(raw, &a.ReleaseYear) + if err != nil { + return fmt.Errorf("error reading 'release_year': %w", err) + } + delete(object, "release_year") + } + + if raw, found := object["studio_id"]; found { + err = json.Unmarshal(raw, &a.StudioId) + if err != nil { + return fmt.Errorf("error reading 'studio_id': %w", err) + } + delete(object, "studio_id") + } + + if raw, found := object["title_names"]; found { + err = json.Unmarshal(raw, &a.TitleNames) + if err != nil { + return fmt.Errorf("error reading 'title_names': %w", err) + } + delete(object, "title_names") + } + + if raw, found := object["title_status"]; found { + err = json.Unmarshal(raw, &a.TitleStatus) + if err != nil { + return fmt.Errorf("error reading 'title_status': %w", err) + } + delete(object, "title_status") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for Title to handle AdditionalProperties +func (a Title) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + if a.EpisodesAired != nil { + object["episodes_aired"], err = json.Marshal(a.EpisodesAired) + if err != nil { + return nil, fmt.Errorf("error marshaling 'episodes_aired': %w", err) + } + } + + if a.EpisodesAll != nil { + object["episodes_all"], err = json.Marshal(a.EpisodesAll) + if err != nil { + return nil, fmt.Errorf("error marshaling 'episodes_all': %w", err) + } + } + + if a.EpisodesLen != nil { + object["episodes_len"], err = json.Marshal(a.EpisodesLen) + if err != nil { + return nil, fmt.Errorf("error marshaling 'episodes_len': %w", err) + } + } + + if a.Id != nil { + object["id"], err = json.Marshal(a.Id) + if err != nil { + return nil, fmt.Errorf("error marshaling 'id': %w", err) + } + } + + if a.PosterId != nil { + object["poster_id"], err = json.Marshal(a.PosterId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'poster_id': %w", err) + } + } + + if a.Rating != nil { + object["rating"], err = json.Marshal(a.Rating) + if err != nil { + return nil, fmt.Errorf("error marshaling 'rating': %w", err) + } + } + + if a.RatingCount != nil { + object["rating_count"], err = json.Marshal(a.RatingCount) + if err != nil { + return nil, fmt.Errorf("error marshaling 'rating_count': %w", err) + } + } + + if a.ReleaseSeason != nil { + object["release_season"], err = json.Marshal(a.ReleaseSeason) + if err != nil { + return nil, fmt.Errorf("error marshaling 'release_season': %w", err) + } + } + + if a.ReleaseYear != nil { + object["release_year"], err = json.Marshal(a.ReleaseYear) + if err != nil { + return nil, fmt.Errorf("error marshaling 'release_year': %w", err) + } + } + + if a.StudioId != nil { + object["studio_id"], err = json.Marshal(a.StudioId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'studio_id': %w", err) + } + } + + if a.TitleNames != nil { + object["title_names"], err = json.Marshal(a.TitleNames) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title_names': %w", err) + } + } + + if a.TitleStatus != nil { + object["title_status"], err = json.Marshal(a.TitleStatus) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title_status': %w", err) + } + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + // ServerInterface represents all server handlers. type ServerInterface interface { + // Get titles + // (GET /title) + GetTitle(c *gin.Context, params GetTitleParams) + // Add new user + // (POST /users) + PostUsers(c *gin.Context) // Get user info // (GET /users/{user_id}) GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) @@ -61,6 +359,101 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) +// GetTitle operation middleware +func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitleParams + + // ------------- Optional query parameter "word" ------------- + + err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter word: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameter("form", true, false, "status", c.Request.URL.Query(), ¶ms.Status) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "rating" ------------- + + err = runtime.BindQueryParameter("form", true, false, "rating", c.Request.URL.Query(), ¶ms.Rating) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter rating: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_year" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_year", c.Request.URL.Query(), ¶ms.ReleaseYear) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_year: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_season" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_season", c.Request.URL.Query(), ¶ms.ReleaseSeason) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_season: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitle(c, params) +} + +// PostUsers operation middleware +func (siw *ServerInterfaceWrapper) PostUsers(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostUsers(c) +} + // GetUsersUserId operation middleware func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { @@ -123,9 +516,65 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } + router.GET(options.BaseURL+"/title", wrapper.GetTitle) + router.POST(options.BaseURL+"/users", wrapper.PostUsers) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) } +type GetTitleRequestObject struct { + Params GetTitleParams +} + +type GetTitleResponseObject interface { + VisitGetTitleResponse(w http.ResponseWriter) error +} + +type GetTitle200JSONResponse []Title + +func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitle204Response struct { +} + +func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetTitle500Response struct { +} + +func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type PostUsersRequestObject struct { + Body *PostUsersJSONRequestBody +} + +type PostUsersResponseObject interface { + VisitPostUsersResponse(w http.ResponseWriter) error +} + +type PostUsers200JSONResponse struct { + Error *string `json:"error,omitempty"` + Success *bool `json:"success,omitempty"` + UserJson *User `json:"user_json,omitempty"` +} + +func (response PostUsers200JSONResponse) VisitPostUsersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + type GetUsersUserIdRequestObject struct { UserId string `json:"user_id"` Params GetUsersUserIdParams @@ -154,6 +603,12 @@ func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.Res // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // Get titles + // (GET /title) + GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) + // Add new user + // (POST /users) + PostUsers(ctx context.Context, request PostUsersRequestObject) (PostUsersResponseObject, error) // Get user info // (GET /users/{user_id}) GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) @@ -171,6 +626,66 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// GetTitle operation middleware +func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { + var request GetTitleRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitleResponseObject); ok { + if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostUsers operation middleware +func (sh *strictHandler) PostUsers(ctx *gin.Context) { + var request PostUsersRequestObject + + var body PostUsersJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostUsers(ctx, request.(PostUsersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostUsers") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostUsersResponseObject); ok { + if err := validResponse.VisitPostUsersResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetUsersUserId operation middleware func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { var request GetUsersUserIdRequestObject diff --git a/go.mod b/go.mod index b7a66f2..7c34aeb 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect diff --git a/go.sum b/go.sum index 1af1a7c..121ca40 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -95,6 +97,7 @@ golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index b1dd8af..b90ec6a 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -38,12 +38,39 @@ WHERE id = $1; -- DELETE FROM users -- WHERE user_id = $1; --- -- name: GetTitleByID :one --- SELECT title_id, title_names, studio_id, poster_id, signal_ids, --- title_status, rating, rating_count, release_year, release_season, --- season, episodes_aired, episodes_all, episodes_len --- FROM titles --- WHERE title_id = $1; +-- name: SearchTitles :many +SELECT + * +FROM titles +WHERE + CASE + WHEN sqlc.narg('word')::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + + AND (sqlc.narg('status')::title_status_t IS NULL OR title_status = sqlc.narg('status')::title_status_t) + AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float) + AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int) + AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t) + +LIMIT COALESCE(sqlc.narg('limit')::int, 100) -- 100 is default limit +OFFSET sqlc.narg('offset')::int; -- -- name: ListTitles :many -- SELECT title_id, title_names, studio_id, poster_id, signal_ids, From d2450ffc89d1ee16311c140c3422dea8f166745f Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 15 Nov 2025 00:52:23 +0300 Subject: [PATCH 036/224] feat: titles.go added --- api/api.gen.go | 24 ++++++++ api/openapi.yaml | 10 ++++ modules/backend/handlers/titles.go | 96 ++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 modules/backend/handlers/titles.go diff --git a/api/api.gen.go b/api/api.gen.go index 40c0fa2..d17b591 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -546,6 +546,14 @@ func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) return nil } +type GetTitle400Response struct { +} + +func (response GetTitle400Response) VisitGetTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + type GetTitle500Response struct { } @@ -593,6 +601,14 @@ func (response GetUsersUserId200JSONResponse) VisitGetUsersUserIdResponse(w http return json.NewEncoder(w).Encode(response) } +type GetUsersUserId400Response struct { +} + +func (response GetUsersUserId400Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + type GetUsersUserId404Response struct { } @@ -601,6 +617,14 @@ func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.Res return nil } +type GetUsersUserId500Response struct { +} + +func (response GetUsersUserId500Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Get titles diff --git a/api/openapi.yaml b/api/openapi.yaml index b17b539..43e380d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -10,6 +10,7 @@ paths: summary: Get titles parameters: - in: query +<<<<<<< Updated upstream name: word schema: type: string @@ -32,6 +33,12 @@ paths: schema: $ref: '#/components/schemas/ReleaseSeason' - in: query +======= + name: query + schema: + type: string + - in: query +>>>>>>> Stashed changes name: limit schema: type: integer @@ -57,10 +64,13 @@ paths: $ref: '#/components/schemas/Title' '204': description: No titles found +<<<<<<< Updated upstream '400': description: Request params are not correct '500': description: Unknown server error +======= +>>>>>>> Stashed changes # /title/{title_id}: # get: diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go new file mode 100644 index 0000000..7bfcf58 --- /dev/null +++ b/modules/backend/handlers/titles.go @@ -0,0 +1,96 @@ +package handlers + +import ( + "context" + "fmt" + oapi "nyanimedb/api" + sqlc "nyanimedb/sql" + + log "github.com/sirupsen/logrus" +) + +func Word2Sqlc(s *string) *string { + if s == nil { + return nil + } + if *s == "" { + return nil + } + return s +} + +func TitleStatus2Sqlc(s *oapi.TitleStatus) (*sqlc.TitleStatusT, error) { + if s == nil { + return nil, nil + } + var t sqlc.TitleStatusT + if *s == "finished" { + t = "finished" + } else if *s == "ongoing" { + t = "ongoing" + } else if *s == "planned" { + t = "planned" + } else { + return nil, fmt.Errorf("unexpected tittle status: %s", *s) + } + return &t, nil +} + +func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { + if s == nil { + return nil, nil + } + var t sqlc.ReleaseSeasonT + if *s == "winter" { + t = "winter" + } else if *s == "spring" { + t = "spring" + } else if *s == "summer" { + t = "summer" + } else if *s == "fall" { + t = "fall" + } else { + return nil, fmt.Errorf("unexpected release season: %s", *s) + } + return &t, nil +} + +func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { + var result []oapi.Title + + word := Word2Sqlc(request.Params.Word) + status, err := TitleStatus2Sqlc(request.Params.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitle400Response{}, err + } + season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitle400Response{}, err + } + // param = nil means it will not be used + titles, err := s.db.SearchTitles(ctx, sqlc.SearchTitlesParams{ + Word: word, + Status: status, + Rating: request.Params.Rating, + ReleaseYear: request.Params.ReleaseYear, + ReleaseSeason: season, + }) + if err != nil { + return oapi.GetTitle500Response{}, nil + } + if len(titles) == 0 { + return oapi.GetTitle204Response{}, nil + } + + for _, title := range titles { + t := oapi.Title{ + Rating: title.Rating, + PosterId: title.PosterID, + } + result = append(result, t) + } + + return oapi.GetTitle200JSONResponse(result), nil +} From d04248ab7a8b82c7852a082a2bfec92c2be0d20f Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 15 Nov 2025 01:02:30 +0300 Subject: [PATCH 037/224] feat --- api/openapi.yaml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 43e380d..f1c40f9 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -10,7 +10,6 @@ paths: summary: Get titles parameters: - in: query -<<<<<<< Updated upstream name: word schema: type: string @@ -33,12 +32,6 @@ paths: schema: $ref: '#/components/schemas/ReleaseSeason' - in: query -======= - name: query - schema: - type: string - - in: query ->>>>>>> Stashed changes name: limit schema: type: integer @@ -64,14 +57,10 @@ paths: $ref: '#/components/schemas/Title' '204': description: No titles found -<<<<<<< Updated upstream '400': description: Request params are not correct '500': description: Unknown server error -======= ->>>>>>> Stashed changes - # /title/{title_id}: # get: # summary: Get title description From ae01eec0fdf2d3ff4f14e8df756983d4a6d97c76 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 15 Nov 2025 02:50:29 +0300 Subject: [PATCH 038/224] fix!: some types were changed --- api/api.gen.go | 10 ++++++---- api/openapi.yaml | 20 +++++++++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index d17b591..a235db8 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -36,9 +36,9 @@ type ReleaseSeason string // Title defines model for Title. type Title struct { - EpisodesAired *int32 `json:"episodes_aired,omitempty"` - EpisodesAll *int32 `json:"episodes_all,omitempty"` - EpisodesLen *[]float64 `json:"episodes_len,omitempty"` + EpisodesAired *int32 `json:"episodes_aired,omitempty"` + EpisodesAll *int32 `json:"episodes_all,omitempty"` + EpisodesLen *map[string]float64 `json:"episodes_len,omitempty"` // Id Unique title ID (primary key) Id *int64 `json:"id,omitempty"` @@ -50,7 +50,9 @@ type Title struct { ReleaseSeason *ReleaseSeason `json:"release_season,omitempty"` ReleaseYear *int32 `json:"release_year,omitempty"` StudioId *int64 `json:"studio_id,omitempty"` - TitleNames *[]string `json:"title_names,omitempty"` + + // TitleNames Localized titles. Key = language (ISO 639-1), value = list of names + TitleNames *map[string][]string `json:"title_names,omitempty"` // TitleStatus Title status TitleStatus *TitleStatus `json:"title_status,omitempty"` diff --git a/api/openapi.yaml b/api/openapi.yaml index f1c40f9..4187ebb 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -592,9 +592,19 @@ components: description: Unique title ID (primary key) example: 1 title_names: - type: array - items: - type: string + type: object + description: "Localized titles. Key = language (ISO 639-1), value = list of names" + additionalProperties: + type: array + items: + type: string + example: "Attack on Titan" + minItems: 1 + example: ["Attack on Titan", "AoT"] + example: + en: ["Attack on Titan", "AoT"] + ru: ["Атака титанов", "Титаны"] + ja: ["進撃の巨人"] studio_id: type: integer format: int64 @@ -621,8 +631,8 @@ components: type: integer format: int32 episodes_len: - type: array - items: + type: object + additionalProperties: type: number format: double additionalProperties: true From e8783a0e9df1eaeb25c52644504deaf750b45cdf Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 15 Nov 2025 02:51:13 +0300 Subject: [PATCH 039/224] feat: get title func written --- modules/backend/handlers/titles.go | 47 +++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 7bfcf58..85f9f45 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "encoding/json" "fmt" oapi "nyanimedb/api" sqlc "nyanimedb/sql" @@ -40,9 +41,10 @@ func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { if s == nil { return nil, nil } + //TODO var t sqlc.ReleaseSeasonT - if *s == "winter" { - t = "winter" + if *s == oapi.Winter { + t = sqlc.ReleaseSeasonTWinter } else if *s == "spring" { t = "spring" } else if *s == "summer" { @@ -55,6 +57,23 @@ func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { return &t, nil } +// unmarshall jsonb to map[string][]string +func jsonb2map4names(b []byte) (*map[string][]string, error) { + var t map[string][]string + if err := json.Unmarshal(b, &t); err != nil { + return nil, fmt.Errorf("invalid title_names JSON for title: %w", err) + } + return &t, nil +} + +func jsonb2map4len(b []byte) (*map[string]float64, error) { + var t map[string]float64 + if err := json.Unmarshal(b, &t); err != nil { + return nil, fmt.Errorf("invalid episodes_len JSON for title: %w", err) + } + return &t, nil +} + func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { var result []oapi.Title @@ -85,9 +104,29 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject } for _, title := range titles { + title_names, err := jsonb2map4names(title.TitleNames) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitle500Response{}, err + } + episodes_lens, err := jsonb2map4len(title.EpisodesLen) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitle500Response{}, err + } t := oapi.Title{ - Rating: title.Rating, - PosterId: title.PosterID, + Id: &title.ID, + PosterId: title.PosterID, + Rating: title.Rating, + RatingCount: title.RatingCount, + ReleaseSeason: (*oapi.ReleaseSeason)(title.ReleaseSeason), + ReleaseYear: title.ReleaseYear, + StudioId: &title.StudioID, + TitleNames: title_names, + TitleStatus: (*oapi.TitleStatus)(&title.TitleStatus), + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAll, + EpisodesLen: episodes_lens, } result = append(result, t) } From 5cc67579000c0f3369de0ce83aa1faeb8900f5b6 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 15 Nov 2025 02:51:52 +0300 Subject: [PATCH 040/224] feat: minor changes to db and new query --- sql/migrations/000001_init.up.sql | 4 +- sql/models.go | 37 ++++++---- sql/queries.sql.go | 114 ++++++++++++++++++++++++++++++ sql/sqlc.yaml | 12 +++- 4 files changed, 152 insertions(+), 15 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index a5e89b8..c325dc8 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -44,6 +44,7 @@ CREATE TABLE studios ( CREATE TABLE titles ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, studio_id bigint NOT NULL REFERENCES studios (id), poster_id bigint REFERENCES images (id), @@ -55,6 +56,7 @@ CREATE TABLE titles ( season int CHECK (season >= 0), episodes_aired int CHECK (episodes_aired >= 0), episodes_all int CHECK (episodes_all >= 0), + -- example { "1": "50.50", "2": "23.23"} episodes_len jsonb, CHECK ((episodes_aired IS NULL AND episodes_all IS NULL) OR (episodes_aired IS NOT NULL AND episodes_all IS NOT NULL @@ -86,7 +88,7 @@ CREATE TABLE signals ( ); CREATE TABLE external_ids ( - user_id NOT NULL REFERENCES users (id), + user_id bigint NOT NULL REFERENCES users (id), service_id bigint REFERENCES external_services (id), external_id text NOT NULL ); diff --git a/sql/models.go b/sql/models.go index 928d5ac..6583b71 100644 --- a/sql/models.go +++ b/sql/models.go @@ -185,6 +185,17 @@ func (ns NullUsertitleStatusT) Value() (driver.Value, error) { return string(ns.UsertitleStatusT), nil } +type ExternalID struct { + UserID int64 `json:"user_id"` + ServiceID *int64 `json:"service_id"` + ExternalID string `json:"external_id"` +} + +type ExternalService struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + type Image struct { ID int64 `json:"id"` StorageType StorageTypeT `json:"storage_type"` @@ -218,19 +229,19 @@ type Tag struct { } type Title struct { - ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason NullReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` + ID int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` } type TitleTag struct { diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 8f92c2a..2ef4178 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -71,3 +71,117 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er ) return i, err } + +const searchTitles = `-- name: SearchTitles :many + + + + +SELECT + id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len +FROM titles +WHERE + CASE + WHEN $1::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($1::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + + AND ($2::title_status_t IS NULL OR title_status = $2::title_status_t) + AND ($3::float IS NULL OR rating >= $3::float) + AND ($4::int IS NULL OR release_year = $4::int) + AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t) + +LIMIT COALESCE($7::int, 100) -- 100 is default limit +OFFSET $6::int +` + +type SearchTitlesParams struct { + Word *string `json:"word"` + Status *TitleStatusT `json:"status"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Offset *int32 `json:"offset"` + Limit *int32 `json:"limit"` +} + +// -- name: ListUsers :many +// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +// FROM users +// ORDER BY user_id +// LIMIT $1 OFFSET $2; +// -- name: CreateUser :one +// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) +// VALUES ($1, $2, $3, $4, $5, $6, $7) +// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +// -- name: UpdateUser :one +// UPDATE users +// SET +// +// avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), +// disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), +// user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), +// passhash = COALESCE(sqlc.narg('passhash'), passhash) +// +// WHERE user_id = sqlc.arg('user_id') +// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +// -- name: DeleteUser :exec +// DELETE FROM users +// WHERE user_id = $1; +func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) { + rows, err := q.db.Query(ctx, searchTitles, + arg.Word, + arg.Status, + arg.Rating, + arg.ReleaseYear, + arg.ReleaseSeason, + arg.Offset, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Title + for rows.Next() { + var i Title + if err := rows.Scan( + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index f44761e..f74d2ad 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -24,4 +24,14 @@ sql: nullable: false go_type: import: "time" - type: "Time" \ No newline at end of file + type: "Time" + - db_type: "title_status_t" + nullable: true + go_type: + pointer: true + type: "TitleStatusT" + - db_type: "release_season_t" + nullable: true + go_type: + pointer: true + type: "ReleaseSeasonT" \ No newline at end of file From bbe57e07d59ed06bb8cfdae815b570c99c3886ef Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 15 Nov 2025 02:53:25 +0300 Subject: [PATCH 041/224] feat: initial auth service support --- Dockerfiles/Dockerfile_auth | 6 + auth/auth.gen.go | 329 ++++++++++++++++++++++++++++++ auth/auth/auth.gen.go | 329 ++++++++++++++++++++++++++++++ auth/oapi-auth-codegen.yaml | 6 + auth/openapi-auth.yaml | 112 ++++++++++ go.mod | 3 +- go.sum | 2 + modules/auth/handlers/handlers.go | 108 ++++++++++ modules/auth/main.go | 38 ++++ modules/auth/types.go | 6 + 10 files changed, 938 insertions(+), 1 deletion(-) create mode 100644 Dockerfiles/Dockerfile_auth create mode 100644 auth/auth.gen.go create mode 100644 auth/auth/auth.gen.go create mode 100644 auth/oapi-auth-codegen.yaml create mode 100644 auth/openapi-auth.yaml create mode 100644 modules/auth/handlers/handlers.go create mode 100644 modules/auth/main.go create mode 100644 modules/auth/types.go diff --git a/Dockerfiles/Dockerfile_auth b/Dockerfiles/Dockerfile_auth new file mode 100644 index 0000000..5280e86 --- /dev/null +++ b/Dockerfiles/Dockerfile_auth @@ -0,0 +1,6 @@ +FROM ubuntu:22.04 + +WORKDIR /app +COPY --chmod=755 modules/auth/auth /app +EXPOSE 8082 +ENTRYPOINT ["/app/auth"] \ No newline at end of file diff --git a/auth/auth.gen.go b/auth/auth.gen.go new file mode 100644 index 0000000..1f16575 --- /dev/null +++ b/auth/auth.gen.go @@ -0,0 +1,329 @@ +// Package auth provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" +) + +// PostAuthSignInJSONBody defines parameters for PostAuthSignIn. +type PostAuthSignInJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthSignUpJSONBody defines parameters for PostAuthSignUp. +type PostAuthSignUpJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthVerifyTokenJSONBody defines parameters for PostAuthVerifyToken. +type PostAuthVerifyTokenJSONBody struct { + // Token JWT token to validate + Token string `json:"token"` +} + +// PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. +type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody + +// PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. +type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody + +// PostAuthVerifyTokenJSONRequestBody defines body for PostAuthVerifyToken for application/json ContentType. +type PostAuthVerifyTokenJSONRequestBody PostAuthVerifyTokenJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(c *gin.Context) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(c *gin.Context) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(c *gin.Context) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// PostAuthSignIn operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignIn(c) +} + +// PostAuthSignUp operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignUp(c) +} + +// PostAuthVerifyToken operation middleware +func (siw *ServerInterfaceWrapper) PostAuthVerifyToken(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthVerifyToken(c) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) + router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) + router.POST(options.BaseURL+"/auth/verify-token", wrapper.PostAuthVerifyToken) +} + +type PostAuthSignInRequestObject struct { + Body *PostAuthSignInJSONRequestBody +} + +type PostAuthSignInResponseObject interface { + VisitPostAuthSignInResponse(w http.ResponseWriter) error +} + +type PostAuthSignIn200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + + // Token JWT token to access protected endpoints + Token *string `json:"token"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthSignUpRequestObject struct { + Body *PostAuthSignUpJSONRequestBody +} + +type PostAuthSignUpResponseObject interface { + VisitPostAuthSignUpResponse(w http.ResponseWriter) error +} + +type PostAuthSignUp200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthVerifyTokenRequestObject struct { + Body *PostAuthVerifyTokenJSONRequestBody +} + +type PostAuthVerifyTokenResponseObject interface { + VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error +} + +type PostAuthVerifyToken200JSONResponse struct { + // Error Error message if token is invalid + Error *string `json:"error"` + + // UserId User ID extracted from token if valid + UserId *string `json:"user_id"` + + // Valid True if token is valid + Valid *bool `json:"valid,omitempty"` +} + +func (response PostAuthVerifyToken200JSONResponse) VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(ctx context.Context, request PostAuthSignInRequestObject) (PostAuthSignInResponseObject, error) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(ctx context.Context, request PostAuthVerifyTokenRequestObject) (PostAuthVerifyTokenResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// PostAuthSignIn operation middleware +func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { + var request PostAuthSignInRequestObject + + var body PostAuthSignInJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignIn(ctx, request.(PostAuthSignInRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignIn") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignInResponseObject); ok { + if err := validResponse.VisitPostAuthSignInResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthSignUp operation middleware +func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { + var request PostAuthSignUpRequestObject + + var body PostAuthSignUpJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignUp(ctx, request.(PostAuthSignUpRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignUp") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignUpResponseObject); ok { + if err := validResponse.VisitPostAuthSignUpResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthVerifyToken operation middleware +func (sh *strictHandler) PostAuthVerifyToken(ctx *gin.Context) { + var request PostAuthVerifyTokenRequestObject + + var body PostAuthVerifyTokenJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthVerifyToken(ctx, request.(PostAuthVerifyTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthVerifyToken") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthVerifyTokenResponseObject); ok { + if err := validResponse.VisitPostAuthVerifyTokenResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/auth/auth/auth.gen.go b/auth/auth/auth.gen.go new file mode 100644 index 0000000..12b6622 --- /dev/null +++ b/auth/auth/auth.gen.go @@ -0,0 +1,329 @@ +// Package oapi_auth provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +package oapi_auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" +) + +// PostAuthSignInJSONBody defines parameters for PostAuthSignIn. +type PostAuthSignInJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthSignUpJSONBody defines parameters for PostAuthSignUp. +type PostAuthSignUpJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthVerifyTokenJSONBody defines parameters for PostAuthVerifyToken. +type PostAuthVerifyTokenJSONBody struct { + // Token JWT token to validate + Token string `json:"token"` +} + +// PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. +type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody + +// PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. +type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody + +// PostAuthVerifyTokenJSONRequestBody defines body for PostAuthVerifyToken for application/json ContentType. +type PostAuthVerifyTokenJSONRequestBody PostAuthVerifyTokenJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(c *gin.Context) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(c *gin.Context) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(c *gin.Context) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// PostAuthSignIn operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignIn(c) +} + +// PostAuthSignUp operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignUp(c) +} + +// PostAuthVerifyToken operation middleware +func (siw *ServerInterfaceWrapper) PostAuthVerifyToken(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthVerifyToken(c) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) + router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) + router.POST(options.BaseURL+"/auth/verify-token", wrapper.PostAuthVerifyToken) +} + +type PostAuthSignInRequestObject struct { + Body *PostAuthSignInJSONRequestBody +} + +type PostAuthSignInResponseObject interface { + VisitPostAuthSignInResponse(w http.ResponseWriter) error +} + +type PostAuthSignIn200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + + // Token JWT token to access protected endpoints + Token *string `json:"token"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthSignUpRequestObject struct { + Body *PostAuthSignUpJSONRequestBody +} + +type PostAuthSignUpResponseObject interface { + VisitPostAuthSignUpResponse(w http.ResponseWriter) error +} + +type PostAuthSignUp200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthVerifyTokenRequestObject struct { + Body *PostAuthVerifyTokenJSONRequestBody +} + +type PostAuthVerifyTokenResponseObject interface { + VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error +} + +type PostAuthVerifyToken200JSONResponse struct { + // Error Error message if token is invalid + Error *string `json:"error"` + + // UserId User ID extracted from token if valid + UserId *string `json:"user_id"` + + // Valid True if token is valid + Valid *bool `json:"valid,omitempty"` +} + +func (response PostAuthVerifyToken200JSONResponse) VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(ctx context.Context, request PostAuthSignInRequestObject) (PostAuthSignInResponseObject, error) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(ctx context.Context, request PostAuthVerifyTokenRequestObject) (PostAuthVerifyTokenResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// PostAuthSignIn operation middleware +func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { + var request PostAuthSignInRequestObject + + var body PostAuthSignInJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignIn(ctx, request.(PostAuthSignInRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignIn") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignInResponseObject); ok { + if err := validResponse.VisitPostAuthSignInResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthSignUp operation middleware +func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { + var request PostAuthSignUpRequestObject + + var body PostAuthSignUpJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignUp(ctx, request.(PostAuthSignUpRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignUp") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignUpResponseObject); ok { + if err := validResponse.VisitPostAuthSignUpResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthVerifyToken operation middleware +func (sh *strictHandler) PostAuthVerifyToken(ctx *gin.Context) { + var request PostAuthVerifyTokenRequestObject + + var body PostAuthVerifyTokenJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthVerifyToken(ctx, request.(PostAuthVerifyTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthVerifyToken") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthVerifyTokenResponseObject); ok { + if err := validResponse.VisitPostAuthVerifyTokenResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/auth/oapi-auth-codegen.yaml b/auth/oapi-auth-codegen.yaml new file mode 100644 index 0000000..6792391 --- /dev/null +++ b/auth/oapi-auth-codegen.yaml @@ -0,0 +1,6 @@ +package: auth +generate: + strict-server: true + gin-server: true + models: true +output: auth/auth.gen.go \ No newline at end of file diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml new file mode 100644 index 0000000..7ffc60e --- /dev/null +++ b/auth/openapi-auth.yaml @@ -0,0 +1,112 @@ +openapi: 3.1.0 +info: + title: Auth Service + version: 1.0.0 + +paths: + /auth/sign-up: + post: + summary: Sign up a new user + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [nickname, pass] + properties: + nickname: + type: string + pass: + type: string + format: password + responses: + "200": + description: Sign-up result + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + error: + type: string + nullable: true + user_id: + type: string + nullable: true + + /auth/sign-in: + post: + summary: Sign in a user and return JWT + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [nickname, pass] + properties: + nickname: + type: string + pass: + type: string + format: password + responses: + "200": + description: Sign-in result with JWT + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + error: + type: string + nullable: true + user_id: + type: string + nullable: true + token: + type: string + description: JWT token to access protected endpoints + nullable: true + + /auth/verify-token: + post: + summary: Verify JWT validity + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: + type: string + description: JWT token to validate + responses: + "200": + description: Token validation result + content: + application/json: + schema: + type: object + properties: + valid: + type: boolean + description: True if token is valid + user_id: + type: string + nullable: true + description: User ID extracted from token if valid + error: + type: string + nullable: true + description: Error message if token is invalid \ No newline at end of file diff --git a/go.mod b/go.mod index b7a66f2..4089c02 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,10 @@ go 1.25.0 require ( github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/jackc/pgx/v5 v5.7.6 github.com/oapi-codegen/runtime v1.1.2 github.com/pelletier/go-toml/v2 v2.2.4 - golang.org/x/crypto v0.40.0 ) require ( @@ -38,6 +38,7 @@ require ( github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect diff --git a/go.sum b/go.sum index 1af1a7c..d8c4265 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go new file mode 100644 index 0000000..ca72192 --- /dev/null +++ b/modules/auth/handlers/handlers.go @@ -0,0 +1,108 @@ +package handlers + +import ( + "context" + "fmt" + auth "nyanimedb/auth" + sqlc "nyanimedb/sql" + "strconv" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var secretKey = []byte("my_secret_key") + +func generateToken(userID string) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(time.Hour * 24).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(secretKey) +} + +var UserDb = make(map[string]string) //TEMP + +type Server struct { + db *sqlc.Queries +} + +func NewServer(db *sqlc.Queries) Server { + return Server{db: db} +} + +func parseInt64(s string) (int32, error) { + i, err := strconv.ParseInt(s, 10, 64) + return int32(i), err +} + +func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { + err := "" + success := true + t, _ := generateToken(req.Body.Nickname) + + UserDb[req.Body.Nickname] = req.Body.Pass + + return auth.PostAuthSignIn200JSONResponse{ + Error: &err, + Success: &success, + UserId: &req.Body.Nickname, + Token: &t, + }, nil +} + +func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { + err := "" + success := true + UserDb[req.Body.Nickname] = req.Body.Pass + + return auth.PostAuthSignUp200JSONResponse{ + Error: &err, + Success: &success, + UserId: &req.Body.Nickname, + }, nil +} + +func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { + valid := false + var userID *string + var errStr *string + + token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return secretKey, nil + }) + + if err != nil { + e := err.Error() + errStr = &e + return auth.PostAuthVerifyToken200JSONResponse{ + Valid: &valid, + UserId: userID, + Error: errStr, + }, nil + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + if uid, ok := claims["user_id"].(string); ok { + valid = true + userID = &uid + } else { + e := "user_id not found in token" + errStr = &e + } + } else { + e := "invalid token claims" + errStr = &e + } + + return auth.PostAuthVerifyToken200JSONResponse{ + Valid: &valid, + UserId: userID, + Error: errStr, + }, nil +} diff --git a/modules/auth/main.go b/modules/auth/main.go new file mode 100644 index 0000000..c001e8b --- /dev/null +++ b/modules/auth/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "time" + + auth "nyanimedb/auth" + handlers "nyanimedb/modules/auth/handlers" + sqlc "nyanimedb/sql" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +var AppConfig Config + +func main() { + r := gin.Default() + + var queries *sqlc.Queries = nil + + server := handlers.NewServer(queries) + + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + auth.RegisterHandlers(r, auth.NewStrictHandler( + server, + []auth.StrictMiddlewareFunc{}, + )) + + r.Run(":8082") +} diff --git a/modules/auth/types.go b/modules/auth/types.go new file mode 100644 index 0000000..038b179 --- /dev/null +++ b/modules/auth/types.go @@ -0,0 +1,6 @@ +package main + +type Config struct { + JwtPrivateKey string + LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` +} From f49fad2307e17b4b704c833c8d14dd958adc2a85 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 15 Nov 2025 03:01:38 +0300 Subject: [PATCH 042/224] fix: tmp commeneted method --- api/api.gen.go | 77 ------------------------------- api/openapi.yaml | 44 +++++++++--------- modules/backend/handlers/users.go | 2 +- 3 files changed, 23 insertions(+), 100 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index a235db8..417554a 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -103,9 +103,6 @@ type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// PostUsersJSONRequestBody defines body for PostUsers for application/json ContentType. -type PostUsersJSONRequestBody = User - // Getter for additional properties for Title. Returns the specified // element and whether it was found func (a Title) Get(fieldName string) (value interface{}, found bool) { @@ -344,9 +341,6 @@ type ServerInterface interface { // Get titles // (GET /title) GetTitle(c *gin.Context, params GetTitleParams) - // Add new user - // (POST /users) - PostUsers(c *gin.Context) // Get user info // (GET /users/{user_id}) GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) @@ -443,19 +437,6 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { siw.Handler.GetTitle(c, params) } -// PostUsers operation middleware -func (siw *ServerInterfaceWrapper) PostUsers(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostUsers(c) -} - // GetUsersUserId operation middleware func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { @@ -519,7 +500,6 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options } router.GET(options.BaseURL+"/title", wrapper.GetTitle) - router.POST(options.BaseURL+"/users", wrapper.PostUsers) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) } @@ -564,27 +544,6 @@ func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) return nil } -type PostUsersRequestObject struct { - Body *PostUsersJSONRequestBody -} - -type PostUsersResponseObject interface { - VisitPostUsersResponse(w http.ResponseWriter) error -} - -type PostUsers200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` - UserJson *User `json:"user_json,omitempty"` -} - -func (response PostUsers200JSONResponse) VisitPostUsersResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - type GetUsersUserIdRequestObject struct { UserId string `json:"user_id"` Params GetUsersUserIdParams @@ -632,9 +591,6 @@ type StrictServerInterface interface { // Get titles // (GET /title) GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) - // Add new user - // (POST /users) - PostUsers(ctx context.Context, request PostUsersRequestObject) (PostUsersResponseObject, error) // Get user info // (GET /users/{user_id}) GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) @@ -679,39 +635,6 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { } } -// PostUsers operation middleware -func (sh *strictHandler) PostUsers(ctx *gin.Context) { - var request PostUsersRequestObject - - var body PostUsersJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostUsers(ctx, request.(PostUsersRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostUsers") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostUsersResponseObject); ok { - if err := validResponse.VisitPostUsersResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - // GetUsersUserId operation middleware func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { var request GetUsersUserIdRequestObject diff --git a/api/openapi.yaml b/api/openapi.yaml index 4187ebb..bfd9aae 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -243,28 +243,28 @@ paths: # items: # $ref: '#/components/schemas/User' - post: - summary: Add new user - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/User' - responses: - '200': - description: Add result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string - user_json: - $ref: '#/components/schemas/User' + # post: + # summary: Add new user + # requestBody: + # required: true + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/User' + # responses: + # '200': + # description: Add result + # content: + # application/json: + # schema: + # type: object + # properties: + # success: + # type: boolean + # error: + # type: string + # user_json: + # $ref: '#/components/schemas/User' # /users/{user_id}/titles: # get: diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 3da61de..9e59261 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -25,7 +25,7 @@ import ( func mapUser(u sqlc.GetUserByIDRow) oapi.User { return oapi.User{ AvatarId: u.AvatarID, - CreationDate: u.CreationDate, + CreationDate: &u.CreationDate, DispName: u.DispName, Id: &u.ID, Mail: (*types.Email)(u.Mail), From 2edf96571b0edcbaf4d8e03a456b41e6a79163d0 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 15 Nov 2025 18:20:27 +0300 Subject: [PATCH 043/224] feat: field studio_name added to title in openapi --- api/api.gen.go | 93 ++++++------------------------- api/openapi.yaml | 46 +++++++-------- modules/backend/handlers/users.go | 2 +- 3 files changed, 41 insertions(+), 100 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index a235db8..9adc19d 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -50,6 +50,7 @@ type Title struct { ReleaseSeason *ReleaseSeason `json:"release_season,omitempty"` ReleaseYear *int32 `json:"release_year,omitempty"` StudioId *int64 `json:"studio_id,omitempty"` + StudioName *string `json:"studio_name,omitempty"` // TitleNames Localized titles. Key = language (ISO 639-1), value = list of names TitleNames *map[string][]string `json:"title_names,omitempty"` @@ -103,9 +104,6 @@ type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// PostUsersJSONRequestBody defines body for PostUsers for application/json ContentType. -type PostUsersJSONRequestBody = User - // Getter for additional properties for Title. Returns the specified // element and whether it was found func (a Title) Get(fieldName string) (value interface{}, found bool) { @@ -211,6 +209,14 @@ func (a *Title) UnmarshalJSON(b []byte) error { delete(object, "studio_id") } + if raw, found := object["studio_name"]; found { + err = json.Unmarshal(raw, &a.StudioName) + if err != nil { + return fmt.Errorf("error reading 'studio_name': %w", err) + } + delete(object, "studio_name") + } + if raw, found := object["title_names"]; found { err = json.Unmarshal(raw, &a.TitleNames) if err != nil { @@ -316,6 +322,13 @@ func (a Title) MarshalJSON() ([]byte, error) { } } + if a.StudioName != nil { + object["studio_name"], err = json.Marshal(a.StudioName) + if err != nil { + return nil, fmt.Errorf("error marshaling 'studio_name': %w", err) + } + } + if a.TitleNames != nil { object["title_names"], err = json.Marshal(a.TitleNames) if err != nil { @@ -344,9 +357,6 @@ type ServerInterface interface { // Get titles // (GET /title) GetTitle(c *gin.Context, params GetTitleParams) - // Add new user - // (POST /users) - PostUsers(c *gin.Context) // Get user info // (GET /users/{user_id}) GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) @@ -443,19 +453,6 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { siw.Handler.GetTitle(c, params) } -// PostUsers operation middleware -func (siw *ServerInterfaceWrapper) PostUsers(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostUsers(c) -} - // GetUsersUserId operation middleware func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { @@ -519,7 +516,6 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options } router.GET(options.BaseURL+"/title", wrapper.GetTitle) - router.POST(options.BaseURL+"/users", wrapper.PostUsers) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) } @@ -564,27 +560,6 @@ func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) return nil } -type PostUsersRequestObject struct { - Body *PostUsersJSONRequestBody -} - -type PostUsersResponseObject interface { - VisitPostUsersResponse(w http.ResponseWriter) error -} - -type PostUsers200JSONResponse struct { - Error *string `json:"error,omitempty"` - Success *bool `json:"success,omitempty"` - UserJson *User `json:"user_json,omitempty"` -} - -func (response PostUsers200JSONResponse) VisitPostUsersResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - type GetUsersUserIdRequestObject struct { UserId string `json:"user_id"` Params GetUsersUserIdParams @@ -632,9 +607,6 @@ type StrictServerInterface interface { // Get titles // (GET /title) GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) - // Add new user - // (POST /users) - PostUsers(ctx context.Context, request PostUsersRequestObject) (PostUsersResponseObject, error) // Get user info // (GET /users/{user_id}) GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) @@ -679,39 +651,6 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { } } -// PostUsers operation middleware -func (sh *strictHandler) PostUsers(ctx *gin.Context) { - var request PostUsersRequestObject - - var body PostUsersJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostUsers(ctx, request.(PostUsersRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostUsers") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostUsersResponseObject); ok { - if err := validResponse.VisitPostUsersResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - // GetUsersUserId operation middleware func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { var request GetUsersUserIdRequestObject diff --git a/api/openapi.yaml b/api/openapi.yaml index 4187ebb..c899595 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -243,28 +243,28 @@ paths: # items: # $ref: '#/components/schemas/User' - post: - summary: Add new user - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/User' - responses: - '200': - description: Add result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - error: - type: string - user_json: - $ref: '#/components/schemas/User' + # post: + # summary: Add new user + # requestBody: + # required: true + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/User' + # responses: + # '200': + # description: Add result + # content: + # application/json: + # schema: + # type: object + # properties: + # success: + # type: boolean + # error: + # type: string + # user_json: + # $ref: '#/components/schemas/User' # /users/{user_id}/titles: # get: @@ -608,6 +608,8 @@ components: studio_id: type: integer format: int64 + studio_name: + type: string poster_id: type: integer format: int64 diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 3da61de..9e59261 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -25,7 +25,7 @@ import ( func mapUser(u sqlc.GetUserByIDRow) oapi.User { return oapi.User{ AvatarId: u.AvatarID, - CreationDate: u.CreationDate, + CreationDate: &u.CreationDate, DispName: u.DispName, Id: &u.ID, Mail: (*types.Email)(u.Mail), From 3cca6ee16821dda7be772056f0ba3f62beab30c3 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 15 Nov 2025 18:56:46 +0300 Subject: [PATCH 044/224] feat: statements for studios table added --- modules/backend/queries.sql | 14 ++++++++++++ sql/queries.sql.go | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index b90ec6a..29c941e 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -13,6 +13,20 @@ SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date FROM users WHERE id = $1; + +-- name: GetStudioByID :one +SELECT * +FROM studios +WHERE id = sqlc.arg('studio_id')::int; + +-- name: InsertStudio :one +INSERT INTO studios (studio_name, illust_id, studio_desc) +VALUES ( + sqlc.arg('studio_name')::text, + sqlc.narg('illust_id')::bigint, + sqlc.narg('studio_desc')::text) +RETURNING id, studio_name, illust_id, studio_desc; + -- -- name: ListUsers :many -- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -- FROM users diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 2ef4178..b3ee9f4 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -41,6 +41,24 @@ func (q *Queries) GetImageByID(ctx context.Context, id int64) (Image, error) { return i, err } +const getStudioByID = `-- name: GetStudioByID :one +SELECT id, studio_name, illust_id, studio_desc +FROM studios +WHERE id = $1::int +` + +func (q *Queries) GetStudioByID(ctx context.Context, studioID int32) (Studio, error) { + row := q.db.QueryRow(ctx, getStudioByID, studioID) + var i Studio + err := row.Scan( + &i.ID, + &i.StudioName, + &i.IllustID, + &i.StudioDesc, + ) + return i, err +} + const getUserByID = `-- name: GetUserByID :one SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date FROM users @@ -72,6 +90,33 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er return i, err } +const insertStudio = `-- name: InsertStudio :one +INSERT INTO studios (studio_name, illust_id, studio_desc) +VALUES ( + $1::text, + $2::bigint, + $3::text) +RETURNING id, studio_name, illust_id, studio_desc +` + +type InsertStudioParams struct { + StudioName string `json:"studio_name"` + IllustID *int64 `json:"illust_id"` + StudioDesc *string `json:"studio_desc"` +} + +func (q *Queries) InsertStudio(ctx context.Context, arg InsertStudioParams) (Studio, error) { + row := q.db.QueryRow(ctx, insertStudio, arg.StudioName, arg.IllustID, arg.StudioDesc) + var i Studio + err := row.Scan( + &i.ID, + &i.StudioName, + &i.IllustID, + &i.StudioDesc, + ) + return i, err +} + const searchTitles = `-- name: SearchTitles :many From e6dc27fd55abe64c96ae51e746c2c31625d9982c Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 15 Nov 2025 19:19:39 +0300 Subject: [PATCH 045/224] feat: statements for tags added --- modules/backend/queries.sql | 20 ++++++++++++ sql/queries.sql.go | 62 +++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 29c941e..a4c0bb9 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -27,6 +27,26 @@ VALUES ( sqlc.narg('studio_desc')::text) RETURNING id, studio_name, illust_id, studio_desc; +-- name: GetTitleTags :many +SELECT + tag_names +FROM tags as g +JOIN title_tags as t ON(t.tag_id = g.id) +WHERE t.title_id = sqlc.arg('title_id')::bigint; + +-- name: InsertTitleTags :one +INSERT INTO title_tags (title_id, tag_id) +VALUES ( + sqlc.arg('title_id')::bigint, + sqlc.arg('tag_id')::bigint) +RETURNING title_id, tag_id; + +-- name: InsertTag :one +INSERT INTO tags (tag_names) +VALUES ( + sqlc.arg('tag_names')::jsonb) +RETURNING id, tag_names; + -- -- name: ListUsers :many -- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -- FROM users diff --git a/sql/queries.sql.go b/sql/queries.sql.go index b3ee9f4..a73889c 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -59,6 +59,34 @@ func (q *Queries) GetStudioByID(ctx context.Context, studioID int32) (Studio, er return i, err } +const getTitleTags = `-- name: GetTitleTags :many +SELECT + tag_names +FROM tags as g +JOIN title_tags as t ON(t.tag_id = g.id) +WHERE t.title_id = $1::bigint +` + +func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([][]byte, error) { + rows, err := q.db.Query(ctx, getTitleTags, titleID) + if err != nil { + return nil, err + } + defer rows.Close() + var items [][]byte + for rows.Next() { + var tag_names []byte + if err := rows.Scan(&tag_names); err != nil { + return nil, err + } + items = append(items, tag_names) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getUserByID = `-- name: GetUserByID :one SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date FROM users @@ -117,6 +145,40 @@ func (q *Queries) InsertStudio(ctx context.Context, arg InsertStudioParams) (Stu return i, err } +const insertTag = `-- name: InsertTag :one +INSERT INTO tags (tag_names) +VALUES ( + $1::jsonb) +RETURNING id, tag_names +` + +func (q *Queries) InsertTag(ctx context.Context, tagNames []byte) (Tag, error) { + row := q.db.QueryRow(ctx, insertTag, tagNames) + var i Tag + err := row.Scan(&i.ID, &i.TagNames) + return i, err +} + +const insertTitleTags = `-- name: InsertTitleTags :one +INSERT INTO title_tags (title_id, tag_id) +VALUES ( + $1::bigint, + $2::bigint) +RETURNING title_id, tag_id +` + +type InsertTitleTagsParams struct { + TitleID int64 `json:"title_id"` + TagID int64 `json:"tag_id"` +} + +func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams) (TitleTag, error) { + row := q.db.QueryRow(ctx, insertTitleTags, arg.TitleID, arg.TagID) + var i TitleTag + err := row.Scan(&i.TitleID, &i.TagID) + return i, err +} + const searchTitles = `-- name: SearchTitles :many From 4949a3c25faeb933e6c92ae9938001adf7fefab8 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 15 Nov 2025 23:00:40 +0300 Subject: [PATCH 046/224] refactor: new types --- api/api.gen.go | 4 ++-- api/openapi.yaml | 2 ++ modules/backend/handlers/titles.go | 27 +++++++++++++++++---------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index 9adc19d..ebb4c01 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -94,8 +94,8 @@ type GetTitleParams struct { Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int32 `form:"offset,omitempty" json:"offset,omitempty"` Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } diff --git a/api/openapi.yaml b/api/openapi.yaml index c899595..200cc47 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -35,11 +35,13 @@ paths: name: limit schema: type: integer + format: int32 default: 10 - in: query name: offset schema: type: integer + format: int32 default: 0 - in: query name: fields diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 85f9f45..99217ca 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -57,21 +57,25 @@ func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { return &t, nil } +type TileNames *map[string][]string + // unmarshall jsonb to map[string][]string -func jsonb2map4names(b []byte) (*map[string][]string, error) { - var t map[string][]string - if err := json.Unmarshal(b, &t); err != nil { +func jsonb2TitleNames(b []byte) (TileNames, error) { + var t TileNames + if err := json.Unmarshal(b, t); err != nil { return nil, fmt.Errorf("invalid title_names JSON for title: %w", err) } - return &t, nil + return t, nil } -func jsonb2map4len(b []byte) (*map[string]float64, error) { - var t map[string]float64 - if err := json.Unmarshal(b, &t); err != nil { +type EpisodeLens *map[string]float64 + +func jsonb2EpisodeLens(b []byte) (EpisodeLens, error) { + var t EpisodeLens + if err := json.Unmarshal(b, t); err != nil { return nil, fmt.Errorf("invalid episodes_len JSON for title: %w", err) } - return &t, nil + return t, nil } func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { @@ -95,6 +99,8 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, + Offset: request.Params.Offset, + Limit: request.Params.Limit, }) if err != nil { return oapi.GetTitle500Response{}, nil @@ -104,12 +110,12 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject } for _, title := range titles { - title_names, err := jsonb2map4names(title.TitleNames) + title_names, err := jsonb2TitleNames(title.TitleNames) if err != nil { log.Errorf("%v", err) return oapi.GetTitle500Response{}, err } - episodes_lens, err := jsonb2map4len(title.EpisodesLen) + episodes_lens, err := jsonb2EpisodeLens(title.EpisodesLen) if err != nil { log.Errorf("%v", err) return oapi.GetTitle500Response{}, err @@ -122,6 +128,7 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject ReleaseSeason: (*oapi.ReleaseSeason)(title.ReleaseSeason), ReleaseYear: title.ReleaseYear, StudioId: &title.StudioID, + // StudioName: , TitleNames: title_names, TitleStatus: (*oapi.TitleStatus)(&title.TitleStatus), EpisodesAired: title.EpisodesAired, From e18f4a44c3acf4b0fc36889690e853dd7b416c0e Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 16 Nov 2025 01:47:11 +0300 Subject: [PATCH 047/224] feat: more fields for titles and refactored schemas --- api/api.gen.go | 71 ++++++++++++++++++++++++++++-------------------- api/openapi.yaml | 71 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 101 insertions(+), 41 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index ebb4c01..c3ef4da 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -34,6 +34,21 @@ const ( // ReleaseSeason Title release season type ReleaseSeason string +// Studio defines model for Studio. +type Studio struct { + Description *string `json:"description,omitempty"` + Id *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + PosterId *int64 `json:"poster_id,omitempty"` + PosterPath *string `json:"poster_path,omitempty"` +} + +// Tag A localized tag: keys are language codes (ISO 639-1), values are tag names +type Tag map[string]string + +// Tags Array of localized tags +type Tags = []Tag + // Title defines model for Title. type Title struct { EpisodesAired *int32 `json:"episodes_aired,omitempty"` @@ -41,7 +56,7 @@ type Title struct { EpisodesLen *map[string]float64 `json:"episodes_len,omitempty"` // Id Unique title ID (primary key) - Id *int64 `json:"id,omitempty"` + Id int64 `json:"id"` PosterId *int64 `json:"poster_id,omitempty"` Rating *float64 `json:"rating,omitempty"` RatingCount *int32 `json:"rating_count,omitempty"` @@ -49,11 +64,13 @@ type Title struct { // ReleaseSeason Title release season ReleaseSeason *ReleaseSeason `json:"release_season,omitempty"` ReleaseYear *int32 `json:"release_year,omitempty"` - StudioId *int64 `json:"studio_id,omitempty"` - StudioName *string `json:"studio_name,omitempty"` + Studio *Studio `json:"studio,omitempty"` + + // Tags Array of localized tags + Tags Tags `json:"tags"` // TitleNames Localized titles. Key = language (ISO 639-1), value = list of names - TitleNames *map[string][]string `json:"title_names,omitempty"` + TitleNames map[string][]string `json:"title_names"` // TitleStatus Title status TitleStatus *TitleStatus `json:"title_status,omitempty"` @@ -201,20 +218,20 @@ func (a *Title) UnmarshalJSON(b []byte) error { delete(object, "release_year") } - if raw, found := object["studio_id"]; found { - err = json.Unmarshal(raw, &a.StudioId) + if raw, found := object["studio"]; found { + err = json.Unmarshal(raw, &a.Studio) if err != nil { - return fmt.Errorf("error reading 'studio_id': %w", err) + return fmt.Errorf("error reading 'studio': %w", err) } - delete(object, "studio_id") + delete(object, "studio") } - if raw, found := object["studio_name"]; found { - err = json.Unmarshal(raw, &a.StudioName) + if raw, found := object["tags"]; found { + err = json.Unmarshal(raw, &a.Tags) if err != nil { - return fmt.Errorf("error reading 'studio_name': %w", err) + return fmt.Errorf("error reading 'tags': %w", err) } - delete(object, "studio_name") + delete(object, "tags") } if raw, found := object["title_names"]; found { @@ -273,11 +290,9 @@ func (a Title) MarshalJSON() ([]byte, error) { } } - if a.Id != nil { - object["id"], err = json.Marshal(a.Id) - if err != nil { - return nil, fmt.Errorf("error marshaling 'id': %w", err) - } + object["id"], err = json.Marshal(a.Id) + if err != nil { + return nil, fmt.Errorf("error marshaling 'id': %w", err) } if a.PosterId != nil { @@ -315,25 +330,21 @@ func (a Title) MarshalJSON() ([]byte, error) { } } - if a.StudioId != nil { - object["studio_id"], err = json.Marshal(a.StudioId) + if a.Studio != nil { + object["studio"], err = json.Marshal(a.Studio) if err != nil { - return nil, fmt.Errorf("error marshaling 'studio_id': %w", err) + return nil, fmt.Errorf("error marshaling 'studio': %w", err) } } - if a.StudioName != nil { - object["studio_name"], err = json.Marshal(a.StudioName) - if err != nil { - return nil, fmt.Errorf("error marshaling 'studio_name': %w", err) - } + object["tags"], err = json.Marshal(a.Tags) + if err != nil { + return nil, fmt.Errorf("error marshaling 'tags': %w", err) } - if a.TitleNames != nil { - object["title_names"], err = json.Marshal(a.TitleNames) - if err != nil { - return nil, fmt.Errorf("error marshaling 'title_names': %w", err) - } + object["title_names"], err = json.Marshal(a.TitleNames) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title_names': %w", err) } if a.TitleStatus != nil { diff --git a/api/openapi.yaml b/api/openapi.yaml index 200cc47..0356898 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2,8 +2,10 @@ openapi: 3.1.1 info: title: Titles, Users, Reviews, Tags, and Media API version: 1.0.0 + servers: - url: /api/v1 + paths: /title: get: @@ -63,6 +65,7 @@ paths: description: Request params are not correct '500': description: Unknown server error + # /title/{title_id}: # get: # summary: Get title description @@ -569,6 +572,7 @@ components: - finished - ongoing - planned + ReleaseSeason: type: string description: Title release season @@ -577,6 +581,7 @@ components: - spring - summer - fall + UserTitleStatus: type: string description: User's title status @@ -585,8 +590,57 @@ components: - planned - dropped - in-progress + + Review: + type: object + additionalProperties: true + + Tag: + type: object + description: "A localized tag: keys are language codes (ISO 639-1), values are tag names" + additionalProperties: + type: string + example: + en: "Shojo" + ru: "Сёдзё" + ja: "少女" + + Tags: + type: array + description: "Array of localized tags" + items: + $ref: '#/components/schemas/Tag' + example: + - en: "Shojo" + ru: "Сёдзё" + ja: "少女" + - en: "Shounen" + ru: "Сёнен" + ja: "少年" + + Studio: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + poster_id: + type: integer + format: int64 + poster_path: + type: string + description: + type: string + + Title: type: object + required: + - id + - title_names + - tags properties: id: type: integer @@ -607,11 +661,10 @@ components: en: ["Attack on Titan", "AoT"] ru: ["Атака титанов", "Титаны"] ja: ["進撃の巨人"] - studio_id: - type: integer - format: int64 - studio_name: - type: string + studio: + $ref: '#/components/schemas/Studio' + tags: + $ref: '#/components/schemas/Tags' poster_id: type: integer format: int64 @@ -640,6 +693,7 @@ components: type: number format: double additionalProperties: true + User: type: object properties: @@ -683,12 +737,7 @@ components: - user_id - nickname # - creation_date + UserTitle: type: object additionalProperties: true - Review: - type: object - additionalProperties: true - Tag: - type: object - additionalProperties: true From d1180a426ff4f043baaaec58afd894b4c2f5ffb4 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 16 Nov 2025 02:14:38 +0300 Subject: [PATCH 048/224] feat: new schema for images --- api/api.gen.go | 23 +++++++++++++++-------- api/openapi.yaml | 16 +++++++++++++--- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index c3ef4da..b3c51f6 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -31,6 +31,13 @@ const ( Planned TitleStatus = "planned" ) +// Image defines model for Image. +type Image struct { + Id *int64 `json:"id,omitempty"` + ImagePath *string `json:"image_path,omitempty"` + StorageType *string `json:"storage_type,omitempty"` +} + // ReleaseSeason Title release season type ReleaseSeason string @@ -57,7 +64,7 @@ type Title struct { // Id Unique title ID (primary key) Id int64 `json:"id"` - PosterId *int64 `json:"poster_id,omitempty"` + Poster *Image `json:"poster,omitempty"` Rating *float64 `json:"rating,omitempty"` RatingCount *int32 `json:"rating_count,omitempty"` @@ -178,12 +185,12 @@ func (a *Title) UnmarshalJSON(b []byte) error { delete(object, "id") } - if raw, found := object["poster_id"]; found { - err = json.Unmarshal(raw, &a.PosterId) + if raw, found := object["poster"]; found { + err = json.Unmarshal(raw, &a.Poster) if err != nil { - return fmt.Errorf("error reading 'poster_id': %w", err) + return fmt.Errorf("error reading 'poster': %w", err) } - delete(object, "poster_id") + delete(object, "poster") } if raw, found := object["rating"]; found { @@ -295,10 +302,10 @@ func (a Title) MarshalJSON() ([]byte, error) { return nil, fmt.Errorf("error marshaling 'id': %w", err) } - if a.PosterId != nil { - object["poster_id"], err = json.Marshal(a.PosterId) + if a.Poster != nil { + object["poster"], err = json.Marshal(a.Poster) if err != nil { - return nil, fmt.Errorf("error marshaling 'poster_id': %w", err) + return nil, fmt.Errorf("error marshaling 'poster': %w", err) } } diff --git a/api/openapi.yaml b/api/openapi.yaml index 0356898..d523f06 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -565,6 +565,17 @@ paths: components: schemas: + Image: + type: object + properties: + id: + type: integer + format: int64 + storage_type: + type: string + image_path: + type: string + TitleStatus: type: string description: Title status @@ -665,9 +676,8 @@ components: $ref: '#/components/schemas/Studio' tags: $ref: '#/components/schemas/Tags' - poster_id: - type: integer - format: int64 + poster: + $ref: '#/components/schemas/Image' title_status: $ref: '#/components/schemas/TitleStatus' rating: From 13a283ae8da0e138c1723d53517d1c9c6f01cc32 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 16 Nov 2025 02:38:36 +0300 Subject: [PATCH 049/224] feat: GetTitles now returns all the field needed --- api/api.gen.go | 3 +- api/openapi.yaml | 7 +- modules/backend/handlers/titles.go | 147 +++++++++++++++++++++-------- modules/backend/queries.sql | 4 +- sql/migrations/000001_init.up.sql | 1 + sql/queries.sql.go | 8 +- 6 files changed, 119 insertions(+), 51 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index b3c51f6..0057c22 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -46,8 +46,7 @@ type Studio struct { Description *string `json:"description,omitempty"` Id *int64 `json:"id,omitempty"` Name *string `json:"name,omitempty"` - PosterId *int64 `json:"poster_id,omitempty"` - PosterPath *string `json:"poster_path,omitempty"` + Poster *Image `json:"poster,omitempty"` } // Tag A localized tag: keys are language codes (ISO 639-1), values are tag names diff --git a/api/openapi.yaml b/api/openapi.yaml index d523f06..4628c6d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -637,11 +637,8 @@ components: format: int64 name: type: string - poster_id: - type: integer - format: int64 - poster_path: - type: string + poster: + $ref: '#/components/schemas/Image' description: type: string diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 99217ca..182f450 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -25,13 +25,14 @@ func TitleStatus2Sqlc(s *oapi.TitleStatus) (*sqlc.TitleStatusT, error) { return nil, nil } var t sqlc.TitleStatusT - if *s == "finished" { - t = "finished" - } else if *s == "ongoing" { - t = "ongoing" - } else if *s == "planned" { - t = "planned" - } else { + switch *s { + case oapi.Finished: + t = sqlc.TitleStatusTFinished + case oapi.Ongoing: + t = sqlc.TitleStatusTOngoing + case oapi.Planned: + t = sqlc.TitleStatusTPlanned + default: return nil, fmt.Errorf("unexpected tittle status: %s", *s) } return &t, nil @@ -41,41 +42,86 @@ func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { if s == nil { return nil, nil } - //TODO var t sqlc.ReleaseSeasonT - if *s == oapi.Winter { + switch *s { + case oapi.Winter: t = sqlc.ReleaseSeasonTWinter - } else if *s == "spring" { - t = "spring" - } else if *s == "summer" { - t = "summer" - } else if *s == "fall" { - t = "fall" - } else { + case oapi.Spring: + t = sqlc.ReleaseSeasonTSpring + case oapi.Summer: + t = sqlc.ReleaseSeasonTSummer + case oapi.Fall: + t = sqlc.ReleaseSeasonTFall + default: return nil, fmt.Errorf("unexpected release season: %s", *s) } return &t, nil } -type TileNames *map[string][]string +type TitleNames *map[string][]string +type EpisodeLens *map[string]float64 +type TagNames []map[string]string -// unmarshall jsonb to map[string][]string -func jsonb2TitleNames(b []byte) (TileNames, error) { - var t TileNames - if err := json.Unmarshal(b, t); err != nil { - return nil, fmt.Errorf("invalid title_names JSON for title: %w", err) +func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, error) { + sqlc_title_tags, err := s.db.GetTitleTags(ctx, id) + if err != nil { + log.Errorf("%v", err) + return nil, err } - return t, nil + + var oapi_tag_names oapi.Tags + for _, title_tag := range sqlc_title_tags { + var oapi_tag_name map[string]string + err = json.Unmarshal(title_tag, &oapi_tag_name) + if err != nil { + log.Errorf("invalid JSON for %s: %v", "TagNames", err) + return nil, err + } + oapi_tag_names = append(oapi_tag_names, oapi_tag_name) + } + + return oapi_tag_names, nil } -type EpisodeLens *map[string]float64 +func (s Server) GetImage(ctx context.Context, id int64) (oapi.Image, error) { -func jsonb2EpisodeLens(b []byte) (EpisodeLens, error) { - var t EpisodeLens - if err := json.Unmarshal(b, t); err != nil { - return nil, fmt.Errorf("invalid episodes_len JSON for title: %w", err) + var oapi_image oapi.Image + + sqlc_image, err := s.db.GetImageByID(ctx, id) + if err != nil { + log.Errorf("%v", err) + return oapi_image, err } - return t, nil + //can cast and dont use brain cause all this fiels required + oapi_image.Id = &sqlc_image.ID + oapi_image.ImagePath = &sqlc_image.ImagePath + oapi_image.StorageType = (*string)(&sqlc_image.StorageType) + + return oapi_image, nil +} + +func (s Server) GetStudio(ctx context.Context, id int64) (oapi.Studio, error) { + + var oapi_studio oapi.Studio + + sqlc_studio, err := s.db.GetStudioByID(ctx, id) + if err != nil { + log.Errorf("%v", err) + return oapi_studio, err + } + + oapi_studio.Id = &sqlc_studio.ID + oapi_studio.Name = sqlc_studio.StudioName + oapi_studio.Description = sqlc_studio.StudioDesc + + oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) + if err != nil { + log.Errorf("%v", err) + return oapi_studio, err + } + oapi_studio.Poster = &oapi_illust + + return oapi_studio, nil } func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { @@ -103,6 +149,7 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject Limit: request.Params.Limit, }) if err != nil { + log.Errorf("%v", err) return oapi.GetTitle500Response{}, nil } if len(titles) == 0 { @@ -110,26 +157,50 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject } for _, title := range titles { - title_names, err := jsonb2TitleNames(title.TitleNames) + + var title_names TitleNames + err := json.Unmarshal(title.TitleNames, &title_names) if err != nil { - log.Errorf("%v", err) + log.Errorf("invalid JSON for %s: %v", "TitleNames", err) return oapi.GetTitle500Response{}, err } - episodes_lens, err := jsonb2EpisodeLens(title.EpisodesLen) + + var episodes_lens EpisodeLens + err = json.Unmarshal(title.EpisodesLen, &episodes_lens) if err != nil { - log.Errorf("%v", err) + log.Errorf("invalid JSON for %s: %v", "EpisodeLens", err) return oapi.GetTitle500Response{}, err } + + oapi_tag_names, err := s.GetTagsByTitleId(ctx, title.ID) + if err != nil { + log.Errorf("error while getting tags %v", err) + return oapi.GetTitle500Response{}, err + } + + oapi_image, err := s.GetImage(ctx, *title.PosterID) + if err != nil { + log.Errorf("error while getting image %v", err) + return oapi.GetTitle500Response{}, err + } + + oapi_studio, err := s.GetStudio(ctx, title.StudioID) + if err != nil { + log.Errorf("error while getting studio %v", err) + return oapi.GetTitle500Response{}, err + } + t := oapi.Title{ - Id: &title.ID, - PosterId: title.PosterID, + + Id: title.ID, + Poster: &oapi_image, Rating: title.Rating, RatingCount: title.RatingCount, ReleaseSeason: (*oapi.ReleaseSeason)(title.ReleaseSeason), ReleaseYear: title.ReleaseYear, - StudioId: &title.StudioID, - // StudioName: , - TitleNames: title_names, + Studio: &oapi_studio, + Tags: oapi_tag_names, + TitleNames: *title_names, TitleStatus: (*oapi.TitleStatus)(&title.TitleStatus), EpisodesAired: title.EpisodesAired, EpisodesAll: title.EpisodesAll, diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index a4c0bb9..7717f9f 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -1,7 +1,7 @@ -- name: GetImageByID :one SELECT id, storage_type, image_path FROM images -WHERE id = $1; +WHERE id = sqlc.arg('illust_id'); -- name: CreateImage :one INSERT INTO images (storage_type, image_path) @@ -17,7 +17,7 @@ WHERE id = $1; -- name: GetStudioByID :one SELECT * FROM studios -WHERE id = sqlc.arg('studio_id')::int; +WHERE id = sqlc.arg('studio_id')::bigint; -- name: InsertStudio :one INSERT INTO studios (studio_name, illust_id, studio_desc) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index c325dc8..669143a 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -14,6 +14,7 @@ CREATE TABLE providers ( CREATE TABLE tags ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + -- example: { "ru": "Сёдзё", "en": "Shojo", "jp": "少女"} tag_names jsonb NOT NULL ); diff --git a/sql/queries.sql.go b/sql/queries.sql.go index a73889c..865ec73 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -34,8 +34,8 @@ FROM images WHERE id = $1 ` -func (q *Queries) GetImageByID(ctx context.Context, id int64) (Image, error) { - row := q.db.QueryRow(ctx, getImageByID, id) +func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, error) { + row := q.db.QueryRow(ctx, getImageByID, illustID) var i Image err := row.Scan(&i.ID, &i.StorageType, &i.ImagePath) return i, err @@ -44,10 +44,10 @@ func (q *Queries) GetImageByID(ctx context.Context, id int64) (Image, error) { const getStudioByID = `-- name: GetStudioByID :one SELECT id, studio_name, illust_id, studio_desc FROM studios -WHERE id = $1::int +WHERE id = $1::bigint ` -func (q *Queries) GetStudioByID(ctx context.Context, studioID int32) (Studio, error) { +func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, error) { row := q.db.QueryRow(ctx, getStudioByID, studioID) var i Studio err := row.Scan( From cefbbec1dc2fa2ad1bcc7dc711fe2ddb16f7eb56 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 16 Nov 2025 02:43:28 +0300 Subject: [PATCH 050/224] feat: wrote query to get one title --- modules/backend/queries.sql | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 7717f9f..f58d33c 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -72,6 +72,11 @@ RETURNING id, tag_names; -- DELETE FROM users -- WHERE user_id = $1; +--name: GetTitleByID :one +SELECT * +FROM titles +WHERE id = sqlc.arg("title_id")::bigint; + -- name: SearchTitles :many SELECT * From 47989ab10da73e351f9550dbae328698c38bc68e Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 16 Nov 2025 03:38:51 +0300 Subject: [PATCH 051/224] feat: /titles/{id} endpoint implemented --- api/api.gen.go | 117 +++++++++++++++++++++++ api/openapi.yaml | 51 +++++----- modules/backend/handlers/titles.go | 144 ++++++++++++++++------------- modules/backend/queries.sql | 4 +- sql/queries.sql.go | 81 ++++++++++------ 5 files changed, 283 insertions(+), 114 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index 0057c22..3f4f5dd 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -122,6 +122,11 @@ type GetTitleParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// GetTitleTitleIdParams defines parameters for GetTitleTitleId. +type GetTitleTitleIdParams struct { + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + // GetUsersUserIdParams defines parameters for GetUsersUserId. type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` @@ -374,6 +379,9 @@ type ServerInterface interface { // Get titles // (GET /title) GetTitle(c *gin.Context, params GetTitleParams) + // Get title description + // (GET /title/{title_id}) + GetTitleTitleId(c *gin.Context, titleId int64, params GetTitleTitleIdParams) // Get user info // (GET /users/{user_id}) GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) @@ -470,6 +478,41 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { siw.Handler.GetTitle(c, params) } +// GetTitleTitleId operation middleware +func (siw *ServerInterfaceWrapper) GetTitleTitleId(c *gin.Context) { + + var err error + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetTitleTitleIdParams + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetTitleTitleId(c, titleId, params) +} + // GetUsersUserId operation middleware func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { @@ -533,6 +576,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options } router.GET(options.BaseURL+"/title", wrapper.GetTitle) + router.GET(options.BaseURL+"/title/:title_id", wrapper.GetTitleTitleId) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) } @@ -577,6 +621,48 @@ func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) return nil } +type GetTitleTitleIdRequestObject struct { + TitleId int64 `json:"title_id"` + Params GetTitleTitleIdParams +} + +type GetTitleTitleIdResponseObject interface { + VisitGetTitleTitleIdResponse(w http.ResponseWriter) error +} + +type GetTitleTitleId200JSONResponse Title + +func (response GetTitleTitleId200JSONResponse) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTitleTitleId400Response struct { +} + +func (response GetTitleTitleId400Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetTitleTitleId404Response struct { +} + +func (response GetTitleTitleId404Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type GetTitleTitleId500Response struct { +} + +func (response GetTitleTitleId500Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type GetUsersUserIdRequestObject struct { UserId string `json:"user_id"` Params GetUsersUserIdParams @@ -624,6 +710,9 @@ type StrictServerInterface interface { // Get titles // (GET /title) GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) + // Get title description + // (GET /title/{title_id}) + GetTitleTitleId(ctx context.Context, request GetTitleTitleIdRequestObject) (GetTitleTitleIdResponseObject, error) // Get user info // (GET /users/{user_id}) GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) @@ -668,6 +757,34 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { } } +// GetTitleTitleId operation middleware +func (sh *strictHandler) GetTitleTitleId(ctx *gin.Context, titleId int64, params GetTitleTitleIdParams) { + var request GetTitleTitleIdRequestObject + + request.TitleId = titleId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetTitleTitleId(ctx, request.(GetTitleTitleIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTitleTitleId") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetTitleTitleIdResponseObject); ok { + if err := validResponse.VisitGetTitleTitleIdResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetUsersUserId operation middleware func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { var request GetUsersUserIdRequestObject diff --git a/api/openapi.yaml b/api/openapi.yaml index 4628c6d..9ea20f4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -66,29 +66,34 @@ paths: '500': description: Unknown server error -# /title/{title_id}: -# get: -# summary: Get title description -# parameters: -# - in: path -# name: title_id -# required: true -# schema: -# type: string -# - in: query -# name: fields -# schema: -# type: string -# default: all -# responses: -# '200': -# description: Title description -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/Title' -# '404': -# description: Title not found + /title/{title_id}: + get: + summary: Get title description + parameters: + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: Title description + content: + application/json: + schema: + $ref: '#/components/schemas/Title' + '404': + description: Title not found + '400': + description: Request params are not correct + '500': + description: Unknown server error # patch: # summary: Update title info diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 182f450..3bbaa10 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -58,15 +58,15 @@ func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { return &t, nil } -type TitleNames *map[string][]string -type EpisodeLens *map[string]float64 -type TagNames []map[string]string +// type TitleNames map[string][]string +// type EpisodeLens map[string]float64 +// type TagNames []map[string]string func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, error) { + sqlc_title_tags, err := s.db.GetTitleTags(ctx, id) if err != nil { - log.Errorf("%v", err) - return nil, err + return nil, fmt.Errorf("query GetTitleTags: %v", err) } var oapi_tag_names oapi.Tags @@ -74,8 +74,7 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro var oapi_tag_name map[string]string err = json.Unmarshal(title_tag, &oapi_tag_name) if err != nil { - log.Errorf("invalid JSON for %s: %v", "TagNames", err) - return nil, err + return nil, fmt.Errorf("unmarshalling title_tag: %v", err) } oapi_tag_names = append(oapi_tag_names, oapi_tag_name) } @@ -89,8 +88,7 @@ func (s Server) GetImage(ctx context.Context, id int64) (oapi.Image, error) { sqlc_image, err := s.db.GetImageByID(ctx, id) if err != nil { - log.Errorf("%v", err) - return oapi_image, err + return oapi_image, fmt.Errorf("query GetImageByID: %v", err) } //can cast and dont use brain cause all this fiels required oapi_image.Id = &sqlc_image.ID @@ -106,8 +104,7 @@ func (s Server) GetStudio(ctx context.Context, id int64) (oapi.Studio, error) { sqlc_studio, err := s.db.GetStudioByID(ctx, id) if err != nil { - log.Errorf("%v", err) - return oapi_studio, err + return oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) } oapi_studio.Id = &sqlc_studio.ID @@ -116,16 +113,82 @@ func (s Server) GetStudio(ctx context.Context, id int64) (oapi.Studio, error) { oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) if err != nil { - log.Errorf("%v", err) - return oapi_studio, err + return oapi_studio, fmt.Errorf("GetImage: %v", err) } oapi_studio.Poster = &oapi_illust return oapi_studio, nil } +func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, error) { + var oapi_title oapi.Title + + var title_names map[string][]string + err := json.Unmarshal(title.TitleNames, &title_names) + if err != nil { + return oapi_title, fmt.Errorf("unmarshal TitleNames: %v", err) + } + + var episodes_lens map[string]float64 + err = json.Unmarshal(title.EpisodesLen, &episodes_lens) + if err != nil { + return oapi_title, fmt.Errorf("unmarshal EpisodesLen: %v", err) + } + + oapi_tag_names, err := s.GetTagsByTitleId(ctx, title.ID) + if err != nil { + return oapi_title, fmt.Errorf("GetTagsByTitleId: %v", err) + } + + oapi_image, err := s.GetImage(ctx, *title.PosterID) + if err != nil { + return oapi_title, fmt.Errorf("GetImage: %v", err) + } + + oapi_studio, err := s.GetStudio(ctx, title.StudioID) + if err != nil { + return oapi_title, fmt.Errorf("GetStudio: %v", err) + } + + oapi_title = oapi.Title{ + + Id: title.ID, + Poster: &oapi_image, + Rating: title.Rating, + RatingCount: title.RatingCount, + ReleaseSeason: (*oapi.ReleaseSeason)(title.ReleaseSeason), + ReleaseYear: title.ReleaseYear, + Studio: &oapi_studio, + Tags: oapi_tag_names, + TitleNames: title_names, + TitleStatus: (*oapi.TitleStatus)(&title.TitleStatus), + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAll, + EpisodesLen: &episodes_lens, + } + return oapi_title, nil +} + +func (s Server) GetTitleTitleId(ctx context.Context, request oapi.GetTitleTitleIdRequestObject) (oapi.GetTitleTitleIdResponseObject, error) { + var oapi_title oapi.Title + + sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitleTitleId500Response{}, nil + } + + oapi_title, err = s.mapTitle(ctx, sqlc_title) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitleTitleId500Response{}, nil + } + + return oapi.GetTitleTitleId200JSONResponse(oapi_title), nil +} + func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { - var result []oapi.Title + var opai_titles []oapi.Title word := Word2Sqlc(request.Params.Word) status, err := TitleStatus2Sqlc(request.Params.Status) @@ -158,56 +221,13 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject for _, title := range titles { - var title_names TitleNames - err := json.Unmarshal(title.TitleNames, &title_names) + t, err := s.mapTitle(ctx, title) if err != nil { - log.Errorf("invalid JSON for %s: %v", "TitleNames", err) - return oapi.GetTitle500Response{}, err + log.Errorf("%v", err) + return oapi.GetTitle500Response{}, nil } - - var episodes_lens EpisodeLens - err = json.Unmarshal(title.EpisodesLen, &episodes_lens) - if err != nil { - log.Errorf("invalid JSON for %s: %v", "EpisodeLens", err) - return oapi.GetTitle500Response{}, err - } - - oapi_tag_names, err := s.GetTagsByTitleId(ctx, title.ID) - if err != nil { - log.Errorf("error while getting tags %v", err) - return oapi.GetTitle500Response{}, err - } - - oapi_image, err := s.GetImage(ctx, *title.PosterID) - if err != nil { - log.Errorf("error while getting image %v", err) - return oapi.GetTitle500Response{}, err - } - - oapi_studio, err := s.GetStudio(ctx, title.StudioID) - if err != nil { - log.Errorf("error while getting studio %v", err) - return oapi.GetTitle500Response{}, err - } - - t := oapi.Title{ - - Id: title.ID, - Poster: &oapi_image, - Rating: title.Rating, - RatingCount: title.RatingCount, - ReleaseSeason: (*oapi.ReleaseSeason)(title.ReleaseSeason), - ReleaseYear: title.ReleaseYear, - Studio: &oapi_studio, - Tags: oapi_tag_names, - TitleNames: *title_names, - TitleStatus: (*oapi.TitleStatus)(&title.TitleStatus), - EpisodesAired: title.EpisodesAired, - EpisodesAll: title.EpisodesAll, - EpisodesLen: episodes_lens, - } - result = append(result, t) + opai_titles = append(opai_titles, t) } - return oapi.GetTitle200JSONResponse(result), nil + return oapi.GetTitle200JSONResponse(opai_titles), nil } diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index f58d33c..0570604 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -72,10 +72,10 @@ RETURNING id, tag_names; -- DELETE FROM users -- WHERE user_id = $1; ---name: GetTitleByID :one +-- name: GetTitleByID :one SELECT * FROM titles -WHERE id = sqlc.arg("title_id")::bigint; +WHERE id = sqlc.arg('title_id')::bigint; -- name: SearchTitles :many SELECT diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 865ec73..8539d8d 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -59,6 +59,60 @@ func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, er return i, err } +const getTitleByID = `-- name: GetTitleByID :one + + + + +SELECT id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len +FROM titles +WHERE id = $1::bigint +` + +// -- name: ListUsers :many +// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +// FROM users +// ORDER BY user_id +// LIMIT $1 OFFSET $2; +// -- name: CreateUser :one +// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) +// VALUES ($1, $2, $3, $4, $5, $6, $7) +// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +// -- name: UpdateUser :one +// UPDATE users +// SET +// +// avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), +// disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), +// user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), +// passhash = COALESCE(sqlc.narg('passhash'), passhash) +// +// WHERE user_id = sqlc.arg('user_id') +// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +// -- name: DeleteUser :exec +// DELETE FROM users +// WHERE user_id = $1; +func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (Title, error) { + row := q.db.QueryRow(ctx, getTitleByID, titleID) + var i Title + err := row.Scan( + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ) + return i, err +} + const getTitleTags = `-- name: GetTitleTags :many SELECT tag_names @@ -180,10 +234,6 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams } const searchTitles = `-- name: SearchTitles :many - - - - SELECT id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len FROM titles @@ -228,29 +278,6 @@ type SearchTitlesParams struct { Limit *int32 `json:"limit"` } -// -- name: ListUsers :many -// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -// FROM users -// ORDER BY user_id -// LIMIT $1 OFFSET $2; -// -- name: CreateUser :one -// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) -// VALUES ($1, $2, $3, $4, $5, $6, $7) -// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; -// -- name: UpdateUser :one -// UPDATE users -// SET -// -// avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), -// disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), -// user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), -// passhash = COALESCE(sqlc.narg('passhash'), passhash) -// -// WHERE user_id = sqlc.arg('user_id') -// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; -// -- name: DeleteUser :exec -// DELETE FROM users -// WHERE user_id = $1; func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) { rows, err := q.db.Query(ctx, searchTitles, arg.Word, From 29f0a612996039d3be25563873f5eab23298d03d Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 16 Nov 2025 04:15:33 +0300 Subject: [PATCH 052/224] feat: reviews table added --- sql/migrations/000001_init.up.sql | 16 ++++++++++++++++ sql/models.go | 15 +++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 669143a..97f881d 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -24,6 +24,22 @@ CREATE TABLE images ( image_path text UNIQUE NOT NULL ); +CREATE TABLE reviews ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + data text NOT NULL, + rating int CHECK (rating >= 0 AND rating <= 10), + illust_id bigint REFERENCES images (id), + user_id bigint REFERENCES users (id), + title_id bigint REFERENCES titles (id), + created_at timestamptz DEFAULT NOW() +); + +CREATE TABLE review_images ( + PRIMARY KEY (review_id, image_id), + review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, + image_id bigint NOT NULL REFERENCES images(id) ON DELETE CASCADE +); + CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, avatar_id bigint REFERENCES images (id), diff --git a/sql/models.go b/sql/models.go index 6583b71..a36c6fa 100644 --- a/sql/models.go +++ b/sql/models.go @@ -208,6 +208,21 @@ type Provider struct { Credentials []byte `json:"credentials"` } +type Review struct { + ID int64 `json:"id"` + Data string `json:"data"` + Rating *int32 `json:"rating"` + IllustID *int64 `json:"illust_id"` + UserID *int64 `json:"user_id"` + TitleID *int64 `json:"title_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type ReviewImage struct { + ReviewID int64 `json:"review_id"` + ImageID int64 `json:"image_id"` +} + type Signal struct { ID int64 `json:"id"` TitleID *int64 `json:"title_id"` From b81cc86beb8f1bff094084d28b115f520efd2437 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 16 Nov 2025 04:52:11 +0300 Subject: [PATCH 053/224] fix: --- modules/backend/handlers/titles.go | 40 ++++++++++++++++++------------ 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 3bbaa10..e5fcf18 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -90,10 +90,12 @@ func (s Server) GetImage(ctx context.Context, id int64) (oapi.Image, error) { if err != nil { return oapi_image, fmt.Errorf("query GetImageByID: %v", err) } + //can cast and dont use brain cause all this fiels required oapi_image.Id = &sqlc_image.ID oapi_image.ImagePath = &sqlc_image.ImagePath - oapi_image.StorageType = (*string)(&sqlc_image.StorageType) + storageTypeStr := string(sqlc_image.StorageType) // или fmt.Sprint(...), если int + oapi_image.StorageType = &storageTypeStr return oapi_image, nil } @@ -150,22 +152,28 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, err return oapi_title, fmt.Errorf("GetStudio: %v", err) } - oapi_title = oapi.Title{ - - Id: title.ID, - Poster: &oapi_image, - Rating: title.Rating, - RatingCount: title.RatingCount, - ReleaseSeason: (*oapi.ReleaseSeason)(title.ReleaseSeason), - ReleaseYear: title.ReleaseYear, - Studio: &oapi_studio, - Tags: oapi_tag_names, - TitleNames: title_names, - TitleStatus: (*oapi.TitleStatus)(&title.TitleStatus), - EpisodesAired: title.EpisodesAired, - EpisodesAll: title.EpisodesAll, - EpisodesLen: &episodes_lens, + if title.ReleaseSeason != nil { + rs := oapi.ReleaseSeason(*title.ReleaseSeason) + oapi_title.ReleaseSeason = &rs + } else { + oapi_title.ReleaseSeason = nil } + + ts := oapi.TitleStatus(title.TitleStatus) + oapi_title.TitleStatus = &ts + + oapi_title.Id = title.ID + oapi_title.Poster = &oapi_image + oapi_title.Rating = title.Rating + oapi_title.RatingCount = title.RatingCount + oapi_title.ReleaseYear = title.ReleaseYear + oapi_title.Studio = &oapi_studio + oapi_title.Tags = oapi_tag_names + oapi_title.TitleNames = title_names + oapi_title.EpisodesAired = title.EpisodesAired + oapi_title.EpisodesAll = title.EpisodesAll + oapi_title.EpisodesLen = &episodes_lens + return oapi_title, nil } From f888ac9b70ae3514baa643fbbaa07c70b2de5e99 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Mon, 17 Nov 2025 02:41:45 +0300 Subject: [PATCH 054/224] review: review notes for titles.go --- modules/backend/handlers/titles.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index e5fcf18..6e3967a 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -11,6 +11,7 @@ import ( ) func Word2Sqlc(s *string) *string { + // TODO: merge two ifs if s == nil { return nil } @@ -69,6 +70,7 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro return nil, fmt.Errorf("query GetTitleTags: %v", err) } + // TODO: check if underlying slice initialized as nil var oapi_tag_names oapi.Tags for _, title_tag := range sqlc_title_tags { var oapi_tag_name map[string]string @@ -91,6 +93,7 @@ func (s Server) GetImage(ctx context.Context, id int64) (oapi.Image, error) { return oapi_image, fmt.Errorf("query GetImageByID: %v", err) } + // TODO: clearer comment //can cast and dont use brain cause all this fiels required oapi_image.Id = &sqlc_image.ID oapi_image.ImagePath = &sqlc_image.ImagePath @@ -101,7 +104,7 @@ func (s Server) GetImage(ctx context.Context, id int64) (oapi.Image, error) { } func (s Server) GetStudio(ctx context.Context, id int64) (oapi.Studio, error) { - + // TODO: check all possibly nil fields var oapi_studio oapi.Studio sqlc_studio, err := s.db.GetStudioByID(ctx, id) @@ -123,6 +126,7 @@ func (s Server) GetStudio(ctx context.Context, id int64) (oapi.Studio, error) { } func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, error) { + // TODO: check all possibly nil fields var oapi_title oapi.Title var title_names map[string][]string From 35a4e173f08898ccad3daaee18532ab8063e124f Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 17 Nov 2025 03:55:42 +0300 Subject: [PATCH 055/224] feat: query GetReviewById added --- modules/backend/queries.sql | 10 ++++---- sql/queries.sql.go | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 0570604..a4ed1fe 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -135,10 +135,10 @@ OFFSET sqlc.narg('offset')::int; -- WHERE title_id = sqlc.arg('title_id') -- RETURNING *; --- -- name: GetReviewByID :one --- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date --- FROM reviews --- WHERE review_id = $1; +-- name: GetReviewByID :one +SELECT * +FROM reviews +WHERE review_id = sqlc.arg('review_id')::bigint; -- -- name: CreateReview :one -- INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) @@ -157,7 +157,7 @@ OFFSET sqlc.narg('offset')::int; -- DELETE FROM reviews -- WHERE review_id = $1; --- -- name: ListReviewsByTitle :many +-- name: ListReviewsByTitle :many -- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -- FROM reviews -- WHERE title_id = $1 diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 8539d8d..c5e6f8a 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -41,6 +41,55 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro return i, err } +const getReviewByID = `-- name: GetReviewByID :one + + +SELECT id, data, rating, illust_id, user_id, title_id, created_at +FROM reviews +WHERE review_id = $1::bigint +` + +// -- name: ListTitles :many +// SELECT title_id, title_names, studio_id, poster_id, signal_ids, +// +// title_status, rating, rating_count, release_year, release_season, +// season, episodes_aired, episodes_all, episodes_len +// +// FROM titles +// ORDER BY title_id +// LIMIT $1 OFFSET $2; +// -- name: UpdateTitle :one +// UPDATE titles +// SET +// +// title_names = COALESCE(sqlc.narg('title_names'), title_names), +// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), +// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), +// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), +// title_status = COALESCE(sqlc.narg('title_status'), title_status), +// release_year = COALESCE(sqlc.narg('release_year'), release_year), +// release_season = COALESCE(sqlc.narg('release_season'), release_season), +// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), +// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), +// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) +// +// WHERE title_id = sqlc.arg('title_id') +// RETURNING *; +func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, error) { + row := q.db.QueryRow(ctx, getReviewByID, reviewID) + var i Review + err := row.Scan( + &i.ID, + &i.Data, + &i.Rating, + &i.IllustID, + &i.UserID, + &i.TitleID, + &i.CreatedAt, + ) + return i, err +} + const getStudioByID = `-- name: GetStudioByID :one SELECT id, studio_name, illust_id, studio_desc FROM studios From df45a327e6902348a5d80674757e53e1e1eef6bd Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 17 Nov 2025 09:26:58 +0300 Subject: [PATCH 056/224] fix --- api/api.gen.go | 12 ++++- api/openapi.yaml | 5 ++ modules/backend/handlers/titles.go | 74 +++++++++++++++++------------- sql/migrations/000001_init.up.sql | 2 +- sql/models.go | 2 +- 5 files changed, 60 insertions(+), 35 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index 3f4f5dd..5222930 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -44,8 +44,8 @@ type ReleaseSeason string // Studio defines model for Studio. type Studio struct { Description *string `json:"description,omitempty"` - Id *int64 `json:"id,omitempty"` - Name *string `json:"name,omitempty"` + Id int64 `json:"id"` + Name string `json:"name"` Poster *Image `json:"poster,omitempty"` } @@ -639,6 +639,14 @@ func (response GetTitleTitleId200JSONResponse) VisitGetTitleTitleIdResponse(w ht return json.NewEncoder(w).Encode(response) } +type GetTitleTitleId204Response struct { +} + +func (response GetTitleTitleId204Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + type GetTitleTitleId400Response struct { } diff --git a/api/openapi.yaml b/api/openapi.yaml index 9ea20f4..7c83426 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -94,6 +94,8 @@ paths: description: Request params are not correct '500': description: Unknown server error + '204': + description: No title found # patch: # summary: Update title info @@ -636,6 +638,9 @@ components: Studio: type: object + required: + - id + - name properties: id: type: integer diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 6e3967a..c66fef5 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "database/sql" "encoding/json" "fmt" oapi "nyanimedb/api" @@ -11,13 +12,10 @@ import ( ) func Word2Sqlc(s *string) *string { - // TODO: merge two ifs - if s == nil { - return nil - } - if *s == "" { + if s == nil || *s == "" { return nil } + return s } @@ -59,21 +57,19 @@ func ReleaseSeason2sqlc(s *oapi.ReleaseSeason) (*sqlc.ReleaseSeasonT, error) { return &t, nil } -// type TitleNames map[string][]string -// type EpisodeLens map[string]float64 -// type TagNames []map[string]string - func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, error) { sqlc_title_tags, err := s.db.GetTitleTags(ctx, id) if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } return nil, fmt.Errorf("query GetTitleTags: %v", err) } - // TODO: check if underlying slice initialized as nil - var oapi_tag_names oapi.Tags + oapi_tag_names := make(oapi.Tags, 1) for _, title_tag := range sqlc_title_tags { - var oapi_tag_name map[string]string + oapi_tag_name := make(map[string]string, 1) err = json.Unmarshal(title_tag, &oapi_tag_name) if err != nil { return nil, fmt.Errorf("unmarshalling title_tag: %v", err) @@ -84,58 +80,65 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro return oapi_tag_names, nil } -func (s Server) GetImage(ctx context.Context, id int64) (oapi.Image, error) { +func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { - var oapi_image oapi.Image + var oapi_image *oapi.Image sqlc_image, err := s.db.GetImageByID(ctx, id) if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } return oapi_image, fmt.Errorf("query GetImageByID: %v", err) } - // TODO: clearer comment - //can cast and dont use brain cause all this fiels required + //can cast and dont use brain cause all this fields required in image table oapi_image.Id = &sqlc_image.ID oapi_image.ImagePath = &sqlc_image.ImagePath - storageTypeStr := string(sqlc_image.StorageType) // или fmt.Sprint(...), если int + storageTypeStr := string(sqlc_image.StorageType) oapi_image.StorageType = &storageTypeStr return oapi_image, nil } -func (s Server) GetStudio(ctx context.Context, id int64) (oapi.Studio, error) { - // TODO: check all possibly nil fields +func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { + var oapi_studio oapi.Studio sqlc_studio, err := s.db.GetStudioByID(ctx, id) if err != nil { - return oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) + if err == sql.ErrNoRows { + return nil, nil + } + return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) } - oapi_studio.Id = &sqlc_studio.ID + oapi_studio.Id = sqlc_studio.ID oapi_studio.Name = sqlc_studio.StudioName oapi_studio.Description = sqlc_studio.StudioDesc oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) if err != nil { - return oapi_studio, fmt.Errorf("GetImage: %v", err) + return &oapi_studio, fmt.Errorf("GetImage: %v", err) + } + if oapi_illust != nil { + oapi_studio.Poster = oapi_illust } - oapi_studio.Poster = &oapi_illust - return oapi_studio, nil + return &oapi_studio, nil } func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, error) { - // TODO: check all possibly nil fields + var oapi_title oapi.Title - var title_names map[string][]string + title_names := make(map[string][]string, 1) err := json.Unmarshal(title.TitleNames, &title_names) if err != nil { return oapi_title, fmt.Errorf("unmarshal TitleNames: %v", err) } - var episodes_lens map[string]float64 + episodes_lens := make(map[string]float64, 1) err = json.Unmarshal(title.EpisodesLen, &episodes_lens) if err != nil { return oapi_title, fmt.Errorf("unmarshal EpisodesLen: %v", err) @@ -145,16 +148,25 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, err if err != nil { return oapi_title, fmt.Errorf("GetTagsByTitleId: %v", err) } + if oapi_tag_names != nil { + oapi_title.Tags = oapi_tag_names + } oapi_image, err := s.GetImage(ctx, *title.PosterID) if err != nil { return oapi_title, fmt.Errorf("GetImage: %v", err) } + if oapi_image != nil { + oapi_title.Poster = oapi_image + } oapi_studio, err := s.GetStudio(ctx, title.StudioID) if err != nil { return oapi_title, fmt.Errorf("GetStudio: %v", err) } + if oapi_studio != nil { + oapi_title.Studio = oapi_studio + } if title.ReleaseSeason != nil { rs := oapi.ReleaseSeason(*title.ReleaseSeason) @@ -167,12 +179,9 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, err oapi_title.TitleStatus = &ts oapi_title.Id = title.ID - oapi_title.Poster = &oapi_image oapi_title.Rating = title.Rating oapi_title.RatingCount = title.RatingCount oapi_title.ReleaseYear = title.ReleaseYear - oapi_title.Studio = &oapi_studio - oapi_title.Tags = oapi_tag_names oapi_title.TitleNames = title_names oapi_title.EpisodesAired = title.EpisodesAired oapi_title.EpisodesAll = title.EpisodesAll @@ -186,6 +195,9 @@ func (s Server) GetTitleTitleId(ctx context.Context, request oapi.GetTitleTitleI sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId) if err != nil { + if err == sql.ErrNoRows { + return oapi.GetTitleTitleId204Response{}, nil + } log.Errorf("%v", err) return oapi.GetTitleTitleId500Response{}, nil } @@ -200,7 +212,7 @@ func (s Server) GetTitleTitleId(ctx context.Context, request oapi.GetTitleTitleI } func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { - var opai_titles []oapi.Title + opai_titles := make([]oapi.Title, 1) word := Word2Sqlc(request.Params.Word) status, err := TitleStatus2Sqlc(request.Params.Status) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 97f881d..81f2801 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -54,7 +54,7 @@ CREATE TABLE users ( CREATE TABLE studios ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - studio_name text UNIQUE, + studio_name text NOT NULL UNIQUE, illust_id bigint REFERENCES images (id), studio_desc text ); diff --git a/sql/models.go b/sql/models.go index a36c6fa..a593504 100644 --- a/sql/models.go +++ b/sql/models.go @@ -233,7 +233,7 @@ type Signal struct { type Studio struct { ID int64 `json:"id"` - StudioName *string `json:"studio_name"` + StudioName string `json:"studio_name"` IllustID *int64 `json:"illust_id"` StudioDesc *string `json:"studio_desc"` } From 148ae646b1d301d8f71b1bf0a2fe4671a84e14b9 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 17 Nov 2025 09:39:26 +0300 Subject: [PATCH 057/224] feat --- modules/backend/handlers/titles.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index c66fef5..45d4c9b 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -2,12 +2,12 @@ package handlers import ( "context" - "database/sql" "encoding/json" "fmt" oapi "nyanimedb/api" sqlc "nyanimedb/sql" + "github.com/jackc/pgx/v5" log "github.com/sirupsen/logrus" ) @@ -61,7 +61,7 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro sqlc_title_tags, err := s.db.GetTitleTags(ctx, id) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { return nil, nil } return nil, fmt.Errorf("query GetTitleTags: %v", err) @@ -82,14 +82,14 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { - var oapi_image *oapi.Image + var oapi_image oapi.Image sqlc_image, err := s.db.GetImageByID(ctx, id) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { return nil, nil } - return oapi_image, fmt.Errorf("query GetImageByID: %v", err) + return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) } //can cast and dont use brain cause all this fields required in image table @@ -98,7 +98,7 @@ func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { storageTypeStr := string(sqlc_image.StorageType) oapi_image.StorageType = &storageTypeStr - return oapi_image, nil + return &oapi_image, nil } func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { @@ -107,7 +107,7 @@ func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { sqlc_studio, err := s.db.GetStudioByID(ctx, id) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { return nil, nil } return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) @@ -195,7 +195,7 @@ func (s Server) GetTitleTitleId(ctx context.Context, request oapi.GetTitleTitleI sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { return oapi.GetTitleTitleId204Response{}, nil } log.Errorf("%v", err) From 83711211300f3dcb00eabe35b0bb38aec9aae4f1 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 18 Nov 2025 03:12:16 +0300 Subject: [PATCH 058/224] fix --- modules/backend/handlers/titles.go | 17 +++++++++++------ sql/migrations/000001_init.up.sql | 2 ++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 45d4c9b..9f076d5 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -117,6 +117,9 @@ func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { oapi_studio.Name = sqlc_studio.StudioName oapi_studio.Description = sqlc_studio.StudioDesc + if sqlc_studio.IllustID == nil { + return &oapi_studio, nil + } oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) if err != nil { return &oapi_studio, fmt.Errorf("GetImage: %v", err) @@ -152,12 +155,14 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, err oapi_title.Tags = oapi_tag_names } - oapi_image, err := s.GetImage(ctx, *title.PosterID) - if err != nil { - return oapi_title, fmt.Errorf("GetImage: %v", err) - } - if oapi_image != nil { - oapi_title.Poster = oapi_image + if title.PosterID != nil { + oapi_image, err := s.GetImage(ctx, *title.PosterID) + if err != nil { + return oapi_title, fmt.Errorf("GetImage: %v", err) + } + if oapi_image != nil { + oapi_title.Poster = oapi_image + } } oapi_studio, err := s.GetStudio(ctx, title.StudioID) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 81f2801..8bd40d1 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -60,6 +60,7 @@ CREATE TABLE studios ( ); CREATE TABLE titles ( + // TODO: anime type (film, season etc) id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, @@ -88,6 +89,7 @@ CREATE TABLE usertitles ( rate int CHECK (rate > 0 AND rate <= 10), review_text text, review_date timestamptz + // TODO: series status ); CREATE TABLE title_tags ( From 8deba7afd9e320d2b182e9888baf1afe9c415c51 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 18 Nov 2025 04:19:34 +0300 Subject: [PATCH 059/224] fix --- api/openapi.yaml | 22 ++++++++++++++++++++++ modules/backend/handlers/titles.go | 4 ++-- modules/backend/queries.sql | 2 +- sql/migrations/000001_init.up.sql | 7 +++---- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 7c83426..0bbedca 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -757,4 +757,26 @@ components: UserTitle: type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time additionalProperties: true diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 9f076d5..45f0ef0 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -87,7 +87,7 @@ func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { sqlc_image, err := s.db.GetImageByID(ctx, id) if err != nil { if err == pgx.ErrNoRows { - return nil, nil + return nil, nil //todo: error reference in db } return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) } @@ -217,7 +217,7 @@ func (s Server) GetTitleTitleId(ctx context.Context, request oapi.GetTitleTitleI } func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { - opai_titles := make([]oapi.Title, 1) + opai_titles := make([]oapi.Title, 0) word := Word2Sqlc(request.Params.Word) status, err := TitleStatus2Sqlc(request.Params.Status) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index a4ed1fe..423be37 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -1,7 +1,7 @@ -- name: GetImageByID :one SELECT id, storage_type, image_path FROM images -WHERE id = sqlc.arg('illust_id'); +WHERE id = sqlc.arg('illust_id')::bigint; -- name: CreateImage :one INSERT INTO images (storage_type, image_path) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 8bd40d1..7b79300 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -28,7 +28,6 @@ CREATE TABLE reviews ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, data text NOT NULL, rating int CHECK (rating >= 0 AND rating <= 10), - illust_id bigint REFERENCES images (id), user_id bigint REFERENCES users (id), title_id bigint REFERENCES titles (id), created_at timestamptz DEFAULT NOW() @@ -87,9 +86,9 @@ CREATE TABLE usertitles ( title_id bigint NOT NULL REFERENCES titles (id), status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), - review_text text, - review_date timestamptz - // TODO: series status + review_id bigint NOT NULL REFERENCES reviews (id), + ctime timestamptz + -- // TODO: series status ); CREATE TABLE title_tags ( From 09d0d1eb4d511c91c4ec4d20f1c7237f9b9b06e7 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 18 Nov 2025 04:59:19 +0300 Subject: [PATCH 060/224] feat: get user titles described --- api/api.gen.go | 447 ++++++++++++++++++++++++----- api/openapi.yaml | 99 ++++--- modules/backend/handlers/titles.go | 30 +- modules/backend/handlers/users.go | 7 + sql/migrations/000001_init.up.sql | 2 +- 5 files changed, 461 insertions(+), 124 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index 5222930..74a2d52 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -26,9 +26,17 @@ const ( // Defines values for TitleStatus. const ( - Finished TitleStatus = "finished" - Ongoing TitleStatus = "ongoing" - Planned TitleStatus = "planned" + TitleStatusFinished TitleStatus = "finished" + TitleStatusOngoing TitleStatus = "ongoing" + TitleStatusPlanned TitleStatus = "planned" +) + +// Defines values for UserTitleStatus. +const ( + UserTitleStatusDropped UserTitleStatus = "dropped" + UserTitleStatusFinished UserTitleStatus = "finished" + UserTitleStatusInProgress UserTitleStatus = "in-progress" + UserTitleStatusPlanned UserTitleStatus = "planned" ) // Image defines model for Image. @@ -110,8 +118,27 @@ type User struct { UserDesc *string `json:"user_desc,omitempty"` } -// GetTitleParams defines parameters for GetTitle. -type GetTitleParams struct { +// UserTitle defines model for UserTitle. +type UserTitle struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// UserTitleStatus User's title status +type UserTitleStatus string + +// Cursor defines model for cursor. +type Cursor = string + +// GetTitlesParams defines parameters for GetTitles. +type GetTitlesParams struct { Word *string `form:"word,omitempty" json:"word,omitempty"` Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` @@ -122,8 +149,8 @@ type GetTitleParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// GetTitleTitleIdParams defines parameters for GetTitleTitleId. -type GetTitleTitleIdParams struct { +// GetTitlesTitleIdParams defines parameters for GetTitlesTitleId. +type GetTitlesTitleIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } @@ -132,6 +159,15 @@ type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. +type GetUsersUserIdTitlesParams struct { + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Query *string `form:"query,omitempty" json:"query,omitempty"` + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + // Getter for additional properties for Title. Returns the specified // element and whether it was found func (a Title) Get(fieldName string) (value interface{}, found bool) { @@ -374,17 +410,157 @@ func (a Title) MarshalJSON() ([]byte, error) { return json.Marshal(object) } +// Getter for additional properties for UserTitle. Returns the specified +// element and whether it was found +func (a UserTitle) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for UserTitle +func (a *UserTitle) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for UserTitle to handle AdditionalProperties +func (a *UserTitle) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["ctime"]; found { + err = json.Unmarshal(raw, &a.Ctime) + if err != nil { + return fmt.Errorf("error reading 'ctime': %w", err) + } + delete(object, "ctime") + } + + if raw, found := object["rate"]; found { + err = json.Unmarshal(raw, &a.Rate) + if err != nil { + return fmt.Errorf("error reading 'rate': %w", err) + } + delete(object, "rate") + } + + if raw, found := object["review_id"]; found { + err = json.Unmarshal(raw, &a.ReviewId) + if err != nil { + return fmt.Errorf("error reading 'review_id': %w", err) + } + delete(object, "review_id") + } + + if raw, found := object["status"]; found { + err = json.Unmarshal(raw, &a.Status) + if err != nil { + return fmt.Errorf("error reading 'status': %w", err) + } + delete(object, "status") + } + + if raw, found := object["title_id"]; found { + err = json.Unmarshal(raw, &a.TitleId) + if err != nil { + return fmt.Errorf("error reading 'title_id': %w", err) + } + delete(object, "title_id") + } + + if raw, found := object["user_id"]; found { + err = json.Unmarshal(raw, &a.UserId) + if err != nil { + return fmt.Errorf("error reading 'user_id': %w", err) + } + delete(object, "user_id") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for UserTitle to handle AdditionalProperties +func (a UserTitle) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + if a.Ctime != nil { + object["ctime"], err = json.Marshal(a.Ctime) + if err != nil { + return nil, fmt.Errorf("error marshaling 'ctime': %w", err) + } + } + + if a.Rate != nil { + object["rate"], err = json.Marshal(a.Rate) + if err != nil { + return nil, fmt.Errorf("error marshaling 'rate': %w", err) + } + } + + if a.ReviewId != nil { + object["review_id"], err = json.Marshal(a.ReviewId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'review_id': %w", err) + } + } + + object["status"], err = json.Marshal(a.Status) + if err != nil { + return nil, fmt.Errorf("error marshaling 'status': %w", err) + } + + object["title_id"], err = json.Marshal(a.TitleId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title_id': %w", err) + } + + object["user_id"], err = json.Marshal(a.UserId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'user_id': %w", err) + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + // ServerInterface represents all server handlers. type ServerInterface interface { // Get titles - // (GET /title) - GetTitle(c *gin.Context, params GetTitleParams) + // (GET /titles) + GetTitles(c *gin.Context, params GetTitlesParams) // Get title description - // (GET /title/{title_id}) - GetTitleTitleId(c *gin.Context, titleId int64, params GetTitleTitleIdParams) + // (GET /titles/{title_id}) + GetTitlesTitleId(c *gin.Context, titleId int64, params GetTitlesTitleIdParams) // Get user info // (GET /users/{user_id}) GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) + // Get user titles + // (GET /users/{user_id}/titles/) + GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) } // ServerInterfaceWrapper converts contexts to parameters. @@ -396,13 +572,13 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) -// GetTitle operation middleware -func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { +// GetTitles operation middleware +func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { var err error // Parameter object where we will unmarshal all parameters from the context - var params GetTitleParams + var params GetTitlesParams // ------------- Optional query parameter "word" ------------- @@ -475,11 +651,11 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { } } - siw.Handler.GetTitle(c, params) + siw.Handler.GetTitles(c, params) } -// GetTitleTitleId operation middleware -func (siw *ServerInterfaceWrapper) GetTitleTitleId(c *gin.Context) { +// GetTitlesTitleId operation middleware +func (siw *ServerInterfaceWrapper) GetTitlesTitleId(c *gin.Context) { var err error @@ -493,7 +669,7 @@ func (siw *ServerInterfaceWrapper) GetTitleTitleId(c *gin.Context) { } // Parameter object where we will unmarshal all parameters from the context - var params GetTitleTitleIdParams + var params GetTitlesTitleIdParams // ------------- Optional query parameter "fields" ------------- @@ -510,7 +686,7 @@ func (siw *ServerInterfaceWrapper) GetTitleTitleId(c *gin.Context) { } } - siw.Handler.GetTitleTitleId(c, titleId, params) + siw.Handler.GetTitlesTitleId(c, titleId, params) } // GetUsersUserId operation middleware @@ -548,6 +724,73 @@ func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { siw.Handler.GetUsersUserId(c, userId, params) } +// GetUsersUserIdTitles operation middleware +func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersUserIdTitlesParams + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor", c.Request.URL.Query(), ¶ms.Cursor) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter cursor: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "query" ------------- + + err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "fields" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter fields: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsersUserIdTitles(c, userId, params) +} + // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -575,98 +818,99 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } - router.GET(options.BaseURL+"/title", wrapper.GetTitle) - router.GET(options.BaseURL+"/title/:title_id", wrapper.GetTitleTitleId) + router.GET(options.BaseURL+"/titles", wrapper.GetTitles) + router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitlesTitleId) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) + router.GET(options.BaseURL+"/users/:user_id/titles/", wrapper.GetUsersUserIdTitles) } -type GetTitleRequestObject struct { - Params GetTitleParams +type GetTitlesRequestObject struct { + Params GetTitlesParams } -type GetTitleResponseObject interface { - VisitGetTitleResponse(w http.ResponseWriter) error +type GetTitlesResponseObject interface { + VisitGetTitlesResponse(w http.ResponseWriter) error } -type GetTitle200JSONResponse []Title +type GetTitles200JSONResponse []Title -func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error { +func (response GetTitles200JSONResponse) VisitGetTitlesResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetTitle204Response struct { +type GetTitles204Response struct { } -func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { +func (response GetTitles204Response) VisitGetTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type GetTitle400Response struct { +type GetTitles400Response struct { } -func (response GetTitle400Response) VisitGetTitleResponse(w http.ResponseWriter) error { +func (response GetTitles400Response) VisitGetTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(400) return nil } -type GetTitle500Response struct { +type GetTitles500Response struct { } -func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) error { +func (response GetTitles500Response) VisitGetTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(500) return nil } -type GetTitleTitleIdRequestObject struct { +type GetTitlesTitleIdRequestObject struct { TitleId int64 `json:"title_id"` - Params GetTitleTitleIdParams + Params GetTitlesTitleIdParams } -type GetTitleTitleIdResponseObject interface { - VisitGetTitleTitleIdResponse(w http.ResponseWriter) error +type GetTitlesTitleIdResponseObject interface { + VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error } -type GetTitleTitleId200JSONResponse Title +type GetTitlesTitleId200JSONResponse Title -func (response GetTitleTitleId200JSONResponse) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitlesTitleId200JSONResponse) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetTitleTitleId204Response struct { +type GetTitlesTitleId204Response struct { } -func (response GetTitleTitleId204Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitlesTitleId204Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type GetTitleTitleId400Response struct { +type GetTitlesTitleId400Response struct { } -func (response GetTitleTitleId400Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitlesTitleId400Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { w.WriteHeader(400) return nil } -type GetTitleTitleId404Response struct { +type GetTitlesTitleId404Response struct { } -func (response GetTitleTitleId404Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitlesTitleId404Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { w.WriteHeader(404) return nil } -type GetTitleTitleId500Response struct { +type GetTitlesTitleId500Response struct { } -func (response GetTitleTitleId500Response) VisitGetTitleTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitlesTitleId500Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { w.WriteHeader(500) return nil } @@ -713,17 +957,62 @@ func (response GetUsersUserId500Response) VisitGetUsersUserIdResponse(w http.Res return nil } +type GetUsersUserIdTitlesRequestObject struct { + UserId string `json:"user_id"` + Params GetUsersUserIdTitlesParams +} + +type GetUsersUserIdTitlesResponseObject interface { + VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error +} + +type GetUsersUserIdTitles200JSONResponse []UserTitle + +func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsersUserIdTitles204Response struct { +} + +func (response GetUsersUserIdTitles204Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetUsersUserIdTitles400Response struct { +} + +func (response GetUsersUserIdTitles400Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetUsersUserIdTitles500Response struct { +} + +func (response GetUsersUserIdTitles500Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Get titles - // (GET /title) - GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) + // (GET /titles) + GetTitles(ctx context.Context, request GetTitlesRequestObject) (GetTitlesResponseObject, error) // Get title description - // (GET /title/{title_id}) - GetTitleTitleId(ctx context.Context, request GetTitleTitleIdRequestObject) (GetTitleTitleIdResponseObject, error) + // (GET /titles/{title_id}) + GetTitlesTitleId(ctx context.Context, request GetTitlesTitleIdRequestObject) (GetTitlesTitleIdResponseObject, error) // Get user info // (GET /users/{user_id}) GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) + // Get user titles + // (GET /users/{user_id}/titles/) + GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -738,17 +1027,17 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } -// GetTitle operation middleware -func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { - var request GetTitleRequestObject +// GetTitles operation middleware +func (sh *strictHandler) GetTitles(ctx *gin.Context, params GetTitlesParams) { + var request GetTitlesRequestObject request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject)) + return sh.ssi.GetTitles(ctx, request.(GetTitlesRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitle") + handler = middleware(handler, "GetTitles") } response, err := handler(ctx, request) @@ -756,8 +1045,8 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitleResponseObject); ok { - if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetTitlesResponseObject); ok { + if err := validResponse.VisitGetTitlesResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -765,18 +1054,18 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { } } -// GetTitleTitleId operation middleware -func (sh *strictHandler) GetTitleTitleId(ctx *gin.Context, titleId int64, params GetTitleTitleIdParams) { - var request GetTitleTitleIdRequestObject +// GetTitlesTitleId operation middleware +func (sh *strictHandler) GetTitlesTitleId(ctx *gin.Context, titleId int64, params GetTitlesTitleIdParams) { + var request GetTitlesTitleIdRequestObject request.TitleId = titleId request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitleTitleId(ctx, request.(GetTitleTitleIdRequestObject)) + return sh.ssi.GetTitlesTitleId(ctx, request.(GetTitlesTitleIdRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitleTitleId") + handler = middleware(handler, "GetTitlesTitleId") } response, err := handler(ctx, request) @@ -784,8 +1073,8 @@ func (sh *strictHandler) GetTitleTitleId(ctx *gin.Context, titleId int64, params if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitleTitleIdResponseObject); ok { - if err := validResponse.VisitGetTitleTitleIdResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetTitlesTitleIdResponseObject); ok { + if err := validResponse.VisitGetTitlesTitleIdResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -820,3 +1109,31 @@ func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params ctx.Error(fmt.Errorf("unexpected response type: %T", response)) } } + +// GetUsersUserIdTitles operation middleware +func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { + var request GetUsersUserIdTitlesRequestObject + + request.UserId = userId + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsersUserIdTitles(ctx, request.(GetUsersUserIdTitlesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsersUserIdTitles") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersUserIdTitlesResponseObject); ok { + if err := validResponse.VisitGetUsersUserIdTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/api/openapi.yaml b/api/openapi.yaml index 0bbedca..bc7fb15 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -7,7 +7,7 @@ servers: - url: /api/v1 paths: - /title: + /titles: get: summary: Get titles parameters: @@ -66,7 +66,7 @@ paths: '500': description: Unknown server error - /title/{title_id}: + /titles/{title_id}: get: summary: Get title description parameters: @@ -126,7 +126,7 @@ paths: # user_json: # $ref: '#/components/schemas/User' -# /title/{title_id}/reviews: +# /titles/{title_id}/reviews: # get: # summary: Get title reviews # parameters: @@ -278,45 +278,50 @@ paths: # user_json: # $ref: '#/components/schemas/User' -# /users/{user_id}/titles: -# get: -# summary: Get user titles -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# - in: query -# name: query -# schema: -# type: string -# - in: query -# name: limit -# schema: -# type: integer -# default: 10 -# - in: query -# name: offset -# schema: -# type: integer -# default: 0 -# - in: query -# name: fields -# schema: -# type: string -# default: all -# responses: -# '200': -# description: List of user titles -# content: -# application/json: -# schema: -# type: array -# items: -# $ref: '#/components/schemas/UserTitle' -# '204': -# description: No titles found + /users/{user_id}/titles/: + get: + summary: Get user titles + parameters: + - $ref: '#/components/parameters/cursor' + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: query + schema: + type: string + - in: query + name: limit + schema: + type: integer + default: 10 + - in: query + name: offset + schema: + type: integer + default: 0 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of user titles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserTitle' + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error # post: # summary: Add user title @@ -571,6 +576,14 @@ paths: # type: string components: + parameters: + cursor: + in: query + name: cursor + required: false + schema: + type: string + schemas: Image: type: object @@ -769,7 +782,7 @@ components: type: integer format: int64 status: - $ref: '#components/schemas/UserTitleStatus' + $ref: '#/components/schemas/UserTitleStatus' rate: type: integer format: int32 diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 45f0ef0..46ff982 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -25,11 +25,11 @@ func TitleStatus2Sqlc(s *oapi.TitleStatus) (*sqlc.TitleStatusT, error) { } var t sqlc.TitleStatusT switch *s { - case oapi.Finished: + case oapi.TitleStatusFinished: t = sqlc.TitleStatusTFinished - case oapi.Ongoing: + case oapi.TitleStatusOngoing: t = sqlc.TitleStatusTOngoing - case oapi.Planned: + case oapi.TitleStatusPlanned: t = sqlc.TitleStatusTPlanned default: return nil, fmt.Errorf("unexpected tittle status: %s", *s) @@ -195,40 +195,40 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, err return oapi_title, nil } -func (s Server) GetTitleTitleId(ctx context.Context, request oapi.GetTitleTitleIdRequestObject) (oapi.GetTitleTitleIdResponseObject, error) { +func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitleIdRequestObject) (oapi.GetTitlesTitleIdResponseObject, error) { var oapi_title oapi.Title sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId) if err != nil { if err == pgx.ErrNoRows { - return oapi.GetTitleTitleId204Response{}, nil + return oapi.GetTitlesTitleId204Response{}, nil } log.Errorf("%v", err) - return oapi.GetTitleTitleId500Response{}, nil + return oapi.GetTitlesTitleId500Response{}, nil } oapi_title, err = s.mapTitle(ctx, sqlc_title) if err != nil { log.Errorf("%v", err) - return oapi.GetTitleTitleId500Response{}, nil + return oapi.GetTitlesTitleId500Response{}, nil } - return oapi.GetTitleTitleId200JSONResponse(oapi_title), nil + return oapi.GetTitlesTitleId200JSONResponse(oapi_title), nil } -func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { +func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { opai_titles := make([]oapi.Title, 0) word := Word2Sqlc(request.Params.Word) status, err := TitleStatus2Sqlc(request.Params.Status) if err != nil { log.Errorf("%v", err) - return oapi.GetTitle400Response{}, err + return oapi.GetTitles400Response{}, err } season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) if err != nil { log.Errorf("%v", err) - return oapi.GetTitle400Response{}, err + return oapi.GetTitles400Response{}, err } // param = nil means it will not be used titles, err := s.db.SearchTitles(ctx, sqlc.SearchTitlesParams{ @@ -242,10 +242,10 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject }) if err != nil { log.Errorf("%v", err) - return oapi.GetTitle500Response{}, nil + return oapi.GetTitles500Response{}, nil } if len(titles) == 0 { - return oapi.GetTitle204Response{}, nil + return oapi.GetTitles204Response{}, nil } for _, title := range titles { @@ -253,10 +253,10 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject t, err := s.mapTitle(ctx, title) if err != nil { log.Errorf("%v", err) - return oapi.GetTitle500Response{}, nil + return oapi.GetTitles500Response{}, nil } opai_titles = append(opai_titles, t) } - return oapi.GetTitle200JSONResponse(opai_titles), nil + return oapi.GetTitles200JSONResponse(opai_titles), nil } diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 9e59261..2179977 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -48,3 +48,10 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque } return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil } + +func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) { + + // oapi_user_titles := make([]oapi.UserTitle, 0) + + return nil, nil +} diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 7b79300..49cca3d 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -86,7 +86,7 @@ CREATE TABLE usertitles ( title_id bigint NOT NULL REFERENCES titles (id), status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), - review_id bigint NOT NULL REFERENCES reviews (id), + review_id bigint REFERENCES reviews (id), ctime timestamptz -- // TODO: series status ); From 6836cfa057a7cea8fd7d2ec5b8dac202190d114c Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 18 Nov 2025 05:11:35 +0300 Subject: [PATCH 061/224] feat: stub for get user titles written --- api/openapi.yaml | 2 +- modules/backend/handlers/users.go | 30 ++++++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index bc7fb15..a33fe89 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -233,7 +233,7 @@ paths: # error: # type: string - /users: + # /users: # get: # summary: Search user # parameters: diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 2179977..0fa903f 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -4,6 +4,7 @@ import ( "context" oapi "nyanimedb/api" sqlc "nyanimedb/sql" + "time" "github.com/jackc/pgx/v5" "github.com/oapi-codegen/runtime/types" @@ -51,7 +52,32 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) { - // oapi_user_titles := make([]oapi.UserTitle, 0) + var rate int32 = 9 + var review_id int64 = 3 + time := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) - return nil, nil + var userTitles = []oapi.UserTitle{ + { + UserId: 101, + TitleId: 2001, + Status: oapi.UserTitleStatusFinished, + Rate: &rate, + Ctime: &time, + }, + { + UserId: 102, + TitleId: 2002, + Status: oapi.UserTitleStatusInProgress, + ReviewId: &review_id, + Ctime: &time, + }, + { + UserId: 103, + TitleId: 2003, + Status: oapi.UserTitleStatusDropped, + Ctime: &time, + }, + } + + return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil } From b976c35b8ead35dae8511f77a344559dc5b56418 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 18 Nov 2025 05:15:38 +0300 Subject: [PATCH 062/224] feat: titles page --- modules/frontend/src/App.tsx | 4 +- modules/frontend/src/api/index.ts | 7 + modules/frontend/src/api/models/Image.ts | 10 + .../frontend/src/api/models/ReleaseSeason.ts | 13 + modules/frontend/src/api/models/Studio.ts | 12 + modules/frontend/src/api/models/Tag.ts | 5 +- modules/frontend/src/api/models/Tags.ts | 9 + .../frontend/src/api/models/TitleStatus.ts | 12 + modules/frontend/src/api/models/User.ts | 4 +- .../src/api/models/UserTitleStatus.ts | 13 + modules/frontend/src/api/models/cursor.ts | 5 + .../src/api/services/DefaultService.ts | 113 ++++++ modules/frontend/src/api_/core/ApiError.ts | 25 ++ .../src/api_/core/ApiRequestOptions.ts | 17 + modules/frontend/src/api_/core/ApiResult.ts | 11 + .../src/api_/core/CancelablePromise.ts | 131 +++++++ modules/frontend/src/api_/core/OpenAPI.ts | 32 ++ modules/frontend/src/api_/core/request.ts | 323 ++++++++++++++++++ modules/frontend/src/api_/index.ts | 23 ++ modules/frontend/src/api_/models/Image.ts | 10 + .../frontend/src/api_/models/ReleaseSeason.ts | 13 + modules/frontend/src/api_/models/Review.ts | 5 + modules/frontend/src/api_/models/Studio.ts | 12 + modules/frontend/src/api_/models/Tag.ts | 8 + modules/frontend/src/api_/models/Tags.ts | 9 + modules/frontend/src/api_/models/Title.ts | 5 + .../frontend/src/api_/models/TitleStatus.ts | 12 + modules/frontend/src/api_/models/User.ts | 35 ++ modules/frontend/src/api_/models/UserTitle.ts | 5 + .../src/api_/models/UserTitleStatus.ts | 13 + modules/frontend/src/api_/models/cursor.ts | 5 + .../src/api_/services/DefaultService.ts | 148 ++++++++ .../src/components/ListView/ListView.tsx | 52 +++ .../src/components/ListView/useListView.tsx | 37 ++ .../components/UserPage/UserPage.module.css | 97 ------ .../components/cards/TitleCardHorizontal.tsx | 22 ++ .../src/components/cards/TitleCardSquare.tsx | 22 ++ .../src/pages/TitlePage/TitlePage.module.css | 0 .../src/pages/TitlePage/TitlePage.tsx | 64 ++++ .../pages/TitlesPage/TitlesPage.module.css | 59 ++++ .../src/pages/TitlesPage/TitlesPage.tsx | 114 +++++++ .../src/pages/UserPage/UserPage.module.css | 103 ++++++ .../UserPage/UserPage.tsx | 15 +- modules/frontend/src/types/list.ts | 12 + 44 files changed, 1539 insertions(+), 107 deletions(-) create mode 100644 modules/frontend/src/api/models/Image.ts create mode 100644 modules/frontend/src/api/models/ReleaseSeason.ts create mode 100644 modules/frontend/src/api/models/Studio.ts create mode 100644 modules/frontend/src/api/models/Tags.ts create mode 100644 modules/frontend/src/api/models/TitleStatus.ts create mode 100644 modules/frontend/src/api/models/UserTitleStatus.ts create mode 100644 modules/frontend/src/api/models/cursor.ts create mode 100644 modules/frontend/src/api_/core/ApiError.ts create mode 100644 modules/frontend/src/api_/core/ApiRequestOptions.ts create mode 100644 modules/frontend/src/api_/core/ApiResult.ts create mode 100644 modules/frontend/src/api_/core/CancelablePromise.ts create mode 100644 modules/frontend/src/api_/core/OpenAPI.ts create mode 100644 modules/frontend/src/api_/core/request.ts create mode 100644 modules/frontend/src/api_/index.ts create mode 100644 modules/frontend/src/api_/models/Image.ts create mode 100644 modules/frontend/src/api_/models/ReleaseSeason.ts create mode 100644 modules/frontend/src/api_/models/Review.ts create mode 100644 modules/frontend/src/api_/models/Studio.ts create mode 100644 modules/frontend/src/api_/models/Tag.ts create mode 100644 modules/frontend/src/api_/models/Tags.ts create mode 100644 modules/frontend/src/api_/models/Title.ts create mode 100644 modules/frontend/src/api_/models/TitleStatus.ts create mode 100644 modules/frontend/src/api_/models/User.ts create mode 100644 modules/frontend/src/api_/models/UserTitle.ts create mode 100644 modules/frontend/src/api_/models/UserTitleStatus.ts create mode 100644 modules/frontend/src/api_/models/cursor.ts create mode 100644 modules/frontend/src/api_/services/DefaultService.ts create mode 100644 modules/frontend/src/components/ListView/ListView.tsx create mode 100644 modules/frontend/src/components/ListView/useListView.tsx delete mode 100644 modules/frontend/src/components/UserPage/UserPage.module.css create mode 100644 modules/frontend/src/components/cards/TitleCardHorizontal.tsx create mode 100644 modules/frontend/src/components/cards/TitleCardSquare.tsx create mode 100644 modules/frontend/src/pages/TitlePage/TitlePage.module.css create mode 100644 modules/frontend/src/pages/TitlePage/TitlePage.tsx create mode 100644 modules/frontend/src/pages/TitlesPage/TitlesPage.module.css create mode 100644 modules/frontend/src/pages/TitlesPage/TitlesPage.tsx create mode 100644 modules/frontend/src/pages/UserPage/UserPage.module.css rename modules/frontend/src/{components => pages}/UserPage/UserPage.tsx (88%) create mode 100644 modules/frontend/src/types/list.ts diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index a88ad57..1256086 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,12 +1,14 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; -import UserPage from "./components/UserPage/UserPage"; +import UserPage from "./pages/UserPage/UserPage"; +import TitlesPage from "./pages/UserPage/UserPage"; const App: React.FC = () => { return ( <Router> <Routes> <Route path="/users/:id" element={<UserPage />} /> + <Route path="/titles" element={<TitlesPage />} /> </Routes> </Router> ); diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index e4f4ef4..f0d09ee 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -7,10 +7,17 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; +export type { cursor } from './models/cursor'; +export type { Image } from './models/Image'; +export { ReleaseSeason } from './models/ReleaseSeason'; export type { Review } from './models/Review'; +export type { Studio } from './models/Studio'; export type { Tag } from './models/Tag'; +export type { Tags } from './models/Tags'; export type { Title } from './models/Title'; +export { TitleStatus } from './models/TitleStatus'; export type { User } from './models/User'; export type { UserTitle } from './models/UserTitle'; +export { UserTitleStatus } from './models/UserTitleStatus'; export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api/models/Image.ts b/modules/frontend/src/api/models/Image.ts new file mode 100644 index 0000000..1317db7 --- /dev/null +++ b/modules/frontend/src/api/models/Image.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Image = { + id?: number; + storage_type?: string; + image_path?: string; +}; + diff --git a/modules/frontend/src/api/models/ReleaseSeason.ts b/modules/frontend/src/api/models/ReleaseSeason.ts new file mode 100644 index 0000000..182b980 --- /dev/null +++ b/modules/frontend/src/api/models/ReleaseSeason.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Title release season + */ +export enum ReleaseSeason { + WINTER = 'winter', + SPRING = 'spring', + SUMMER = 'summer', + FALL = 'fall', +} diff --git a/modules/frontend/src/api/models/Studio.ts b/modules/frontend/src/api/models/Studio.ts new file mode 100644 index 0000000..062695a --- /dev/null +++ b/modules/frontend/src/api/models/Studio.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Image } from './Image'; +export type Studio = { + id: number; + name: string; + poster?: Image; + description?: string; +}; + diff --git a/modules/frontend/src/api/models/Tag.ts b/modules/frontend/src/api/models/Tag.ts index 9560ea8..665c724 100644 --- a/modules/frontend/src/api/models/Tag.ts +++ b/modules/frontend/src/api/models/Tag.ts @@ -2,4 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Tag = Record<string, any>; +/** + * A localized tag: keys are language codes (ISO 639-1), values are tag names + */ +export type Tag = Record<string, string>; diff --git a/modules/frontend/src/api/models/Tags.ts b/modules/frontend/src/api/models/Tags.ts new file mode 100644 index 0000000..748f066 --- /dev/null +++ b/modules/frontend/src/api/models/Tags.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Tag } from './Tag'; +/** + * Array of localized tags + */ +export type Tags = Array<Tag>; diff --git a/modules/frontend/src/api/models/TitleStatus.ts b/modules/frontend/src/api/models/TitleStatus.ts new file mode 100644 index 0000000..811ece8 --- /dev/null +++ b/modules/frontend/src/api/models/TitleStatus.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Title status + */ +export enum TitleStatus { + FINISHED = 'finished', + ONGOING = 'ongoing', + PLANNED = 'planned', +} diff --git a/modules/frontend/src/api/models/User.ts b/modules/frontend/src/api/models/User.ts index b03d22f..541028e 100644 --- a/modules/frontend/src/api/models/User.ts +++ b/modules/frontend/src/api/models/User.ts @@ -6,7 +6,7 @@ export type User = { /** * Unique user ID (primary key) */ - id?: number; + id: number; /** * ID of the user avatar (references images table) */ @@ -30,6 +30,6 @@ export type User = { /** * Timestamp when the user was created */ - creation_date: string; + creation_date?: string; }; diff --git a/modules/frontend/src/api/models/UserTitleStatus.ts b/modules/frontend/src/api/models/UserTitleStatus.ts new file mode 100644 index 0000000..20651fe --- /dev/null +++ b/modules/frontend/src/api/models/UserTitleStatus.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * User's title status + */ +export enum UserTitleStatus { + FINISHED = 'finished', + PLANNED = 'planned', + DROPPED = 'dropped', + IN_PROGRESS = 'in-progress', +} diff --git a/modules/frontend/src/api/models/cursor.ts b/modules/frontend/src/api/models/cursor.ts new file mode 100644 index 0000000..5788e14 --- /dev/null +++ b/modules/frontend/src/api/models/cursor.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type cursor = string; diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 7ebd129..b0ae54d 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -2,11 +2,84 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { ReleaseSeason } from '../models/ReleaseSeason'; +import type { Title } from '../models/Title'; +import type { TitleStatus } from '../models/TitleStatus'; import type { User } from '../models/User'; +import type { UserTitle } from '../models/UserTitle'; import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; import { request as __request } from '../core/request'; export class DefaultService { + /** + * Get titles + * @param word + * @param status + * @param rating + * @param releaseYear + * @param releaseSeason + * @param limit + * @param offset + * @param fields + * @returns Title List of titles + * @throws ApiError + */ + public static getTitles( + word?: string, + status?: TitleStatus, + rating?: number, + releaseYear?: number, + releaseSeason?: ReleaseSeason, + limit: number = 10, + offset?: number, + fields: string = 'all', + ): CancelablePromise<Array<Title>> { + return __request(OpenAPI, { + method: 'GET', + url: '/titles', + query: { + 'word': word, + 'status': status, + 'rating': rating, + 'release_year': releaseYear, + 'release_season': releaseSeason, + 'limit': limit, + 'offset': offset, + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get title description + * @param titleId + * @param fields + * @returns Title Title description + * @throws ApiError + */ + public static getTitles1( + titleId: number, + fields: string = 'all', + ): CancelablePromise<Title> { + return __request(OpenAPI, { + method: 'GET', + url: '/titles/{title_id}', + path: { + 'title_id': titleId, + }, + query: { + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 404: `Title not found`, + 500: `Unknown server error`, + }, + }); + } /** * Get user info * @param userId @@ -28,7 +101,47 @@ export class DefaultService { 'fields': fields, }, errors: { + 400: `Request params are not correct`, 404: `User not found`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get user titles + * @param userId + * @param cursor + * @param query + * @param limit + * @param offset + * @param fields + * @returns UserTitle List of user titles + * @throws ApiError + */ + public static getUsersTitles( + userId: string, + cursor?: string, + query?: string, + limit: number = 10, + offset?: number, + fields: string = 'all', + ): CancelablePromise<Array<UserTitle>> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + query: { + 'cursor': cursor, + 'query': query, + 'limit': limit, + 'offset': offset, + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 500: `Unknown server error`, }, }); } diff --git a/modules/frontend/src/api_/core/ApiError.ts b/modules/frontend/src/api_/core/ApiError.ts new file mode 100644 index 0000000..ec7b16a --- /dev/null +++ b/modules/frontend/src/api_/core/ApiError.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: any; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} diff --git a/modules/frontend/src/api_/core/ApiRequestOptions.ts b/modules/frontend/src/api_/core/ApiRequestOptions.ts new file mode 100644 index 0000000..93143c3 --- /dev/null +++ b/modules/frontend/src/api_/core/ApiRequestOptions.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiRequestOptions = { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record<string, any>; + readonly cookies?: Record<string, any>; + readonly headers?: Record<string, any>; + readonly query?: Record<string, any>; + readonly formData?: Record<string, any>; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record<number, string>; +}; diff --git a/modules/frontend/src/api_/core/ApiResult.ts b/modules/frontend/src/api_/core/ApiResult.ts new file mode 100644 index 0000000..ee1126e --- /dev/null +++ b/modules/frontend/src/api_/core/ApiResult.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiResult = { + readonly url: string; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly body: any; +}; diff --git a/modules/frontend/src/api_/core/CancelablePromise.ts b/modules/frontend/src/api_/core/CancelablePromise.ts new file mode 100644 index 0000000..d70de92 --- /dev/null +++ b/modules/frontend/src/api_/core/CancelablePromise.ts @@ -0,0 +1,131 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export class CancelError extends Error { + + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise<T> implements Promise<T> { + #isResolved: boolean; + #isRejected: boolean; + #isCancelled: boolean; + readonly #cancelHandlers: (() => void)[]; + readonly #promise: Promise<T>; + #resolve?: (value: T | PromiseLike<T>) => void; + #reject?: (reason?: any) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike<T>) => void, + reject: (reason?: any) => void, + onCancel: OnCancel + ) => void + ) { + this.#isResolved = false; + this.#isRejected = false; + this.#isCancelled = false; + this.#cancelHandlers = []; + this.#promise = new Promise<T>((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + + const onResolve = (value: T | PromiseLike<T>): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isResolved = true; + if (this.#resolve) this.#resolve(value); + }; + + const onReject = (reason?: any): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isRejected = true; + if (this.#reject) this.#reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this.#isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this.#isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this.#isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + get [Symbol.toStringTag]() { + return "Cancellable Promise"; + } + + public then<TResult1 = T, TResult2 = never>( + onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null + ): Promise<TResult1 | TResult2> { + return this.#promise.then(onFulfilled, onRejected); + } + + public catch<TResult = never>( + onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null + ): Promise<T | TResult> { + return this.#promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise<T> { + return this.#promise.finally(onFinally); + } + + public cancel(): void { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isCancelled = true; + if (this.#cancelHandlers.length) { + try { + for (const cancelHandler of this.#cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this.#cancelHandlers.length = 0; + if (this.#reject) this.#reject(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this.#isCancelled; + } +} diff --git a/modules/frontend/src/api_/core/OpenAPI.ts b/modules/frontend/src/api_/core/OpenAPI.ts new file mode 100644 index 0000000..185e5c3 --- /dev/null +++ b/modules/frontend/src/api_/core/OpenAPI.ts @@ -0,0 +1,32 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; + +type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; +type Headers = Record<string, string>; + +export type OpenAPIConfig = { + BASE: string; + VERSION: string; + WITH_CREDENTIALS: boolean; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + TOKEN?: string | Resolver<string> | undefined; + USERNAME?: string | Resolver<string> | undefined; + PASSWORD?: string | Resolver<string> | undefined; + HEADERS?: Headers | Resolver<Headers> | undefined; + ENCODE_PATH?: ((path: string) => string) | undefined; +}; + +export const OpenAPI: OpenAPIConfig = { + BASE: '/api/v1', + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'include', + TOKEN: undefined, + USERNAME: undefined, + PASSWORD: undefined, + HEADERS: undefined, + ENCODE_PATH: undefined, +}; diff --git a/modules/frontend/src/api_/core/request.ts b/modules/frontend/src/api_/core/request.ts new file mode 100644 index 0000000..1dc6fef --- /dev/null +++ b/modules/frontend/src/api_/core/request.ts @@ -0,0 +1,323 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; +import FormData from 'form-data'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => { + return value !== undefined && value !== null; +}; + +export const isString = (value: any): value is string => { + return typeof value === 'string'; +}; + +export const isStringWithValue = (value: any): value is string => { + return isString(value) && value !== ''; +}; + +export const isBlob = (value: any): value is Blob => { + return ( + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +export const isFormData = (value: any): value is FormData => { + return value instanceof FormData; +}; + +export const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + +export const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +export const getQueryString = (params: Record<string, any>): string => { + const qs: string[] = []; + + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach(v => { + process(key, v); + }); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; + + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); + + if (qs.length > 0) { + return `?${qs.join('&')}`; + } + + return ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; +}; + +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; + +export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => { + if (typeof resolver === 'function') { + return (resolver as Resolver<T>)(options); + } + return resolver; +}; + +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) + .filter(([_, value]) => isDefined(value)) + .reduce((headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), {} as Record<string, string>); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return headers; +}; + +export const getRequestBody = (options: ApiRequestOptions): any => { + if (options.body) { + return options.body; + } + return undefined; +}; + +export const sendRequest = async <T>( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Record<string, string>, + onCancel: OnCancel, + axiosClient: AxiosInstance +): Promise<AxiosResponse<T>> => { + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, + headers, + data: body ?? formData, + method: options.method, + withCredentials: config.WITH_CREDENTIALS, + withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, + cancelToken: source.token, + }; + + onCancel(() => source.cancel('The user aborted a request.')); + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError<T>; + if (axiosError.response) { + return axiosError.response; + } + throw error; + } +}; + +export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; + } + } + return undefined; +}; + +export const getResponseBody = (response: AxiosResponse<any>): any => { + if (response.status !== 204) { + return response.data; + } + return undefined; +}; + +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record<number, string> = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError(options, result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @param axiosClient The axios client instance to use + * @returns CancelablePromise<T> + * @throws ApiError + */ +export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + if (!onCancel.isCancelled) { + const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient); + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/modules/frontend/src/api_/index.ts b/modules/frontend/src/api_/index.ts new file mode 100644 index 0000000..f0d09ee --- /dev/null +++ b/modules/frontend/src/api_/index.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export { ApiError } from './core/ApiError'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI } from './core/OpenAPI'; +export type { OpenAPIConfig } from './core/OpenAPI'; + +export type { cursor } from './models/cursor'; +export type { Image } from './models/Image'; +export { ReleaseSeason } from './models/ReleaseSeason'; +export type { Review } from './models/Review'; +export type { Studio } from './models/Studio'; +export type { Tag } from './models/Tag'; +export type { Tags } from './models/Tags'; +export type { Title } from './models/Title'; +export { TitleStatus } from './models/TitleStatus'; +export type { User } from './models/User'; +export type { UserTitle } from './models/UserTitle'; +export { UserTitleStatus } from './models/UserTitleStatus'; + +export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api_/models/Image.ts b/modules/frontend/src/api_/models/Image.ts new file mode 100644 index 0000000..1317db7 --- /dev/null +++ b/modules/frontend/src/api_/models/Image.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Image = { + id?: number; + storage_type?: string; + image_path?: string; +}; + diff --git a/modules/frontend/src/api_/models/ReleaseSeason.ts b/modules/frontend/src/api_/models/ReleaseSeason.ts new file mode 100644 index 0000000..182b980 --- /dev/null +++ b/modules/frontend/src/api_/models/ReleaseSeason.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Title release season + */ +export enum ReleaseSeason { + WINTER = 'winter', + SPRING = 'spring', + SUMMER = 'summer', + FALL = 'fall', +} diff --git a/modules/frontend/src/api_/models/Review.ts b/modules/frontend/src/api_/models/Review.ts new file mode 100644 index 0000000..9b453b7 --- /dev/null +++ b/modules/frontend/src/api_/models/Review.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Review = Record<string, any>; diff --git a/modules/frontend/src/api_/models/Studio.ts b/modules/frontend/src/api_/models/Studio.ts new file mode 100644 index 0000000..062695a --- /dev/null +++ b/modules/frontend/src/api_/models/Studio.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Image } from './Image'; +export type Studio = { + id: number; + name: string; + poster?: Image; + description?: string; +}; + diff --git a/modules/frontend/src/api_/models/Tag.ts b/modules/frontend/src/api_/models/Tag.ts new file mode 100644 index 0000000..665c724 --- /dev/null +++ b/modules/frontend/src/api_/models/Tag.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * A localized tag: keys are language codes (ISO 639-1), values are tag names + */ +export type Tag = Record<string, string>; diff --git a/modules/frontend/src/api_/models/Tags.ts b/modules/frontend/src/api_/models/Tags.ts new file mode 100644 index 0000000..748f066 --- /dev/null +++ b/modules/frontend/src/api_/models/Tags.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Tag } from './Tag'; +/** + * Array of localized tags + */ +export type Tags = Array<Tag>; diff --git a/modules/frontend/src/api_/models/Title.ts b/modules/frontend/src/api_/models/Title.ts new file mode 100644 index 0000000..4da7aa3 --- /dev/null +++ b/modules/frontend/src/api_/models/Title.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Title = Record<string, any>; diff --git a/modules/frontend/src/api_/models/TitleStatus.ts b/modules/frontend/src/api_/models/TitleStatus.ts new file mode 100644 index 0000000..811ece8 --- /dev/null +++ b/modules/frontend/src/api_/models/TitleStatus.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Title status + */ +export enum TitleStatus { + FINISHED = 'finished', + ONGOING = 'ongoing', + PLANNED = 'planned', +} diff --git a/modules/frontend/src/api_/models/User.ts b/modules/frontend/src/api_/models/User.ts new file mode 100644 index 0000000..541028e --- /dev/null +++ b/modules/frontend/src/api_/models/User.ts @@ -0,0 +1,35 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type User = { + /** + * Unique user ID (primary key) + */ + id: number; + /** + * ID of the user avatar (references images table) + */ + avatar_id?: number | null; + /** + * User email + */ + mail?: string; + /** + * Username (alphanumeric + _ or -) + */ + nickname: string; + /** + * Display name + */ + disp_name?: string; + /** + * User description + */ + user_desc?: string; + /** + * Timestamp when the user was created + */ + creation_date?: string; +}; + diff --git a/modules/frontend/src/api_/models/UserTitle.ts b/modules/frontend/src/api_/models/UserTitle.ts new file mode 100644 index 0000000..26d5ddc --- /dev/null +++ b/modules/frontend/src/api_/models/UserTitle.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type UserTitle = Record<string, any>; diff --git a/modules/frontend/src/api_/models/UserTitleStatus.ts b/modules/frontend/src/api_/models/UserTitleStatus.ts new file mode 100644 index 0000000..20651fe --- /dev/null +++ b/modules/frontend/src/api_/models/UserTitleStatus.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * User's title status + */ +export enum UserTitleStatus { + FINISHED = 'finished', + PLANNED = 'planned', + DROPPED = 'dropped', + IN_PROGRESS = 'in-progress', +} diff --git a/modules/frontend/src/api_/models/cursor.ts b/modules/frontend/src/api_/models/cursor.ts new file mode 100644 index 0000000..5788e14 --- /dev/null +++ b/modules/frontend/src/api_/models/cursor.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type cursor = string; diff --git a/modules/frontend/src/api_/services/DefaultService.ts b/modules/frontend/src/api_/services/DefaultService.ts new file mode 100644 index 0000000..b0ae54d --- /dev/null +++ b/modules/frontend/src/api_/services/DefaultService.ts @@ -0,0 +1,148 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ReleaseSeason } from '../models/ReleaseSeason'; +import type { Title } from '../models/Title'; +import type { TitleStatus } from '../models/TitleStatus'; +import type { User } from '../models/User'; +import type { UserTitle } from '../models/UserTitle'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class DefaultService { + /** + * Get titles + * @param word + * @param status + * @param rating + * @param releaseYear + * @param releaseSeason + * @param limit + * @param offset + * @param fields + * @returns Title List of titles + * @throws ApiError + */ + public static getTitles( + word?: string, + status?: TitleStatus, + rating?: number, + releaseYear?: number, + releaseSeason?: ReleaseSeason, + limit: number = 10, + offset?: number, + fields: string = 'all', + ): CancelablePromise<Array<Title>> { + return __request(OpenAPI, { + method: 'GET', + url: '/titles', + query: { + 'word': word, + 'status': status, + 'rating': rating, + 'release_year': releaseYear, + 'release_season': releaseSeason, + 'limit': limit, + 'offset': offset, + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get title description + * @param titleId + * @param fields + * @returns Title Title description + * @throws ApiError + */ + public static getTitles1( + titleId: number, + fields: string = 'all', + ): CancelablePromise<Title> { + return __request(OpenAPI, { + method: 'GET', + url: '/titles/{title_id}', + path: { + 'title_id': titleId, + }, + query: { + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 404: `Title not found`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get user info + * @param userId + * @param fields + * @returns User User info + * @throws ApiError + */ + public static getUsers( + userId: string, + fields: string = 'all', + ): CancelablePromise<User> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}', + path: { + 'user_id': userId, + }, + query: { + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 404: `User not found`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get user titles + * @param userId + * @param cursor + * @param query + * @param limit + * @param offset + * @param fields + * @returns UserTitle List of user titles + * @throws ApiError + */ + public static getUsersTitles( + userId: string, + cursor?: string, + query?: string, + limit: number = 10, + offset?: number, + fields: string = 'all', + ): CancelablePromise<Array<UserTitle>> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + query: { + 'cursor': cursor, + 'query': query, + 'limit': limit, + 'offset': offset, + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 500: `Unknown server error`, + }, + }); + } +} diff --git a/modules/frontend/src/components/ListView/ListView.tsx b/modules/frontend/src/components/ListView/ListView.tsx new file mode 100644 index 0000000..77fea97 --- /dev/null +++ b/modules/frontend/src/components/ListView/ListView.tsx @@ -0,0 +1,52 @@ +import React from "react"; + +interface ListViewProps<TItem> { + hook: ReturnType<typeof import("./useListView.tsx").useListView<TItem>>; + renderHorizontal: (item: TItem) => React.ReactNode; + renderSquare: (item: TItem) => React.ReactNode; +} + +export function ListView<TItem>({ + hook, + renderHorizontal, + renderSquare +}: ListViewProps<TItem>) { + const { items, search, setSearch, viewMode, setViewMode, loadMore, hasMore } = hook; + + return ( + <div> + {/* Search + Layout Switcher */} + <div style={{ display: "flex", gap: 8, marginBottom: 16 }}> + <input + placeholder="Search..." + value={search} + onChange={e => setSearch(e.target.value)} + /> + + <button onClick={() => setViewMode("horizontal")}>Horizontal</button> + <button onClick={() => setViewMode("square")}>Square</button> + </div> + + {/* Items */} + <div + style={{ + display: "grid", + gridTemplateColumns: viewMode === "square" ? "repeat(auto-fill, 160px)" : "1fr", + gap: 12 + }} + > + {items.map(item => + viewMode === "horizontal" + ? renderHorizontal(item) + : renderSquare(item) + )} + </div> + + {hasMore && ( + <button onClick={loadMore} style={{ marginTop: 16 }}> + Load More + </button> + )} + </div> + ); +} \ No newline at end of file diff --git a/modules/frontend/src/components/ListView/useListView.tsx b/modules/frontend/src/components/ListView/useListView.tsx new file mode 100644 index 0000000..20c3597 --- /dev/null +++ b/modules/frontend/src/components/ListView/useListView.tsx @@ -0,0 +1,37 @@ +import { useState, useEffect } from "react"; +import type { FetchFunction } from "../../types/list"; + +export function useListView<TItem>(fetchFn: FetchFunction<TItem>) { + const [items, setItems] = useState<TItem[]>([]); + const [cursor, setCursor] = useState<string | undefined>(); + const [search, setSearch] = useState(""); + const [viewMode, setViewMode] = useState<"horizontal" | "square">("horizontal"); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + loadItems(true); + }, [search]); + + const loadItems = async (reset = false) => { + setIsLoading(true); + const result = await fetchFn({ + search, + cursor: reset ? undefined : cursor, + }); + + setItems(prev => reset ? result.items : [...prev, ...result.items]); + setCursor(result.nextCursor); + setIsLoading(false); + }; + + return { + items, + search, + setSearch, + viewMode, + setViewMode, + loadMore: () => loadItems(), + hasMore: Boolean(cursor), + isLoading, + }; +} \ No newline at end of file diff --git a/modules/frontend/src/components/UserPage/UserPage.module.css b/modules/frontend/src/components/UserPage/UserPage.module.css deleted file mode 100644 index 75195bf..0000000 --- a/modules/frontend/src/components/UserPage/UserPage.module.css +++ /dev/null @@ -1,97 +0,0 @@ -.container { - display: flex; - justify-content: center; - align-items: flex-start; - padding: 3rem 1rem; - background-color: #f5f6fa; - min-height: 100vh; - font-family: "Inter", sans-serif; -} - -.card { - background-color: #ffffff; - border-radius: 1rem; - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); - padding: 2rem; - max-width: 400px; - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; -} - -.avatar { - margin-bottom: 1.5rem; -} - -.avatarImg { - width: 120px; - height: 120px; - border-radius: 50%; - object-fit: cover; - border: 3px solid #4a90e2; -} - -.avatarPlaceholder { - width: 120px; - height: 120px; - border-radius: 50%; - background-color: #dcdde1; - display: flex; - align-items: center; - justify-content: center; - font-size: 3rem; - color: #4a4a4a; - font-weight: bold; - border: 3px solid #4a90e2; -} - -.info { - display: flex; - flex-direction: column; - align-items: center; -} - -.name { - font-size: 1.8rem; - font-weight: 700; - margin: 0.25rem 0; - color: #2f3640; -} - -.nickname { - font-size: 1rem; - color: #718093; - margin-bottom: 1rem; -} - -.desc { - font-size: 1rem; - color: #353b48; - margin-bottom: 1rem; -} - -.created { - font-size: 0.9rem; - color: #7f8fa6; -} - -.loader { - display: flex; - justify-content: center; - align-items: center; - height: 80vh; - font-size: 1.5rem; - color: #4a90e2; -} - -.error { - display: flex; - justify-content: center; - align-items: center; - height: 80vh; - color: #e84118; - font-weight: 600; - font-size: 1.2rem; -} diff --git a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx new file mode 100644 index 0000000..c3a8159 --- /dev/null +++ b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx @@ -0,0 +1,22 @@ +import type { Title } from "../../api/models/Title"; + +export function TitleCardHorizontal({ title }: { title: Title }) { + return ( + <div style={{ + display: "flex", + gap: 12, + padding: 12, + border: "1px solid #ddd", + borderRadius: 8 + }}> + {title.posterUrl && ( + <img src={title.posterUrl} width={80} /> + )} + <div> + <h3>{title.name}</h3> + <p>{title.year} · {title.season} · Rating: {title.rating}</p> + <p>Status: {title.status}</p> + </div> + </div> + ); +} diff --git a/modules/frontend/src/components/cards/TitleCardSquare.tsx b/modules/frontend/src/components/cards/TitleCardSquare.tsx new file mode 100644 index 0000000..0fc0339 --- /dev/null +++ b/modules/frontend/src/components/cards/TitleCardSquare.tsx @@ -0,0 +1,22 @@ +// TitleCardSquare.tsx +import type { Title } from "../../api/models/Title"; + +export function TitleCardSquare({ title }: { title: Title }) { + return ( + <div style={{ + width: 160, + border: "1px solid #ddd", + padding: 8, + borderRadius: 8, + textAlign: "center" + }}> + {title.posterUrl && ( + <img src={title.posterUrl} width={140} /> + )} + <div> + <h4>{title.name}</h4> + <small>{title.year} • {title.rating}</small> + </div> + </div> + ); +} diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.module.css b/modules/frontend/src/pages/TitlePage/TitlePage.module.css new file mode 100644 index 0000000..e69de29 diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx new file mode 100644 index 0000000..7fe9de7 --- /dev/null +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -0,0 +1,64 @@ +// import React, { useEffect, useState } from "react"; +// import { useParams } from "react-router-dom"; +// import { DefaultService } from "../../api/services/DefaultService"; +// import type { User } from "../../api/models/User"; +// import styles from "./UserPage.module.css"; + +// const UserPage: React.FC = () => { +// const { id } = useParams<{ id: string }>(); +// const [user, setUser] = useState<User | null>(null); +// const [loading, setLoading] = useState(true); +// const [error, setError] = useState<string | null>(null); + +// useEffect(() => { +// if (!id) return; + +// const getTitleInfo = async () => { +// try { +// const userInfo = await DefaultService.getTitle(id, "all"); +// setUser(userInfo); +// } catch (err) { +// console.error(err); +// setError("Failed to fetch user info."); +// } finally { +// setLoading(false); +// } +// }; +// getTitleInfo(); +// }, [id]); + +// if (loading) return <div className={styles.loader}>Loading...</div>; +// if (error) return <div className={styles.error}>{error}</div>; +// if (!user) return <div className={styles.error}>User not found.</div>; + +// return ( +// <div className={styles.container}> +// <div className={styles.card}> +// <div className={styles.avatar}> +// {user.avatar_id ? ( +// <img +// src={`/images/${user.avatar_id}.png`} +// alt="User Avatar" +// className={styles.avatarImg} +// /> +// ) : ( +// <div className={styles.avatarPlaceholder}> +// {user.disp_name?.[0] || "U"} +// </div> +// )} +// </div> + +// <div className={styles.info}> +// <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> +// <p className={styles.nickname}>@{user.nickname}</p> +// {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} +// <p className={styles.created}> +// Joined: {new Date(user.creation_date).toLocaleDateString()} +// </p> +// </div> +// </div> +// </div> +// ); +// }; + +// export default UserPage; diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css new file mode 100644 index 0000000..9cc728b --- /dev/null +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css @@ -0,0 +1,59 @@ +.container { + padding: 24px; +} + +.header { + display: flex; + justify-content: space-between; + margin-bottom: 16px; +} + +.searchInput { + padding: 8px; + width: 240px; +} + +.list { + display: grid; + gap: 12px; +} + +.card { + display: flex; + padding: 10px; + border: 1px solid #ddd; + border-radius: 8px; + gap: 12px; +} + +.poster { + width: 80px; + height: 120px; + object-fit: cover; + border-radius: 4px; +} + +.posterPlaceholder { + width: 80px; + height: 120px; + background: #eee; + display: flex; + align-items: center; + justify-content: center; +} + +.cardInfo { + display: flex; + flex-direction: column; +} + +.loadMore { + margin-top: 16px; + padding: 8px 16px; +} + +.loader, +.error { + padding: 20px; + text-align: center; +} diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx new file mode 100644 index 0000000..438d828 --- /dev/null +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -0,0 +1,114 @@ +import React, { useEffect, useState } from "react"; +import { DefaultService } from "../../api/services/DefaultService"; +import type { Title } from "../../api/models/Title"; +import styles from "./TitlesPage.module.css"; + +const LIMIT = 20; + +const TitlesPage: React.FC = () => { + const [titles, setTitles] = useState<Title[]>([]); + const [search, setSearch] = useState(""); + const [offset, setOffset] = useState(0); + + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState<string | null>(null); + + const fetchTitles = async (reset: boolean) => { + try { + if (reset) { + setLoading(true); + setOffset(0); + } else { + setLoadingMore(true); + } + + const result = await DefaultService.getTitles( + search || undefined, + undefined, // status + undefined, // rating + undefined, // release_year + undefined, // release_season + LIMIT, + reset ? 0 : offset, + "all" + ); + + if (reset) { + setTitles(result); + } else { + setTitles(prev => [...prev, ...result]); + } + + if (result.length > 0) { + setOffset(prev => prev + LIMIT); + } + + } catch (err) { + console.error(err); + setError("Failed to fetch titles."); + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + useEffect(() => { + fetchTitles(true); + }, [search]); + + if (loading) return <div className={styles.loader}>Loading...</div>; + if (error) return <div className={styles.error}>{error}</div>; + + return ( + <div className={styles.container}> + <div className={styles.header}> + <h1>Titles</h1> + + <input + className={styles.searchInput} + placeholder="Search titles..." + value={search} + onChange={e => setSearch(e.target.value)} + /> + </div> + + <div className={styles.list}> + {titles.map((t) => ( + <div key={t.id} className={styles.card}> + {t.poster_id ? ( + <img + src={`/images/${t.poster_id}.png`} + alt="Poster" + className={styles.poster} + /> + ) : ( + <div className={styles.posterPlaceholder}>No Image</div> + )} + + <div className={styles.cardInfo}> + <h3 className={styles.titleName}>{t.name}</h3> + <p className={styles.meta}> + {t.release_year} • {t.release_season} + </p> + <p className={styles.rating}>Rating: {t.rating}</p> + <p className={styles.status}>{t.status}</p> + </div> + </div> + ))} + </div> + + {titles.length > 0 && ( + <button + className={styles.loadMore} + onClick={() => fetchTitles(false)} + disabled={loadingMore} + > + {loadingMore ? "Loading..." : "Load More"} + </button> + )} + </div> + ); +}; + +export default TitlesPage; diff --git a/modules/frontend/src/pages/UserPage/UserPage.module.css b/modules/frontend/src/pages/UserPage/UserPage.module.css new file mode 100644 index 0000000..7f350c8 --- /dev/null +++ b/modules/frontend/src/pages/UserPage/UserPage.module.css @@ -0,0 +1,103 @@ +body, +html { + width: 100%; + margin: 0; + background-color: #777; + color: #fff; +} + +html, +body, +#root { + height: 100%; +} + +.header { + width: 100vw; + padding: 30px 40px; + background: #f7f7f7; + display: flex; + align-items: center; + gap: 25px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + border-bottom: 1px solid #e5e5e5; + color: #000000; +} + +.avatarWrapper { + width: 120px; + height: 120px; + min-width: 120px; + border-radius: 50%; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: #ddd; +} + +.avatarImg { + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatarPlaceholder { + width: 100%; + height: 100%; + border-radius: 50%; + background: #ccc; + font-size: 42px; + font-weight: bold; + color: #555; + display: flex; + align-items: center; + justify-content: center; +} + +.userInfo { + display: flex; + flex-direction: column; +} + +.name { + font-size: 32px; + font-weight: 700; + margin: 0; +} + +.nickname { + font-size: 18px; + color: #666; + margin-top: 6px; +} + +.container { + max-width: 100vw; + width: 100%; + position: absolute; + top: 0%; + /* margin: 25px auto; */ + /* padding: 0 20px; */ +} + +.content { + margin-top: 20px; +} + +.desc { + font-size: 18px; + margin-bottom: 10px; +} + +.created { + font-size: 16px; + color: #888; +} + +.loader, +.error { + text-align: center; + margin-top: 40px; + font-size: 18px; +} diff --git a/modules/frontend/src/components/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx similarity index 88% rename from modules/frontend/src/components/UserPage/UserPage.tsx rename to modules/frontend/src/pages/UserPage/UserPage.tsx index 0a83679..52c5574 100644 --- a/modules/frontend/src/components/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -33,8 +33,8 @@ const UserPage: React.FC = () => { return ( <div className={styles.container}> - <div className={styles.card}> - <div className={styles.avatar}> + <div className={styles.header}> + <div className={styles.avatarWrapper}> {user.avatar_id ? ( <img src={`/images/${user.avatar_id}.png`} @@ -48,13 +48,16 @@ const UserPage: React.FC = () => { )} </div> - <div className={styles.info}> + <div className={styles.userInfo}> <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> <p className={styles.nickname}>@{user.nickname}</p> - {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} - <p className={styles.created}> + {/* <p className={styles.created}> Joined: {new Date(user.creation_date).toLocaleDateString()} - </p> + </p> */} + </div> + + <div className={styles.content}> + {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} </div> </div> </div> diff --git a/modules/frontend/src/types/list.ts b/modules/frontend/src/types/list.ts new file mode 100644 index 0000000..582da39 --- /dev/null +++ b/modules/frontend/src/types/list.ts @@ -0,0 +1,12 @@ +export interface PaginatedResult<TItem> { + items: TItem[]; + nextCursor?: string; +} + +export interface FetchParams { + search: string; + cursor?: string; +} + +export type FetchFunction<TItem> = + (params: FetchParams) => Promise<PaginatedResult<TItem>>; From 7e41b6b9ce4a961e0d37e10a1d6535572226c17c Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 18 Nov 2025 05:26:01 +0300 Subject: [PATCH 063/224] fix --- modules/frontend/tsconfig.node.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/frontend/tsconfig.node.json b/modules/frontend/tsconfig.node.json index 8a67f62..3439137 100644 --- a/modules/frontend/tsconfig.node.json +++ b/modules/frontend/tsconfig.node.json @@ -18,7 +18,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, From ecccc29aa8a09fa867760145e281dff3c0b64566 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 18 Nov 2025 05:34:42 +0300 Subject: [PATCH 064/224] fix --- modules/frontend/tsconfig.app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/frontend/tsconfig.app.json b/modules/frontend/tsconfig.app.json index a9b5a59..2f416e5 100644 --- a/modules/frontend/tsconfig.app.json +++ b/modules/frontend/tsconfig.app.json @@ -20,7 +20,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, From 8504746d2797c00912a869f1348c578bb3d906d5 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 18 Nov 2025 05:39:11 +0300 Subject: [PATCH 065/224] fix: updated package.json --- modules/frontend/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/frontend/package.json b/modules/frontend/package.json index b4977aa..f15ffd5 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -29,5 +29,8 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" + }, + "engines": { + "node": "20.x" } } From a9391c18b90de18b09df346b814233fa858efb4b Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 18 Nov 2025 15:30:11 +0300 Subject: [PATCH 066/224] fix: TitlesPage import path --- modules/frontend/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 1256086..f67c37e 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,7 +1,7 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./pages/UserPage/UserPage"; -import TitlesPage from "./pages/UserPage/UserPage"; +import TitlesPage from "./pages/TitlesPage/TitlesPage"; const App: React.FC = () => { return ( From c0be58ba0775a1eda624c14bad63ca8ace41cc03 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 18 Nov 2025 15:39:24 +0300 Subject: [PATCH 067/224] feat: use pgxpool in backend --- go.mod | 5 +++-- modules/backend/main.go | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 7c34aeb..80a9ab1 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/jackc/pgx/v5 v5.7.6 github.com/oapi-codegen/runtime v1.1.2 github.com/pelletier/go-toml/v2 v2.2.4 - golang.org/x/crypto v0.40.0 + github.com/sirupsen/logrus v1.9.3 ) require ( @@ -26,6 +26,7 @@ require ( github.com/google/uuid v1.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -34,11 +35,11 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect diff --git a/modules/backend/main.go b/modules/backend/main.go index 42a66d3..3ac6603 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -13,7 +13,7 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" "github.com/pelletier/go-toml/v2" ) @@ -31,17 +31,17 @@ func main() { // log.Fatalf("Failed to init config: %v\n", err) // } - conn, err := pgx.Connect(context.Background(), os.Getenv("DATABASE_URL")) + pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) if err != nil { fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) os.Exit(1) } - defer conn.Close(context.Background()) + defer pool.Close() r := gin.Default() - queries := sqlc.New(conn) + queries := sqlc.New(pool) server := handlers.NewServer(queries) // r.LoadHTMLGlob("templates/*") From cdfa14cece9584ad059c10dbff061d4a00028098 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Wed, 19 Nov 2025 00:41:54 +0300 Subject: [PATCH 068/224] feat: cursor added --- api/api.gen.go | 106 ++++++++++++++++++++++++++++++++++++++++------- api/openapi.yaml | 56 +++++++++++++++++++++---- 2 files changed, 139 insertions(+), 23 deletions(-) diff --git a/api/api.gen.go b/api/api.gen.go index 74a2d52..427f5af 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -24,6 +24,14 @@ const ( Winter ReleaseSeason = "winter" ) +// Defines values for TitleSort. +const ( + Id TitleSort = "id" + Rating TitleSort = "rating" + Views TitleSort = "views" + Year TitleSort = "year" +) + // Defines values for TitleStatus. const ( TitleStatusFinished TitleStatus = "finished" @@ -91,6 +99,9 @@ type Title struct { AdditionalProperties map[string]interface{} `json:"-"` } +// TitleSort Title sort order +type TitleSort string + // TitleStatus Title status type TitleStatus string @@ -139,6 +150,9 @@ type Cursor = string // GetTitlesParams defines parameters for GetTitles. type GetTitlesParams struct { + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` + SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` Word *string `form:"word,omitempty" json:"word,omitempty"` Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` @@ -161,11 +175,15 @@ type GetUsersUserIdParams struct { // GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. type GetUsersUserIdTitlesParams struct { - Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` - Query *string `form:"query,omitempty" json:"query,omitempty"` - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Word *string `form:"word,omitempty" json:"word,omitempty"` + Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` + WatchStatus *UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` + Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` + ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` + ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } // Getter for additional properties for Title. Returns the specified @@ -580,6 +598,30 @@ func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { // Parameter object where we will unmarshal all parameters from the context var params GetTitlesParams + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor", c.Request.URL.Query(), ¶ms.Cursor) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter cursor: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "sort" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort", c.Request.URL.Query(), ¶ms.Sort) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "sort_forward" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort_forward", c.Request.URL.Query(), ¶ms.SortForward) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort_forward: %w", err), http.StatusBadRequest) + return + } + // ------------- Optional query parameter "word" ------------- err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) @@ -749,11 +791,51 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { return } - // ------------- Optional query parameter "query" ------------- + // ------------- Optional query parameter "word" ------------- - err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) + err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter word: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameter("form", true, false, "status", c.Request.URL.Query(), ¶ms.Status) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "watch_status" ------------- + + err = runtime.BindQueryParameter("form", true, false, "watch_status", c.Request.URL.Query(), ¶ms.WatchStatus) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter watch_status: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "rating" ------------- + + err = runtime.BindQueryParameter("form", true, false, "rating", c.Request.URL.Query(), ¶ms.Rating) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter rating: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_year" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_year", c.Request.URL.Query(), ¶ms.ReleaseYear) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_year: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "release_season" ------------- + + err = runtime.BindQueryParameter("form", true, false, "release_season", c.Request.URL.Query(), ¶ms.ReleaseSeason) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter release_season: %w", err), http.StatusBadRequest) return } @@ -765,14 +847,6 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { return } - // ------------- Optional query parameter "offset" ------------- - - err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), ¶ms.Offset) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) - return - } - // ------------- Optional query parameter "fields" ------------- err = runtime.BindQueryParameter("form", true, false, "fields", c.Request.URL.Query(), ¶ms.Fields) diff --git a/api/openapi.yaml b/api/openapi.yaml index a33fe89..a6ae12c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -11,6 +11,13 @@ paths: get: summary: Get titles parameters: + - $ref: '#/components/parameters/cursor' + - $ref: '#/components/parameters/title_sort' + - in: query + name: sort_forward + default: true + schema: + type: boolean - in: query name: word schema: @@ -289,19 +296,37 @@ paths: schema: type: string - in: query - name: query + name: word schema: type: string + - in: query + name: status + schema: + $ref: '#/components/schemas/TitleStatus' + - in: query + name: watch_status + schema: + $ref: '#/components/schemas/UserTitleStatus' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '#/components/schemas/ReleaseSeason' - in: query name: limit schema: type: integer + format: int32 default: 10 - - in: query - name: offset - schema: - type: integer - default: 0 - in: query name: fields schema: @@ -577,14 +602,31 @@ paths: components: parameters: - cursor: + cursor: # example base64( {id: 1, param: 2019}) in: query name: cursor required: false schema: type: string + + title_sort: + in: query + name: sort + required: false + schema: + $ref: '#/components/schemas/TitleSort' schemas: + TitleSort: + type: string + description: Title sort order + default: id + enum: + - id + - year + - rating + - views + Image: type: object properties: From 9ed09500ed773b53c63afcb217e236a2764095f7 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Wed, 19 Nov 2025 01:42:40 +0300 Subject: [PATCH 069/224] refact: slightly refactored openapi spec --- api/openapi.yaml | 438 +++-------------------------------------------- 1 file changed, 26 insertions(+), 412 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index a6ae12c..b52ba58 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.1 +openapi: 3.0.4 info: title: Titles, Users, Reviews, Tags, and Media API version: 1.0.0 @@ -15,9 +15,9 @@ paths: - $ref: '#/components/parameters/title_sort' - in: query name: sort_forward - default: true schema: type: boolean + default: true - in: query name: word schema: @@ -59,13 +59,22 @@ paths: default: all responses: '200': - description: List of titles + description: List of titles with cursor content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Title' + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Title' + description: List of titles + cursor: + $ref: "#/components/schemas/CursorObj" + required: + - data + - cursor '204': description: No titles found '400': @@ -104,66 +113,6 @@ paths: '204': description: No title found -# patch: -# summary: Update title info -# parameters: -# - in: path -# name: title_id -# required: true -# schema: -# type: string -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/Title' -# responses: -# '200': -# description: Update result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string -# user_json: -# $ref: '#/components/schemas/User' - -# /titles/{title_id}/reviews: -# get: -# summary: Get title reviews -# parameters: -# - in: path -# name: title_id -# required: true -# schema: -# type: string -# - in: query -# name: limit -# schema: -# type: integer -# default: 10 -# - in: query -# name: offset -# schema: -# type: integer -# default: 0 -# responses: -# '200': -# description: List of reviews -# content: -# application/json: -# schema: -# type: array -# items: -# $ref: '#/components/schemas/Review' -# '204': -# description: No reviews found - /users/{user_id}: get: summary: Get user info @@ -192,99 +141,6 @@ paths: '500': description: Unknown server error - # patch: - # summary: Update user - # parameters: - # - in: path - # name: user_id - # required: true - # schema: - # type: string - # requestBody: - # required: true - # content: - # application/json: - # schema: - # $ref: '#/components/schemas/User' - # responses: - # '200': - # description: Update result - # content: - # application/json: - # schema: - # type: object - # properties: - # success: - # type: boolean - # error: - # type: string - - # delete: - # summary: Delete user - # parameters: - # - in: path - # name: user_id - # required: true - # schema: - # type: string - # responses: - # '200': - # description: Delete result - # content: - # application/json: - # schema: - # type: object - # properties: - # success: - # type: boolean - # error: - # type: string - - # /users: - # get: - # summary: Search user - # parameters: - # - in: query - # name: query - # schema: - # type: string - # - in: query - # name: fields - # schema: - # type: string - # responses: - # '200': - # description: List of users - # content: - # application/json: - # schema: - # type: array - # items: - # $ref: '#/components/schemas/User' - - # post: - # summary: Add new user - # requestBody: - # required: true - # content: - # application/json: - # schema: - # $ref: '#/components/schemas/User' - # responses: - # '200': - # description: Add result - # content: - # application/json: - # schema: - # type: object - # properties: - # success: - # type: boolean - # error: - # type: string - # user_json: - # $ref: '#/components/schemas/User' - /users/{user_id}/titles/: get: summary: Get user titles @@ -348,258 +204,6 @@ paths: '500': description: Unknown server error -# post: -# summary: Add user title -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# requestBody: -# required: true -# content: -# application/json: -# schema: -# type: object -# properties: -# title_id: -# type: string -# status: -# type: string -# responses: -# '200': -# description: Add result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# patch: -# summary: Update user title -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/UserTitle' -# responses: -# '200': -# description: Update result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# delete: -# summary: Delete user title -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# - in: query -# name: title_id -# schema: -# type: string -# responses: -# '200': -# description: Delete result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# /users/{user_id}/reviews: -# get: -# summary: Get user reviews -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# - in: query -# name: limit -# schema: -# type: integer -# default: 10 -# - in: query -# name: offset -# schema: -# type: integer -# default: 0 -# responses: -# '200': -# description: List of reviews -# content: -# application/json: -# schema: -# type: array -# items: -# $ref: '#/components/schemas/Review' - -# /reviews: -# post: -# summary: Add review -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/Review' -# responses: -# '200': -# description: Add result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# /reviews/{review_id}: -# patch: -# summary: Update review -# parameters: -# - in: path -# name: review_id -# required: true -# schema: -# type: string -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/Review' -# responses: -# '200': -# description: Update result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string -# delete: -# summary: Delete review -# parameters: -# - in: path -# name: review_id -# required: true -# schema: -# type: string -# responses: -# '200': -# description: Delete result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string - -# /tags: -# get: -# summary: Get tags -# parameters: -# - in: query -# name: limit -# schema: -# type: integer -# default: 10 -# - in: query -# name: offset -# schema: -# type: integer -# default: 0 -# - in: query -# name: fields -# schema: -# type: string -# responses: -# '200': -# description: List of tags -# content: -# application/json: -# schema: -# type: array -# items: -# $ref: '#/components/schemas/Tag' - -# /media: -# post: -# summary: Upload image -# responses: -# '200': -# description: Upload result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string -# image_id: -# type: string - -# get: -# summary: Get image path -# parameters: -# - in: query -# name: image_id -# required: true -# schema: -# type: string -# responses: -# '200': -# description: Image path -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string -# image_path: -# type: string - components: parameters: cursor: # example base64( {id: 1, param: 2019}) @@ -617,6 +221,17 @@ components: $ref: '#/components/schemas/TitleSort' schemas: + CursorObj: + type: object + required: + - id + properties: + id: + type: integer + format: int64 + param: + type: string + TitleSort: type: string description: Title sort order @@ -778,7 +393,6 @@ components: type: integer format: int64 description: ID of the user avatar (references images table) - nullable: true example: null mail: type: string From 2025bb451fbde55c50f235117674aaea1ba751ee Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Wed, 19 Nov 2025 03:14:41 +0300 Subject: [PATCH 070/224] refact: openapi splitted into separate files --- api/openapi.yaml | 441 +------------------------ api/parameters/_index.yaml | 4 + api/parameters/cursor.yaml | 5 + api/parameters/title_sort.yaml | 5 + api/paths/titles-id.yaml | 29 ++ api/paths/titles.yaml | 73 ++++ api/paths/users-id-titles.yaml | 61 ++++ api/paths/users-id.yaml | 26 ++ api/schemas/CursorObj.yaml | 9 + api/schemas/Image.yaml | 9 + api/schemas/Review.yaml | 2 + api/schemas/Studio.yaml | 14 + api/schemas/Tag.yaml | 8 + api/schemas/Tags.yaml | 11 + api/schemas/Title.yaml | 63 ++++ api/schemas/TitleSort.yaml | 8 + api/schemas/User.yaml | 40 +++ api/schemas/UserTitle.yaml | 24 ++ api/schemas/_index.yaml | 26 ++ api/schemas/enums/ReleaseSeason.yaml | 7 + api/schemas/enums/TitleStatus.yaml | 6 + api/schemas/enums/UserTitleStatus.yaml | 7 + 22 files changed, 443 insertions(+), 435 deletions(-) create mode 100644 api/parameters/_index.yaml create mode 100644 api/parameters/cursor.yaml create mode 100644 api/parameters/title_sort.yaml create mode 100644 api/paths/titles-id.yaml create mode 100644 api/paths/titles.yaml create mode 100644 api/paths/users-id-titles.yaml create mode 100644 api/paths/users-id.yaml create mode 100644 api/schemas/CursorObj.yaml create mode 100644 api/schemas/Image.yaml create mode 100644 api/schemas/Review.yaml create mode 100644 api/schemas/Studio.yaml create mode 100644 api/schemas/Tag.yaml create mode 100644 api/schemas/Tags.yaml create mode 100644 api/schemas/Title.yaml create mode 100644 api/schemas/TitleSort.yaml create mode 100644 api/schemas/User.yaml create mode 100644 api/schemas/UserTitle.yaml create mode 100644 api/schemas/_index.yaml create mode 100644 api/schemas/enums/ReleaseSeason.yaml create mode 100644 api/schemas/enums/TitleStatus.yaml create mode 100644 api/schemas/enums/UserTitleStatus.yaml diff --git a/api/openapi.yaml b/api/openapi.yaml index b52ba58..281fe82 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -8,444 +8,15 @@ servers: paths: /titles: - get: - summary: Get titles - parameters: - - $ref: '#/components/parameters/cursor' - - $ref: '#/components/parameters/title_sort' - - in: query - name: sort_forward - schema: - type: boolean - default: true - - in: query - name: word - schema: - type: string - - in: query - name: status - schema: - $ref: '#/components/schemas/TitleStatus' - - in: query - name: rating - schema: - type: number - format: double - - in: query - name: release_year - schema: - type: integer - format: int32 - - in: query - name: release_season - schema: - $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit - schema: - type: integer - format: int32 - default: 10 - - in: query - name: offset - schema: - type: integer - format: int32 - default: 0 - - in: query - name: fields - schema: - type: string - default: all - responses: - '200': - description: List of titles with cursor - content: - application/json: - schema: - type: object - properties: - data: - type: array - items: - $ref: '#/components/schemas/Title' - description: List of titles - cursor: - $ref: "#/components/schemas/CursorObj" - required: - - data - - cursor - '204': - description: No titles found - '400': - description: Request params are not correct - '500': - description: Unknown server error - + $ref: "./paths/titles.yaml" /titles/{title_id}: - get: - summary: Get title description - parameters: - - in: path - name: title_id - required: true - schema: - type: integer - format: int64 - - in: query - name: fields - schema: - type: string - default: all - responses: - '200': - description: Title description - content: - application/json: - schema: - $ref: '#/components/schemas/Title' - '404': - description: Title not found - '400': - description: Request params are not correct - '500': - description: Unknown server error - '204': - description: No title found - + $ref: "./paths/titles-id.yaml" /users/{user_id}: - get: - summary: Get user info - parameters: - - in: path - name: user_id - required: true - schema: - type: string - - in: query - name: fields - schema: - type: string - default: all - responses: - '200': - description: User info - content: - application/json: - schema: - $ref: '#/components/schemas/User' - '404': - description: User not found - '400': - description: Request params are not correct - '500': - description: Unknown server error - + $ref: "./paths/users-id.yaml" /users/{user_id}/titles/: - get: - summary: Get user titles - parameters: - - $ref: '#/components/parameters/cursor' - - in: path - name: user_id - required: true - schema: - type: string - - in: query - name: word - schema: - type: string - - in: query - name: status - schema: - $ref: '#/components/schemas/TitleStatus' - - in: query - name: watch_status - schema: - $ref: '#/components/schemas/UserTitleStatus' - - in: query - name: rating - schema: - type: number - format: double - - in: query - name: release_year - schema: - type: integer - format: int32 - - in: query - name: release_season - schema: - $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit - schema: - type: integer - format: int32 - default: 10 - - in: query - name: fields - schema: - type: string - default: all - responses: - '200': - description: List of user titles - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/UserTitle' - '204': - description: No titles found - '400': - description: Request params are not correct - '500': - description: Unknown server error - + $ref: "./paths/users-id-titles.yaml" components: parameters: - cursor: # example base64( {id: 1, param: 2019}) - in: query - name: cursor - required: false - schema: - type: string - - title_sort: - in: query - name: sort - required: false - schema: - $ref: '#/components/schemas/TitleSort' - + $ref: "./parameters/_index.yaml" schemas: - CursorObj: - type: object - required: - - id - properties: - id: - type: integer - format: int64 - param: - type: string - - TitleSort: - type: string - description: Title sort order - default: id - enum: - - id - - year - - rating - - views - - Image: - type: object - properties: - id: - type: integer - format: int64 - storage_type: - type: string - image_path: - type: string - - TitleStatus: - type: string - description: Title status - enum: - - finished - - ongoing - - planned - - ReleaseSeason: - type: string - description: Title release season - enum: - - winter - - spring - - summer - - fall - - UserTitleStatus: - type: string - description: User's title status - enum: - - finished - - planned - - dropped - - in-progress - - Review: - type: object - additionalProperties: true - - Tag: - type: object - description: "A localized tag: keys are language codes (ISO 639-1), values are tag names" - additionalProperties: - type: string - example: - en: "Shojo" - ru: "Сёдзё" - ja: "少女" - - Tags: - type: array - description: "Array of localized tags" - items: - $ref: '#/components/schemas/Tag' - example: - - en: "Shojo" - ru: "Сёдзё" - ja: "少女" - - en: "Shounen" - ru: "Сёнен" - ja: "少年" - - Studio: - type: object - required: - - id - - name - properties: - id: - type: integer - format: int64 - name: - type: string - poster: - $ref: '#/components/schemas/Image' - description: - type: string - - - Title: - type: object - required: - - id - - title_names - - tags - properties: - id: - type: integer - format: int64 - description: Unique title ID (primary key) - example: 1 - title_names: - type: object - description: "Localized titles. Key = language (ISO 639-1), value = list of names" - additionalProperties: - type: array - items: - type: string - example: "Attack on Titan" - minItems: 1 - example: ["Attack on Titan", "AoT"] - example: - en: ["Attack on Titan", "AoT"] - ru: ["Атака титанов", "Титаны"] - ja: ["進撃の巨人"] - studio: - $ref: '#/components/schemas/Studio' - tags: - $ref: '#/components/schemas/Tags' - poster: - $ref: '#/components/schemas/Image' - title_status: - $ref: '#/components/schemas/TitleStatus' - rating: - type: number - format: double - rating_count: - type: integer - format: int32 - release_year: - type: integer - format: int32 - release_season: - $ref: '#/components/schemas/ReleaseSeason' - episodes_aired: - type: integer - format: int32 - episodes_all: - type: integer - format: int32 - episodes_len: - type: object - additionalProperties: - type: number - format: double - additionalProperties: true - - User: - type: object - properties: - id: - type: integer - format: int64 - description: Unique user ID (primary key) - example: 1 - avatar_id: - type: integer - format: int64 - description: ID of the user avatar (references images table) - example: null - mail: - type: string - format: email - description: User email - example: "john.doe@example.com" - nickname: - type: string - description: Username (alphanumeric + _ or -) - maxLength: 16 - example: "john_doe_42" - disp_name: - type: string - description: Display name - maxLength: 32 - example: "John Doe" - user_desc: - type: string - description: User description - maxLength: 512 - example: "Just a regular user." - creation_date: - type: string - format: date-time - description: Timestamp when the user was created - example: "2025-10-10T23:45:47.908073Z" - required: - - user_id - - nickname - # - creation_date - - UserTitle: - type: object - required: - - user_id - - title_id - - status - properties: - user_id: - type: integer - format: int64 - title_id: - type: integer - format: int64 - status: - $ref: '#/components/schemas/UserTitleStatus' - rate: - type: integer - format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time - additionalProperties: true + $ref: "./schemas/_index.yaml" diff --git a/api/parameters/_index.yaml b/api/parameters/_index.yaml new file mode 100644 index 0000000..6249e7d --- /dev/null +++ b/api/parameters/_index.yaml @@ -0,0 +1,4 @@ +cursor: + $ref: "./cursor.yaml" +title_sort: + $ref: "./title_sort.yaml" \ No newline at end of file diff --git a/api/parameters/cursor.yaml b/api/parameters/cursor.yaml new file mode 100644 index 0000000..1730f18 --- /dev/null +++ b/api/parameters/cursor.yaml @@ -0,0 +1,5 @@ +in: query +name: cursor +required: false +schema: + type: string diff --git a/api/parameters/title_sort.yaml b/api/parameters/title_sort.yaml new file mode 100644 index 0000000..615294b --- /dev/null +++ b/api/parameters/title_sort.yaml @@ -0,0 +1,5 @@ +in: query +name: sort +required: false +schema: + $ref: '../schemas/TitleSort.yaml' diff --git a/api/paths/titles-id.yaml b/api/paths/titles-id.yaml new file mode 100644 index 0000000..01fa504 --- /dev/null +++ b/api/paths/titles-id.yaml @@ -0,0 +1,29 @@ +get: + summary: Get title description + parameters: + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: Title description + content: + application/json: + schema: + $ref: "../schemas/Title.yaml" + '404': + description: Title not found + '400': + description: Request params are not correct + '500': + description: Unknown server error + '204': + description: No title found diff --git a/api/paths/titles.yaml b/api/paths/titles.yaml new file mode 100644 index 0000000..4fd010d --- /dev/null +++ b/api/paths/titles.yaml @@ -0,0 +1,73 @@ +get: + summary: Get titles + parameters: + - $ref: ../parameters/cursor.yaml + - $ref: ../parameters/title_sort.yaml + - in: query + name: sort_forward + schema: + type: boolean + default: true + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + $ref: '../schemas/enums/TitleStatus.yaml' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '../schemas/enums/ReleaseSeason.yaml' + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: offset + schema: + type: integer + format: int32 + default: 0 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of titles with cursor + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '../schemas/Title.yaml' + description: List of titles + cursor: + $ref: '../schemas/CursorObj.yaml' + required: + - data + - cursor + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml new file mode 100644 index 0000000..0cde5af --- /dev/null +++ b/api/paths/users-id-titles.yaml @@ -0,0 +1,61 @@ +get: + summary: Get user titles + parameters: + - $ref: '../parameters/cursor.yaml' + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + $ref: '../schemas/enums/TitleStatus.yaml' + - in: query + name: watch_status + schema: + $ref: '../schemas/enums/UserTitleStatus.yaml' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '../schemas/enums/ReleaseSeason.yaml' + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of user titles + content: + application/json: + schema: + type: array + items: + $ref: '../schemas/UserTitle.yaml' + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml new file mode 100644 index 0000000..0acdb81 --- /dev/null +++ b/api/paths/users-id.yaml @@ -0,0 +1,26 @@ +get: + summary: Get user info + parameters: + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: User info + content: + application/json: + schema: + $ref: '../schemas/User.yaml' + '404': + description: User not found + '400': + description: Request params are not correct + '500': + description: Unknown server error diff --git a/api/schemas/CursorObj.yaml b/api/schemas/CursorObj.yaml new file mode 100644 index 0000000..5a3c96c --- /dev/null +++ b/api/schemas/CursorObj.yaml @@ -0,0 +1,9 @@ +type: object +required: + - id +properties: + id: + type: integer + format: int64 + param: + type: string diff --git a/api/schemas/Image.yaml b/api/schemas/Image.yaml new file mode 100644 index 0000000..7226b29 --- /dev/null +++ b/api/schemas/Image.yaml @@ -0,0 +1,9 @@ +type: object +properties: + id: + type: integer + format: int64 + storage_type: + type: string + image_path: + type: string diff --git a/api/schemas/Review.yaml b/api/schemas/Review.yaml new file mode 100644 index 0000000..a116dde --- /dev/null +++ b/api/schemas/Review.yaml @@ -0,0 +1,2 @@ +type: object +additionalProperties: true diff --git a/api/schemas/Studio.yaml b/api/schemas/Studio.yaml new file mode 100644 index 0000000..35b40a8 --- /dev/null +++ b/api/schemas/Studio.yaml @@ -0,0 +1,14 @@ +type: object +required: + - id + - name +properties: + id: + type: integer + format: int64 + name: + type: string + poster: + $ref: ./Image.yaml + description: + type: string diff --git a/api/schemas/Tag.yaml b/api/schemas/Tag.yaml new file mode 100644 index 0000000..7239b10 --- /dev/null +++ b/api/schemas/Tag.yaml @@ -0,0 +1,8 @@ +type: object +description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' +additionalProperties: + type: string +example: + en: Shojo + ru: Сёдзё + ja: 少女 diff --git a/api/schemas/Tags.yaml b/api/schemas/Tags.yaml new file mode 100644 index 0000000..ca8c4fd --- /dev/null +++ b/api/schemas/Tags.yaml @@ -0,0 +1,11 @@ +type: array +description: Array of localized tags +items: + $ref: ./Tag.yaml +example: +- en: Shojo + ru: Сёдзё + ja: 少女 +- en: Shounen + ru: Сёнен + ja: 少年 diff --git a/api/schemas/Title.yaml b/api/schemas/Title.yaml new file mode 100644 index 0000000..7497d1f --- /dev/null +++ b/api/schemas/Title.yaml @@ -0,0 +1,63 @@ +type: object +required: + - id + - title_names + - tags +properties: + id: + type: integer + format: int64 + description: Unique title ID (primary key) + example: 1 + title_names: + type: object + description: Localized titles. Key = language (ISO 639-1), value = list of names + additionalProperties: + type: array + items: + type: string + example: Attack on Titan + minItems: 1 + example: + - Attack on Titan + - AoT + example: + en: + - Attack on Titan + - AoT + ru: + - Атака титанов + - Титаны + ja: + - 進撃の巨人 + studio: + $ref: ./Studio.yaml + tags: + $ref: ./Tags.yaml + poster: + $ref: ./Image.yaml + title_status: + $ref: ./enums/TitleStatus.yaml + rating: + type: number + format: double + rating_count: + type: integer + format: int32 + release_year: + type: integer + format: int32 + release_season: + $ref: ./enums/ReleaseSeason.yaml + episodes_aired: + type: integer + format: int32 + episodes_all: + type: integer + format: int32 + episodes_len: + type: object + additionalProperties: + type: number + format: double +additionalProperties: true diff --git a/api/schemas/TitleSort.yaml b/api/schemas/TitleSort.yaml new file mode 100644 index 0000000..d8ce8f7 --- /dev/null +++ b/api/schemas/TitleSort.yaml @@ -0,0 +1,8 @@ +type: string +description: Title sort order +default: id +enum: + - id + - year + - rating + - views diff --git a/api/schemas/User.yaml b/api/schemas/User.yaml new file mode 100644 index 0000000..8b4d88d --- /dev/null +++ b/api/schemas/User.yaml @@ -0,0 +1,40 @@ +type: object +properties: + id: + type: integer + format: int64 + description: Unique user ID (primary key) + example: 1 + avatar_id: + type: integer + format: int64 + description: ID of the user avatar (references images table) + example: null + mail: + type: string + format: email + description: User email + example: john.doe@example.com + nickname: + type: string + description: Username (alphanumeric + _ or -) + maxLength: 16 + example: john_doe_42 + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Doe + user_desc: + type: string + description: User description + maxLength: 512 + example: Just a regular user. + creation_date: + type: string + format: date-time + description: Timestamp when the user was created + example: '2025-10-10T23:45:47.908073Z' +required: + - user_id + - nickname diff --git a/api/schemas/UserTitle.yaml b/api/schemas/UserTitle.yaml new file mode 100644 index 0000000..658e350 --- /dev/null +++ b/api/schemas/UserTitle.yaml @@ -0,0 +1,24 @@ +type: object +required: + - user_id + - title_id + - status +properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: ./enums/UserTitleStatus.yaml + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time +additionalProperties: true diff --git a/api/schemas/_index.yaml b/api/schemas/_index.yaml new file mode 100644 index 0000000..ac49f37 --- /dev/null +++ b/api/schemas/_index.yaml @@ -0,0 +1,26 @@ +CursorObj: + $ref: ./CursorObj.yaml +TitleSort: + $ref: "./TitleSort.yaml" +Image: + $ref: "./Image.yaml" +TitleStatus: + $ref: "./enums/TitleStatus.yaml" +ReleaseSeason: + $ref: "./enums/ReleaseSeason.yaml" +UserTitleStatus: + $ref: "./enums/UserTitleStatus.yaml" +Review: + $ref: "./Review.yaml" +Tag: + $ref: "./Tag.yaml" +Tags: + $ref: "./Tags.yaml" +Studio: + $ref: "./Studio.yaml" +Title: + $ref: "./Title.yaml" +User: + $ref: "./User.yaml" +UserTitle: + $ref: "./UserTitle.yaml" diff --git a/api/schemas/enums/ReleaseSeason.yaml b/api/schemas/enums/ReleaseSeason.yaml new file mode 100644 index 0000000..5cf988d --- /dev/null +++ b/api/schemas/enums/ReleaseSeason.yaml @@ -0,0 +1,7 @@ +type: string +description: Title release season +enum: + - winter + - spring + - summer + - fall diff --git a/api/schemas/enums/TitleStatus.yaml b/api/schemas/enums/TitleStatus.yaml new file mode 100644 index 0000000..0bfce7d --- /dev/null +++ b/api/schemas/enums/TitleStatus.yaml @@ -0,0 +1,6 @@ +type: string +description: Title status +enum: + - finished + - ongoing + - planned diff --git a/api/schemas/enums/UserTitleStatus.yaml b/api/schemas/enums/UserTitleStatus.yaml new file mode 100644 index 0000000..0b1a90d --- /dev/null +++ b/api/schemas/enums/UserTitleStatus.yaml @@ -0,0 +1,7 @@ +type: string +description: User's title status +enum: + - finished + - planned + - dropped + - in-progress From fbf3f1d3a28fa45f0698aece2e824f7568de1e8b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Wed, 19 Nov 2025 03:57:44 +0300 Subject: [PATCH 071/224] feat: now use _build to build --- api/_build/oapi-codegen.yaml | 6 + api/_build/openapi.yaml | 436 +++++++++++++++++++++++++++++++++++ api/api.gen.go | 15 +- api/paths/titles.yaml | 4 +- 4 files changed, 457 insertions(+), 4 deletions(-) create mode 100644 api/_build/oapi-codegen.yaml create mode 100644 api/_build/openapi.yaml diff --git a/api/_build/oapi-codegen.yaml b/api/_build/oapi-codegen.yaml new file mode 100644 index 0000000..32e029a --- /dev/null +++ b/api/_build/oapi-codegen.yaml @@ -0,0 +1,6 @@ +package: oapi +generate: + strict-server: true + gin-server: true + models: true +output: api/api.gen.go \ No newline at end of file diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml new file mode 100644 index 0000000..5ff77e0 --- /dev/null +++ b/api/_build/openapi.yaml @@ -0,0 +1,436 @@ +openapi: 3.0.4 +info: + title: 'Titles, Users, Reviews, Tags, and Media API' + version: 1.0.0 +servers: + - url: /api/v1 +paths: + /titles: + get: + summary: Get titles + parameters: + - $ref: '#/components/parameters/cursor' + - $ref: '#/components/parameters/title_sort' + - in: query + name: sort_forward + schema: + type: boolean + default: true + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + $ref: '#/components/schemas/TitleStatus' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '#/components/schemas/ReleaseSeason' + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: offset + schema: + type: integer + format: int32 + default: 0 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of titles with cursor + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Title' + description: List of titles + cursor: + $ref: '#/components/schemas/CursorObj' + required: + - data + - cursor + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error + '/titles/{title_id}': + get: + summary: Get title description + parameters: + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: Title description + content: + application/json: + schema: + $ref: '#/components/schemas/Title' + '204': + description: No title found + '400': + description: Request params are not correct + '404': + description: Title not found + '500': + description: Unknown server error + '/users/{user_id}': + get: + summary: Get user info + parameters: + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: User info + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Request params are not correct + '404': + description: User not found + '500': + description: Unknown server error + '/users/{user_id}/titles/': + get: + summary: Get user titles + parameters: + - $ref: '#/components/parameters/cursor' + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + $ref: '#/components/schemas/TitleStatus' + - in: query + name: watch_status + schema: + $ref: '#/components/schemas/UserTitleStatus' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '#/components/schemas/ReleaseSeason' + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of user titles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserTitle' + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error +components: + parameters: + cursor: + in: query + name: cursor + required: false + schema: + type: string + title_sort: + in: query + name: sort + required: false + schema: + $ref: '#/components/schemas/TitleSort' + schemas: + CursorObj: + type: object + required: + - id + properties: + id: + type: integer + format: int64 + param: + type: string + TitleSort: + type: string + description: Title sort order + default: id + enum: + - id + - year + - rating + - views + Image: + type: object + properties: + id: + type: integer + format: int64 + storage_type: + type: string + image_path: + type: string + TitleStatus: + type: string + description: Title status + enum: + - finished + - ongoing + - planned + ReleaseSeason: + type: string + description: Title release season + enum: + - winter + - spring + - summer + - fall + UserTitleStatus: + type: string + description: User's title status + enum: + - finished + - planned + - dropped + - in-progress + Review: + type: object + additionalProperties: true + Tag: + type: object + description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' + additionalProperties: + type: string + example: + en: Shojo + ru: Сёдзё + ja: 少女 + Tags: + type: array + description: Array of localized tags + items: + $ref: '#/components/schemas/Tag' + example: + - en: Shojo + ru: Сёдзё + ja: 少女 + - en: Shounen + ru: Сёнен + ja: 少年 + Studio: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + poster: + $ref: '#/components/schemas/Image' + description: + type: string + Title: + type: object + required: + - id + - title_names + - tags + properties: + id: + type: integer + format: int64 + description: Unique title ID (primary key) + example: 1 + title_names: + type: object + description: 'Localized titles. Key = language (ISO 639-1), value = list of names' + additionalProperties: + type: array + items: + type: string + example: Attack on Titan + minItems: 1 + example: + - Attack on Titan + - AoT + example: + en: + - Attack on Titan + - AoT + ru: + - Атака титанов + - Титаны + ja: + - 進撃の巨人 + studio: + $ref: '#/components/schemas/Studio' + tags: + $ref: '#/components/schemas/Tags' + poster: + $ref: '#/components/schemas/Image' + title_status: + $ref: '#/components/schemas/TitleStatus' + rating: + type: number + format: double + rating_count: + type: integer + format: int32 + release_year: + type: integer + format: int32 + release_season: + $ref: '#/components/schemas/ReleaseSeason' + episodes_aired: + type: integer + format: int32 + episodes_all: + type: integer + format: int32 + episodes_len: + type: object + additionalProperties: + type: number + format: double + additionalProperties: true + User: + type: object + properties: + id: + type: integer + format: int64 + description: Unique user ID (primary key) + example: 1 + avatar_id: + type: integer + format: int64 + description: ID of the user avatar (references images table) + example: null + mail: + type: string + format: email + description: User email + example: john.doe@example.com + nickname: + type: string + description: Username (alphanumeric + _ or -) + maxLength: 16 + example: john_doe_42 + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Doe + user_desc: + type: string + description: User description + maxLength: 512 + example: Just a regular user. + creation_date: + type: string + format: date-time + description: Timestamp when the user was created + example: '2025-10-10T23:45:47.908073Z' + required: + - user_id + - nickname + UserTitle: + type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + additionalProperties: true diff --git a/api/api.gen.go b/api/api.gen.go index 427f5af..f252a5a 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -47,6 +47,12 @@ const ( UserTitleStatusPlanned UserTitleStatus = "planned" ) +// CursorObj defines model for CursorObj. +type CursorObj struct { + Id int64 `json:"id"` + Param *string `json:"param,omitempty"` +} + // Image defines model for Image. type Image struct { Id *int64 `json:"id,omitempty"` @@ -108,7 +114,7 @@ type TitleStatus string // User defines model for User. type User struct { // AvatarId ID of the user avatar (references images table) - AvatarId *int64 `json:"avatar_id"` + AvatarId *int64 `json:"avatar_id,omitempty"` // CreationDate Timestamp when the user was created CreationDate *time.Time `json:"creation_date,omitempty"` @@ -906,7 +912,12 @@ type GetTitlesResponseObject interface { VisitGetTitlesResponse(w http.ResponseWriter) error } -type GetTitles200JSONResponse []Title +type GetTitles200JSONResponse struct { + Cursor CursorObj `json:"cursor"` + + // Data List of titles + Data []Title `json:"data"` +} func (response GetTitles200JSONResponse) VisitGetTitlesResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") diff --git a/api/paths/titles.yaml b/api/paths/titles.yaml index 4fd010d..e868ed6 100644 --- a/api/paths/titles.yaml +++ b/api/paths/titles.yaml @@ -1,8 +1,8 @@ get: summary: Get titles parameters: - - $ref: ../parameters/cursor.yaml - - $ref: ../parameters/title_sort.yaml + - $ref: "../parameters/cursor.yaml" + - $ref: "../parameters/title_sort.yaml" - in: query name: sort_forward schema: From 34d9341e75dbdf36ebbf9258f82e5065fc38909c Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Wed, 19 Nov 2025 03:58:46 +0300 Subject: [PATCH 072/224] feat: cursor stub added --- modules/backend/handlers/titles.go | 6 +- modules/backend/queries.sql | 62 +++++++++++- sql/migrations/000001_init.up.sql | 2 +- sql/models.go | 13 ++- sql/queries.sql.go | 151 +++++++++++++++++++++++++++-- 5 files changed, 214 insertions(+), 20 deletions(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 46ff982..e8a3bff 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -218,6 +218,9 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { opai_titles := make([]oapi.Title, 0) + cursor := oapi.CursorObj{ + Id: 1, + } word := Word2Sqlc(request.Params.Word) status, err := TitleStatus2Sqlc(request.Params.Status) @@ -237,7 +240,6 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, - Offset: request.Params.Offset, Limit: request.Params.Limit, }) if err != nil { @@ -258,5 +260,5 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje opai_titles = append(opai_titles, t) } - return oapi.GetTitles200JSONResponse(opai_titles), nil + return oapi.GetTitles200JSONResponse{Cursor: cursor, Data: opai_titles}, nil } diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 423be37..8962895 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -107,9 +107,67 @@ WHERE AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t) +ORDER BY + -- Основной ключ: выбранное поле + CASE + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'name' THEN name + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views + END ASC, + CASE + WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id + WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'name' THEN name + WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year + WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating + WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views + END DESC, -LIMIT COALESCE(sqlc.narg('limit')::int, 100) -- 100 is default limit -OFFSET sqlc.narg('offset')::int; + -- Вторичный ключ: id — только если НЕ сортируем по id + CASE + WHEN sqlc.arg(sort_by)::text != 'id' AND sqlc.arg(forward)::boolean THEN id + END ASC, + CASE + WHEN sqlc.arg(sort_by)::text != 'id' AND NOT sqlc.arg(forward)::boolean THEN id + END DESC +LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit +-- OFFSET sqlc.narg('offset')::int; + +-- name: SearchUserTitles :many +SELECT + * +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +WHERE + CASE + WHEN sqlc.narg('word')::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + + AND (sqlc.narg('status')::title_status_t IS NULL OR t.title_status = sqlc.narg('status')::title_status_t) + AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) + AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) + AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) + AND (sqlc.narg('usertitle_status')::usertitle_status_t IS NULL OR u.usertitle_status = sqlc.narg('usertitle_status')::usertitle_status_t) + +LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit -- -- name: ListTitles :many -- SELECT title_id, title_names, studio_id, poster_id, signal_ids, diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 49cca3d..e6ed628 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -59,7 +59,7 @@ CREATE TABLE studios ( ); CREATE TABLE titles ( - // TODO: anime type (film, season etc) + -- // TODO: anime type (film, season etc) id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, diff --git a/sql/models.go b/sql/models.go index a593504..93cecca 100644 --- a/sql/models.go +++ b/sql/models.go @@ -212,7 +212,6 @@ type Review struct { ID int64 `json:"id"` Data string `json:"data"` Rating *int32 `json:"rating"` - IllustID *int64 `json:"illust_id"` UserID *int64 `json:"user_id"` TitleID *int64 `json:"title_id"` CreatedAt pgtype.Timestamptz `json:"created_at"` @@ -277,10 +276,10 @@ type User struct { } type Usertitle struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewText *string `json:"review_text"` - ReviewDate pgtype.Timestamptz `json:"review_date"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime pgtype.Timestamptz `json:"ctime"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index c5e6f8a..4e28f40 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -8,6 +8,8 @@ package sqlc import ( "context" "time" + + "github.com/jackc/pgx/v5/pgtype" ) const createImage = `-- name: CreateImage :one @@ -31,7 +33,7 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images -WHERE id = $1 +WHERE id = $1::bigint ` func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, error) { @@ -44,11 +46,13 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro const getReviewByID = `-- name: GetReviewByID :one -SELECT id, data, rating, illust_id, user_id, title_id, created_at + +SELECT id, data, rating, user_id, title_id, created_at FROM reviews WHERE review_id = $1::bigint ` +// 100 is default limit // -- name: ListTitles :many // SELECT title_id, title_names, studio_id, poster_id, signal_ids, // @@ -82,7 +86,6 @@ func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, er &i.ID, &i.Data, &i.Rating, - &i.IllustID, &i.UserID, &i.TitleID, &i.CreatedAt, @@ -312,9 +315,18 @@ WHERE AND ($3::float IS NULL OR rating >= $3::float) AND ($4::int IS NULL OR release_year = $4::int) AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t) - -LIMIT COALESCE($7::int, 100) -- 100 is default limit -OFFSET $6::int +ORDER BY CASE + WHEN $6::boolean AND $7::text = 'name' THEN name + WHEN $8::boolean AND $7::text = 'id' THEN id + WHEN $8::boolean AND $7::text = 'name' THEN name + WHEN $8::boolean AND $7::text = 'id' THEN id +END ASC, CASE + WHEN NOT $8::boolean AND $7::text = 'name' THEN name + WHEN NOT $8::boolean AND $7::text = 'id' THEN id + WHEN NOT $8::boolean AND $7::text = 'name' THEN name + WHEN NOT $8::boolean AND $7::text = 'id' THEN id +END DESC +LIMIT COALESCE($9::int, 100) ` type SearchTitlesParams struct { @@ -323,7 +335,9 @@ type SearchTitlesParams struct { Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Offset *int32 `json:"offset"` + Forward bool `json:"forward"` + OrderBy string `json:"order_by"` + Reverse bool `json:"reverse"` Limit *int32 `json:"limit"` } @@ -334,7 +348,9 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, - arg.Offset, + arg.Forward, + arg.OrderBy, + arg.Reverse, arg.Limit, ) if err != nil { @@ -368,3 +384,122 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T } return items, nil } + +const searchUserTitles = `-- name: SearchUserTitles :many + +SELECT + user_id, title_id, status, rate, review_id, ctime, id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +WHERE + CASE + WHEN $1::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($1::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + + AND ($2::title_status_t IS NULL OR t.title_status = $2::title_status_t) + AND ($3::float IS NULL OR t.rating >= $3::float) + AND ($4::int IS NULL OR t.release_year = $4::int) + AND ($5::release_season_t IS NULL OR t.release_season = $5::release_season_t) + AND ($6::usertitle_status_t IS NULL OR u.usertitle_status = $6::usertitle_status_t) + +LIMIT COALESCE($7::int, 100) +` + +type SearchUserTitlesParams struct { + Word *string `json:"word"` + Status *TitleStatusT `json:"status"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + UsertitleStatus NullUsertitleStatusT `json:"usertitle_status"` + Limit *int32 `json:"limit"` +} + +type SearchUserTitlesRow struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime pgtype.Timestamptz `json:"ctime"` + ID int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` +} + +// 100 is default limit +// OFFSET sqlc.narg('offset')::int; +func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { + rows, err := q.db.Query(ctx, searchUserTitles, + arg.Word, + arg.Status, + arg.Rating, + arg.ReleaseYear, + arg.ReleaseSeason, + arg.UsertitleStatus, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchUserTitlesRow + for rows.Next() { + var i SearchUserTitlesRow + if err := rows.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} From e2ac80610cb0a0de71d7775dd97571c67fdd0f08 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Wed, 19 Nov 2025 04:11:31 +0300 Subject: [PATCH 073/224] fix: now gettitles must work --- modules/backend/handlers/titles.go | 2 ++ modules/backend/queries.sql | 2 -- sql/queries.sql.go | 41 ++++++++++++++++++------------ 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index e8a3bff..f187cc4 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -240,6 +240,8 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, + Forward: true, + SortBy: "id", Limit: request.Params.Limit, }) if err != nil { diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 8962895..9da104f 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -111,14 +111,12 @@ ORDER BY -- Основной ключ: выбранное поле CASE WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id - WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'name' THEN name WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views END ASC, CASE WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id - WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'name' THEN name WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 4e28f40..14a3f8c 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -315,18 +315,29 @@ WHERE AND ($3::float IS NULL OR rating >= $3::float) AND ($4::int IS NULL OR release_year = $4::int) AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t) -ORDER BY CASE - WHEN $6::boolean AND $7::text = 'name' THEN name - WHEN $8::boolean AND $7::text = 'id' THEN id - WHEN $8::boolean AND $7::text = 'name' THEN name - WHEN $8::boolean AND $7::text = 'id' THEN id -END ASC, CASE - WHEN NOT $8::boolean AND $7::text = 'name' THEN name - WHEN NOT $8::boolean AND $7::text = 'id' THEN id - WHEN NOT $8::boolean AND $7::text = 'name' THEN name - WHEN NOT $8::boolean AND $7::text = 'id' THEN id -END DESC -LIMIT COALESCE($9::int, 100) +ORDER BY + -- Основной ключ: выбранное поле + CASE + WHEN $6::boolean AND $7::text = 'id' THEN id + WHEN $6::boolean AND $7::text = 'year' THEN release_year + WHEN $6::boolean AND $7::text = 'rating' THEN rating + WHEN $6::boolean AND $7::text = 'views' THEN views + END ASC, + CASE + WHEN NOT $6::boolean AND $7::text = 'id' THEN id + WHEN NOT $6::boolean AND $7::text = 'year' THEN release_year + WHEN NOT $6::boolean AND $7::text = 'rating' THEN rating + WHEN NOT $6::boolean AND $7::text = 'views' THEN views + END DESC, + + -- Вторичный ключ: id — только если НЕ сортируем по id + CASE + WHEN $7::text != 'id' AND $6::boolean THEN id + END ASC, + CASE + WHEN $7::text != 'id' AND NOT $6::boolean THEN id + END DESC +LIMIT COALESCE($8::int, 100) ` type SearchTitlesParams struct { @@ -336,8 +347,7 @@ type SearchTitlesParams struct { ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` Forward bool `json:"forward"` - OrderBy string `json:"order_by"` - Reverse bool `json:"reverse"` + SortBy string `json:"sort_by"` Limit *int32 `json:"limit"` } @@ -349,8 +359,7 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T arg.ReleaseYear, arg.ReleaseSeason, arg.Forward, - arg.OrderBy, - arg.Reverse, + arg.SortBy, arg.Limit, ) if err != nil { From 9c0fada00e0ef74e9b482948b51463ff9d1087c6 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Wed, 19 Nov 2025 04:16:23 +0300 Subject: [PATCH 074/224] fix: delete views column --- modules/backend/queries.sql | 4 ++-- sql/queries.sql.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 9da104f..c05edff 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -113,13 +113,13 @@ ORDER BY WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating - WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views + -- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views END ASC, CASE WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating - WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views + -- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views END DESC, -- Вторичный ключ: id — только если НЕ сортируем по id diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 14a3f8c..5a1d13c 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -321,13 +321,13 @@ ORDER BY WHEN $6::boolean AND $7::text = 'id' THEN id WHEN $6::boolean AND $7::text = 'year' THEN release_year WHEN $6::boolean AND $7::text = 'rating' THEN rating - WHEN $6::boolean AND $7::text = 'views' THEN views + -- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views END ASC, CASE WHEN NOT $6::boolean AND $7::text = 'id' THEN id WHEN NOT $6::boolean AND $7::text = 'year' THEN release_year WHEN NOT $6::boolean AND $7::text = 'rating' THEN rating - WHEN NOT $6::boolean AND $7::text = 'views' THEN views + -- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views END DESC, -- Вторичный ключ: id — только если НЕ сортируем по id From 397d2bcf70c07aad5fc443f7864eea65895ea0b2 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Wed, 19 Nov 2025 10:54:52 +0300 Subject: [PATCH 075/224] feat: /titles page implementation with cursor pagination --- modules/frontend/package-lock.json | 651 ++++++++++++++++-- modules/frontend/package.json | 5 +- modules/frontend/src/App.tsx | 2 +- modules/frontend/src/api/index.ts | 9 +- .../cursor.ts => api/models/CursorObj.ts} | 6 +- .../frontend/src/api/models/ReleaseSeason.ts | 7 +- .../Tags.ts => api/models/TitleSort.ts} | 5 +- .../frontend/src/api/models/TitleStatus.ts | 6 +- modules/frontend/src/api/models/User.ts | 4 +- .../src/api/models/UserTitleStatus.ts | 7 +- .../Title.ts => api/models/title_sort.ts} | 3 +- .../src/api/services/DefaultService.ts | 48 +- modules/frontend/src/api_/core/ApiError.ts | 25 - .../src/api_/core/ApiRequestOptions.ts | 17 - modules/frontend/src/api_/core/ApiResult.ts | 11 - .../src/api_/core/CancelablePromise.ts | 131 ---- modules/frontend/src/api_/core/OpenAPI.ts | 32 - modules/frontend/src/api_/core/request.ts | 323 --------- modules/frontend/src/api_/index.ts | 23 - modules/frontend/src/api_/models/Image.ts | 10 - .../frontend/src/api_/models/ReleaseSeason.ts | 13 - modules/frontend/src/api_/models/Review.ts | 5 - modules/frontend/src/api_/models/Studio.ts | 12 - modules/frontend/src/api_/models/Tag.ts | 8 - .../frontend/src/api_/models/TitleStatus.ts | 12 - modules/frontend/src/api_/models/User.ts | 35 - modules/frontend/src/api_/models/UserTitle.ts | 5 - .../src/api_/models/UserTitleStatus.ts | 13 - .../src/api_/services/DefaultService.ts | 148 ---- .../src/components/ListView/ListView.tsx | 133 ++-- .../src/components/ListView/useListView.tsx | 37 - .../components/cards/TitleCardHorizontal.tsx | 10 +- .../src/components/cards/TitleCardSquare.tsx | 8 +- modules/frontend/src/index.css | 72 +- .../pages/TitlesPage/TitlesPage.module.css | 60 +- .../src/pages/TitlesPage/TitlesPage.tsx | 142 ++-- modules/frontend/vite.config.ts | 6 +- 37 files changed, 797 insertions(+), 1247 deletions(-) rename modules/frontend/src/{api_/models/cursor.ts => api/models/CursorObj.ts} (66%) rename modules/frontend/src/{api_/models/Tags.ts => api/models/TitleSort.ts} (60%) rename modules/frontend/src/{api_/models/Title.ts => api/models/title_sort.ts} (61%) delete mode 100644 modules/frontend/src/api_/core/ApiError.ts delete mode 100644 modules/frontend/src/api_/core/ApiRequestOptions.ts delete mode 100644 modules/frontend/src/api_/core/ApiResult.ts delete mode 100644 modules/frontend/src/api_/core/CancelablePromise.ts delete mode 100644 modules/frontend/src/api_/core/OpenAPI.ts delete mode 100644 modules/frontend/src/api_/core/request.ts delete mode 100644 modules/frontend/src/api_/index.ts delete mode 100644 modules/frontend/src/api_/models/Image.ts delete mode 100644 modules/frontend/src/api_/models/ReleaseSeason.ts delete mode 100644 modules/frontend/src/api_/models/Review.ts delete mode 100644 modules/frontend/src/api_/models/Studio.ts delete mode 100644 modules/frontend/src/api_/models/Tag.ts delete mode 100644 modules/frontend/src/api_/models/TitleStatus.ts delete mode 100644 modules/frontend/src/api_/models/User.ts delete mode 100644 modules/frontend/src/api_/models/UserTitle.ts delete mode 100644 modules/frontend/src/api_/models/UserTitleStatus.ts delete mode 100644 modules/frontend/src/api_/services/DefaultService.ts delete mode 100644 modules/frontend/src/components/ListView/useListView.tsx diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index 6a06afb..f5dde46 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -8,10 +8,13 @@ "name": "nyanimedb-frontend", "version": "0.0.0", "dependencies": { + "@heroicons/react": "^2.2.0", + "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.4" + "react-router-dom": "^7.9.4", + "tailwindcss": "^4.1.17" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -337,7 +340,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -354,7 +356,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -371,7 +372,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -388,7 +388,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -405,7 +404,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -422,7 +420,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -439,7 +436,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -456,7 +452,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -473,7 +468,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -490,7 +484,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -507,7 +500,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -524,7 +516,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -541,7 +532,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -558,7 +548,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -575,7 +564,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -592,7 +580,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -609,7 +596,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -626,7 +612,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -643,7 +628,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -660,7 +644,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -677,7 +660,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -694,7 +676,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -711,7 +692,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -728,7 +708,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -745,7 +724,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -762,7 +740,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -929,6 +906,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -985,7 +971,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -996,7 +981,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1007,7 +991,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1017,14 +1000,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1090,7 +1071,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1104,7 +1084,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1118,7 +1097,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1132,7 +1110,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1146,7 +1123,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1160,7 +1136,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1174,7 +1149,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1188,7 +1162,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1202,7 +1175,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1216,7 +1188,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1230,7 +1201,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1244,7 +1214,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1258,7 +1227,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1272,7 +1240,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1286,7 +1253,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1300,7 +1266,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1314,7 +1279,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1328,7 +1292,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1342,7 +1305,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1356,7 +1318,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1370,7 +1331,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1384,13 +1344,269 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", + "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "tailwindcss": "4.1.17" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1440,7 +1656,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1454,7 +1669,7 @@ "version": "24.7.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz", "integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -2127,6 +2342,15 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2148,6 +2372,19 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2197,7 +2434,6 @@ "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -2617,7 +2853,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2726,7 +2961,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -2884,6 +3118,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2988,6 +3231,255 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3021,6 +3513,15 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3109,7 +3610,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -3249,7 +3749,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -3269,7 +3768,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3437,7 +3935,6 @@ "version": "4.52.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -3558,7 +4055,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3590,11 +4086,29 @@ "node": ">=8" } }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3611,7 +4125,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3629,7 +4142,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "peer": true, "engines": { @@ -3735,7 +4247,7 @@ "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/universalify": { @@ -3793,7 +4305,6 @@ "version": "7.1.9", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -3869,7 +4380,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3887,7 +4397,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "peer": true, "engines": { diff --git a/modules/frontend/package.json b/modules/frontend/package.json index b4977aa..beb2b2a 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -10,10 +10,13 @@ "preview": "vite preview" }, "dependencies": { + "@heroicons/react": "^2.2.0", + "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.4" + "react-router-dom": "^7.9.4", + "tailwindcss": "^4.1.17" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 1256086..f67c37e 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,7 +1,7 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./pages/UserPage/UserPage"; -import TitlesPage from "./pages/UserPage/UserPage"; +import TitlesPage from "./pages/TitlesPage/TitlesPage"; const App: React.FC = () => { return ( diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index f0d09ee..80ae491 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -8,16 +8,19 @@ export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; export type { cursor } from './models/cursor'; +export type { CursorObj } from './models/CursorObj'; export type { Image } from './models/Image'; -export { ReleaseSeason } from './models/ReleaseSeason'; +export type { ReleaseSeason } from './models/ReleaseSeason'; export type { Review } from './models/Review'; export type { Studio } from './models/Studio'; export type { Tag } from './models/Tag'; export type { Tags } from './models/Tags'; export type { Title } from './models/Title'; -export { TitleStatus } from './models/TitleStatus'; +export type { title_sort } from './models/title_sort'; +export type { TitleSort } from './models/TitleSort'; +export type { TitleStatus } from './models/TitleStatus'; export type { User } from './models/User'; export type { UserTitle } from './models/UserTitle'; -export { UserTitleStatus } from './models/UserTitleStatus'; +export type { UserTitleStatus } from './models/UserTitleStatus'; export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api_/models/cursor.ts b/modules/frontend/src/api/models/CursorObj.ts similarity index 66% rename from modules/frontend/src/api_/models/cursor.ts rename to modules/frontend/src/api/models/CursorObj.ts index 5788e14..f54abb1 100644 --- a/modules/frontend/src/api_/models/cursor.ts +++ b/modules/frontend/src/api/models/CursorObj.ts @@ -2,4 +2,8 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type cursor = string; +export type CursorObj = { + id: number; + param?: string; +}; + diff --git a/modules/frontend/src/api/models/ReleaseSeason.ts b/modules/frontend/src/api/models/ReleaseSeason.ts index 182b980..ad9f930 100644 --- a/modules/frontend/src/api/models/ReleaseSeason.ts +++ b/modules/frontend/src/api/models/ReleaseSeason.ts @@ -5,9 +5,4 @@ /** * Title release season */ -export enum ReleaseSeason { - WINTER = 'winter', - SPRING = 'spring', - SUMMER = 'summer', - FALL = 'fall', -} +export type ReleaseSeason = 'winter' | 'spring' | 'summer' | 'fall'; diff --git a/modules/frontend/src/api_/models/Tags.ts b/modules/frontend/src/api/models/TitleSort.ts similarity index 60% rename from modules/frontend/src/api_/models/Tags.ts rename to modules/frontend/src/api/models/TitleSort.ts index 748f066..1c9385e 100644 --- a/modules/frontend/src/api_/models/Tags.ts +++ b/modules/frontend/src/api/models/TitleSort.ts @@ -2,8 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { Tag } from './Tag'; /** - * Array of localized tags + * Title sort order */ -export type Tags = Array<Tag>; +export type TitleSort = 'id' | 'year' | 'rating' | 'views'; diff --git a/modules/frontend/src/api/models/TitleStatus.ts b/modules/frontend/src/api/models/TitleStatus.ts index 811ece8..72e0261 100644 --- a/modules/frontend/src/api/models/TitleStatus.ts +++ b/modules/frontend/src/api/models/TitleStatus.ts @@ -5,8 +5,4 @@ /** * Title status */ -export enum TitleStatus { - FINISHED = 'finished', - ONGOING = 'ongoing', - PLANNED = 'planned', -} +export type TitleStatus = 'finished' | 'ongoing' | 'planned'; diff --git a/modules/frontend/src/api/models/User.ts b/modules/frontend/src/api/models/User.ts index 541028e..cd76dbe 100644 --- a/modules/frontend/src/api/models/User.ts +++ b/modules/frontend/src/api/models/User.ts @@ -6,11 +6,11 @@ export type User = { /** * Unique user ID (primary key) */ - id: number; + id?: number; /** * ID of the user avatar (references images table) */ - avatar_id?: number | null; + avatar_id?: number; /** * User email */ diff --git a/modules/frontend/src/api/models/UserTitleStatus.ts b/modules/frontend/src/api/models/UserTitleStatus.ts index 20651fe..0a29626 100644 --- a/modules/frontend/src/api/models/UserTitleStatus.ts +++ b/modules/frontend/src/api/models/UserTitleStatus.ts @@ -5,9 +5,4 @@ /** * User's title status */ -export enum UserTitleStatus { - FINISHED = 'finished', - PLANNED = 'planned', - DROPPED = 'dropped', - IN_PROGRESS = 'in-progress', -} +export type UserTitleStatus = 'finished' | 'planned' | 'dropped' | 'in-progress'; diff --git a/modules/frontend/src/api_/models/Title.ts b/modules/frontend/src/api/models/title_sort.ts similarity index 61% rename from modules/frontend/src/api_/models/Title.ts rename to modules/frontend/src/api/models/title_sort.ts index 4da7aa3..69b01a7 100644 --- a/modules/frontend/src/api_/models/Title.ts +++ b/modules/frontend/src/api/models/title_sort.ts @@ -2,4 +2,5 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Title = Record<string, any>; +import type { TitleSort } from './TitleSort'; +export type title_sort = TitleSort; diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index b0ae54d..52321b8 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -2,17 +2,23 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { CursorObj } from '../models/CursorObj'; import type { ReleaseSeason } from '../models/ReleaseSeason'; import type { Title } from '../models/Title'; +import type { TitleSort } from '../models/TitleSort'; import type { TitleStatus } from '../models/TitleStatus'; import type { User } from '../models/User'; import type { UserTitle } from '../models/UserTitle'; +import type { UserTitleStatus } from '../models/UserTitleStatus'; import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; import { request as __request } from '../core/request'; export class DefaultService { /** * Get titles + * @param cursor + * @param sort + * @param sortForward * @param word * @param status * @param rating @@ -21,10 +27,13 @@ export class DefaultService { * @param limit * @param offset * @param fields - * @returns Title List of titles + * @returns any List of titles with cursor * @throws ApiError */ public static getTitles( + cursor?: string, + sort?: TitleSort, + sortForward: boolean = true, word?: string, status?: TitleStatus, rating?: number, @@ -33,11 +42,20 @@ export class DefaultService { limit: number = 10, offset?: number, fields: string = 'all', - ): CancelablePromise<Array<Title>> { + ): CancelablePromise<{ + /** + * List of titles + */ + data: Array<Title>; + cursor: CursorObj; + }> { return __request(OpenAPI, { method: 'GET', url: '/titles', query: { + 'cursor': cursor, + 'sort': sort, + 'sort_forward': sortForward, 'word': word, 'status': status, 'rating': rating, @@ -111,9 +129,13 @@ export class DefaultService { * Get user titles * @param userId * @param cursor - * @param query + * @param word + * @param status + * @param watchStatus + * @param rating + * @param releaseYear + * @param releaseSeason * @param limit - * @param offset * @param fields * @returns UserTitle List of user titles * @throws ApiError @@ -121,22 +143,30 @@ export class DefaultService { public static getUsersTitles( userId: string, cursor?: string, - query?: string, + word?: string, + status?: TitleStatus, + watchStatus?: UserTitleStatus, + rating?: number, + releaseYear?: number, + releaseSeason?: ReleaseSeason, limit: number = 10, - offset?: number, fields: string = 'all', ): CancelablePromise<Array<UserTitle>> { return __request(OpenAPI, { method: 'GET', - url: '/users/{user_id}/titles', + url: '/users/{user_id}/titles/', path: { 'user_id': userId, }, query: { 'cursor': cursor, - 'query': query, + 'word': word, + 'status': status, + 'watch_status': watchStatus, + 'rating': rating, + 'release_year': releaseYear, + 'release_season': releaseSeason, 'limit': limit, - 'offset': offset, 'fields': fields, }, errors: { diff --git a/modules/frontend/src/api_/core/ApiError.ts b/modules/frontend/src/api_/core/ApiError.ts deleted file mode 100644 index ec7b16a..0000000 --- a/modules/frontend/src/api_/core/ApiError.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ApiRequestOptions } from './ApiRequestOptions'; -import type { ApiResult } from './ApiResult'; - -export class ApiError extends Error { - public readonly url: string; - public readonly status: number; - public readonly statusText: string; - public readonly body: any; - public readonly request: ApiRequestOptions; - - constructor(request: ApiRequestOptions, response: ApiResult, message: string) { - super(message); - - this.name = 'ApiError'; - this.url = response.url; - this.status = response.status; - this.statusText = response.statusText; - this.body = response.body; - this.request = request; - } -} diff --git a/modules/frontend/src/api_/core/ApiRequestOptions.ts b/modules/frontend/src/api_/core/ApiRequestOptions.ts deleted file mode 100644 index 93143c3..0000000 --- a/modules/frontend/src/api_/core/ApiRequestOptions.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type ApiRequestOptions = { - readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; - readonly url: string; - readonly path?: Record<string, any>; - readonly cookies?: Record<string, any>; - readonly headers?: Record<string, any>; - readonly query?: Record<string, any>; - readonly formData?: Record<string, any>; - readonly body?: any; - readonly mediaType?: string; - readonly responseHeader?: string; - readonly errors?: Record<number, string>; -}; diff --git a/modules/frontend/src/api_/core/ApiResult.ts b/modules/frontend/src/api_/core/ApiResult.ts deleted file mode 100644 index ee1126e..0000000 --- a/modules/frontend/src/api_/core/ApiResult.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type ApiResult = { - readonly url: string; - readonly ok: boolean; - readonly status: number; - readonly statusText: string; - readonly body: any; -}; diff --git a/modules/frontend/src/api_/core/CancelablePromise.ts b/modules/frontend/src/api_/core/CancelablePromise.ts deleted file mode 100644 index d70de92..0000000 --- a/modules/frontend/src/api_/core/CancelablePromise.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export class CancelError extends Error { - - constructor(message: string) { - super(message); - this.name = 'CancelError'; - } - - public get isCancelled(): boolean { - return true; - } -} - -export interface OnCancel { - readonly isResolved: boolean; - readonly isRejected: boolean; - readonly isCancelled: boolean; - - (cancelHandler: () => void): void; -} - -export class CancelablePromise<T> implements Promise<T> { - #isResolved: boolean; - #isRejected: boolean; - #isCancelled: boolean; - readonly #cancelHandlers: (() => void)[]; - readonly #promise: Promise<T>; - #resolve?: (value: T | PromiseLike<T>) => void; - #reject?: (reason?: any) => void; - - constructor( - executor: ( - resolve: (value: T | PromiseLike<T>) => void, - reject: (reason?: any) => void, - onCancel: OnCancel - ) => void - ) { - this.#isResolved = false; - this.#isRejected = false; - this.#isCancelled = false; - this.#cancelHandlers = []; - this.#promise = new Promise<T>((resolve, reject) => { - this.#resolve = resolve; - this.#reject = reject; - - const onResolve = (value: T | PromiseLike<T>): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isResolved = true; - if (this.#resolve) this.#resolve(value); - }; - - const onReject = (reason?: any): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isRejected = true; - if (this.#reject) this.#reject(reason); - }; - - const onCancel = (cancelHandler: () => void): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#cancelHandlers.push(cancelHandler); - }; - - Object.defineProperty(onCancel, 'isResolved', { - get: (): boolean => this.#isResolved, - }); - - Object.defineProperty(onCancel, 'isRejected', { - get: (): boolean => this.#isRejected, - }); - - Object.defineProperty(onCancel, 'isCancelled', { - get: (): boolean => this.#isCancelled, - }); - - return executor(onResolve, onReject, onCancel as OnCancel); - }); - } - - get [Symbol.toStringTag]() { - return "Cancellable Promise"; - } - - public then<TResult1 = T, TResult2 = never>( - onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, - onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null - ): Promise<TResult1 | TResult2> { - return this.#promise.then(onFulfilled, onRejected); - } - - public catch<TResult = never>( - onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null - ): Promise<T | TResult> { - return this.#promise.catch(onRejected); - } - - public finally(onFinally?: (() => void) | null): Promise<T> { - return this.#promise.finally(onFinally); - } - - public cancel(): void { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isCancelled = true; - if (this.#cancelHandlers.length) { - try { - for (const cancelHandler of this.#cancelHandlers) { - cancelHandler(); - } - } catch (error) { - console.warn('Cancellation threw an error', error); - return; - } - } - this.#cancelHandlers.length = 0; - if (this.#reject) this.#reject(new CancelError('Request aborted')); - } - - public get isCancelled(): boolean { - return this.#isCancelled; - } -} diff --git a/modules/frontend/src/api_/core/OpenAPI.ts b/modules/frontend/src/api_/core/OpenAPI.ts deleted file mode 100644 index 185e5c3..0000000 --- a/modules/frontend/src/api_/core/OpenAPI.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ApiRequestOptions } from './ApiRequestOptions'; - -type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; -type Headers = Record<string, string>; - -export type OpenAPIConfig = { - BASE: string; - VERSION: string; - WITH_CREDENTIALS: boolean; - CREDENTIALS: 'include' | 'omit' | 'same-origin'; - TOKEN?: string | Resolver<string> | undefined; - USERNAME?: string | Resolver<string> | undefined; - PASSWORD?: string | Resolver<string> | undefined; - HEADERS?: Headers | Resolver<Headers> | undefined; - ENCODE_PATH?: ((path: string) => string) | undefined; -}; - -export const OpenAPI: OpenAPIConfig = { - BASE: '/api/v1', - VERSION: '1.0.0', - WITH_CREDENTIALS: false, - CREDENTIALS: 'include', - TOKEN: undefined, - USERNAME: undefined, - PASSWORD: undefined, - HEADERS: undefined, - ENCODE_PATH: undefined, -}; diff --git a/modules/frontend/src/api_/core/request.ts b/modules/frontend/src/api_/core/request.ts deleted file mode 100644 index 1dc6fef..0000000 --- a/modules/frontend/src/api_/core/request.ts +++ /dev/null @@ -1,323 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import axios from 'axios'; -import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; -import FormData from 'form-data'; - -import { ApiError } from './ApiError'; -import type { ApiRequestOptions } from './ApiRequestOptions'; -import type { ApiResult } from './ApiResult'; -import { CancelablePromise } from './CancelablePromise'; -import type { OnCancel } from './CancelablePromise'; -import type { OpenAPIConfig } from './OpenAPI'; - -export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => { - return value !== undefined && value !== null; -}; - -export const isString = (value: any): value is string => { - return typeof value === 'string'; -}; - -export const isStringWithValue = (value: any): value is string => { - return isString(value) && value !== ''; -}; - -export const isBlob = (value: any): value is Blob => { - return ( - typeof value === 'object' && - typeof value.type === 'string' && - typeof value.stream === 'function' && - typeof value.arrayBuffer === 'function' && - typeof value.constructor === 'function' && - typeof value.constructor.name === 'string' && - /^(Blob|File)$/.test(value.constructor.name) && - /^(Blob|File)$/.test(value[Symbol.toStringTag]) - ); -}; - -export const isFormData = (value: any): value is FormData => { - return value instanceof FormData; -}; - -export const isSuccess = (status: number): boolean => { - return status >= 200 && status < 300; -}; - -export const base64 = (str: string): string => { - try { - return btoa(str); - } catch (err) { - // @ts-ignore - return Buffer.from(str).toString('base64'); - } -}; - -export const getQueryString = (params: Record<string, any>): string => { - const qs: string[] = []; - - const append = (key: string, value: any) => { - qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); - }; - - const process = (key: string, value: any) => { - if (isDefined(value)) { - if (Array.isArray(value)) { - value.forEach(v => { - process(key, v); - }); - } else if (typeof value === 'object') { - Object.entries(value).forEach(([k, v]) => { - process(`${key}[${k}]`, v); - }); - } else { - append(key, value); - } - } - }; - - Object.entries(params).forEach(([key, value]) => { - process(key, value); - }); - - if (qs.length > 0) { - return `?${qs.join('&')}`; - } - - return ''; -}; - -const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { - const encoder = config.ENCODE_PATH || encodeURI; - - const path = options.url - .replace('{api-version}', config.VERSION) - .replace(/{(.*?)}/g, (substring: string, group: string) => { - if (options.path?.hasOwnProperty(group)) { - return encoder(String(options.path[group])); - } - return substring; - }); - - const url = `${config.BASE}${path}`; - if (options.query) { - return `${url}${getQueryString(options.query)}`; - } - return url; -}; - -export const getFormData = (options: ApiRequestOptions): FormData | undefined => { - if (options.formData) { - const formData = new FormData(); - - const process = (key: string, value: any) => { - if (isString(value) || isBlob(value)) { - formData.append(key, value); - } else { - formData.append(key, JSON.stringify(value)); - } - }; - - Object.entries(options.formData) - .filter(([_, value]) => isDefined(value)) - .forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach(v => process(key, v)); - } else { - process(key, value); - } - }); - - return formData; - } - return undefined; -}; - -type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; - -export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => { - if (typeof resolver === 'function') { - return (resolver as Resolver<T>)(options); - } - return resolver; -}; - -export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => { - const [token, username, password, additionalHeaders] = await Promise.all([ - resolve(options, config.TOKEN), - resolve(options, config.USERNAME), - resolve(options, config.PASSWORD), - resolve(options, config.HEADERS), - ]); - - const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} - - const headers = Object.entries({ - Accept: 'application/json', - ...additionalHeaders, - ...options.headers, - ...formHeaders, - }) - .filter(([_, value]) => isDefined(value)) - .reduce((headers, [key, value]) => ({ - ...headers, - [key]: String(value), - }), {} as Record<string, string>); - - if (isStringWithValue(token)) { - headers['Authorization'] = `Bearer ${token}`; - } - - if (isStringWithValue(username) && isStringWithValue(password)) { - const credentials = base64(`${username}:${password}`); - headers['Authorization'] = `Basic ${credentials}`; - } - - if (options.body !== undefined) { - if (options.mediaType) { - headers['Content-Type'] = options.mediaType; - } else if (isBlob(options.body)) { - headers['Content-Type'] = options.body.type || 'application/octet-stream'; - } else if (isString(options.body)) { - headers['Content-Type'] = 'text/plain'; - } else if (!isFormData(options.body)) { - headers['Content-Type'] = 'application/json'; - } - } - - return headers; -}; - -export const getRequestBody = (options: ApiRequestOptions): any => { - if (options.body) { - return options.body; - } - return undefined; -}; - -export const sendRequest = async <T>( - config: OpenAPIConfig, - options: ApiRequestOptions, - url: string, - body: any, - formData: FormData | undefined, - headers: Record<string, string>, - onCancel: OnCancel, - axiosClient: AxiosInstance -): Promise<AxiosResponse<T>> => { - const source = axios.CancelToken.source(); - - const requestConfig: AxiosRequestConfig = { - url, - headers, - data: body ?? formData, - method: options.method, - withCredentials: config.WITH_CREDENTIALS, - withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, - cancelToken: source.token, - }; - - onCancel(() => source.cancel('The user aborted a request.')); - - try { - return await axiosClient.request(requestConfig); - } catch (error) { - const axiosError = error as AxiosError<T>; - if (axiosError.response) { - return axiosError.response; - } - throw error; - } -}; - -export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => { - if (responseHeader) { - const content = response.headers[responseHeader]; - if (isString(content)) { - return content; - } - } - return undefined; -}; - -export const getResponseBody = (response: AxiosResponse<any>): any => { - if (response.status !== 204) { - return response.data; - } - return undefined; -}; - -export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { - const errors: Record<number, string> = { - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 500: 'Internal Server Error', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - ...options.errors, - } - - const error = errors[result.status]; - if (error) { - throw new ApiError(options, result, error); - } - - if (!result.ok) { - const errorStatus = result.status ?? 'unknown'; - const errorStatusText = result.statusText ?? 'unknown'; - const errorBody = (() => { - try { - return JSON.stringify(result.body, null, 2); - } catch (e) { - return undefined; - } - })(); - - throw new ApiError(options, result, - `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` - ); - } -}; - -/** - * Request method - * @param config The OpenAPI configuration object - * @param options The request options from the service - * @param axiosClient The axios client instance to use - * @returns CancelablePromise<T> - * @throws ApiError - */ -export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => { - return new CancelablePromise(async (resolve, reject, onCancel) => { - try { - const url = getUrl(config, options); - const formData = getFormData(options); - const body = getRequestBody(options); - const headers = await getHeaders(config, options, formData); - - if (!onCancel.isCancelled) { - const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient); - const responseBody = getResponseBody(response); - const responseHeader = getResponseHeader(response, options.responseHeader); - - const result: ApiResult = { - url, - ok: isSuccess(response.status), - status: response.status, - statusText: response.statusText, - body: responseHeader ?? responseBody, - }; - - catchErrorCodes(options, result); - - resolve(result.body); - } - } catch (error) { - reject(error); - } - }); -}; diff --git a/modules/frontend/src/api_/index.ts b/modules/frontend/src/api_/index.ts deleted file mode 100644 index f0d09ee..0000000 --- a/modules/frontend/src/api_/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export { ApiError } from './core/ApiError'; -export { CancelablePromise, CancelError } from './core/CancelablePromise'; -export { OpenAPI } from './core/OpenAPI'; -export type { OpenAPIConfig } from './core/OpenAPI'; - -export type { cursor } from './models/cursor'; -export type { Image } from './models/Image'; -export { ReleaseSeason } from './models/ReleaseSeason'; -export type { Review } from './models/Review'; -export type { Studio } from './models/Studio'; -export type { Tag } from './models/Tag'; -export type { Tags } from './models/Tags'; -export type { Title } from './models/Title'; -export { TitleStatus } from './models/TitleStatus'; -export type { User } from './models/User'; -export type { UserTitle } from './models/UserTitle'; -export { UserTitleStatus } from './models/UserTitleStatus'; - -export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api_/models/Image.ts b/modules/frontend/src/api_/models/Image.ts deleted file mode 100644 index 1317db7..0000000 --- a/modules/frontend/src/api_/models/Image.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type Image = { - id?: number; - storage_type?: string; - image_path?: string; -}; - diff --git a/modules/frontend/src/api_/models/ReleaseSeason.ts b/modules/frontend/src/api_/models/ReleaseSeason.ts deleted file mode 100644 index 182b980..0000000 --- a/modules/frontend/src/api_/models/ReleaseSeason.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Title release season - */ -export enum ReleaseSeason { - WINTER = 'winter', - SPRING = 'spring', - SUMMER = 'summer', - FALL = 'fall', -} diff --git a/modules/frontend/src/api_/models/Review.ts b/modules/frontend/src/api_/models/Review.ts deleted file mode 100644 index 9b453b7..0000000 --- a/modules/frontend/src/api_/models/Review.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type Review = Record<string, any>; diff --git a/modules/frontend/src/api_/models/Studio.ts b/modules/frontend/src/api_/models/Studio.ts deleted file mode 100644 index 062695a..0000000 --- a/modules/frontend/src/api_/models/Studio.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { Image } from './Image'; -export type Studio = { - id: number; - name: string; - poster?: Image; - description?: string; -}; - diff --git a/modules/frontend/src/api_/models/Tag.ts b/modules/frontend/src/api_/models/Tag.ts deleted file mode 100644 index 665c724..0000000 --- a/modules/frontend/src/api_/models/Tag.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * A localized tag: keys are language codes (ISO 639-1), values are tag names - */ -export type Tag = Record<string, string>; diff --git a/modules/frontend/src/api_/models/TitleStatus.ts b/modules/frontend/src/api_/models/TitleStatus.ts deleted file mode 100644 index 811ece8..0000000 --- a/modules/frontend/src/api_/models/TitleStatus.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Title status - */ -export enum TitleStatus { - FINISHED = 'finished', - ONGOING = 'ongoing', - PLANNED = 'planned', -} diff --git a/modules/frontend/src/api_/models/User.ts b/modules/frontend/src/api_/models/User.ts deleted file mode 100644 index 541028e..0000000 --- a/modules/frontend/src/api_/models/User.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type User = { - /** - * Unique user ID (primary key) - */ - id: number; - /** - * ID of the user avatar (references images table) - */ - avatar_id?: number | null; - /** - * User email - */ - mail?: string; - /** - * Username (alphanumeric + _ or -) - */ - nickname: string; - /** - * Display name - */ - disp_name?: string; - /** - * User description - */ - user_desc?: string; - /** - * Timestamp when the user was created - */ - creation_date?: string; -}; - diff --git a/modules/frontend/src/api_/models/UserTitle.ts b/modules/frontend/src/api_/models/UserTitle.ts deleted file mode 100644 index 26d5ddc..0000000 --- a/modules/frontend/src/api_/models/UserTitle.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type UserTitle = Record<string, any>; diff --git a/modules/frontend/src/api_/models/UserTitleStatus.ts b/modules/frontend/src/api_/models/UserTitleStatus.ts deleted file mode 100644 index 20651fe..0000000 --- a/modules/frontend/src/api_/models/UserTitleStatus.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * User's title status - */ -export enum UserTitleStatus { - FINISHED = 'finished', - PLANNED = 'planned', - DROPPED = 'dropped', - IN_PROGRESS = 'in-progress', -} diff --git a/modules/frontend/src/api_/services/DefaultService.ts b/modules/frontend/src/api_/services/DefaultService.ts deleted file mode 100644 index b0ae54d..0000000 --- a/modules/frontend/src/api_/services/DefaultService.ts +++ /dev/null @@ -1,148 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ReleaseSeason } from '../models/ReleaseSeason'; -import type { Title } from '../models/Title'; -import type { TitleStatus } from '../models/TitleStatus'; -import type { User } from '../models/User'; -import type { UserTitle } from '../models/UserTitle'; -import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; -import { request as __request } from '../core/request'; -export class DefaultService { - /** - * Get titles - * @param word - * @param status - * @param rating - * @param releaseYear - * @param releaseSeason - * @param limit - * @param offset - * @param fields - * @returns Title List of titles - * @throws ApiError - */ - public static getTitles( - word?: string, - status?: TitleStatus, - rating?: number, - releaseYear?: number, - releaseSeason?: ReleaseSeason, - limit: number = 10, - offset?: number, - fields: string = 'all', - ): CancelablePromise<Array<Title>> { - return __request(OpenAPI, { - method: 'GET', - url: '/titles', - query: { - 'word': word, - 'status': status, - 'rating': rating, - 'release_year': releaseYear, - 'release_season': releaseSeason, - 'limit': limit, - 'offset': offset, - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 500: `Unknown server error`, - }, - }); - } - /** - * Get title description - * @param titleId - * @param fields - * @returns Title Title description - * @throws ApiError - */ - public static getTitles1( - titleId: number, - fields: string = 'all', - ): CancelablePromise<Title> { - return __request(OpenAPI, { - method: 'GET', - url: '/titles/{title_id}', - path: { - 'title_id': titleId, - }, - query: { - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 404: `Title not found`, - 500: `Unknown server error`, - }, - }); - } - /** - * Get user info - * @param userId - * @param fields - * @returns User User info - * @throws ApiError - */ - public static getUsers( - userId: string, - fields: string = 'all', - ): CancelablePromise<User> { - return __request(OpenAPI, { - method: 'GET', - url: '/users/{user_id}', - path: { - 'user_id': userId, - }, - query: { - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 404: `User not found`, - 500: `Unknown server error`, - }, - }); - } - /** - * Get user titles - * @param userId - * @param cursor - * @param query - * @param limit - * @param offset - * @param fields - * @returns UserTitle List of user titles - * @throws ApiError - */ - public static getUsersTitles( - userId: string, - cursor?: string, - query?: string, - limit: number = 10, - offset?: number, - fields: string = 'all', - ): CancelablePromise<Array<UserTitle>> { - return __request(OpenAPI, { - method: 'GET', - url: '/users/{user_id}/titles', - path: { - 'user_id': userId, - }, - query: { - 'cursor': cursor, - 'query': query, - 'limit': limit, - 'offset': offset, - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 500: `Unknown server error`, - }, - }); - } -} diff --git a/modules/frontend/src/components/ListView/ListView.tsx b/modules/frontend/src/components/ListView/ListView.tsx index 77fea97..e0e8ab9 100644 --- a/modules/frontend/src/components/ListView/ListView.tsx +++ b/modules/frontend/src/components/ListView/ListView.tsx @@ -1,52 +1,103 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; +import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; +import type { CursorObj } from "../../api"; -interface ListViewProps<TItem> { - hook: ReturnType<typeof import("./useListView.tsx").useListView<TItem>>; - renderHorizontal: (item: TItem) => React.ReactNode; - renderSquare: (item: TItem) => React.ReactNode; -} +export type ListViewProps<T> = { + fetchItems: (cursor: string, limit: number) => Promise<{ items: T[]; cursor: CursorObj}>; + renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode; + pageSize?: number; + searchPlaceholder?: string; + setSearch: any; +}; -export function ListView<TItem>({ - hook, - renderHorizontal, - renderSquare -}: ListViewProps<TItem>) { - const { items, search, setSearch, viewMode, setViewMode, loadMore, hasMore } = hook; +export function ListView<T>({ + fetchItems, + renderItem, + pageSize = 20, + searchPlaceholder = "Search...", +}: ListViewProps<T>) { + const [items, setItems] = useState<T[]>([]); + const [cursorObj, setCursorObj] = useState<CursorObj | undefined>(undefined); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [search, setSearch] = useState(""); + const [layout, setLayout] = useState<"square" | "horizontal">("horizontal"); + const [error, setError] = useState<string | null>(null); + + const loadItems = async (reset: boolean = false) => { + try { + if (reset) { + setLoading(true); + setCursorObj(undefined); + } else { + setLoadingMore(true); + } + + const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)) : "" + console.log("encoded cursor: " + cursorStr) + + const result = await fetchItems(cursorStr, pageSize); + + if (reset) setItems(result.items); + else setItems(prev => [...prev, ...result.items]); + + setCursorObj(result.cursor); + setError(null); + } catch (err: any) { + console.error(err); + setError("Failed to fetch items."); + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + useEffect(() => { + loadItems(true); + }, [search]); return ( - <div> - {/* Search + Layout Switcher */} - <div style={{ display: "flex", gap: 8, marginBottom: 16 }}> + <div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> + <div className="w-full sm:w-4/5 flex gap-4 mb-8"> <input - placeholder="Search..." - value={search} + type="text" + placeholder={searchPlaceholder} + // value={search} onChange={e => setSearch(e.target.value)} + className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-black" /> - - <button onClick={() => setViewMode("horizontal")}>Horizontal</button> - <button onClick={() => setViewMode("square")}>Square</button> - </div> - - {/* Items */} - <div - style={{ - display: "grid", - gridTemplateColumns: viewMode === "square" ? "repeat(auto-fill, 160px)" : "1fr", - gap: 12 - }} - > - {items.map(item => - viewMode === "horizontal" - ? renderHorizontal(item) - : renderSquare(item) - )} - </div> - - {hasMore && ( - <button onClick={loadMore} style={{ marginTop: 16 }}> - Load More + <button + className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" + onClick={() => + setLayout(prev => (prev === "square" ? "horizontal" : "square")) + }> + {layout === "square" ? <Squares2X2Icon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />} </button> + </div> + + {error && <div className="text-red-600 mb-6 font-medium">{error}</div>} + + <div + className={`w-full sm:w-4/5 grid gap-6 ${ + layout === "square" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" : "grid-cols-1" + }`} + > + {items.map(item => renderItem(item, layout))} + </div> + + {cursorObj && ( + <div className="mt-8 flex justify-center w-full sm:w-4/5"> + <button + className="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed" + onClick={() => loadItems(false)} + disabled={loadingMore} + > + {loadingMore ? "Loading..." : "Load More"} + </button> + </div> )} + + {loading && <div className="mt-20 font-medium">Loading...</div>} </div> ); -} \ No newline at end of file +} diff --git a/modules/frontend/src/components/ListView/useListView.tsx b/modules/frontend/src/components/ListView/useListView.tsx deleted file mode 100644 index 20c3597..0000000 --- a/modules/frontend/src/components/ListView/useListView.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useState, useEffect } from "react"; -import type { FetchFunction } from "../../types/list"; - -export function useListView<TItem>(fetchFn: FetchFunction<TItem>) { - const [items, setItems] = useState<TItem[]>([]); - const [cursor, setCursor] = useState<string | undefined>(); - const [search, setSearch] = useState(""); - const [viewMode, setViewMode] = useState<"horizontal" | "square">("horizontal"); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - loadItems(true); - }, [search]); - - const loadItems = async (reset = false) => { - setIsLoading(true); - const result = await fetchFn({ - search, - cursor: reset ? undefined : cursor, - }); - - setItems(prev => reset ? result.items : [...prev, ...result.items]); - setCursor(result.nextCursor); - setIsLoading(false); - }; - - return { - items, - search, - setSearch, - viewMode, - setViewMode, - loadMore: () => loadItems(), - hasMore: Boolean(cursor), - isLoading, - }; -} \ No newline at end of file diff --git a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx index c3a8159..cde6037 100644 --- a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx +++ b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx @@ -9,13 +9,13 @@ export function TitleCardHorizontal({ title }: { title: Title }) { border: "1px solid #ddd", borderRadius: 8 }}> - {title.posterUrl && ( - <img src={title.posterUrl} width={80} /> + {title.poster?.image_path && ( + <img src={title.poster.image_path} width={80} /> )} <div> - <h3>{title.name}</h3> - <p>{title.year} · {title.season} · Rating: {title.rating}</p> - <p>Status: {title.status}</p> + <h3>{title.title_names["en"]}</h3> + <p>{title.release_year} · {title.release_season} · Rating: {title.rating}</p> + <p>Status: {title.title_status}</p> </div> </div> ); diff --git a/modules/frontend/src/components/cards/TitleCardSquare.tsx b/modules/frontend/src/components/cards/TitleCardSquare.tsx index 0fc0339..e21c258 100644 --- a/modules/frontend/src/components/cards/TitleCardSquare.tsx +++ b/modules/frontend/src/components/cards/TitleCardSquare.tsx @@ -10,12 +10,12 @@ export function TitleCardSquare({ title }: { title: Title }) { borderRadius: 8, textAlign: "center" }}> - {title.posterUrl && ( - <img src={title.posterUrl} width={140} /> + {title.poster?.image_path && ( + <img src={title.poster.image_path} width={140} /> )} <div> - <h4>{title.name}</h4> - <small>{title.year} • {title.rating}</small> + <h4>{title.title_names["en"]}</h4> + <small>{title.release_year} • {title.rating}</small> </div> </div> ); diff --git a/modules/frontend/src/index.css b/modules/frontend/src/index.css index 08a3ac9..e20de02 100644 --- a/modules/frontend/src/index.css +++ b/modules/frontend/src/index.css @@ -1,68 +1,8 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; +@import "tailwindcss"; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { +html, body, #root { margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} + padding: 0; + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css index 9cc728b..f1d8c73 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css @@ -1,59 +1 @@ -.container { - padding: 24px; -} - -.header { - display: flex; - justify-content: space-between; - margin-bottom: 16px; -} - -.searchInput { - padding: 8px; - width: 240px; -} - -.list { - display: grid; - gap: 12px; -} - -.card { - display: flex; - padding: 10px; - border: 1px solid #ddd; - border-radius: 8px; - gap: 12px; -} - -.poster { - width: 80px; - height: 120px; - object-fit: cover; - border-radius: 4px; -} - -.posterPlaceholder { - width: 80px; - height: 120px; - background: #eee; - display: flex; - align-items: center; - justify-content: center; -} - -.cardInfo { - display: flex; - flex-direction: column; -} - -.loadMore { - margin-top: 16px; - padding: 8px 16px; -} - -.loader, -.error { - padding: 20px; - text-align: center; -} +@import "tailwindcss"; diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 438d828..b59f737 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -1,114 +1,52 @@ -import React, { useEffect, useState } from "react"; +import { ListView } from "../../components/ListView/ListView"; import { DefaultService } from "../../api/services/DefaultService"; -import type { Title } from "../../api/models/Title"; -import styles from "./TitlesPage.module.css"; +import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; +import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; +import type { Title } from "../../api"; +import { useState, useEffect } from "react"; -const LIMIT = 20; - -const TitlesPage: React.FC = () => { - const [titles, setTitles] = useState<Title[]>([]); +const PAGE_SIZE = 20; +export default function TitlesPage() { const [search, setSearch] = useState(""); - const [offset, setOffset] = useState(0); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState<string | null>(null); + const loadTitles = async (cursor: string, limit: number) => { + const result = await DefaultService.getTitles( + cursor, + undefined, + true, + search, + undefined, + undefined, + undefined, + undefined, + limit, + undefined, + 'all' + ); - const fetchTitles = async (reset: boolean) => { - try { - if (reset) { - setLoading(true); - setOffset(0); - } else { - setLoadingMore(true); - } - - const result = await DefaultService.getTitles( - search || undefined, - undefined, // status - undefined, // rating - undefined, // release_year - undefined, // release_season - LIMIT, - reset ? 0 : offset, - "all" - ); - - if (reset) { - setTitles(result); - } else { - setTitles(prev => [...prev, ...result]); - } - - if (result.length > 0) { - setOffset(prev => prev + LIMIT); - } - - } catch (err) { - console.error(err); - setError("Failed to fetch titles."); - } finally { - setLoading(false); - setLoadingMore(false); - } + return { + items: result.data ?? [], + cursor: result.cursor ?? null, + }; }; - useEffect(() => { - fetchTitles(true); - }, [search]); - - if (loading) return <div className={styles.loader}>Loading...</div>; - if (error) return <div className={styles.error}>{error}</div>; - return ( - <div className={styles.container}> - <div className={styles.header}> - <h1>Titles</h1> + <div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> + + <h1 className="text-4xl font-bold mb-6 text-center">Titles</h1> - <input - className={styles.searchInput} - placeholder="Search titles..." - value={search} - onChange={e => setSearch(e.target.value)} - /> - </div> - - <div className={styles.list}> - {titles.map((t) => ( - <div key={t.id} className={styles.card}> - {t.poster_id ? ( - <img - src={`/images/${t.poster_id}.png`} - alt="Poster" - className={styles.poster} - /> - ) : ( - <div className={styles.posterPlaceholder}>No Image</div> - )} - - <div className={styles.cardInfo}> - <h3 className={styles.titleName}>{t.name}</h3> - <p className={styles.meta}> - {t.release_year} • {t.release_season} - </p> - <p className={styles.rating}>Rating: {t.rating}</p> - <p className={styles.status}>{t.status}</p> - </div> - </div> - ))} - </div> - - {titles.length > 0 && ( - <button - className={styles.loadMore} - onClick={() => fetchTitles(false)} - disabled={loadingMore} - > - {loadingMore ? "Loading..." : "Load More"} - </button> - )} + <ListView<Title> + pageSize={PAGE_SIZE} + fetchItems={loadTitles} + searchPlaceholder="Search titles..." + renderItem={(title, layout) => + layout === "square" + ? <TitleCardSquare title={title} /> + : <TitleCardHorizontal title={title} /> + } + setSearch={setSearch} + /> </div> ); -}; +} -export default TitlesPage; diff --git a/modules/frontend/vite.config.ts b/modules/frontend/vite.config.ts index 4cfbdd0..6c261e6 100644 --- a/modules/frontend/vite.config.ts +++ b/modules/frontend/vite.config.ts @@ -1,9 +1,13 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + tailwindcss() + ], server: { host: '127.0.0.1', port: 8083, From 31a95fabeaa860b02d3be9b565238232235156c1 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Wed, 19 Nov 2025 11:06:09 +0300 Subject: [PATCH 076/224] fix: useEffect --- modules/frontend/src/pages/TitlesPage/TitlesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index b59f737..4aeb5ec 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -3,7 +3,7 @@ import { DefaultService } from "../../api/services/DefaultService"; import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; import type { Title } from "../../api"; -import { useState, useEffect } from "react"; +import { useState } from "react"; const PAGE_SIZE = 20; export default function TitlesPage() { From af0492cdf1101f44e1e916f9bd9faa42787040c4 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 00:01:48 +0300 Subject: [PATCH 077/224] feat: cursor implemented --- api/openapi.yaml | 1 + api/schemas/_index.yaml | 2 +- modules/backend/handlers/common.go | 65 ++++++++++++ modules/backend/handlers/cursor.go | 156 +++++++++++++++++++++++++++++ modules/backend/handlers/titles.go | 38 +++++-- modules/backend/queries.sql | 124 +++++++++++++++-------- sql/queries.sql.go | 154 ++++++++++++++++++---------- sql/sqlc.yaml | 1 + 8 files changed, 435 insertions(+), 106 deletions(-) create mode 100644 modules/backend/handlers/cursor.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 281fe82..c8bdbc4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -20,3 +20,4 @@ components: $ref: "./parameters/_index.yaml" schemas: $ref: "./schemas/_index.yaml" + \ No newline at end of file diff --git a/api/schemas/_index.yaml b/api/schemas/_index.yaml index ac49f37..d893ced 100644 --- a/api/schemas/_index.yaml +++ b/api/schemas/_index.yaml @@ -1,5 +1,5 @@ CursorObj: - $ref: ./CursorObj.yaml + $ref: "./CursorObj.yaml" TitleSort: $ref: "./TitleSort.yaml" Image: diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index b85d022..3d61b91 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -13,6 +13,71 @@ func NewServer(db *sqlc.Queries) Server { return Server{db: db} } +// type Cursor interface { +// ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error) + +// Values() map[string]interface{} +// // for logs only +// Type() string +// } + +// type CursorByID struct { +// ID int64 +// } + +// func (c CursorByID) ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error) { +// var cur CursorByID +// if err := json.Unmarshal(data, &cur); err != nil { +// return nil, fmt.Errorf("invalid cursor (id): %w", err) +// } +// if cur.ID == 0 { +// return nil, errors.New("cursor id must be non-zero") +// } +// return cur, nil +// } + +// func (c CursorByID) Values() map[string]interface{} { +// return map[string]interface{}{ +// "cursor_id": c.ID, +// "cursor_year": nil, +// "cursor_rating": nil, +// } +// } + +// func (c CursorByID) Type() string { return "id" } + +// func NewCursor(sortBy string) (Cursor, error) { +// switch Type(sortBy) { +// case TypeID: +// return CursorByID{}, nil +// case TypeYear: +// return CursorByYear{}, nil +// case TypeRating: +// return CursorByRating{}, nil +// default: +// return nil, fmt.Errorf("unsupported sort_by: %q", sortBy) +// } +// } + +// decodes a base64-encoded JSON string into a CursorObj +// Returns the parsed CursorObj and an error +// func parseCursor(encoded oapi.Cursor) (*oapi.CursorObj, error) { + +// // Decode base64 +// decoded, err := base64.StdEncoding.DecodeString(encoded) +// if err != nil { +// return nil, fmt.Errorf("parseCursor: %v", err) +// } + +// // Parse JSON +// var cursor oapi.CursorObj +// if err := json.Unmarshal(decoded, &cursor); err != nil { +// return nil, fmt.Errorf("parseCursor: %v", err) +// } + +// return &cursor, nil +// } + func parseInt64(s string) (int32, error) { i, err := strconv.ParseInt(s, 10, 64) return int32(i), err diff --git a/modules/backend/handlers/cursor.go b/modules/backend/handlers/cursor.go new file mode 100644 index 0000000..fd2821e --- /dev/null +++ b/modules/backend/handlers/cursor.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "reflect" + "strconv" +) + +// ParseCursorInto parses an opaque base64 cursor and injects values into target struct. +// +// Supported sort types: +// - "id" → sets CursorID (must be *int64) +// - "year" → sets CursorID (*int64) + CursorYear (*int32) +// - "rating" → sets CursorID (*int64) + CursorRating (*float64) +// +// Target struct may have any subset of these fields (e.g. only CursorID). +// Unknown fields are ignored. Missing fields → values are dropped (safe). +// +// Returns error if cursor is invalid or inconsistent with sort_by. +func ParseCursorInto(sortBy, cursorStr string, target any) error { + if cursorStr == "" { + return nil // no cursor → nothing to do + } + + // 1. Decode cursor + payload, err := decodeCursor(cursorStr) + if err != nil { + return err + } + + // 2. Extract ID (required for all types) + id, err := extractInt64(payload, "id") + if err != nil { + return fmt.Errorf("cursor: %v", err) + } + + // 3. Get reflect value of target (must be ptr to struct) + v := reflect.ValueOf(target) + if v.Kind() != reflect.Ptr || v.IsNil() { + return fmt.Errorf("target must be non-nil pointer to struct") + } + v = v.Elem() + if v.Kind() != reflect.Struct { + return fmt.Errorf("target must be pointer to struct") + } + + // 4. Helper: set field if exists and compatible + setField := func(fieldName string, value any) { + f := v.FieldByName(fieldName) + if !f.IsValid() || !f.CanSet() { + return // field not found or unexported + } + ft := f.Type() + vv := reflect.ValueOf(value) + + // Case: field is *T, value is T → wrap in pointer + if ft.Kind() == reflect.Ptr { + elemType := ft.Elem() + if vv.Type().AssignableTo(elemType) { + ptr := reflect.New(elemType) + ptr.Elem().Set(vv) + f.Set(ptr) + } + // nil → leave as zero (nil pointer) + } else if vv.Type().AssignableTo(ft) { + f.Set(vv) + } + // else: type mismatch → silently skip (safe) + } + + // 5. Dispatch by sort type + switch sortBy { + case "id": + setField("CursorID", id) + + case "year": + setField("CursorID", id) + param, err := extractString(payload, "param") + if err != nil { + return fmt.Errorf("cursor year: %w", err) + } + year, err := strconv.Atoi(param) + if err != nil { + return fmt.Errorf("cursor year: param must be integer, got %q", param) + } + setField("CursorYear", int32(year)) // or int, depending on your schema + + case "rating": + setField("CursorID", id) + param, err := extractString(payload, "param") + if err != nil { + return fmt.Errorf("cursor rating: %w", err) + } + rating, err := strconv.ParseFloat(param, 64) + if err != nil { + return fmt.Errorf("cursor rating: param must be float, got %q", param) + } + setField("CursorRating", rating) + + default: + return fmt.Errorf("unsupported sort_by: %q", sortBy) + } + + return nil +} + +// --- helpers --- +func decodeCursor(cursorStr string) (map[string]any, error) { + data, err := base64.RawURLEncoding.DecodeString(cursorStr) + if err != nil { + data, err = base64.StdEncoding.DecodeString(cursorStr) + if err != nil { + return nil, fmt.Errorf("invalid base64 cursor") + } + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("invalid cursor JSON: %w", err) + } + return m, nil +} + +func extractInt64(m map[string]any, key string) (int64, error) { + v, ok := m[key] + if !ok { + return 0, fmt.Errorf("missing %q", key) + } + switch x := v.(type) { + case float64: + if x == float64(int64(x)) { + return int64(x), nil + } + case string: + i, err := strconv.ParseInt(x, 10, 64) + if err == nil { + return i, nil + } + case int64, int, int32: + return reflect.ValueOf(x).Int(), nil + } + return 0, fmt.Errorf("%q must be integer", key) +} + +func extractString(m map[string]interface{}, key string) (string, error) { + v, ok := m[key] + if !ok { + return "", fmt.Errorf("missing %q", key) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%q must be string", key) + } + return s, nil +} diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index f187cc4..fc3d621 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -218,9 +218,17 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { opai_titles := make([]oapi.Title, 0) - cursor := oapi.CursorObj{ - Id: 1, - } + + // old_cursor := oapi.CursorObj{ + // Id: 1, + // } + + // if request.Params.Cursor != nil { + // if old_cursor, err := parseCursor(*request.Params.Cursor); err != nil { + // log.Errorf("%v", err) + // return oapi.GetTitles400Response{}, err + // } + // } word := Word2Sqlc(request.Params.Word) status, err := TitleStatus2Sqlc(request.Params.Status) @@ -233,17 +241,29 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje log.Errorf("%v", err) return oapi.GetTitles400Response{}, err } - // param = nil means it will not be used - titles, err := s.db.SearchTitles(ctx, sqlc.SearchTitlesParams{ + + params := sqlc.SearchTitlesParams{ Word: word, Status: status, Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, Forward: true, - SortBy: "id", - Limit: request.Params.Limit, - }) + // SortBy: "id", + Limit: request.Params.Limit, + } + + if request.Params.Sort != nil { + if request.Params.Cursor != nil { + err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles400Response{}, nil + } + } + } + // param = nil means it will not be used + titles, err := s.db.SearchTitles(ctx, params) if err != nil { log.Errorf("%v", err) return oapi.GetTitles500Response{}, nil @@ -262,5 +282,5 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje opai_titles = append(opai_titles, t) } - return oapi.GetTitles200JSONResponse{Cursor: cursor, Data: opai_titles}, nil + return oapi.GetTitles200JSONResponse{Cursor: oapi.CursorObj{}, Data: opai_titles}, nil } diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index c05edff..7118781 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -81,56 +81,96 @@ WHERE id = sqlc.arg('title_id')::bigint; SELECT * FROM titles -WHERE - CASE - WHEN sqlc.narg('word')::text IS NOT NULL THEN - ( - SELECT bool_and( - EXISTS ( - SELECT 1 - FROM jsonb_each_text(title_names) AS t(key, val) - WHERE val ILIKE pattern - ) - ) - FROM unnest( - ARRAY( - SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true +WHERE + CASE + WHEN sqlc.arg('forward')::boolean THEN + -- forward: greater than cursor (next page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (release_year > sqlc.narg('cursor_year')::int) OR + (release_year = sqlc.narg('cursor_year')::int AND id > sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (rating > sqlc.narg('cursor_rating')::float) OR + (rating = sqlc.narg('cursor_rating')::float AND id > sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (id > sqlc.narg('cursor_id')::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (release_year < sqlc.narg('cursor_year')::int) OR + (release_year = sqlc.narg('cursor_year')::int AND id < sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (rating < sqlc.narg('cursor_rating')::float) OR + (rating = sqlc.narg('cursor_rating')::float AND id < sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (id < sqlc.narg('cursor_id')::bigint) + + ELSE true + END END + AND ( + CASE + WHEN sqlc.narg('word')::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) + AND (sqlc.narg('status')::title_status_t IS NULL OR title_status = sqlc.narg('status')::title_status_t) AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t) -ORDER BY - -- Основной ключ: выбранное поле - CASE - WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id - WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year - WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating - -- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views - END ASC, - CASE - WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id - WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year - WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating - -- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views - END DESC, - -- Вторичный ключ: id — только если НЕ сортируем по id - CASE - WHEN sqlc.arg(sort_by)::text != 'id' AND sqlc.arg(forward)::boolean THEN id - END ASC, - CASE - WHEN sqlc.arg(sort_by)::text != 'id' AND NOT sqlc.arg(forward)::boolean THEN id - END DESC +ORDER BY + CASE WHEN sqlc.arg('forward')::boolean THEN + CASE + WHEN sqlc.arg('sort_by')::text = 'id' THEN id + WHEN sqlc.arg('sort_by')::text = 'year' THEN release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN rating + END + END ASC, + CASE WHEN NOT sqlc.arg('forward')::boolean THEN + CASE + WHEN sqlc.arg('sort_by')::text = 'id' THEN id + WHEN sqlc.arg('sort_by')::text = 'year' THEN release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN rating + END + END DESC, + + CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN id END ASC + LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit --- OFFSET sqlc.narg('offset')::int; -- name: SearchUserTitles :many SELECT diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 5a1d13c..7d970cb 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -179,7 +179,7 @@ func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([][]byte, er return nil, err } defer rows.Close() - var items [][]byte + items := [][]byte{} for rows.Next() { var tag_names []byte if err := rows.Scan(&tag_names); err != nil { @@ -289,84 +289,131 @@ const searchTitles = `-- name: SearchTitles :many SELECT id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len FROM titles -WHERE - CASE - WHEN $1::text IS NOT NULL THEN - ( - SELECT bool_and( - EXISTS ( - SELECT 1 - FROM jsonb_each_text(title_names) AS t(key, val) - WHERE val ILIKE pattern - ) - ) - FROM unnest( - ARRAY( - SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array($1::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true +WHERE + CASE + WHEN $1::boolean THEN + -- forward: greater than cursor (next page) + CASE $2::text + WHEN 'year' THEN + ($3::int IS NULL) OR + (release_year > $3::int) OR + (release_year = $3::int AND id > $4::bigint) + + WHEN 'rating' THEN + ($5::float IS NULL) OR + (rating > $5::float) OR + (rating = $5::float AND id > $4::bigint) + + WHEN 'id' THEN + ($4::bigint IS NULL) OR + (id > $4::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE $2::text + WHEN 'year' THEN + ($3::int IS NULL) OR + (release_year < $3::int) OR + (release_year = $3::int AND id < $4::bigint) + + WHEN 'rating' THEN + ($5::float IS NULL) OR + (rating < $5::float) OR + (rating = $5::float AND id < $4::bigint) + + WHEN 'id' THEN + ($4::bigint IS NULL) OR + (id < $4::bigint) + + ELSE true + END END - AND ($2::title_status_t IS NULL OR title_status = $2::title_status_t) - AND ($3::float IS NULL OR rating >= $3::float) - AND ($4::int IS NULL OR release_year = $4::int) - AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t) -ORDER BY - -- Основной ключ: выбранное поле - CASE - WHEN $6::boolean AND $7::text = 'id' THEN id - WHEN $6::boolean AND $7::text = 'year' THEN release_year - WHEN $6::boolean AND $7::text = 'rating' THEN rating - -- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views - END ASC, - CASE - WHEN NOT $6::boolean AND $7::text = 'id' THEN id - WHEN NOT $6::boolean AND $7::text = 'year' THEN release_year - WHEN NOT $6::boolean AND $7::text = 'rating' THEN rating - -- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views - END DESC, + AND ( + CASE + WHEN $6::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($6::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) - -- Вторичный ключ: id — только если НЕ сортируем по id - CASE - WHEN $7::text != 'id' AND $6::boolean THEN id - END ASC, - CASE - WHEN $7::text != 'id' AND NOT $6::boolean THEN id - END DESC -LIMIT COALESCE($8::int, 100) + AND ($7::title_status_t IS NULL OR title_status = $7::title_status_t) + AND ($8::float IS NULL OR rating >= $8::float) + AND ($9::int IS NULL OR release_year = $9::int) + AND ($10::release_season_t IS NULL OR release_season = $10::release_season_t) + +ORDER BY + CASE WHEN $1::boolean THEN + CASE + WHEN $2::text = 'id' THEN id + WHEN $2::text = 'year' THEN release_year + WHEN $2::text = 'rating' THEN rating + END + END ASC, + CASE WHEN NOT $1::boolean THEN + CASE + WHEN $2::text = 'id' THEN id + WHEN $2::text = 'year' THEN release_year + WHEN $2::text = 'rating' THEN rating + END + END DESC, + + CASE WHEN $2::text <> 'id' THEN id END ASC + +LIMIT COALESCE($11::int, 100) ` type SearchTitlesParams struct { + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` Status *TitleStatusT `json:"status"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Forward bool `json:"forward"` - SortBy string `json:"sort_by"` Limit *int32 `json:"limit"` } func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) { rows, err := q.db.Query(ctx, searchTitles, + arg.Forward, + arg.SortBy, + arg.CursorYear, + arg.CursorID, + arg.CursorRating, arg.Word, arg.Status, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, - arg.Forward, - arg.SortBy, arg.Limit, ) if err != nil { return nil, err } defer rows.Close() - var items []Title + items := []Title{} for rows.Next() { var i Title if err := rows.Scan( @@ -464,7 +511,6 @@ type SearchUserTitlesRow struct { } // 100 is default limit -// OFFSET sqlc.narg('offset')::int; func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { rows, err := q.db.Query(ctx, searchUserTitles, arg.Word, @@ -479,7 +525,7 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara return nil, err } defer rows.Close() - var items []SearchUserTitlesRow + items := []SearchUserTitlesRow{} for rows.Next() { var i SearchUserTitlesRow if err := rows.Scan( diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index f74d2ad..94b9fb4 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -12,6 +12,7 @@ sql: sql_driver: "github.com/jackc/pgx/v5" emit_json_tags: true emit_pointers_for_null_types: true + emit_empty_slices: true #slices returned by :many queries will be empty instead of nil overrides: - db_type: "uuid" nullable: false From e16adcf6cd5f36bad9ec61adc81af3443e04a277 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 02:04:40 +0300 Subject: [PATCH 078/224] feat: now back sendind real new cursor --- modules/backend/handlers/cursor.go | 6 +++--- modules/backend/handlers/titles.go | 22 +++++++++++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/modules/backend/handlers/cursor.go b/modules/backend/handlers/cursor.go index fd2821e..e3b48b0 100644 --- a/modules/backend/handlers/cursor.go +++ b/modules/backend/handlers/cursor.go @@ -38,7 +38,7 @@ func ParseCursorInto(sortBy, cursorStr string, target any) error { // 3. Get reflect value of target (must be ptr to struct) v := reflect.ValueOf(target) - if v.Kind() != reflect.Ptr || v.IsNil() { + if v.Kind() != reflect.Pointer || v.IsNil() { return fmt.Errorf("target must be non-nil pointer to struct") } v = v.Elem() @@ -56,7 +56,7 @@ func ParseCursorInto(sortBy, cursorStr string, target any) error { vv := reflect.ValueOf(value) // Case: field is *T, value is T → wrap in pointer - if ft.Kind() == reflect.Ptr { + if ft.Kind() == reflect.Pointer { elemType := ft.Elem() if vv.Type().AssignableTo(elemType) { ptr := reflect.New(elemType) @@ -143,7 +143,7 @@ func extractInt64(m map[string]any, key string) (int64, error) { return 0, fmt.Errorf("%q must be integer", key) } -func extractString(m map[string]interface{}, key string) (string, error) { +func extractString(m map[string]any, key string) (string, error) { v, ok := m[key] if !ok { return "", fmt.Errorf("missing %q", key) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index fc3d621..aea6037 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -6,6 +6,7 @@ import ( "fmt" oapi "nyanimedb/api" sqlc "nyanimedb/sql" + "strconv" "github.com/jackc/pgx/v5" log "github.com/sirupsen/logrus" @@ -249,11 +250,12 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, Forward: true, - // SortBy: "id", - Limit: request.Params.Limit, + SortBy: "id", + Limit: request.Params.Limit, } if request.Params.Sort != nil { + params.SortBy = string(*request.Params.Sort) if request.Params.Cursor != nil { err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) if err != nil { @@ -272,6 +274,8 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje return oapi.GetTitles204Response{}, nil } + var new_cursor oapi.CursorObj + for _, title := range titles { t, err := s.mapTitle(ctx, title) @@ -280,7 +284,19 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje return oapi.GetTitles500Response{}, nil } opai_titles = append(opai_titles, t) + + new_cursor.Id = t.Id + if request.Params.Sort != nil { + switch string(*request.Params.Sort) { + case "year": + tmp := string(*t.ReleaseYear) + new_cursor.Param = &tmp + case "rating": + tmp := strconv.FormatFloat(*t.Rating, 'f', -1, 64) + new_cursor.Param = &tmp + } + } } - return oapi.GetTitles200JSONResponse{Cursor: oapi.CursorObj{}, Data: opai_titles}, nil + return oapi.GetTitles200JSONResponse{Cursor: new_cursor, Data: opai_titles}, nil } From 73547187beac3e431e1613e13176cc82b88393aa Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 02:58:51 +0300 Subject: [PATCH 079/224] fix: search titles rewritten --- sql/queries.sql.go | 101 +++++++++++++++++++++++++++++++++------------ 1 file changed, 74 insertions(+), 27 deletions(-) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 7d970cb..c043f8a 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -287,8 +287,23 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams const searchTitles = `-- name: SearchTitles :many SELECT - id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len -FROM titles + t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + g.tag_names as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type as studio_storage_type, + si.image_path as studio_image_path + +FROM titles as t +LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + WHERE CASE WHEN $1::boolean THEN @@ -296,17 +311,17 @@ WHERE CASE $2::text WHEN 'year' THEN ($3::int IS NULL) OR - (release_year > $3::int) OR - (release_year = $3::int AND id > $4::bigint) + (t.release_year > $3::int) OR + (t.release_year = $3::int AND t.id > $4::bigint) WHEN 'rating' THEN ($5::float IS NULL) OR - (rating > $5::float) OR - (rating = $5::float AND id > $4::bigint) + (t.rating > $5::float) OR + (t.rating = $5::float AND t.id > $4::bigint) WHEN 'id' THEN ($4::bigint IS NULL) OR - (id > $4::bigint) + (t.id > $4::bigint) ELSE true -- fallback END @@ -316,17 +331,17 @@ WHERE CASE $2::text WHEN 'year' THEN ($3::int IS NULL) OR - (release_year < $3::int) OR - (release_year = $3::int AND id < $4::bigint) + (t.release_year < $3::int) OR + (t.release_year = $3::int AND t.id < $4::bigint) WHEN 'rating' THEN ($5::float IS NULL) OR - (rating < $5::float) OR - (rating = $5::float AND id < $4::bigint) + (t.rating < $5::float) OR + (t.rating = $5::float AND t.id < $4::bigint) WHEN 'id' THEN ($4::bigint IS NULL) OR - (id < $4::bigint) + (t.id < $4::bigint) ELSE true END @@ -339,7 +354,7 @@ WHERE SELECT bool_and( EXISTS ( SELECT 1 - FROM jsonb_each_text(title_names) AS t(key, val) + FROM jsonb_each_text(t.title_names) AS t(key, val) WHERE val ILIKE pattern ) ) @@ -355,28 +370,28 @@ WHERE END ) - AND ($7::title_status_t IS NULL OR title_status = $7::title_status_t) - AND ($8::float IS NULL OR rating >= $8::float) - AND ($9::int IS NULL OR release_year = $9::int) - AND ($10::release_season_t IS NULL OR release_season = $10::release_season_t) + AND ($7::title_status_t IS NULL OR t.title_status = $7::title_status_t) + AND ($8::float IS NULL OR t.rating >= $8::float) + AND ($9::int IS NULL OR t.release_year = $9::int) + AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) ORDER BY CASE WHEN $1::boolean THEN CASE - WHEN $2::text = 'id' THEN id - WHEN $2::text = 'year' THEN release_year - WHEN $2::text = 'rating' THEN rating + WHEN $2::text = 'id' THEN t.id + WHEN $2::text = 'year' THEN t.release_year + WHEN $2::text = 'rating' THEN t.rating END END ASC, CASE WHEN NOT $1::boolean THEN CASE - WHEN $2::text = 'id' THEN id - WHEN $2::text = 'year' THEN release_year - WHEN $2::text = 'rating' THEN rating + WHEN $2::text = 'id' THEN t.id + WHEN $2::text = 'year' THEN t.release_year + WHEN $2::text = 'rating' THEN t.rating END END DESC, - CASE WHEN $2::text <> 'id' THEN id END ASC + CASE WHEN $2::text <> 'id' THEN t.id END ASC LIMIT COALESCE($11::int, 100) ` @@ -395,7 +410,31 @@ type SearchTitlesParams struct { Limit *int32 `json:"limit"` } -func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) { +type SearchTitlesRow struct { + ID int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` + TitleStorageType NullStorageTypeT `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames []byte `json:"tag_names"` + StudioName *string `json:"studio_name"` + StudioIllustID *int64 `json:"studio_illust_id"` + StudioDesc *string `json:"studio_desc"` + StudioStorageType NullStorageTypeT `json:"studio_storage_type"` + StudioImagePath *string `json:"studio_image_path"` +} + +func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]SearchTitlesRow, error) { rows, err := q.db.Query(ctx, searchTitles, arg.Forward, arg.SortBy, @@ -413,9 +452,9 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T return nil, err } defer rows.Close() - items := []Title{} + items := []SearchTitlesRow{} for rows.Next() { - var i Title + var i SearchTitlesRow if err := rows.Scan( &i.ID, &i.TitleNames, @@ -430,6 +469,14 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T &i.EpisodesAired, &i.EpisodesAll, &i.EpisodesLen, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, + &i.StudioIllustID, + &i.StudioDesc, + &i.StudioStorageType, + &i.StudioImagePath, ); err != nil { return nil, err } From 870bbe2395ae08481f391b4097d8dd0c1b08793b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 05:14:42 +0300 Subject: [PATCH 080/224] feat: now status can be array --- api/paths/titles.yaml | 6 ++++++ api/schemas/Image.yaml | 3 ++- api/schemas/Studio.yaml | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/api/paths/titles.yaml b/api/paths/titles.yaml index e868ed6..3c5cf76 100644 --- a/api/paths/titles.yaml +++ b/api/paths/titles.yaml @@ -15,7 +15,13 @@ get: - in: query name: status schema: + type: array + items: $ref: '../schemas/enums/TitleStatus.yaml' + description: List of title statuses to filter + style: form + explode: true + - in: query name: rating schema: diff --git a/api/schemas/Image.yaml b/api/schemas/Image.yaml index 7226b29..4ae3cb7 100644 --- a/api/schemas/Image.yaml +++ b/api/schemas/Image.yaml @@ -1,6 +1,7 @@ type: object properties: - id: +# id выпиливаем + id: type: integer format: int64 storage_type: diff --git a/api/schemas/Studio.yaml b/api/schemas/Studio.yaml index 35b40a8..26a2adf 100644 --- a/api/schemas/Studio.yaml +++ b/api/schemas/Studio.yaml @@ -3,6 +3,7 @@ required: - id - name properties: +# id не нужен id: type: integer format: int64 From f1f7feffaa6ab39a3c6579501cc4950645c6201f Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 22 Nov 2025 05:45:54 +0300 Subject: [PATCH 081/224] feat: /titles page with search and sort functionality. Website header added --- modules/frontend/package-lock.json | 240 ++++++++++++++++++ modules/frontend/package.json | 1 + modules/frontend/src/App.css | 42 --- modules/frontend/src/App.tsx | 3 + .../frontend/src/components/Header/Header.tsx | 90 +++++++ .../components/LayoutSwitch/LayoutSwitch.tsx | 28 ++ .../src/components/ListView/ListView.tsx | 96 ++----- .../src/components/SearchBar/SearchBar.tsx | 34 +++ .../TitlesSortBox/TitlesSortBox.tsx | 67 +++++ modules/frontend/src/index.css | 1 + .../src/pages/TitlesPage/TitlesPage.tsx | 176 ++++++++++--- modules/frontend/vite.config.ts | 2 +- 12 files changed, 625 insertions(+), 155 deletions(-) create mode 100644 modules/frontend/src/components/Header/Header.tsx create mode 100644 modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx create mode 100644 modules/frontend/src/components/SearchBar/SearchBar.tsx create mode 100644 modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index f5dde46..40bb520 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "nyanimedb-frontend", "version": "0.0.0", "dependencies": { + "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", @@ -30,6 +31,9 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" + }, + "engines": { + "node": "20.x" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -906,6 +910,79 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", + "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/@heroicons/react": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", @@ -1057,6 +1134,103 @@ "node": ">= 8" } }, + "node_modules/@react-aria/focus": { + "version": "3.21.2", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz", + "integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.25.6", + "@react-aria/utils": "^3.31.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.25.6", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", + "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/flags": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", + "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz", + "integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.32.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz", + "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.38", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", @@ -1350,6 +1524,15 @@ "win32" ] }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", @@ -1607,6 +1790,33 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2221,6 +2431,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4086,6 +4305,12 @@ "node": ">=8" } }, + "node_modules/tabbable": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", @@ -4177,6 +4402,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4301,6 +4532,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.1.9", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", diff --git a/modules/frontend/package.json b/modules/frontend/package.json index cc468cf..e0b65ba 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", diff --git a/modules/frontend/src/App.css b/modules/frontend/src/App.css index b9d355d..e69de29 100644 --- a/modules/frontend/src/App.css +++ b/modules/frontend/src/App.css @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index f67c37e..909ad6c 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -2,10 +2,13 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./pages/UserPage/UserPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; +import { Header } from "./components/Header/Header"; const App: React.FC = () => { + const username = "nihonium"; return ( <Router> + <Header username={username} /> <Routes> <Route path="/users/:id" element={<UserPage />} /> <Route path="/titles" element={<TitlesPage />} /> diff --git a/modules/frontend/src/components/Header/Header.tsx b/modules/frontend/src/components/Header/Header.tsx new file mode 100644 index 0000000..98b1295 --- /dev/null +++ b/modules/frontend/src/components/Header/Header.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid"; + +type HeaderProps = { + username?: string; +}; + +export const Header: React.FC<HeaderProps> = ({ username }) => { + const [menuOpen, setMenuOpen] = useState(false); + + const toggleMenu = () => setMenuOpen(!menuOpen); + + return ( + <header className="w-full bg-white shadow-md fixed top-0 left-0 z-50"> + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div className="flex justify-between h-16 items-center"> + + {/* Левый блок — логотип / название */} + <div className="flex-shrink-0"> + <Link to="/" className="text-xl font-bold text-gray-800 hover:text-blue-600"> + NyanimeDB + </Link> + </div> + + {/* Центр — ссылки на разделы (desktop) */} + <nav className="hidden md:flex space-x-4"> + <Link to="/titles" className="text-gray-700 hover:text-blue-600"> + Titles + </Link> + <Link to="/users" className="text-gray-700 hover:text-blue-600"> + Users + </Link> + <Link to="/about" className="text-gray-700 hover:text-blue-600"> + About + </Link> + </nav> + + {/* Правый блок — профиль */} + <div className="hidden md:flex items-center space-x-4"> + {username ? ( + <Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium"> + {username} + </Link> + ) : ( + <Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium"> + Login + </Link> + )} + </div> + + {/* Бургер для мобильного */} + <div className="md:hidden flex items-center"> + <button + onClick={toggleMenu} + className="p-2 rounded-md hover:bg-gray-200 transition" + > + {menuOpen ? ( + <XMarkIcon className="w-6 h-6 text-gray-800" /> + ) : ( + <Bars3Icon className="w-6 h-6 text-gray-800" /> + )} + </button> + </div> + + </div> + </div> + + {/* Мобильное меню */} + {menuOpen && ( + <div className="md:hidden bg-white border-t border-gray-200 shadow-md"> + <nav className="flex flex-col p-4 space-y-2"> + <Link to="/titles" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>Titles</Link> + <Link to="/users" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>Users</Link> + <Link to="/about" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>About</Link> + {username ? ( + <Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}> + {username} + </Link> + ) : ( + <Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}> + Login + </Link> + )} + </nav> + </div> + )} + </header> + ); +}; diff --git a/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx b/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx new file mode 100644 index 0000000..679feea --- /dev/null +++ b/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; + +export type LayoutSwitchProps = { + layout: "square" | "horizontal" + setLayout: (value: React.SetStateAction<"square" | "horizontal">) => void +}; + +export function LayoutSwitch({ + layout, + setLayout +}: LayoutSwitchProps) { + + return ( + <div className="flex justify-end"> + <button + className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" + onClick={() => + setLayout(prev => (prev === "square" ? "horizontal" : "square")) + }> + {layout === "square" + ? <Squares2X2Icon className="w-6 h-6" /> + : <Bars3Icon className="w-6 h-6" /> + } + </button> + </div> + ); +} diff --git a/modules/frontend/src/components/ListView/ListView.tsx b/modules/frontend/src/components/ListView/ListView.tsx index e0e8ab9..67488c0 100644 --- a/modules/frontend/src/components/ListView/ListView.tsx +++ b/modules/frontend/src/components/ListView/ListView.tsx @@ -1,103 +1,49 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; -import type { CursorObj } from "../../api"; export type ListViewProps<T> = { - fetchItems: (cursor: string, limit: number) => Promise<{ items: T[]; cursor: CursorObj}>; + items: T[]; + layout: "square" | "horizontal"; renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode; - pageSize?: number; - searchPlaceholder?: string; - setSearch: any; + onLoadMore: () => void; + hasMore: boolean; + loadingMore: boolean; }; export function ListView<T>({ - fetchItems, + items, + layout, renderItem, - pageSize = 20, - searchPlaceholder = "Search...", + onLoadMore, + hasMore, + loadingMore }: ListViewProps<T>) { - const [items, setItems] = useState<T[]>([]); - const [cursorObj, setCursorObj] = useState<CursorObj | undefined>(undefined); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [search, setSearch] = useState(""); - const [layout, setLayout] = useState<"square" | "horizontal">("horizontal"); - const [error, setError] = useState<string | null>(null); - - const loadItems = async (reset: boolean = false) => { - try { - if (reset) { - setLoading(true); - setCursorObj(undefined); - } else { - setLoadingMore(true); - } - - const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)) : "" - console.log("encoded cursor: " + cursorStr) - - const result = await fetchItems(cursorStr, pageSize); - - if (reset) setItems(result.items); - else setItems(prev => [...prev, ...result.items]); - - setCursorObj(result.cursor); - setError(null); - } catch (err: any) { - console.error(err); - setError("Failed to fetch items."); - } finally { - setLoading(false); - setLoadingMore(false); - } - }; - - useEffect(() => { - loadItems(true); - }, [search]); return ( - <div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> - <div className="w-full sm:w-4/5 flex gap-4 mb-8"> - <input - type="text" - placeholder={searchPlaceholder} - // value={search} - onChange={e => setSearch(e.target.value)} - className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-black" - /> - <button - className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" - onClick={() => - setLayout(prev => (prev === "square" ? "horizontal" : "square")) - }> - {layout === "square" ? <Squares2X2Icon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />} - </button> - </div> - - {error && <div className="text-red-600 mb-6 font-medium">{error}</div>} - + <div className="w-full flex flex-col items-center"> + {/* Items */} <div className={`w-full sm:w-4/5 grid gap-6 ${ - layout === "square" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" : "grid-cols-1" + layout === "square" + ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + : "grid-cols-1" }`} > {items.map(item => renderItem(item, layout))} </div> - {cursorObj && ( - <div className="mt-8 flex justify-center w-full sm:w-4/5"> + {/* Load More */} + {hasMore && ( + <div className="mt-8"> <button - className="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed" - onClick={() => loadItems(false)} + className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50" disabled={loadingMore} + onClick={onLoadMore} > {loadingMore ? "Loading..." : "Load More"} </button> </div> )} - - {loading && <div className="mt-20 font-medium">Loading...</div>} </div> ); } diff --git a/modules/frontend/src/components/SearchBar/SearchBar.tsx b/modules/frontend/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000..87aee66 --- /dev/null +++ b/modules/frontend/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,34 @@ +type SearchBarProps = { + placeholder?: string; + search: string; + setSearch: (value: string) => void; +}; + +export function SearchBar({ + placeholder = "Search...", + search, + setSearch, +}: SearchBarProps) { + return ( + <div className="w-full"> + <input + type="text" + value={search} + placeholder={placeholder} + onChange={(e) => setSearch(e.target.value)} + className=" + w-full + px-4 + py-2 + border + border-gray-300 + rounded-lg + focus:outline-none + focus:ring-2 + focus:ring-blue-500 + text-black + " + /> + </div> + ); +} diff --git a/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx new file mode 100644 index 0000000..f1f8195 --- /dev/null +++ b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; +import type { TitleSort } from "../../api"; +import { ChevronDownIcon, ArrowUpIcon, ArrowDownIcon } from "@heroicons/react/24/solid"; + +type TitlesSortBoxProps = { + sort: TitleSort; + setSort: (value: TitleSort) => void; + sortForward: boolean; + setSortForward: (value: boolean) => void; +}; + +const SORT_OPTIONS: TitleSort[] = ["id", "rating", "year", "views"]; + +export function TitlesSortBox({ + sort, + setSort, + sortForward, + setSortForward, +}: TitlesSortBoxProps) { + const [open, setOpen] = useState(false); + + const toggleSortDirection = () => setSortForward(!sortForward); + const handleSortSelect = (newSort: TitleSort) => { + setSort(newSort); + setOpen(false); + }; + + return ( + <div className="inline-flex relative z-50"> + {/* Левая часть — смена направления */} + <button + onClick={toggleSortDirection} + className="px-4 py-2 flex items-center justify-center bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded-l-lg transition" + > + {sortForward ? <ArrowUpIcon className="w-4 h-4 mr-1" /> : <ArrowDownIcon className="w-4 h-4 mr-1" />} + <span className="text-sm font-medium">Order</span> + </button> + + {/* Правая часть — выбор параметра */} + <button + onClick={() => setOpen(!open)} + className="px-4 py-2 flex items-center justify-center bg-gray-100 hover:bg-gray-200 border border-gray-300 border-l-0 rounded-r-lg transition" + > + <span className="text-sm font-medium">{sort}</span> + <ChevronDownIcon className="w-4 h-4 ml-1" /> + </button> + + {/* Dropdown */} + {open && ( + <ul className="absolute top-full left-0 mt-1 w-40 bg-white border border-gray-300 rounded-md shadow-lg z-[1000]"> + {SORT_OPTIONS.map(option => ( + <li key={option}> + <button + className={`w-full text-left px-4 py-2 hover:bg-gray-100 transition ${ + option === sort ? "font-bold bg-gray-100" : "" + }`} + onClick={() => handleSortSelect(option)} + > + {option} + </button> + </li> + ))} + </ul> + )} + </div> + ); +} diff --git a/modules/frontend/src/index.css b/modules/frontend/src/index.css index e20de02..7b32a9b 100644 --- a/modules/frontend/src/index.css +++ b/modules/frontend/src/index.css @@ -5,4 +5,5 @@ html, body, #root { padding: 0; width: 100%; height: 100%; + @apply text-black bg-white; } \ No newline at end of file diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 4aeb5ec..0fec3c8 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -1,52 +1,154 @@ +import { useEffect, useState } from "react"; import { ListView } from "../../components/ListView/ListView"; +import { SearchBar } from "../../components/SearchBar/SearchBar"; +import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; import { DefaultService } from "../../api/services/DefaultService"; import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; -import type { Title } from "../../api"; -import { useState } from "react"; +import type { CursorObj, Title, TitleSort } from "../../api"; +import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; + +const PAGE_SIZE = 10; -const PAGE_SIZE = 20; export default function TitlesPage() { + const [titles, setTitles] = useState<Title[]>([]); + const [nextPage, setNextPage] = useState<Title[]>([]); + const [cursor, setCursor] = useState<CursorObj | null>(null); const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [sort, setSort] = useState<TitleSort>("id"); + const [sortForward, setSortForward] = useState(true); + const [layout, setLayout] = useState<"square" | "horizontal">("square"); - const loadTitles = async (cursor: string, limit: number) => { - const result = await DefaultService.getTitles( - cursor, - undefined, - true, - search, - undefined, - undefined, - undefined, - undefined, - limit, - undefined, - 'all' - ); + const fetchPage = async (cursorObj: CursorObj | null) => { + const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') : ""; - return { - items: result.data ?? [], - cursor: result.cursor ?? null, - }; + try { + const result = await DefaultService.getTitles( + cursorStr, + sort, + sortForward, + search.trim() || undefined, + undefined, + undefined, + undefined, + undefined, + PAGE_SIZE, + undefined, + "all" + ); + + if ((result === undefined) || !result.data?.length) { + return { items: [], nextCursor: null }; + } + return { + items: result.data ?? [], + nextCursor: result.cursor ?? null + }; + } catch (err: any) { + if (err.status === 204) { + return { items: [], nextCursor: null }; + } + throw err; + } }; - return ( - <div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> - - <h1 className="text-4xl font-bold mb-6 text-center">Titles</h1> + // Инициализация: загружаем сразу две страницы + useEffect(() => { + const initLoad = async () => { + setLoading(true); + setTitles([]); + setNextPage([]); + setCursor(null); - <ListView<Title> - pageSize={PAGE_SIZE} - fetchItems={loadTitles} - searchPlaceholder="Search titles..." - renderItem={(title, layout) => - layout === "square" - ? <TitleCardSquare title={title} /> - : <TitleCardHorizontal title={title} /> - } - setSearch={setSearch} - /> + const firstPage = await fetchPage(null); + const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null }; + + setTitles(firstPage.items); + setNextPage(secondPage.items); + setCursor(secondPage.nextCursor); + setLoading(false); + }; + + initLoad(); + }, [search, sort, sortForward]); + + +const handleLoadMore = async () => { + if (nextPage.length === 0) { + setLoadingMore(false); + return; + } + + setLoadingMore(true); + + setTitles(prev => [...prev, ...nextPage]); + setNextPage([]); + + // Подгружаем следующую страницу с сервера + if (cursor) { + try { + const next = await fetchPage(cursor); + if (next.items.length > 0) { + setNextPage(next.items); + } + setCursor(next.nextCursor); + } catch (err) { + console.error(err); + } + } + + // Любой сценарий – выключаем loadingMore + setLoadingMore(false); +}; + + + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> + + <h1 className="text-4xl font-bold mb-6 text-center text-black">Titles</h1> + + <div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center"> + <SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} /> + <LayoutSwitch layout={layout} setLayout={setLayout} /> + <TitlesSortBox + sort={sort} + setSort={setSort} + sortForward={sortForward} + setSortForward={setSortForward} + /> + </div> + + {loading && <div className="mt-20 font-medium text-black">Loading...</div>} + + {!loading && titles.length === 0 && ( + <div className="mt-20 font-medium text-black">No titles found.</div> + )} + + {titles.length > 0 && ( + <> + <ListView<Title> + items={titles} + layout={layout} + hasMore={!!cursor || nextPage.length > 1} + loadingMore={loadingMore} + onLoadMore={handleLoadMore} + renderItem={(title, layout) => + layout === "square" + ? <TitleCardSquare title={title} /> + : <TitleCardHorizontal title={title} /> + } + /> + + {!cursor && nextPage.length == 0 && ( + <div className="mt-6 font-medium text-black"> + Результатов больше нет, было найдено {titles.length} тайтлов. + </div> + )} + </> + )} </div> ); } - diff --git a/modules/frontend/vite.config.ts b/modules/frontend/vite.config.ts index 6c261e6..554d630 100644 --- a/modules/frontend/vite.config.ts +++ b/modules/frontend/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ tailwindcss() ], server: { - host: '127.0.0.1', + host: '0.0.0.0', port: 8083, }, }) From 86e3df2205e13f03491be59acc80ee7c388e9250 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 22 Nov 2025 05:45:54 +0300 Subject: [PATCH 082/224] feat: /titles page with search and sort functionality. Website header added --- modules/frontend/package-lock.json | 240 ++++++++++++++++++ modules/frontend/package.json | 1 + modules/frontend/src/App.css | 42 --- modules/frontend/src/App.tsx | 3 + .../frontend/src/components/Header/Header.tsx | 90 +++++++ .../components/LayoutSwitch/LayoutSwitch.tsx | 28 ++ .../src/components/ListView/ListView.tsx | 96 ++----- .../src/components/SearchBar/SearchBar.tsx | 34 +++ .../TitlesSortBox/TitlesSortBox.tsx | 67 +++++ modules/frontend/src/index.css | 1 + .../src/pages/TitlesPage/TitlesPage.tsx | 176 ++++++++++--- modules/frontend/vite.config.ts | 2 +- 12 files changed, 625 insertions(+), 155 deletions(-) create mode 100644 modules/frontend/src/components/Header/Header.tsx create mode 100644 modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx create mode 100644 modules/frontend/src/components/SearchBar/SearchBar.tsx create mode 100644 modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index f5dde46..40bb520 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "nyanimedb-frontend", "version": "0.0.0", "dependencies": { + "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", @@ -30,6 +31,9 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" + }, + "engines": { + "node": "20.x" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -906,6 +910,79 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", + "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/@heroicons/react": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", @@ -1057,6 +1134,103 @@ "node": ">= 8" } }, + "node_modules/@react-aria/focus": { + "version": "3.21.2", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz", + "integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.25.6", + "@react-aria/utils": "^3.31.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.25.6", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", + "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/flags": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", + "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz", + "integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.32.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz", + "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.38", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", @@ -1350,6 +1524,15 @@ "win32" ] }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", @@ -1607,6 +1790,33 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2221,6 +2431,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4086,6 +4305,12 @@ "node": ">=8" } }, + "node_modules/tabbable": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", @@ -4177,6 +4402,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4301,6 +4532,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.1.9", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", diff --git a/modules/frontend/package.json b/modules/frontend/package.json index cc468cf..e0b65ba 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", diff --git a/modules/frontend/src/App.css b/modules/frontend/src/App.css index b9d355d..e69de29 100644 --- a/modules/frontend/src/App.css +++ b/modules/frontend/src/App.css @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index f67c37e..909ad6c 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -2,10 +2,13 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./pages/UserPage/UserPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; +import { Header } from "./components/Header/Header"; const App: React.FC = () => { + const username = "nihonium"; return ( <Router> + <Header username={username} /> <Routes> <Route path="/users/:id" element={<UserPage />} /> <Route path="/titles" element={<TitlesPage />} /> diff --git a/modules/frontend/src/components/Header/Header.tsx b/modules/frontend/src/components/Header/Header.tsx new file mode 100644 index 0000000..98b1295 --- /dev/null +++ b/modules/frontend/src/components/Header/Header.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid"; + +type HeaderProps = { + username?: string; +}; + +export const Header: React.FC<HeaderProps> = ({ username }) => { + const [menuOpen, setMenuOpen] = useState(false); + + const toggleMenu = () => setMenuOpen(!menuOpen); + + return ( + <header className="w-full bg-white shadow-md fixed top-0 left-0 z-50"> + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div className="flex justify-between h-16 items-center"> + + {/* Левый блок — логотип / название */} + <div className="flex-shrink-0"> + <Link to="/" className="text-xl font-bold text-gray-800 hover:text-blue-600"> + NyanimeDB + </Link> + </div> + + {/* Центр — ссылки на разделы (desktop) */} + <nav className="hidden md:flex space-x-4"> + <Link to="/titles" className="text-gray-700 hover:text-blue-600"> + Titles + </Link> + <Link to="/users" className="text-gray-700 hover:text-blue-600"> + Users + </Link> + <Link to="/about" className="text-gray-700 hover:text-blue-600"> + About + </Link> + </nav> + + {/* Правый блок — профиль */} + <div className="hidden md:flex items-center space-x-4"> + {username ? ( + <Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium"> + {username} + </Link> + ) : ( + <Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium"> + Login + </Link> + )} + </div> + + {/* Бургер для мобильного */} + <div className="md:hidden flex items-center"> + <button + onClick={toggleMenu} + className="p-2 rounded-md hover:bg-gray-200 transition" + > + {menuOpen ? ( + <XMarkIcon className="w-6 h-6 text-gray-800" /> + ) : ( + <Bars3Icon className="w-6 h-6 text-gray-800" /> + )} + </button> + </div> + + </div> + </div> + + {/* Мобильное меню */} + {menuOpen && ( + <div className="md:hidden bg-white border-t border-gray-200 shadow-md"> + <nav className="flex flex-col p-4 space-y-2"> + <Link to="/titles" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>Titles</Link> + <Link to="/users" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>Users</Link> + <Link to="/about" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>About</Link> + {username ? ( + <Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}> + {username} + </Link> + ) : ( + <Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}> + Login + </Link> + )} + </nav> + </div> + )} + </header> + ); +}; diff --git a/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx b/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx new file mode 100644 index 0000000..679feea --- /dev/null +++ b/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; + +export type LayoutSwitchProps = { + layout: "square" | "horizontal" + setLayout: (value: React.SetStateAction<"square" | "horizontal">) => void +}; + +export function LayoutSwitch({ + layout, + setLayout +}: LayoutSwitchProps) { + + return ( + <div className="flex justify-end"> + <button + className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" + onClick={() => + setLayout(prev => (prev === "square" ? "horizontal" : "square")) + }> + {layout === "square" + ? <Squares2X2Icon className="w-6 h-6" /> + : <Bars3Icon className="w-6 h-6" /> + } + </button> + </div> + ); +} diff --git a/modules/frontend/src/components/ListView/ListView.tsx b/modules/frontend/src/components/ListView/ListView.tsx index e0e8ab9..67488c0 100644 --- a/modules/frontend/src/components/ListView/ListView.tsx +++ b/modules/frontend/src/components/ListView/ListView.tsx @@ -1,103 +1,49 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; -import type { CursorObj } from "../../api"; export type ListViewProps<T> = { - fetchItems: (cursor: string, limit: number) => Promise<{ items: T[]; cursor: CursorObj}>; + items: T[]; + layout: "square" | "horizontal"; renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode; - pageSize?: number; - searchPlaceholder?: string; - setSearch: any; + onLoadMore: () => void; + hasMore: boolean; + loadingMore: boolean; }; export function ListView<T>({ - fetchItems, + items, + layout, renderItem, - pageSize = 20, - searchPlaceholder = "Search...", + onLoadMore, + hasMore, + loadingMore }: ListViewProps<T>) { - const [items, setItems] = useState<T[]>([]); - const [cursorObj, setCursorObj] = useState<CursorObj | undefined>(undefined); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [search, setSearch] = useState(""); - const [layout, setLayout] = useState<"square" | "horizontal">("horizontal"); - const [error, setError] = useState<string | null>(null); - - const loadItems = async (reset: boolean = false) => { - try { - if (reset) { - setLoading(true); - setCursorObj(undefined); - } else { - setLoadingMore(true); - } - - const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)) : "" - console.log("encoded cursor: " + cursorStr) - - const result = await fetchItems(cursorStr, pageSize); - - if (reset) setItems(result.items); - else setItems(prev => [...prev, ...result.items]); - - setCursorObj(result.cursor); - setError(null); - } catch (err: any) { - console.error(err); - setError("Failed to fetch items."); - } finally { - setLoading(false); - setLoadingMore(false); - } - }; - - useEffect(() => { - loadItems(true); - }, [search]); return ( - <div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> - <div className="w-full sm:w-4/5 flex gap-4 mb-8"> - <input - type="text" - placeholder={searchPlaceholder} - // value={search} - onChange={e => setSearch(e.target.value)} - className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-black" - /> - <button - className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" - onClick={() => - setLayout(prev => (prev === "square" ? "horizontal" : "square")) - }> - {layout === "square" ? <Squares2X2Icon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />} - </button> - </div> - - {error && <div className="text-red-600 mb-6 font-medium">{error}</div>} - + <div className="w-full flex flex-col items-center"> + {/* Items */} <div className={`w-full sm:w-4/5 grid gap-6 ${ - layout === "square" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" : "grid-cols-1" + layout === "square" + ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + : "grid-cols-1" }`} > {items.map(item => renderItem(item, layout))} </div> - {cursorObj && ( - <div className="mt-8 flex justify-center w-full sm:w-4/5"> + {/* Load More */} + {hasMore && ( + <div className="mt-8"> <button - className="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed" - onClick={() => loadItems(false)} + className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50" disabled={loadingMore} + onClick={onLoadMore} > {loadingMore ? "Loading..." : "Load More"} </button> </div> )} - - {loading && <div className="mt-20 font-medium">Loading...</div>} </div> ); } diff --git a/modules/frontend/src/components/SearchBar/SearchBar.tsx b/modules/frontend/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000..87aee66 --- /dev/null +++ b/modules/frontend/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,34 @@ +type SearchBarProps = { + placeholder?: string; + search: string; + setSearch: (value: string) => void; +}; + +export function SearchBar({ + placeholder = "Search...", + search, + setSearch, +}: SearchBarProps) { + return ( + <div className="w-full"> + <input + type="text" + value={search} + placeholder={placeholder} + onChange={(e) => setSearch(e.target.value)} + className=" + w-full + px-4 + py-2 + border + border-gray-300 + rounded-lg + focus:outline-none + focus:ring-2 + focus:ring-blue-500 + text-black + " + /> + </div> + ); +} diff --git a/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx new file mode 100644 index 0000000..f1f8195 --- /dev/null +++ b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; +import type { TitleSort } from "../../api"; +import { ChevronDownIcon, ArrowUpIcon, ArrowDownIcon } from "@heroicons/react/24/solid"; + +type TitlesSortBoxProps = { + sort: TitleSort; + setSort: (value: TitleSort) => void; + sortForward: boolean; + setSortForward: (value: boolean) => void; +}; + +const SORT_OPTIONS: TitleSort[] = ["id", "rating", "year", "views"]; + +export function TitlesSortBox({ + sort, + setSort, + sortForward, + setSortForward, +}: TitlesSortBoxProps) { + const [open, setOpen] = useState(false); + + const toggleSortDirection = () => setSortForward(!sortForward); + const handleSortSelect = (newSort: TitleSort) => { + setSort(newSort); + setOpen(false); + }; + + return ( + <div className="inline-flex relative z-50"> + {/* Левая часть — смена направления */} + <button + onClick={toggleSortDirection} + className="px-4 py-2 flex items-center justify-center bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded-l-lg transition" + > + {sortForward ? <ArrowUpIcon className="w-4 h-4 mr-1" /> : <ArrowDownIcon className="w-4 h-4 mr-1" />} + <span className="text-sm font-medium">Order</span> + </button> + + {/* Правая часть — выбор параметра */} + <button + onClick={() => setOpen(!open)} + className="px-4 py-2 flex items-center justify-center bg-gray-100 hover:bg-gray-200 border border-gray-300 border-l-0 rounded-r-lg transition" + > + <span className="text-sm font-medium">{sort}</span> + <ChevronDownIcon className="w-4 h-4 ml-1" /> + </button> + + {/* Dropdown */} + {open && ( + <ul className="absolute top-full left-0 mt-1 w-40 bg-white border border-gray-300 rounded-md shadow-lg z-[1000]"> + {SORT_OPTIONS.map(option => ( + <li key={option}> + <button + className={`w-full text-left px-4 py-2 hover:bg-gray-100 transition ${ + option === sort ? "font-bold bg-gray-100" : "" + }`} + onClick={() => handleSortSelect(option)} + > + {option} + </button> + </li> + ))} + </ul> + )} + </div> + ); +} diff --git a/modules/frontend/src/index.css b/modules/frontend/src/index.css index e20de02..7b32a9b 100644 --- a/modules/frontend/src/index.css +++ b/modules/frontend/src/index.css @@ -5,4 +5,5 @@ html, body, #root { padding: 0; width: 100%; height: 100%; + @apply text-black bg-white; } \ No newline at end of file diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 4aeb5ec..0fec3c8 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -1,52 +1,154 @@ +import { useEffect, useState } from "react"; import { ListView } from "../../components/ListView/ListView"; +import { SearchBar } from "../../components/SearchBar/SearchBar"; +import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; import { DefaultService } from "../../api/services/DefaultService"; import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; -import type { Title } from "../../api"; -import { useState } from "react"; +import type { CursorObj, Title, TitleSort } from "../../api"; +import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; + +const PAGE_SIZE = 10; -const PAGE_SIZE = 20; export default function TitlesPage() { + const [titles, setTitles] = useState<Title[]>([]); + const [nextPage, setNextPage] = useState<Title[]>([]); + const [cursor, setCursor] = useState<CursorObj | null>(null); const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [sort, setSort] = useState<TitleSort>("id"); + const [sortForward, setSortForward] = useState(true); + const [layout, setLayout] = useState<"square" | "horizontal">("square"); - const loadTitles = async (cursor: string, limit: number) => { - const result = await DefaultService.getTitles( - cursor, - undefined, - true, - search, - undefined, - undefined, - undefined, - undefined, - limit, - undefined, - 'all' - ); + const fetchPage = async (cursorObj: CursorObj | null) => { + const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') : ""; - return { - items: result.data ?? [], - cursor: result.cursor ?? null, - }; + try { + const result = await DefaultService.getTitles( + cursorStr, + sort, + sortForward, + search.trim() || undefined, + undefined, + undefined, + undefined, + undefined, + PAGE_SIZE, + undefined, + "all" + ); + + if ((result === undefined) || !result.data?.length) { + return { items: [], nextCursor: null }; + } + return { + items: result.data ?? [], + nextCursor: result.cursor ?? null + }; + } catch (err: any) { + if (err.status === 204) { + return { items: [], nextCursor: null }; + } + throw err; + } }; - return ( - <div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center"> - - <h1 className="text-4xl font-bold mb-6 text-center">Titles</h1> + // Инициализация: загружаем сразу две страницы + useEffect(() => { + const initLoad = async () => { + setLoading(true); + setTitles([]); + setNextPage([]); + setCursor(null); - <ListView<Title> - pageSize={PAGE_SIZE} - fetchItems={loadTitles} - searchPlaceholder="Search titles..." - renderItem={(title, layout) => - layout === "square" - ? <TitleCardSquare title={title} /> - : <TitleCardHorizontal title={title} /> - } - setSearch={setSearch} - /> + const firstPage = await fetchPage(null); + const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null }; + + setTitles(firstPage.items); + setNextPage(secondPage.items); + setCursor(secondPage.nextCursor); + setLoading(false); + }; + + initLoad(); + }, [search, sort, sortForward]); + + +const handleLoadMore = async () => { + if (nextPage.length === 0) { + setLoadingMore(false); + return; + } + + setLoadingMore(true); + + setTitles(prev => [...prev, ...nextPage]); + setNextPage([]); + + // Подгружаем следующую страницу с сервера + if (cursor) { + try { + const next = await fetchPage(cursor); + if (next.items.length > 0) { + setNextPage(next.items); + } + setCursor(next.nextCursor); + } catch (err) { + console.error(err); + } + } + + // Любой сценарий – выключаем loadingMore + setLoadingMore(false); +}; + + + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> + + <h1 className="text-4xl font-bold mb-6 text-center text-black">Titles</h1> + + <div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center"> + <SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} /> + <LayoutSwitch layout={layout} setLayout={setLayout} /> + <TitlesSortBox + sort={sort} + setSort={setSort} + sortForward={sortForward} + setSortForward={setSortForward} + /> + </div> + + {loading && <div className="mt-20 font-medium text-black">Loading...</div>} + + {!loading && titles.length === 0 && ( + <div className="mt-20 font-medium text-black">No titles found.</div> + )} + + {titles.length > 0 && ( + <> + <ListView<Title> + items={titles} + layout={layout} + hasMore={!!cursor || nextPage.length > 1} + loadingMore={loadingMore} + onLoadMore={handleLoadMore} + renderItem={(title, layout) => + layout === "square" + ? <TitleCardSquare title={title} /> + : <TitleCardHorizontal title={title} /> + } + /> + + {!cursor && nextPage.length == 0 && ( + <div className="mt-6 font-medium text-black"> + Результатов больше нет, было найдено {titles.length} тайтлов. + </div> + )} + </> + )} </div> ); } - diff --git a/modules/frontend/vite.config.ts b/modules/frontend/vite.config.ts index 6c261e6..554d630 100644 --- a/modules/frontend/vite.config.ts +++ b/modules/frontend/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ tailwindcss() ], server: { - host: '127.0.0.1', + host: '0.0.0.0', port: 8083, }, }) From 89a05492c350433ee7d83a6256f5cca9e0fe2b06 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 06:34:07 +0300 Subject: [PATCH 083/224] refact: optimizied queries --- api/_build/openapi.yaml | 7 +- api/api.gen.go | 14 ++- api/paths/titles.yaml | 8 +- modules/backend/handlers/titles.go | 177 ++++++++++++++++++----------- modules/backend/queries.sql | 91 ++++++++++----- sql/queries.sql.go | 140 ++++++++++++++++------- 6 files changed, 294 insertions(+), 143 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 5ff77e0..c059166 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -23,7 +23,12 @@ paths: - in: query name: status schema: - $ref: '#/components/schemas/TitleStatus' + type: array + items: + $ref: '#/components/schemas/TitleStatus' + description: List of title statuses to filter + style: form + explode: false - in: query name: rating schema: diff --git a/api/api.gen.go b/api/api.gen.go index f252a5a..e56f6b8 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -156,11 +156,13 @@ type Cursor = string // GetTitlesParams defines parameters for GetTitles. type GetTitlesParams struct { - Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` - Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` - SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` - Word *string `form:"word,omitempty" json:"word,omitempty"` - Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` + SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` + Word *string `form:"word,omitempty" json:"word,omitempty"` + + // Status List of title statuses to filter + Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"` Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` @@ -638,7 +640,7 @@ func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { // ------------- Optional query parameter "status" ------------- - err = runtime.BindQueryParameter("form", true, false, "status", c.Request.URL.Query(), ¶ms.Status) + err = runtime.BindQueryParameter("form", false, false, "status", c.Request.URL.Query(), ¶ms.Status) if err != nil { siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) return diff --git a/api/paths/titles.yaml b/api/paths/titles.yaml index 3c5cf76..af2d17b 100644 --- a/api/paths/titles.yaml +++ b/api/paths/titles.yaml @@ -15,12 +15,12 @@ get: - in: query name: status schema: - type: array - items: - $ref: '../schemas/enums/TitleStatus.yaml' + type: array + items: + $ref: '../schemas/enums/TitleStatus.yaml' description: List of title statuses to filter style: form - explode: true + explode: false - in: query name: rating diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index aea6037..71547c2 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -20,18 +20,44 @@ func Word2Sqlc(s *string) *string { return s } -func TitleStatus2Sqlc(s *oapi.TitleStatus) (*sqlc.TitleStatusT, error) { +type SqlcStatus struct { + ongoing string + finished string + planned string +} + +func TitleStatus2Sqlc(s []oapi.TitleStatus) (*SqlcStatus, error) { if s == nil { return nil, nil } - var t sqlc.TitleStatusT + var sqlc_status SqlcStatus + for _, t := range s { + switch t { + case oapi.TitleStatusFinished: + sqlc_status.finished = "finished" + case oapi.TitleStatusOngoing: + sqlc_status.ongoing = "ongoing" + case oapi.TitleStatusPlanned: + sqlc_status.planned = "planned" + default: + return nil, fmt.Errorf("unexpected tittle status: %s", t) + } + } + return &sqlc_status, nil +} + +func TitleStatus2oapi(s *sqlc.TitleStatusT) (*oapi.TitleStatus, error) { + if s == nil { + return nil, nil + } + var t oapi.TitleStatus switch *s { - case oapi.TitleStatusFinished: - t = sqlc.TitleStatusTFinished - case oapi.TitleStatusOngoing: - t = sqlc.TitleStatusTOngoing - case oapi.TitleStatusPlanned: - t = sqlc.TitleStatusTPlanned + case sqlc.TitleStatusTFinished: + t = oapi.TitleStatusFinished + case sqlc.TitleStatusTOngoing: + t = oapi.TitleStatusOngoing + case sqlc.TitleStatusTPlanned: + t = oapi.TitleStatusPlanned default: return nil, fmt.Errorf("unexpected tittle status: %s", *s) } @@ -132,66 +158,74 @@ func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { return &oapi_studio, nil } -func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, error) { +func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi.Title, error) { - var oapi_title oapi.Title + // var oapi_title oapi.Title - title_names := make(map[string][]string, 1) + title_names := make(map[string][]string, 0) err := json.Unmarshal(title.TitleNames, &title_names) if err != nil { - return oapi_title, fmt.Errorf("unmarshal TitleNames: %v", err) + return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) } - episodes_lens := make(map[string]float64, 1) + episodes_lens := make(map[string]float64, 0) err = json.Unmarshal(title.EpisodesLen, &episodes_lens) if err != nil { - return oapi_title, fmt.Errorf("unmarshal EpisodesLen: %v", err) + return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err) } - oapi_tag_names, err := s.GetTagsByTitleId(ctx, title.ID) + oapi_tag_names := make(oapi.Tags, 0) + err = json.Unmarshal(title.TagNames, &oapi_tag_names) if err != nil { - return oapi_title, fmt.Errorf("GetTagsByTitleId: %v", err) + return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err) } - if oapi_tag_names != nil { - oapi_title.Tags = oapi_tag_names + + var oapi_studio oapi.Studio + + oapi_studio.Id = title.StudioID + if title.StudioName != nil { + oapi_studio.Name = *title.StudioName } + oapi_studio.Description = title.StudioDesc + if title.StudioIllustID != nil { + oapi_studio.Poster.Id = title.StudioIllustID + oapi_studio.Poster.ImagePath = title.StudioImagePath + oapi_studio.Poster.StorageType = &title.StudioStorageType + } + + var oapi_image oapi.Image if title.PosterID != nil { - oapi_image, err := s.GetImage(ctx, *title.PosterID) - if err != nil { - return oapi_title, fmt.Errorf("GetImage: %v", err) - } - if oapi_image != nil { - oapi_title.Poster = oapi_image - } - } - - oapi_studio, err := s.GetStudio(ctx, title.StudioID) - if err != nil { - return oapi_title, fmt.Errorf("GetStudio: %v", err) - } - if oapi_studio != nil { - oapi_title.Studio = oapi_studio + oapi_image.Id = title.PosterID + oapi_image.ImagePath = title.TitleImagePath + oapi_image.StorageType = &title.TitleStorageType } + var release_season oapi.ReleaseSeason if title.ReleaseSeason != nil { - rs := oapi.ReleaseSeason(*title.ReleaseSeason) - oapi_title.ReleaseSeason = &rs - } else { - oapi_title.ReleaseSeason = nil + release_season = oapi.ReleaseSeason(*title.ReleaseSeason) } - ts := oapi.TitleStatus(title.TitleStatus) - oapi_title.TitleStatus = &ts - - oapi_title.Id = title.ID - oapi_title.Rating = title.Rating - oapi_title.RatingCount = title.RatingCount - oapi_title.ReleaseYear = title.ReleaseYear - oapi_title.TitleNames = title_names - oapi_title.EpisodesAired = title.EpisodesAired - oapi_title.EpisodesAll = title.EpisodesAll - oapi_title.EpisodesLen = &episodes_lens + oapi_status, err := TitleStatus2oapi(&title.TitleStatus) + if err != nil { + return oapi.Title{}, fmt.Errorf("TitleStatus2oapi: %v", err) + } + oapi_title := oapi.Title{ + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAired, + EpisodesLen: &episodes_lens, + Id: title.ID, + Poster: &oapi_image, + Rating: title.Rating, + RatingCount: title.RatingCount, + ReleaseSeason: &release_season, + ReleaseYear: title.ReleaseYear, + Studio: &oapi_studio, + Tags: oapi_tag_names, + TitleNames: title_names, + TitleStatus: oapi_status, + // AdditionalProperties: + } return oapi_title, nil } @@ -207,8 +241,29 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl log.Errorf("%v", err) return oapi.GetTitlesTitleId500Response{}, nil } - - oapi_title, err = s.mapTitle(ctx, sqlc_title) + _sqlc_title := sqlc.SearchTitlesRow{ + ID: sqlc_title.ID, + StudioID: sqlc_title.StudioID, + PosterID: sqlc_title.PosterID, + TitleStatus: sqlc_title.TitleStatus, + Rating: sqlc_title.Rating, + RatingCount: sqlc_title.RatingCount, + ReleaseYear: sqlc_title.ReleaseYear, + ReleaseSeason: sqlc_title.ReleaseSeason, + Season: sqlc_title.Season, + EpisodesAired: sqlc_title.EpisodesAired, + EpisodesAll: sqlc_title.EpisodesAll, + EpisodesLen: sqlc_title.EpisodesLen, + TitleStorageType: sqlc_title.TitleStorageType, + TitleImagePath: sqlc_title.TitleImagePath, + TagNames: sqlc_title.TitleNames, + StudioName: sqlc_title.StudioName, + StudioIllustID: sqlc_title.StudioIllustID, + StudioDesc: sqlc_title.StudioDesc, + StudioStorageType: sqlc_title.StudioStorageType, + StudioImagePath: sqlc_title.StudioImagePath, + } + oapi_title, err = s.mapTitle(ctx, _sqlc_title) if err != nil { log.Errorf("%v", err) return oapi.GetTitlesTitleId500Response{}, nil @@ -220,19 +275,8 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { opai_titles := make([]oapi.Title, 0) - // old_cursor := oapi.CursorObj{ - // Id: 1, - // } - - // if request.Params.Cursor != nil { - // if old_cursor, err := parseCursor(*request.Params.Cursor); err != nil { - // log.Errorf("%v", err) - // return oapi.GetTitles400Response{}, err - // } - // } - word := Word2Sqlc(request.Params.Word) - status, err := TitleStatus2Sqlc(request.Params.Status) + status, err := TitleStatus2Sqlc(*request.Params.Status) if err != nil { log.Errorf("%v", err) return oapi.GetTitles400Response{}, err @@ -245,7 +289,9 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje params := sqlc.SearchTitlesParams{ Word: word, - Status: status, + Ongoing: status.ongoing, + Finished: status.finished, + Planned: status.planned, Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, @@ -254,6 +300,9 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje Limit: request.Params.Limit, } + if request.Params.SortForward != nil { + params.Forward = *request.Params.SortForward + } if request.Params.Sort != nil { params.SortBy = string(*request.Params.Sort) if request.Params.Cursor != nil { @@ -289,7 +338,7 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje if request.Params.Sort != nil { switch string(*request.Params.Sort) { case "year": - tmp := string(*t.ReleaseYear) + tmp := fmt.Sprint("%d", *t.ReleaseYear) new_cursor.Param = &tmp case "rating": tmp := strconv.FormatFloat(*t.Rating, 'f', -1, 64) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 7118781..16f8120 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -73,14 +73,48 @@ RETURNING id, tag_names; -- WHERE user_id = $1; -- name: GetTitleByID :one -SELECT * -FROM titles -WHERE id = sqlc.arg('title_id')::bigint; +-- sqlc.struct: TitlesFull +SELECT + t.*, + i.storage_type::text as title_storage_type, + i.image_path as title_image_path, + jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type::text as studio_storage_type, + si.image_path as studio_image_path + +FROM titles as t +LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE id = sqlc.arg('title_id')::bigint +GROUP BY + t.id, i.id, s.id, si.id; -- name: SearchTitles :many SELECT - * -FROM titles + t.*, + i.storage_type::text as title_storage_type, + i.image_path as title_image_path, + jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type::text as studio_storage_type, + si.image_path as studio_image_path + +FROM titles as t +LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + WHERE CASE WHEN sqlc.arg('forward')::boolean THEN @@ -88,17 +122,17 @@ WHERE CASE sqlc.arg('sort_by')::text WHEN 'year' THEN (sqlc.narg('cursor_year')::int IS NULL) OR - (release_year > sqlc.narg('cursor_year')::int) OR - (release_year = sqlc.narg('cursor_year')::int AND id > sqlc.narg('cursor_id')::bigint) + (t.release_year > sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint) WHEN 'rating' THEN (sqlc.narg('cursor_rating')::float IS NULL) OR - (rating > sqlc.narg('cursor_rating')::float) OR - (rating = sqlc.narg('cursor_rating')::float AND id > sqlc.narg('cursor_id')::bigint) + (t.rating > sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint) WHEN 'id' THEN (sqlc.narg('cursor_id')::bigint IS NULL) OR - (id > sqlc.narg('cursor_id')::bigint) + (t.id > sqlc.narg('cursor_id')::bigint) ELSE true -- fallback END @@ -108,17 +142,17 @@ WHERE CASE sqlc.arg('sort_by')::text WHEN 'year' THEN (sqlc.narg('cursor_year')::int IS NULL) OR - (release_year < sqlc.narg('cursor_year')::int) OR - (release_year = sqlc.narg('cursor_year')::int AND id < sqlc.narg('cursor_id')::bigint) + (t.release_year < sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint) WHEN 'rating' THEN (sqlc.narg('cursor_rating')::float IS NULL) OR - (rating < sqlc.narg('cursor_rating')::float) OR - (rating = sqlc.narg('cursor_rating')::float AND id < sqlc.narg('cursor_id')::bigint) + (t.rating < sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint) WHEN 'id' THEN (sqlc.narg('cursor_id')::bigint IS NULL) OR - (id < sqlc.narg('cursor_id')::bigint) + (t.id < sqlc.narg('cursor_id')::bigint) ELSE true END @@ -131,7 +165,7 @@ WHERE SELECT bool_and( EXISTS ( SELECT 1 - FROM jsonb_each_text(title_names) AS t(key, val) + FROM jsonb_each_text(t.title_names) AS t(key, val) WHERE val ILIKE pattern ) ) @@ -147,28 +181,31 @@ WHERE END ) - AND (sqlc.narg('status')::title_status_t IS NULL OR title_status = sqlc.narg('status')::title_status_t) - AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float) - AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int) - AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t) + AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) + AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) + AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) + AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) + +GROUP BY + t.id, i.id, s.id, si.id ORDER BY CASE WHEN sqlc.arg('forward')::boolean THEN CASE - WHEN sqlc.arg('sort_by')::text = 'id' THEN id - WHEN sqlc.arg('sort_by')::text = 'year' THEN release_year - WHEN sqlc.arg('sort_by')::text = 'rating' THEN rating + WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id + WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating END END ASC, CASE WHEN NOT sqlc.arg('forward')::boolean THEN CASE - WHEN sqlc.arg('sort_by')::text = 'id' THEN id - WHEN sqlc.arg('sort_by')::text = 'year' THEN release_year - WHEN sqlc.arg('sort_by')::text = 'rating' THEN rating + WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id + WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating END END DESC, - CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN id END ASC + CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit diff --git a/sql/queries.sql.go b/sql/queries.sql.go index c043f8a..4342a12 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -116,11 +116,53 @@ const getTitleByID = `-- name: GetTitleByID :one -SELECT id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len -FROM titles +SELECT + t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + i.storage_type::text as title_storage_type, + i.image_path as title_image_path, + jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type::text as studio_storage_type, + si.image_path as studio_image_path + +FROM titles as t +LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + WHERE id = $1::bigint +GROUP BY + t.id, i.id, s.id, si.id ` +type GetTitleByIDRow struct { + ID int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` + TitleStorageType string `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames []byte `json:"tag_names"` + StudioName *string `json:"studio_name"` + StudioIllustID *int64 `json:"studio_illust_id"` + StudioDesc *string `json:"studio_desc"` + StudioStorageType string `json:"studio_storage_type"` + StudioImagePath *string `json:"studio_image_path"` +} + // -- name: ListUsers :many // SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date // FROM users @@ -144,9 +186,10 @@ WHERE id = $1::bigint // -- name: DeleteUser :exec // DELETE FROM users // WHERE user_id = $1; -func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (Title, error) { +// sqlc.struct: TitlesFull +func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (GetTitleByIDRow, error) { row := q.db.QueryRow(ctx, getTitleByID, titleID) - var i Title + var i GetTitleByIDRow err := row.Scan( &i.ID, &i.TitleNames, @@ -161,6 +204,14 @@ func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (Title, error &i.EpisodesAired, &i.EpisodesAll, &i.EpisodesLen, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, + &i.StudioIllustID, + &i.StudioDesc, + &i.StudioStorageType, + &i.StudioImagePath, ) return i, err } @@ -288,15 +339,15 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams const searchTitles = `-- name: SearchTitles :many SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, - i.storage_type as title_storage_type, - i.image_path as title_image_path, - g.tag_names as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type as studio_storage_type, - si.image_path as studio_image_path - + i.storage_type::text as title_storage_type, + i.image_path as title_image_path, + jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type::text as studio_storage_type, + si.image_path as studio_image_path + FROM titles as t LEFT JOIN images as i ON (t.image_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) @@ -370,10 +421,13 @@ WHERE END ) - AND ($7::title_status_t IS NULL OR t.title_status = $7::title_status_t) - AND ($8::float IS NULL OR t.rating >= $8::float) - AND ($9::int IS NULL OR t.release_year = $9::int) - AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) + AND (t.title_status::text IN ($7::text, $8::text, $9::text)) + AND ($10::float IS NULL OR t.rating >= $10::float) + AND ($11::int IS NULL OR t.release_year = $11::int) + AND ($12::release_season_t IS NULL OR t.release_season = $12::release_season_t) + +GROUP BY + t.id, i.id, s.id, si.id ORDER BY CASE WHEN $1::boolean THEN @@ -393,7 +447,7 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($11::int, 100) +LIMIT COALESCE($13::int, 100) ` type SearchTitlesParams struct { @@ -403,7 +457,9 @@ type SearchTitlesParams struct { CursorID *int64 `json:"cursor_id"` CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` - Status *TitleStatusT `json:"status"` + Ongoing string `json:"ongoing"` + Finished string `json:"finished"` + Planned string `json:"planned"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` @@ -411,27 +467,27 @@ type SearchTitlesParams struct { } type SearchTitlesRow struct { - ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` - TitleStorageType NullStorageTypeT `json:"title_storage_type"` - TitleImagePath *string `json:"title_image_path"` - TagNames []byte `json:"tag_names"` - StudioName *string `json:"studio_name"` - StudioIllustID *int64 `json:"studio_illust_id"` - StudioDesc *string `json:"studio_desc"` - StudioStorageType NullStorageTypeT `json:"studio_storage_type"` - StudioImagePath *string `json:"studio_image_path"` + ID int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` + TitleStorageType string `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames []byte `json:"tag_names"` + StudioName *string `json:"studio_name"` + StudioIllustID *int64 `json:"studio_illust_id"` + StudioDesc *string `json:"studio_desc"` + StudioStorageType string `json:"studio_storage_type"` + StudioImagePath *string `json:"studio_image_path"` } func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]SearchTitlesRow, error) { @@ -442,7 +498,9 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S arg.CursorID, arg.CursorRating, arg.Word, - arg.Status, + arg.Ongoing, + arg.Finished, + arg.Planned, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, From 6485563a952babf99453957466219e4a4bbf1df1 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 22 Nov 2025 06:37:39 +0300 Subject: [PATCH 084/224] fix: react imports --- modules/frontend/src/components/ListView/ListView.tsx | 3 +-- .../frontend/src/components/TitlesSortBox/TitlesSortBox.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/frontend/src/components/ListView/ListView.tsx b/modules/frontend/src/components/ListView/ListView.tsx index 67488c0..ea6d683 100644 --- a/modules/frontend/src/components/ListView/ListView.tsx +++ b/modules/frontend/src/components/ListView/ListView.tsx @@ -1,5 +1,4 @@ -import React, { useState } from "react"; -import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; +import React from "react"; export type ListViewProps<T> = { items: T[]; diff --git a/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx index f1f8195..ddafd34 100644 --- a/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx +++ b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import { useState } from "react"; import type { TitleSort } from "../../api"; import { ChevronDownIcon, ArrowUpIcon, ArrowDownIcon } from "@heroicons/react/24/solid"; From 258eb749d985081312f55f5feca1236c4247b3d3 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 07:03:02 +0300 Subject: [PATCH 085/224] fix: status handling fixed --- modules/backend/handlers/titles.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 71547c2..84fc87e 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -26,12 +26,12 @@ type SqlcStatus struct { planned string } -func TitleStatus2Sqlc(s []oapi.TitleStatus) (*SqlcStatus, error) { - if s == nil { - return nil, nil - } +func TitleStatus2Sqlc(s *[]oapi.TitleStatus) (*SqlcStatus, error) { var sqlc_status SqlcStatus - for _, t := range s { + if s == nil { + return &sqlc_status, nil + } + for _, t := range *s { switch t { case oapi.TitleStatusFinished: sqlc_status.finished = "finished" @@ -276,11 +276,12 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje opai_titles := make([]oapi.Title, 0) word := Word2Sqlc(request.Params.Word) - status, err := TitleStatus2Sqlc(*request.Params.Status) + status, err := TitleStatus2Sqlc(request.Params.Status) if err != nil { log.Errorf("%v", err) return oapi.GetTitles400Response{}, err } + season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) if err != nil { log.Errorf("%v", err) From ba68b5ee0465fd70e15d229591df9d8dfaacba64 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 07:09:30 +0300 Subject: [PATCH 086/224] feat: getusertitles now must return all the title info --- api/_build/openapi.yaml | 5 ++--- api/api.gen.go | 18 ++++++++++-------- api/schemas/UserTitle.yaml | 5 ++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index c059166..c07dbbc 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -424,9 +424,8 @@ components: user_id: type: integer format: int64 - title_id: - type: integer - format: int64 + title: + $ref: '#/components/schemas/Title' status: $ref: '#/components/schemas/UserTitleStatus' rate: diff --git a/api/api.gen.go b/api/api.gen.go index e56f6b8..320e7a2 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -143,7 +143,7 @@ type UserTitle struct { // Status User's title status Status UserTitleStatus `json:"status"` - TitleId int64 `json:"title_id"` + Title *Title `json:"title,omitempty"` UserId int64 `json:"user_id"` AdditionalProperties map[string]interface{} `json:"-"` } @@ -493,12 +493,12 @@ func (a *UserTitle) UnmarshalJSON(b []byte) error { delete(object, "status") } - if raw, found := object["title_id"]; found { - err = json.Unmarshal(raw, &a.TitleId) + if raw, found := object["title"]; found { + err = json.Unmarshal(raw, &a.Title) if err != nil { - return fmt.Errorf("error reading 'title_id': %w", err) + return fmt.Errorf("error reading 'title': %w", err) } - delete(object, "title_id") + delete(object, "title") } if raw, found := object["user_id"]; found { @@ -554,9 +554,11 @@ func (a UserTitle) MarshalJSON() ([]byte, error) { return nil, fmt.Errorf("error marshaling 'status': %w", err) } - object["title_id"], err = json.Marshal(a.TitleId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'title_id': %w", err) + if a.Title != nil { + object["title"], err = json.Marshal(a.Title) + if err != nil { + return nil, fmt.Errorf("error marshaling 'title': %w", err) + } } object["user_id"], err = json.Marshal(a.UserId) diff --git a/api/schemas/UserTitle.yaml b/api/schemas/UserTitle.yaml index 658e350..3beaec6 100644 --- a/api/schemas/UserTitle.yaml +++ b/api/schemas/UserTitle.yaml @@ -7,9 +7,8 @@ properties: user_id: type: integer format: int64 - title_id: - type: integer - format: int64 + title: + $ref: ./Title.yaml status: $ref: ./enums/UserTitleStatus.yaml rate: From 79485e04c015412d2fe7ab46cf0e1506009b870e Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 07:23:48 +0300 Subject: [PATCH 087/224] fix: mismatch in colimn name --- sql/queries.sql.go | 312 ++++++++++++++++++++++++--------------------- 1 file changed, 166 insertions(+), 146 deletions(-) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 4342a12..9987e78 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -8,8 +8,6 @@ package sqlc import ( "context" "time" - - "github.com/jackc/pgx/v5/pgtype" ) const createImage = `-- name: CreateImage :one @@ -43,56 +41,6 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro return i, err } -const getReviewByID = `-- name: GetReviewByID :one - - - -SELECT id, data, rating, user_id, title_id, created_at -FROM reviews -WHERE review_id = $1::bigint -` - -// 100 is default limit -// -- name: ListTitles :many -// SELECT title_id, title_names, studio_id, poster_id, signal_ids, -// -// title_status, rating, rating_count, release_year, release_season, -// season, episodes_aired, episodes_all, episodes_len -// -// FROM titles -// ORDER BY title_id -// LIMIT $1 OFFSET $2; -// -- name: UpdateTitle :one -// UPDATE titles -// SET -// -// title_names = COALESCE(sqlc.narg('title_names'), title_names), -// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), -// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), -// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), -// title_status = COALESCE(sqlc.narg('title_status'), title_status), -// release_year = COALESCE(sqlc.narg('release_year'), release_year), -// release_season = COALESCE(sqlc.narg('release_season'), release_season), -// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), -// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), -// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) -// -// WHERE title_id = sqlc.arg('title_id') -// RETURNING *; -func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, error) { - row := q.db.QueryRow(ctx, getReviewByID, reviewID) - var i Review - err := row.Scan( - &i.ID, - &i.Data, - &i.Rating, - &i.UserID, - &i.TitleID, - &i.CreatedAt, - ) - return i, err -} - const getStudioByID = `-- name: GetStudioByID :one SELECT id, studio_name, illust_id, studio_desc FROM studios @@ -128,7 +76,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -349,7 +297,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -548,111 +496,183 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S const searchUserTitles = `-- name: SearchUserTitles :many -SELECT - user_id, title_id, status, rate, review_id, ctime, id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len -FROM usertitles as u -JOIN titles as t ON (u.title_id = t.id) -WHERE - CASE - WHEN $1::text IS NOT NULL THEN - ( - SELECT bool_and( - EXISTS ( - SELECT 1 - FROM jsonb_each_text(t.title_names) AS t(key, val) - WHERE val ILIKE pattern - ) - ) - FROM unnest( - ARRAY( - SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array($1::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true - END - AND ($2::title_status_t IS NULL OR t.title_status = $2::title_status_t) - AND ($3::float IS NULL OR t.rating >= $3::float) - AND ($4::int IS NULL OR t.release_year = $4::int) - AND ($5::release_season_t IS NULL OR t.release_season = $5::release_season_t) - AND ($6::usertitle_status_t IS NULL OR u.usertitle_status = $6::usertitle_status_t) -LIMIT COALESCE($7::int, 100) + + + + + + + + + + + + + + + + +SELECT id, data, rating, user_id, title_id, created_at +FROM reviews +WHERE review_id = $1::bigint ` -type SearchUserTitlesParams struct { - Word *string `json:"word"` - Status *TitleStatusT `json:"status"` - Rating *float64 `json:"rating"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - UsertitleStatus NullUsertitleStatusT `json:"usertitle_status"` - Limit *int32 `json:"limit"` -} - -type SearchUserTitlesRow struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` - Ctime pgtype.Timestamptz `json:"ctime"` - ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` -} - // 100 is default limit -func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { - rows, err := q.db.Query(ctx, searchUserTitles, - arg.Word, - arg.Status, - arg.Rating, - arg.ReleaseYear, - arg.ReleaseSeason, - arg.UsertitleStatus, - arg.Limit, - ) +// SELECT +// +// t.*, +// u.user_id as user_id, +// u.status as usertitle_status, +// u.rate as user_rate, +// u.review_id as review_id, +// u.ctime as user_ctime, +// i.storage_type::text as title_storage_type, +// i.image_path as title_image_path, +// jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, +// s.studio_name as studio_name, +// s.illust_id as studio_illust_id, +// s.studio_desc as studio_desc, +// si.storage_type::text as studio_storage_type, +// si.image_path as studio_image_path +// +// FROM usertitles as u +// LEFT JOIN titles as t ON (u.title_id = t.id) +// LEFT JOIN images as i ON (t.poster_id = i.id) +// LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +// LEFT JOIN tags as g ON (tt.tag_id = g.id) +// LEFT JOIN studios as s ON (t.studio_id = s.id) +// LEFT JOIN images as si ON (s.illust_id = si.id) +// WHERE +// +// CASE +// WHEN sqlc.arg('forward')::boolean THEN +// -- forward: greater than cursor (next page) +// CASE sqlc.arg('sort_by')::text +// WHEN 'year' THEN +// (sqlc.narg('cursor_year')::int IS NULL) OR +// (t.release_year > sqlc.narg('cursor_year')::int) OR +// (t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint) +// WHEN 'rating' THEN +// (sqlc.narg('cursor_rating')::float IS NULL) OR +// (t.rating > sqlc.narg('cursor_rating')::float) OR +// (t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint) +// WHEN 'id' THEN +// (sqlc.narg('cursor_id')::bigint IS NULL) OR +// (t.id > sqlc.narg('cursor_id')::bigint) +// ELSE true -- fallback +// END +// ELSE +// -- backward: less than cursor (prev page) +// CASE sqlc.arg('sort_by')::text +// WHEN 'year' THEN +// (sqlc.narg('cursor_year')::int IS NULL) OR +// (t.release_year < sqlc.narg('cursor_year')::int) OR +// (t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint) +// WHEN 'rating' THEN +// (sqlc.narg('cursor_rating')::float IS NULL) OR +// (t.rating < sqlc.narg('cursor_rating')::float) OR +// (t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint) +// WHEN 'id' THEN +// (sqlc.narg('cursor_id')::bigint IS NULL) OR +// (t.id < sqlc.narg('cursor_id')::bigint) +// ELSE true +// END +// END +// AND ( +// CASE +// WHEN sqlc.narg('word')::text IS NOT NULL THEN +// ( +// SELECT bool_and( +// EXISTS ( +// SELECT 1 +// FROM jsonb_each_text(t.title_names) AS t(key, val) +// WHERE val ILIKE pattern +// ) +// ) +// FROM unnest( +// ARRAY( +// SELECT '%' || trim(w) || '%' +// FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w +// WHERE trim(w) <> '' +// ) +// ) AS pattern +// ) +// ELSE true +// END +// ) +// AND (u.::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) +// AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) +// AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) +// AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) +// AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) +// +// GROUP BY +// +// t.id, i.id, s.id, si.id +// +// ORDER BY +// +// CASE WHEN sqlc.arg('forward')::boolean THEN +// CASE +// WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id +// WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year +// WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating +// END +// END ASC, +// CASE WHEN NOT sqlc.arg('forward')::boolean THEN +// CASE +// WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id +// WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year +// WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating +// END +// END DESC, +// CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC +// +// LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit +// -- name: ListTitles :many +// SELECT title_id, title_names, studio_id, poster_id, signal_ids, +// +// title_status, rating, rating_count, release_year, release_season, +// season, episodes_aired, episodes_all, episodes_len +// +// FROM titles +// ORDER BY title_id +// LIMIT $1 OFFSET $2; +// -- name: UpdateTitle :one +// UPDATE titles +// SET +// +// title_names = COALESCE(sqlc.narg('title_names'), title_names), +// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), +// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), +// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), +// title_status = COALESCE(sqlc.narg('title_status'), title_status), +// release_year = COALESCE(sqlc.narg('release_year'), release_year), +// release_season = COALESCE(sqlc.narg('release_season'), release_season), +// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), +// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), +// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) +// +// WHERE title_id = sqlc.arg('title_id') +// RETURNING *; +func (q *Queries) SearchUserTitles(ctx context.Context, reviewID int64) ([]Review, error) { + rows, err := q.db.Query(ctx, searchUserTitles, reviewID) if err != nil { return nil, err } defer rows.Close() - items := []SearchUserTitlesRow{} + items := []Review{} for rows.Next() { - var i SearchUserTitlesRow + var i Review if err := rows.Scan( + &i.ID, + &i.Data, + &i.Rating, &i.UserID, &i.TitleID, - &i.Status, - &i.Rate, - &i.ReviewID, - &i.Ctime, - &i.ID, - &i.TitleNames, - &i.StudioID, - &i.PosterID, - &i.TitleStatus, - &i.Rating, - &i.RatingCount, - &i.ReleaseYear, - &i.ReleaseSeason, - &i.Season, - &i.EpisodesAired, - &i.EpisodesAll, - &i.EpisodesLen, + &i.CreatedAt, ); err != nil { return nil, err } From 32566fe7a2cc5d2469f9f47c1dbd55f35859d18f Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 07:53:50 +0300 Subject: [PATCH 088/224] feat: query for usertitles written --- api/_build/openapi.yaml | 7 +- api/api.gen.go | 10 +- api/paths/users-id-titles.yaml | 7 +- modules/backend/handlers/common.go | 117 +++++++------- modules/backend/handlers/titles.go | 72 --------- modules/backend/handlers/users.go | 138 +++++++++++++--- modules/backend/queries.sql | 144 +++++++++++++---- sql/queries.sql.go | 252 +++++++++++++++++++++-------- 8 files changed, 497 insertions(+), 250 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index c07dbbc..722b7af 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -158,7 +158,12 @@ paths: - in: query name: status schema: - $ref: '#/components/schemas/TitleStatus' + type: array + items: + $ref: '#/components/schemas/TitleStatus' + description: List of title statuses to filter + style: form + explode: false - in: query name: watch_status schema: diff --git a/api/api.gen.go b/api/api.gen.go index 320e7a2..54c8fc1 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -183,9 +183,11 @@ type GetUsersUserIdParams struct { // GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. type GetUsersUserIdTitlesParams struct { - Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` - Word *string `form:"word,omitempty" json:"word,omitempty"` - Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Word *string `form:"word,omitempty" json:"word,omitempty"` + + // Status List of title statuses to filter + Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"` WatchStatus *UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` @@ -811,7 +813,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { // ------------- Optional query parameter "status" ------------- - err = runtime.BindQueryParameter("form", true, false, "status", c.Request.URL.Query(), ¶ms.Status) + err = runtime.BindQueryParameter("form", false, false, "status", c.Request.URL.Query(), ¶ms.Status) if err != nil { siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) return diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 0cde5af..0788319 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -14,7 +14,12 @@ get: - in: query name: status schema: - $ref: '../schemas/enums/TitleStatus.yaml' + type: array + items: + $ref: '../schemas/enums/TitleStatus.yaml' + description: List of title statuses to filter + style: form + explode: false - in: query name: watch_status schema: diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 3d61b91..6618d49 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -1,6 +1,10 @@ package handlers import ( + "context" + "encoding/json" + "fmt" + oapi "nyanimedb/api" sqlc "nyanimedb/sql" "strconv" ) @@ -13,70 +17,75 @@ func NewServer(db *sqlc.Queries) Server { return Server{db: db} } -// type Cursor interface { -// ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error) +func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi.Title, error) { -// Values() map[string]interface{} -// // for logs only -// Type() string -// } + title_names := make(map[string][]string, 0) + err := json.Unmarshal(title.TitleNames, &title_names) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) + } -// type CursorByID struct { -// ID int64 -// } + episodes_lens := make(map[string]float64, 0) + err = json.Unmarshal(title.EpisodesLen, &episodes_lens) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err) + } -// func (c CursorByID) ParseCursor(sortBy oapi.TitleSort, data oapi.Cursor) (Cursor, error) { -// var cur CursorByID -// if err := json.Unmarshal(data, &cur); err != nil { -// return nil, fmt.Errorf("invalid cursor (id): %w", err) -// } -// if cur.ID == 0 { -// return nil, errors.New("cursor id must be non-zero") -// } -// return cur, nil -// } + oapi_tag_names := make(oapi.Tags, 0) + err = json.Unmarshal(title.TagNames, &oapi_tag_names) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err) + } -// func (c CursorByID) Values() map[string]interface{} { -// return map[string]interface{}{ -// "cursor_id": c.ID, -// "cursor_year": nil, -// "cursor_rating": nil, -// } -// } + var oapi_studio oapi.Studio -// func (c CursorByID) Type() string { return "id" } + oapi_studio.Id = title.StudioID + if title.StudioName != nil { + oapi_studio.Name = *title.StudioName + } + oapi_studio.Description = title.StudioDesc + if title.StudioIllustID != nil { + oapi_studio.Poster.Id = title.StudioIllustID + oapi_studio.Poster.ImagePath = title.StudioImagePath + oapi_studio.Poster.StorageType = &title.StudioStorageType + } -// func NewCursor(sortBy string) (Cursor, error) { -// switch Type(sortBy) { -// case TypeID: -// return CursorByID{}, nil -// case TypeYear: -// return CursorByYear{}, nil -// case TypeRating: -// return CursorByRating{}, nil -// default: -// return nil, fmt.Errorf("unsupported sort_by: %q", sortBy) -// } -// } + var oapi_image oapi.Image -// decodes a base64-encoded JSON string into a CursorObj -// Returns the parsed CursorObj and an error -// func parseCursor(encoded oapi.Cursor) (*oapi.CursorObj, error) { + if title.PosterID != nil { + oapi_image.Id = title.PosterID + oapi_image.ImagePath = title.TitleImagePath + oapi_image.StorageType = &title.TitleStorageType + } -// // Decode base64 -// decoded, err := base64.StdEncoding.DecodeString(encoded) -// if err != nil { -// return nil, fmt.Errorf("parseCursor: %v", err) -// } + var release_season oapi.ReleaseSeason + if title.ReleaseSeason != nil { + release_season = oapi.ReleaseSeason(*title.ReleaseSeason) + } -// // Parse JSON -// var cursor oapi.CursorObj -// if err := json.Unmarshal(decoded, &cursor); err != nil { -// return nil, fmt.Errorf("parseCursor: %v", err) -// } + oapi_status, err := TitleStatus2oapi(&title.TitleStatus) + if err != nil { + return oapi.Title{}, fmt.Errorf("TitleStatus2oapi: %v", err) + } + oapi_title := oapi.Title{ + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAired, + EpisodesLen: &episodes_lens, + Id: title.ID, + Poster: &oapi_image, + Rating: title.Rating, + RatingCount: title.RatingCount, + ReleaseSeason: &release_season, + ReleaseYear: title.ReleaseYear, + Studio: &oapi_studio, + Tags: oapi_tag_names, + TitleNames: title_names, + TitleStatus: oapi_status, + // AdditionalProperties: + } -// return &cursor, nil -// } + return oapi_title, nil +} func parseInt64(s string) (int32, error) { i, err := strconv.ParseInt(s, 10, 64) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 84fc87e..332a4b6 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -158,78 +158,6 @@ func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { return &oapi_studio, nil } -func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi.Title, error) { - - // var oapi_title oapi.Title - - title_names := make(map[string][]string, 0) - err := json.Unmarshal(title.TitleNames, &title_names) - if err != nil { - return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) - } - - episodes_lens := make(map[string]float64, 0) - err = json.Unmarshal(title.EpisodesLen, &episodes_lens) - if err != nil { - return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err) - } - - oapi_tag_names := make(oapi.Tags, 0) - err = json.Unmarshal(title.TagNames, &oapi_tag_names) - if err != nil { - return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err) - } - - var oapi_studio oapi.Studio - - oapi_studio.Id = title.StudioID - if title.StudioName != nil { - oapi_studio.Name = *title.StudioName - } - oapi_studio.Description = title.StudioDesc - if title.StudioIllustID != nil { - oapi_studio.Poster.Id = title.StudioIllustID - oapi_studio.Poster.ImagePath = title.StudioImagePath - oapi_studio.Poster.StorageType = &title.StudioStorageType - } - - var oapi_image oapi.Image - - if title.PosterID != nil { - oapi_image.Id = title.PosterID - oapi_image.ImagePath = title.TitleImagePath - oapi_image.StorageType = &title.TitleStorageType - } - - var release_season oapi.ReleaseSeason - if title.ReleaseSeason != nil { - release_season = oapi.ReleaseSeason(*title.ReleaseSeason) - } - - oapi_status, err := TitleStatus2oapi(&title.TitleStatus) - if err != nil { - return oapi.Title{}, fmt.Errorf("TitleStatus2oapi: %v", err) - } - oapi_title := oapi.Title{ - EpisodesAired: title.EpisodesAired, - EpisodesAll: title.EpisodesAired, - EpisodesLen: &episodes_lens, - Id: title.ID, - Poster: &oapi_image, - Rating: title.Rating, - RatingCount: title.RatingCount, - ReleaseSeason: &release_season, - ReleaseYear: title.ReleaseYear, - Studio: &oapi_studio, - Tags: oapi_tag_names, - TitleNames: title_names, - TitleStatus: oapi_status, - // AdditionalProperties: - } - - return oapi_title, nil -} - func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitleIdRequestObject) (oapi.GetTitlesTitleIdResponseObject, error) { var oapi_title oapi.Title diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 0fa903f..0420b91 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -2,12 +2,15 @@ package handlers import ( "context" + "fmt" oapi "nyanimedb/api" sqlc "nyanimedb/sql" "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/oapi-codegen/runtime/types" + log "github.com/sirupsen/logrus" ) // type Server struct { @@ -50,33 +53,120 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil } +func sqlDate2oapi(p_date pgtype.Timestamptz) (time.Time, error) { + return time.Time{}, nil +} + +type SqlcUserStatus struct { + dropped string + finished string + planned string + in_progress string +} + +func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) { + var sqlc_status SqlcUserStatus + if s == nil { + return &sqlc_status, nil + } + for _, t := range *s { + switch t { + case oapi.UserTitleStatusFinished: + sqlc_status.finished = "finished" + case oapi.UserTitleStatusDropped: + sqlc_status.dropped = "dropped" + case oapi.UserTitleStatusPlanned: + sqlc_status.planned = "planned" + case oapi.UserTitleStatusInProgress: + sqlc_status.in_progress = "in-progress" + default: + return nil, fmt.Errorf("unexpected tittle status: %s", t) + } + } + return &sqlc_status, nil +} + func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) { - var rate int32 = 9 - var review_id int64 = 3 - time := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) + oapi_usertitles := make([]oapi.UserTitle, 0) - var userTitles = []oapi.UserTitle{ - { - UserId: 101, - TitleId: 2001, - Status: oapi.UserTitleStatusFinished, - Rate: &rate, - Ctime: &time, - }, - { - UserId: 102, - TitleId: 2002, - Status: oapi.UserTitleStatusInProgress, - ReviewId: &review_id, - Ctime: &time, - }, - { - UserId: 103, - TitleId: 2003, - Status: oapi.UserTitleStatusDropped, - Ctime: &time, - }, + word := Word2Sqlc(request.Params.Word) + status, err := TitleStatus2Sqlc(request.Params.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles400Response{}, err + } + + season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles400Response{}, err + } + + // Forward bool `json:"forward"` + // SortBy string `json:"sort_by"` + // CursorYear *int32 `json:"cursor_year"` + // CursorID *int64 `json:"cursor_id"` + // CursorRating *float64 `json:"cursor_rating"` + // Word *string `json:"word"` + // Ongoing string `json:"ongoing"` + // Planned string `json:"planned"` + // Dropped string `json:"dropped"` + // InProgress string `json:"in-progress"` + // Finished string `json:"finished"` + // Rate *int32 `json:"rate"` + // Rating *float64 `json:"rating"` + // ReleaseYear *int32 `json:"release_year"` + // ReleaseSeason *ReleaseSeasonT `json:"release_season"` + // Limit *int32 `json:"limit"` + params := sqlc.SearchTitlesParams{ + Word: word, + Ongoing: status.ongoing, + Finished: status.finished, + Planned: status.planned, + Rating: request.Params.Rating, + ReleaseYear: request.Params.ReleaseYear, + ReleaseSeason: season, + Forward: true, + SortBy: "id", + Limit: request.Params.Limit, + } + + if request.Params.SortForward != nil { + params.Forward = *request.Params.SortForward + } + if request.Params.Sort != nil { + params.SortBy = string(*request.Params.Sort) + if request.Params.Cursor != nil { + err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles400Response{}, nil + } + } + } + + _sqlc_title := sqlc.SearchTitlesRow{ + ID: sqlc_title.ID, + StudioID: sqlc_title.StudioID, + PosterID: sqlc_title.PosterID, + TitleStatus: sqlc_title.TitleStatus, + Rating: sqlc_title.Rating, + RatingCount: sqlc_title.RatingCount, + ReleaseYear: sqlc_title.ReleaseYear, + ReleaseSeason: sqlc_title.ReleaseSeason, + Season: sqlc_title.Season, + EpisodesAired: sqlc_title.EpisodesAired, + EpisodesAll: sqlc_title.EpisodesAll, + EpisodesLen: sqlc_title.EpisodesLen, + TitleStorageType: sqlc_title.TitleStorageType, + TitleImagePath: sqlc_title.TitleImagePath, + TagNames: sqlc_title.TitleNames, + StudioName: sqlc_title.StudioName, + StudioIllustID: sqlc_title.StudioIllustID, + StudioDesc: sqlc_title.StudioDesc, + StudioStorageType: sqlc_title.StudioStorageType, + StudioImagePath: sqlc_title.StudioImagePath, } return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 16f8120..b8cb8aa 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -86,7 +86,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -109,7 +109,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -210,37 +210,125 @@ ORDER BY LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit -- name: SearchUserTitles :many -SELECT - * -FROM usertitles as u -JOIN titles as t ON (u.title_id = t.id) -WHERE - CASE - WHEN sqlc.narg('word')::text IS NOT NULL THEN - ( - SELECT bool_and( - EXISTS ( - SELECT 1 - FROM jsonb_each_text(t.title_names) AS t(key, val) - WHERE val ILIKE pattern - ) - ) - FROM unnest( - ARRAY( - SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true +SELECT + t.*, + u.user_id as user_id, + u.status as usertitle_status, + u.rate as user_rate, + u.review_id as review_id, + u.ctime as user_ctime, + i.storage_type::text as title_storage_type, + i.image_path as title_image_path, + jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type::text as studio_storage_type, + si.image_path as studio_image_path + +FROM usertitles as u +LEFT JOIN titles as t ON (u.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE + CASE + WHEN sqlc.arg('forward')::boolean THEN + -- forward: greater than cursor (next page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (t.release_year > sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (t.rating > sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (t.id > sqlc.narg('cursor_id')::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (t.release_year < sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (t.rating < sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (t.id < sqlc.narg('cursor_id')::bigint) + + ELSE true + END END - AND (sqlc.narg('status')::title_status_t IS NULL OR t.title_status = sqlc.narg('status')::title_status_t) + AND ( + CASE + WHEN sqlc.narg('word')::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) + + AND (u.status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('planned')::text, sqlc.arg('dropped')::text, sqlc.arg('in-progress')::text)) + AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) + AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) - AND (sqlc.narg('usertitle_status')::usertitle_status_t IS NULL OR u.usertitle_status = sqlc.narg('usertitle_status')::usertitle_status_t) + +GROUP BY + t.id, i.id, s.id, si.id + +ORDER BY + CASE WHEN sqlc.arg('forward')::boolean THEN + CASE + WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id + WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating + WHEN sqlc.arg('sort_by')::text = 'rate' THEN u.rate + END + END ASC, + CASE WHEN NOT sqlc.arg('forward')::boolean THEN + CASE + WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id + WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating + WHEN sqlc.arg('sort_by')::text = 'rate' THEN u.rate + END + END DESC, + + CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 4342a12..2a90265 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -128,7 +128,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -349,7 +349,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -548,82 +548,195 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S const searchUserTitles = `-- name: SearchUserTitles :many -SELECT - user_id, title_id, status, rate, review_id, ctime, id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len -FROM usertitles as u -JOIN titles as t ON (u.title_id = t.id) -WHERE - CASE - WHEN $1::text IS NOT NULL THEN - ( - SELECT bool_and( - EXISTS ( - SELECT 1 - FROM jsonb_each_text(t.title_names) AS t(key, val) - WHERE val ILIKE pattern - ) - ) - FROM unnest( - ARRAY( - SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array($1::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true +SELECT + t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + u.user_id as user_id, + u.status as usertitle_status, + u.rate as user_rate, + u.review_id as review_id, + u.ctime as user_ctime, + i.storage_type::text as title_storage_type, + i.image_path as title_image_path, + jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type::text as studio_storage_type, + si.image_path as studio_image_path + +FROM usertitles as u +LEFT JOIN titles as t ON (u.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE + CASE + WHEN $1::boolean THEN + -- forward: greater than cursor (next page) + CASE $2::text + WHEN 'year' THEN + ($3::int IS NULL) OR + (t.release_year > $3::int) OR + (t.release_year = $3::int AND t.id > $4::bigint) + + WHEN 'rating' THEN + ($5::float IS NULL) OR + (t.rating > $5::float) OR + (t.rating = $5::float AND t.id > $4::bigint) + + WHEN 'id' THEN + ($4::bigint IS NULL) OR + (t.id > $4::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE $2::text + WHEN 'year' THEN + ($3::int IS NULL) OR + (t.release_year < $3::int) OR + (t.release_year = $3::int AND t.id < $4::bigint) + + WHEN 'rating' THEN + ($5::float IS NULL) OR + (t.rating < $5::float) OR + (t.rating = $5::float AND t.id < $4::bigint) + + WHEN 'id' THEN + ($4::bigint IS NULL) OR + (t.id < $4::bigint) + + ELSE true + END END - AND ($2::title_status_t IS NULL OR t.title_status = $2::title_status_t) - AND ($3::float IS NULL OR t.rating >= $3::float) - AND ($4::int IS NULL OR t.release_year = $4::int) - AND ($5::release_season_t IS NULL OR t.release_season = $5::release_season_t) - AND ($6::usertitle_status_t IS NULL OR u.usertitle_status = $6::usertitle_status_t) + AND ( + CASE + WHEN $6::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($6::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) -LIMIT COALESCE($7::int, 100) + AND (u.status::text IN ($7::text, $8::text, $9::text, $10::text)) + AND (t.title_status::text IN ($7::text, $11::text, $8::text)) + AND ($12::int IS NULL OR u.rate >= $12::int) + AND ($13::float IS NULL OR t.rating >= $13::float) + AND ($14::int IS NULL OR t.release_year = $14::int) + AND ($15::release_season_t IS NULL OR t.release_season = $15::release_season_t) + +GROUP BY + t.id, i.id, s.id, si.id + +ORDER BY + CASE WHEN $1::boolean THEN + CASE + WHEN $2::text = 'id' THEN t.id + WHEN $2::text = 'year' THEN t.release_year + WHEN $2::text = 'rating' THEN t.rating + WHEN $2::text = 'rate' THEN u.rate + END + END ASC, + CASE WHEN NOT $1::boolean THEN + CASE + WHEN $2::text = 'id' THEN t.id + WHEN $2::text = 'year' THEN t.release_year + WHEN $2::text = 'rating' THEN t.rating + WHEN $2::text = 'rate' THEN u.rate + END + END DESC, + + CASE WHEN $2::text <> 'id' THEN t.id END ASC + +LIMIT COALESCE($16::int, 100) ` type SearchUserTitlesParams struct { - Word *string `json:"word"` - Status *TitleStatusT `json:"status"` - Rating *float64 `json:"rating"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - UsertitleStatus NullUsertitleStatusT `json:"usertitle_status"` - Limit *int32 `json:"limit"` + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` + Word *string `json:"word"` + Ongoing string `json:"ongoing"` + Planned string `json:"planned"` + Dropped string `json:"dropped"` + InProgress string `json:"in-progress"` + Finished string `json:"finished"` + Rate *int32 `json:"rate"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Limit *int32 `json:"limit"` } type SearchUserTitlesRow struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` - Ctime pgtype.Timestamptz `json:"ctime"` - ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` + ID *int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID *int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus *TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` + UserID int64 `json:"user_id"` + UsertitleStatus UsertitleStatusT `json:"usertitle_status"` + UserRate *int32 `json:"user_rate"` + ReviewID *int64 `json:"review_id"` + UserCtime pgtype.Timestamptz `json:"user_ctime"` + TitleStorageType string `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames []byte `json:"tag_names"` + StudioName *string `json:"studio_name"` + StudioIllustID *int64 `json:"studio_illust_id"` + StudioDesc *string `json:"studio_desc"` + StudioStorageType string `json:"studio_storage_type"` + StudioImagePath *string `json:"studio_image_path"` } // 100 is default limit func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { rows, err := q.db.Query(ctx, searchUserTitles, + arg.Forward, + arg.SortBy, + arg.CursorYear, + arg.CursorID, + arg.CursorRating, arg.Word, - arg.Status, + arg.Ongoing, + arg.Planned, + arg.Dropped, + arg.InProgress, + arg.Finished, + arg.Rate, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, - arg.UsertitleStatus, arg.Limit, ) if err != nil { @@ -634,12 +747,6 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara for rows.Next() { var i SearchUserTitlesRow if err := rows.Scan( - &i.UserID, - &i.TitleID, - &i.Status, - &i.Rate, - &i.ReviewID, - &i.Ctime, &i.ID, &i.TitleNames, &i.StudioID, @@ -653,6 +760,19 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara &i.EpisodesAired, &i.EpisodesAll, &i.EpisodesLen, + &i.UserID, + &i.UsertitleStatus, + &i.UserRate, + &i.ReviewID, + &i.UserCtime, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, + &i.StudioIllustID, + &i.StudioDesc, + &i.StudioStorageType, + &i.StudioImagePath, ); err != nil { return nil, err } From 6fa2ff2eb8d73449e585f88cc58997818ff5e39e Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 08:01:46 +0300 Subject: [PATCH 089/224] fix: minor fix to query --- modules/backend/queries.sql | 4 +- sql/queries.sql.go | 316 +++++++++++++++++------------------- 2 files changed, 150 insertions(+), 170 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 16f8120..42b99d1 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -78,7 +78,7 @@ SELECT t.*, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -101,7 +101,7 @@ SELECT t.*, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 9987e78..c67033c 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -8,6 +8,8 @@ package sqlc import ( "context" "time" + + "github.com/jackc/pgx/v5/pgtype" ) const createImage = `-- name: CreateImage :one @@ -41,6 +43,56 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro return i, err } +const getReviewByID = `-- name: GetReviewByID :one + + + +SELECT id, data, rating, user_id, title_id, created_at +FROM reviews +WHERE review_id = $1::bigint +` + +// 100 is default limit +// -- name: ListTitles :many +// SELECT title_id, title_names, studio_id, poster_id, signal_ids, +// +// title_status, rating, rating_count, release_year, release_season, +// season, episodes_aired, episodes_all, episodes_len +// +// FROM titles +// ORDER BY title_id +// LIMIT $1 OFFSET $2; +// -- name: UpdateTitle :one +// UPDATE titles +// SET +// +// title_names = COALESCE(sqlc.narg('title_names'), title_names), +// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), +// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), +// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), +// title_status = COALESCE(sqlc.narg('title_status'), title_status), +// release_year = COALESCE(sqlc.narg('release_year'), release_year), +// release_season = COALESCE(sqlc.narg('release_season'), release_season), +// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), +// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), +// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) +// +// WHERE title_id = sqlc.arg('title_id') +// RETURNING *; +func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, error) { + row := q.db.QueryRow(ctx, getReviewByID, reviewID) + var i Review + err := row.Scan( + &i.ID, + &i.Data, + &i.Rating, + &i.UserID, + &i.TitleID, + &i.CreatedAt, + ) + return i, err +} + const getStudioByID = `-- name: GetStudioByID :one SELECT id, studio_name, illust_id, studio_desc FROM studios @@ -68,7 +120,7 @@ SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -76,7 +128,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN images as i ON (t.image_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -289,7 +341,7 @@ SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -297,7 +349,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN images as i ON (t.image_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -496,183 +548,111 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S const searchUserTitles = `-- name: SearchUserTitles :many +SELECT + user_id, title_id, status, rate, review_id, ctime, id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +WHERE + CASE + WHEN $1::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($1::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + AND ($2::title_status_t IS NULL OR t.title_status = $2::title_status_t) + AND ($3::float IS NULL OR t.rating >= $3::float) + AND ($4::int IS NULL OR t.release_year = $4::int) + AND ($5::release_season_t IS NULL OR t.release_season = $5::release_season_t) + AND ($6::usertitle_status_t IS NULL OR u.usertitle_status = $6::usertitle_status_t) - - - - - - - - - - - - - - - - -SELECT id, data, rating, user_id, title_id, created_at -FROM reviews -WHERE review_id = $1::bigint +LIMIT COALESCE($7::int, 100) ` +type SearchUserTitlesParams struct { + Word *string `json:"word"` + Status *TitleStatusT `json:"status"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + UsertitleStatus NullUsertitleStatusT `json:"usertitle_status"` + Limit *int32 `json:"limit"` +} + +type SearchUserTitlesRow struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime pgtype.Timestamptz `json:"ctime"` + ID int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` +} + // 100 is default limit -// SELECT -// -// t.*, -// u.user_id as user_id, -// u.status as usertitle_status, -// u.rate as user_rate, -// u.review_id as review_id, -// u.ctime as user_ctime, -// i.storage_type::text as title_storage_type, -// i.image_path as title_image_path, -// jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, -// s.studio_name as studio_name, -// s.illust_id as studio_illust_id, -// s.studio_desc as studio_desc, -// si.storage_type::text as studio_storage_type, -// si.image_path as studio_image_path -// -// FROM usertitles as u -// LEFT JOIN titles as t ON (u.title_id = t.id) -// LEFT JOIN images as i ON (t.poster_id = i.id) -// LEFT JOIN title_tags as tt ON (t.id = tt.title_id) -// LEFT JOIN tags as g ON (tt.tag_id = g.id) -// LEFT JOIN studios as s ON (t.studio_id = s.id) -// LEFT JOIN images as si ON (s.illust_id = si.id) -// WHERE -// -// CASE -// WHEN sqlc.arg('forward')::boolean THEN -// -- forward: greater than cursor (next page) -// CASE sqlc.arg('sort_by')::text -// WHEN 'year' THEN -// (sqlc.narg('cursor_year')::int IS NULL) OR -// (t.release_year > sqlc.narg('cursor_year')::int) OR -// (t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint) -// WHEN 'rating' THEN -// (sqlc.narg('cursor_rating')::float IS NULL) OR -// (t.rating > sqlc.narg('cursor_rating')::float) OR -// (t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint) -// WHEN 'id' THEN -// (sqlc.narg('cursor_id')::bigint IS NULL) OR -// (t.id > sqlc.narg('cursor_id')::bigint) -// ELSE true -- fallback -// END -// ELSE -// -- backward: less than cursor (prev page) -// CASE sqlc.arg('sort_by')::text -// WHEN 'year' THEN -// (sqlc.narg('cursor_year')::int IS NULL) OR -// (t.release_year < sqlc.narg('cursor_year')::int) OR -// (t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint) -// WHEN 'rating' THEN -// (sqlc.narg('cursor_rating')::float IS NULL) OR -// (t.rating < sqlc.narg('cursor_rating')::float) OR -// (t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint) -// WHEN 'id' THEN -// (sqlc.narg('cursor_id')::bigint IS NULL) OR -// (t.id < sqlc.narg('cursor_id')::bigint) -// ELSE true -// END -// END -// AND ( -// CASE -// WHEN sqlc.narg('word')::text IS NOT NULL THEN -// ( -// SELECT bool_and( -// EXISTS ( -// SELECT 1 -// FROM jsonb_each_text(t.title_names) AS t(key, val) -// WHERE val ILIKE pattern -// ) -// ) -// FROM unnest( -// ARRAY( -// SELECT '%' || trim(w) || '%' -// FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w -// WHERE trim(w) <> '' -// ) -// ) AS pattern -// ) -// ELSE true -// END -// ) -// AND (u.::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) -// AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) -// AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) -// AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) -// AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) -// -// GROUP BY -// -// t.id, i.id, s.id, si.id -// -// ORDER BY -// -// CASE WHEN sqlc.arg('forward')::boolean THEN -// CASE -// WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id -// WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year -// WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating -// END -// END ASC, -// CASE WHEN NOT sqlc.arg('forward')::boolean THEN -// CASE -// WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id -// WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year -// WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating -// END -// END DESC, -// CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC -// -// LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit -// -- name: ListTitles :many -// SELECT title_id, title_names, studio_id, poster_id, signal_ids, -// -// title_status, rating, rating_count, release_year, release_season, -// season, episodes_aired, episodes_all, episodes_len -// -// FROM titles -// ORDER BY title_id -// LIMIT $1 OFFSET $2; -// -- name: UpdateTitle :one -// UPDATE titles -// SET -// -// title_names = COALESCE(sqlc.narg('title_names'), title_names), -// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), -// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), -// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), -// title_status = COALESCE(sqlc.narg('title_status'), title_status), -// release_year = COALESCE(sqlc.narg('release_year'), release_year), -// release_season = COALESCE(sqlc.narg('release_season'), release_season), -// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), -// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), -// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) -// -// WHERE title_id = sqlc.arg('title_id') -// RETURNING *; -func (q *Queries) SearchUserTitles(ctx context.Context, reviewID int64) ([]Review, error) { - rows, err := q.db.Query(ctx, searchUserTitles, reviewID) +func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { + rows, err := q.db.Query(ctx, searchUserTitles, + arg.Word, + arg.Status, + arg.Rating, + arg.ReleaseYear, + arg.ReleaseSeason, + arg.UsertitleStatus, + arg.Limit, + ) if err != nil { return nil, err } defer rows.Close() - items := []Review{} + items := []SearchUserTitlesRow{} for rows.Next() { - var i Review + var i SearchUserTitlesRow if err := rows.Scan( - &i.ID, - &i.Data, - &i.Rating, &i.UserID, &i.TitleID, - &i.CreatedAt, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, ); err != nil { return nil, err } From 6a39d89ef99abb9d137bc3e5918615916ec5966b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 08:13:07 +0300 Subject: [PATCH 090/224] fix --- modules/backend/queries.sql | 4 ++-- sql/queries.sql.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 42b99d1..9bb7c36 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -86,7 +86,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -109,7 +109,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index c67033c..dc8f3f6 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -128,7 +128,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) @@ -349,7 +349,7 @@ SELECT si.image_path as studio_image_path FROM titles as t -LEFT JOIN images as i ON (t.image_id = i.id) +LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) From f1ba15d3a4e4ccaf68d8db535d03560b191ac7db Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 08:32:13 +0300 Subject: [PATCH 091/224] fix --- modules/backend/queries.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index b8cb8aa..a16dcbc 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -78,7 +78,7 @@ SELECT t.*, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -101,7 +101,7 @@ SELECT t.*, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -219,7 +219,7 @@ SELECT u.ctime as user_ctime, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, From 4b1ac9177dc2795499af9ba5345875a4424ebb59 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 08:34:26 +0300 Subject: [PATCH 092/224] fix --- modules/backend/queries.sql | 10 ++++++++-- sql/queries.sql.go | 14 ++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 9bb7c36..3c5c10e 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -78,7 +78,10 @@ SELECT t.*, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + ) as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -101,7 +104,10 @@ SELECT t.*, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + ) as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, diff --git a/sql/queries.sql.go b/sql/queries.sql.go index dc8f3f6..7caa7f0 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -120,7 +120,10 @@ SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + ) as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -155,7 +158,7 @@ type GetTitleByIDRow struct { EpisodesLen []byte `json:"episodes_len"` TitleStorageType string `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` - TagNames []byte `json:"tag_names"` + TagNames interface{} `json:"tag_names"` StudioName *string `json:"studio_name"` StudioIllustID *int64 `json:"studio_illust_id"` StudioDesc *string `json:"studio_desc"` @@ -341,7 +344,10 @@ SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + ) as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -482,7 +488,7 @@ type SearchTitlesRow struct { EpisodesLen []byte `json:"episodes_len"` TitleStorageType string `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` - TagNames []byte `json:"tag_names"` + TagNames interface{} `json:"tag_names"` StudioName *string `json:"studio_name"` StudioIllustID *int64 `json:"studio_illust_id"` StudioDesc *string `json:"studio_desc"` From d0c3547ef6aff355045d2cafdb9eb6687206577e Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 08:47:01 +0300 Subject: [PATCH 093/224] fix --- modules/backend/queries.sql | 4 ++-- sql/models.go | 17 +++++++++-------- sql/queries.sql.go | 23 ++++++++++++----------- sql/sqlc.yaml | 2 ++ 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 3c5c10e..8546271 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -81,7 +81,7 @@ SELECT COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), '[]'::jsonb - ) as tag_names, + )::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -107,7 +107,7 @@ SELECT COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), '[]'::jsonb - ) as tag_names, + )::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, diff --git a/sql/models.go b/sql/models.go index 93cecca..36d4c07 100644 --- a/sql/models.go +++ b/sql/models.go @@ -6,6 +6,7 @@ package sqlc import ( "database/sql/driver" + "encoding/json" "fmt" "time" @@ -223,11 +224,11 @@ type ReviewImage struct { } type Signal struct { - ID int64 `json:"id"` - TitleID *int64 `json:"title_id"` - RawData []byte `json:"raw_data"` - ProviderID int64 `json:"provider_id"` - Pending bool `json:"pending"` + ID int64 `json:"id"` + TitleID *int64 `json:"title_id"` + RawData json.RawMessage `json:"raw_data"` + ProviderID int64 `json:"provider_id"` + Pending bool `json:"pending"` } type Studio struct { @@ -238,13 +239,13 @@ type Studio struct { } type Tag struct { - ID int64 `json:"id"` - TagNames []byte `json:"tag_names"` + ID int64 `json:"id"` + TagNames json.RawMessage `json:"tag_names"` } type Title struct { ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` + TitleNames json.RawMessage `json:"title_names"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 7caa7f0..ad1b9a8 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -7,6 +7,7 @@ package sqlc import ( "context" + "encoding/json" "time" "github.com/jackc/pgx/v5/pgtype" @@ -123,7 +124,7 @@ SELECT COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), '[]'::jsonb - ) as tag_names, + )::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -144,7 +145,7 @@ GROUP BY type GetTitleByIDRow struct { ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` + TitleNames json.RawMessage `json:"title_names"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` @@ -158,7 +159,7 @@ type GetTitleByIDRow struct { EpisodesLen []byte `json:"episodes_len"` TitleStorageType string `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` - TagNames interface{} `json:"tag_names"` + TagNames json.RawMessage `json:"tag_names"` StudioName *string `json:"studio_name"` StudioIllustID *int64 `json:"studio_illust_id"` StudioDesc *string `json:"studio_desc"` @@ -227,15 +228,15 @@ JOIN title_tags as t ON(t.tag_id = g.id) WHERE t.title_id = $1::bigint ` -func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([][]byte, error) { +func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([]json.RawMessage, error) { rows, err := q.db.Query(ctx, getTitleTags, titleID) if err != nil { return nil, err } defer rows.Close() - items := [][]byte{} + items := []json.RawMessage{} for rows.Next() { - var tag_names []byte + var tag_names json.RawMessage if err := rows.Scan(&tag_names); err != nil { return nil, err } @@ -312,7 +313,7 @@ VALUES ( RETURNING id, tag_names ` -func (q *Queries) InsertTag(ctx context.Context, tagNames []byte) (Tag, error) { +func (q *Queries) InsertTag(ctx context.Context, tagNames json.RawMessage) (Tag, error) { row := q.db.QueryRow(ctx, insertTag, tagNames) var i Tag err := row.Scan(&i.ID, &i.TagNames) @@ -347,7 +348,7 @@ SELECT COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), '[]'::jsonb - ) as tag_names, + )::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -474,7 +475,7 @@ type SearchTitlesParams struct { type SearchTitlesRow struct { ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` + TitleNames json.RawMessage `json:"title_names"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` @@ -488,7 +489,7 @@ type SearchTitlesRow struct { EpisodesLen []byte `json:"episodes_len"` TitleStorageType string `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` - TagNames interface{} `json:"tag_names"` + TagNames json.RawMessage `json:"tag_names"` StudioName *string `json:"studio_name"` StudioIllustID *int64 `json:"studio_illust_id"` StudioDesc *string `json:"studio_desc"` @@ -607,7 +608,7 @@ type SearchUserTitlesRow struct { ReviewID *int64 `json:"review_id"` Ctime pgtype.Timestamptz `json:"ctime"` ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` + TitleNames json.RawMessage `json:"title_names"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index 94b9fb4..3338c35 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -14,6 +14,8 @@ sql: emit_pointers_for_null_types: true emit_empty_slices: true #slices returned by :many queries will be empty instead of nil overrides: + - db_type: "jsonb" + go_type: "encoding/json.RawMessage" - db_type: "uuid" nullable: false go_type: From ff84b0052617f7ecd5cc47ca68b65f5ea6a7320d Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 08:49:32 +0300 Subject: [PATCH 094/224] fix --- modules/backend/handlers/titles.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 84fc87e..ecb2f00 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -339,7 +339,7 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje if request.Params.Sort != nil { switch string(*request.Params.Sort) { case "year": - tmp := fmt.Sprint("%d", *t.ReleaseYear) + tmp := fmt.Sprint(*t.ReleaseYear) new_cursor.Param = &tmp case "rating": tmp := strconv.FormatFloat(*t.Rating, 'f', -1, 64) From cbbc2c179d2421f98ad9857441ec5fd0d0b6fa12 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 22 Nov 2025 18:29:49 +0300 Subject: [PATCH 095/224] feat: --- modules/backend/handlers/users.go | 100 +++++++++++++++--------------- modules/backend/queries.sql | 10 ++- sql/queries.sql.go | 30 ++++----- 3 files changed, 74 insertions(+), 66 deletions(-) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 0420b91..1ea2c1a 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -103,33 +103,23 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU return oapi.GetUsersUserIdTitles400Response{}, err } - // Forward bool `json:"forward"` - // SortBy string `json:"sort_by"` - // CursorYear *int32 `json:"cursor_year"` - // CursorID *int64 `json:"cursor_id"` - // CursorRating *float64 `json:"cursor_rating"` - // Word *string `json:"word"` - // Ongoing string `json:"ongoing"` - // Planned string `json:"planned"` - // Dropped string `json:"dropped"` - // InProgress string `json:"in-progress"` - // Finished string `json:"finished"` - // Rate *int32 `json:"rate"` - // Rating *float64 `json:"rating"` - // ReleaseYear *int32 `json:"release_year"` - // ReleaseSeason *ReleaseSeasonT `json:"release_season"` - // Limit *int32 `json:"limit"` - params := sqlc.SearchTitlesParams{ - Word: word, - Ongoing: status.ongoing, - Finished: status.finished, - Planned: status.planned, - Rating: request.Params.Rating, - ReleaseYear: request.Params.ReleaseYear, - ReleaseSeason: season, - Forward: true, - SortBy: "id", - Limit: request.Params.Limit, + params := sqlc.SearchUserTitlesParams{ + Forward: true, + SortBy: "id", + // CursorYear : + // CursorID *int64 `json:"cursor_id"` + // CursorRating *float64 `json:"cursor_rating"` + // Word *string `json:"word"` + // Ongoing string `json:"ongoing"` + // Planned string `json:"planned"` + // Dropped string `json:"dropped"` + // InProgress string `json:"in-progress"` + // Finished string `json:"finished"` + // Rate *int32 `json:"rate"` + // Rating *float64 `json:"rating"` + // ReleaseYear *int32 `json:"release_year"` + // ReleaseSeason *ReleaseSeasonT `json:"release_season"` + // Limit *int32 `json:"limit"` } if request.Params.SortForward != nil { @@ -141,32 +131,42 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) if err != nil { log.Errorf("%v", err) - return oapi.GetTitles400Response{}, nil + return oapi.GetUsersUserIdTitles400Response{}, nil } } } - - _sqlc_title := sqlc.SearchTitlesRow{ - ID: sqlc_title.ID, - StudioID: sqlc_title.StudioID, - PosterID: sqlc_title.PosterID, - TitleStatus: sqlc_title.TitleStatus, - Rating: sqlc_title.Rating, - RatingCount: sqlc_title.RatingCount, - ReleaseYear: sqlc_title.ReleaseYear, - ReleaseSeason: sqlc_title.ReleaseSeason, - Season: sqlc_title.Season, - EpisodesAired: sqlc_title.EpisodesAired, - EpisodesAll: sqlc_title.EpisodesAll, - EpisodesLen: sqlc_title.EpisodesLen, - TitleStorageType: sqlc_title.TitleStorageType, - TitleImagePath: sqlc_title.TitleImagePath, - TagNames: sqlc_title.TitleNames, - StudioName: sqlc_title.StudioName, - StudioIllustID: sqlc_title.StudioIllustID, - StudioDesc: sqlc_title.StudioDesc, - StudioStorageType: sqlc_title.StudioStorageType, - StudioImagePath: sqlc_title.StudioImagePath, + // param = nil means it will not be used + titles, err := s.db.SearchUserTitles(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles500Response{}, nil + } + if len(titles) == 0 { + return oapi.GetUsersUserIdTitles204Response{}, nil + } + for _, title := range titles { + _sqlc_title := sqlc.SearchTitlesRow{ + ID: sqlc_title.ID, + StudioID: sqlc_title.StudioID, + PosterID: sqlc_title.PosterID, + TitleStatus: sqlc_title.TitleStatus, + Rating: sqlc_title.Rating, + RatingCount: sqlc_title.RatingCount, + ReleaseYear: sqlc_title.ReleaseYear, + ReleaseSeason: sqlc_title.ReleaseSeason, + Season: sqlc_title.Season, + EpisodesAired: sqlc_title.EpisodesAired, + EpisodesAll: sqlc_title.EpisodesAll, + EpisodesLen: sqlc_title.EpisodesLen, + TitleStorageType: sqlc_title.TitleStorageType, + TitleImagePath: sqlc_title.TitleImagePath, + TagNames: sqlc_title.TitleNames, + StudioName: sqlc_title.StudioName, + StudioIllustID: sqlc_title.StudioIllustID, + StudioDesc: sqlc_title.StudioDesc, + StudioStorageType: sqlc_title.StudioStorageType, + StudioImagePath: sqlc_title.StudioImagePath, + } } return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 307eb4f..ee2a84a 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -95,7 +95,7 @@ LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) LEFT JOIN images as si ON (s.illust_id = si.id) -WHERE id = sqlc.arg('title_id')::bigint +WHERE t.id = sqlc.arg('title_id')::bigint GROUP BY t.id, i.id, s.id, si.id; @@ -187,7 +187,13 @@ WHERE END ) - AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) + AND ( + -- Если массив пуст (NULL или []) — не фильтруем + cardinality(sqlc.arg('title_statuses')::text[]) = 0 + OR + -- Иначе: статус есть в списке + t.title_status = ANY(sqlc.arg('title_statuses')::text[]) +) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index d4fb7a6..bf2f08a 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -138,7 +138,7 @@ LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) LEFT JOIN images as si ON (s.illust_id = si.id) -WHERE id = $1::bigint +WHERE t.id = $1::bigint GROUP BY t.id, i.id, s.id, si.id ` @@ -428,10 +428,16 @@ WHERE END ) - AND (t.title_status::text IN ($7::text, $8::text, $9::text)) - AND ($10::float IS NULL OR t.rating >= $10::float) - AND ($11::int IS NULL OR t.release_year = $11::int) - AND ($12::release_season_t IS NULL OR t.release_season = $12::release_season_t) + AND ( + -- Если массив пуст (NULL или []) — не фильтруем + cardinality($7::text[]) = 0 + OR + -- Иначе: статус есть в списке + t.title_status = ANY($7::text[]) +) + AND ($8::float IS NULL OR t.rating >= $8::float) + AND ($9::int IS NULL OR t.release_year = $9::int) + AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) GROUP BY t.id, i.id, s.id, si.id @@ -454,7 +460,7 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($13::int, 100) +LIMIT COALESCE($11::int, 100) ` type SearchTitlesParams struct { @@ -464,9 +470,7 @@ type SearchTitlesParams struct { CursorID *int64 `json:"cursor_id"` CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` - Ongoing string `json:"ongoing"` - Finished string `json:"finished"` - Planned string `json:"planned"` + TitleStatuses []string `json:"title_statuses"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` @@ -505,9 +509,7 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S arg.CursorID, arg.CursorRating, arg.Word, - arg.Ongoing, - arg.Finished, - arg.Planned, + arg.TitleStatuses, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, @@ -564,7 +566,7 @@ SELECT u.ctime as user_ctime, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg_strict(g.tag_name)'[]'::jsonb as tag_names, + jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, @@ -718,7 +720,7 @@ type SearchUserTitlesRow struct { UserCtime pgtype.Timestamptz `json:"user_ctime"` TitleStorageType string `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` - TagNames []byte `json:"tag_names"` + TagNames json.RawMessage `json:"tag_names"` StudioName *string `json:"studio_name"` StudioIllustID *int64 `json:"studio_illust_id"` StudioDesc *string `json:"studio_desc"` From e64e770783883cd7f2fee60b83f9ad2dd41e254c Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sun, 23 Nov 2025 03:32:58 +0300 Subject: [PATCH 096/224] feat: use SetCookie for access and refresh tokens --- auth/auth.gen.go | 104 ++------------- auth/openapi-auth.yaml | 121 +++++++++++++----- modules/auth/handlers/handlers.go | 203 +++++++++++++++++++++--------- 3 files changed, 246 insertions(+), 182 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index 1f16575..adb2b06 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -25,21 +25,12 @@ type PostAuthSignUpJSONBody struct { Pass string `json:"pass"` } -// PostAuthVerifyTokenJSONBody defines parameters for PostAuthVerifyToken. -type PostAuthVerifyTokenJSONBody struct { - // Token JWT token to validate - Token string `json:"token"` -} - // PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody // PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody -// PostAuthVerifyTokenJSONRequestBody defines body for PostAuthVerifyToken for application/json ContentType. -type PostAuthVerifyTokenJSONRequestBody PostAuthVerifyTokenJSONBody - // ServerInterface represents all server handlers. type ServerInterface interface { // Sign in a user and return JWT @@ -48,9 +39,6 @@ type ServerInterface interface { // Sign up a new user // (POST /auth/sign-up) PostAuthSignUp(c *gin.Context) - // Verify JWT validity - // (POST /auth/verify-token) - PostAuthVerifyToken(c *gin.Context) } // ServerInterfaceWrapper converts contexts to parameters. @@ -88,19 +76,6 @@ func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { siw.Handler.PostAuthSignUp(c) } -// PostAuthVerifyToken operation middleware -func (siw *ServerInterfaceWrapper) PostAuthVerifyToken(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostAuthVerifyToken(c) -} - // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -130,7 +105,6 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) - router.POST(options.BaseURL+"/auth/verify-token", wrapper.PostAuthVerifyToken) } type PostAuthSignInRequestObject struct { @@ -144,10 +118,7 @@ type PostAuthSignInResponseObject interface { type PostAuthSignIn200JSONResponse struct { Error *string `json:"error"` Success *bool `json:"success,omitempty"` - - // Token JWT token to access protected endpoints - Token *string `json:"token"` - UserId *string `json:"user_id"` + UserId *string `json:"user_id"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { @@ -157,6 +128,17 @@ func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http return json.NewEncoder(w).Encode(response) } +type PostAuthSignIn401JSONResponse struct { + Error *string `json:"error,omitempty"` +} + +func (response PostAuthSignIn401JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + type PostAuthSignUpRequestObject struct { Body *PostAuthSignUpJSONRequestBody } @@ -178,32 +160,6 @@ func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http return json.NewEncoder(w).Encode(response) } -type PostAuthVerifyTokenRequestObject struct { - Body *PostAuthVerifyTokenJSONRequestBody -} - -type PostAuthVerifyTokenResponseObject interface { - VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error -} - -type PostAuthVerifyToken200JSONResponse struct { - // Error Error message if token is invalid - Error *string `json:"error"` - - // UserId User ID extracted from token if valid - UserId *string `json:"user_id"` - - // Valid True if token is valid - Valid *bool `json:"valid,omitempty"` -} - -func (response PostAuthVerifyToken200JSONResponse) VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Sign in a user and return JWT @@ -212,9 +168,6 @@ type StrictServerInterface interface { // Sign up a new user // (POST /auth/sign-up) PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) - // Verify JWT validity - // (POST /auth/verify-token) - PostAuthVerifyToken(ctx context.Context, request PostAuthVerifyTokenRequestObject) (PostAuthVerifyTokenResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -294,36 +247,3 @@ func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { ctx.Error(fmt.Errorf("unexpected response type: %T", response)) } } - -// PostAuthVerifyToken operation middleware -func (sh *strictHandler) PostAuthVerifyToken(ctx *gin.Context) { - var request PostAuthVerifyTokenRequestObject - - var body PostAuthVerifyTokenJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostAuthVerifyToken(ctx, request.(PostAuthVerifyTokenRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostAuthVerifyToken") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostAuthVerifyTokenResponseObject); ok { - if err := validResponse.VisitPostAuthVerifyTokenResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 7ffc60e..b9ce76f 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.0 +openapi: 3.1.1 info: title: Auth Service version: 1.0.0 @@ -58,6 +58,14 @@ paths: responses: "200": description: Sign-in result with JWT + # headers: + # Set-Cookie: + # schema: + # type: array + # items: + # type: string + # explode: true + # style: simple content: application/json: schema: @@ -71,42 +79,89 @@ paths: user_id: type: string nullable: true - token: - type: string - description: JWT token to access protected endpoints - nullable: true - - /auth/verify-token: - post: - summary: Verify JWT validity - tags: [Auth] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [token] - properties: - token: - type: string - description: JWT token to validate - responses: - "200": - description: Token validation result + "401": + description: Access denied due to invalid credentials content: application/json: schema: type: object properties: - valid: - type: boolean - description: True if token is valid - user_id: - type: string - nullable: true - description: User ID extracted from token if valid error: type: string - nullable: true - description: Error message if token is invalid \ No newline at end of file + example: "Access denied" + # /auth/verify-token: + # post: + # summary: Verify JWT validity + # tags: [Auth] + # requestBody: + # required: true + # content: + # application/json: + # schema: + # type: object + # required: [token] + # properties: + # token: + # type: string + # description: JWT token to validate + # responses: + # "200": + # description: Token validation result + # content: + # application/json: + # schema: + # type: object + # properties: + # valid: + # type: boolean + # description: True if token is valid + # user_id: + # type: string + # nullable: true + # description: User ID extracted from token if valid + # error: + # type: string + # nullable: true + # description: Error message if token is invalid + # /auth/refresh-token: + # post: + # summary: Refresh JWT using a refresh token + # tags: [Auth] + # requestBody: + # required: true + # content: + # application/json: + # schema: + # type: object + # required: [refresh_token] + # properties: + # refresh_token: + # type: string + # description: JWT refresh token obtained from sign-in + # responses: + # "200": + # description: New access (and optionally refresh) token + # content: + # application/json: + # schema: + # type: object + # properties: + # valid: + # type: boolean + # description: True if refresh token was valid + # user_id: + # type: string + # nullable: true + # description: User ID extracted from refresh token + # access_token: + # type: string + # description: New access token + # nullable: true + # refresh_token: + # type: string + # description: New refresh token (optional) + # nullable: true + # error: + # type: string + # nullable: true + # description: Error message if refresh token is invalid diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index ca72192..9b9b0d3 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -3,27 +3,21 @@ package handlers import ( "context" "fmt" + "log" + "net/http" auth "nyanimedb/auth" sqlc "nyanimedb/sql" "strconv" "time" + "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" ) -var secretKey = []byte("my_secret_key") +var accessSecret = []byte("my_access_secret_key") +var refreshSecret = []byte("my_refresh_secret_key") -func generateToken(userID string) (string, error) { - claims := jwt.MapClaims{ - "user_id": userID, - "exp": time.Now().Add(time.Hour * 24).Unix(), - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(secretKey) -} - -var UserDb = make(map[string]string) //TEMP +var UserDb = make(map[string]string) // TEMP: stores passwords type Server struct { db *sqlc.Queries @@ -38,19 +32,28 @@ func parseInt64(s string) (int32, error) { return int32(i), err } -func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { - err := "" - success := true - t, _ := generateToken(req.Body.Nickname) +func generateTokens(userID string) (accessToken string, refreshToken string, err error) { + accessClaims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(15 * time.Minute).Unix(), + } + at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + accessToken, err = at.SignedString(accessSecret) + if err != nil { + return "", "", err + } - UserDb[req.Body.Nickname] = req.Body.Pass + refreshClaims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), + } + rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) + refreshToken, err = rt.SignedString(refreshSecret) + if err != nil { + return "", "", err + } - return auth.PostAuthSignIn200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, - Token: &t, - }, nil + return accessToken, refreshToken, nil } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { @@ -65,44 +68,130 @@ func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpReque }, nil } -func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { - valid := false - var userID *string - var errStr *string +func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { + // ctx.SetCookie("122") + ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) + if !ok { + log.Print("failed to get gin context") + // TODO: change to 500 + return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") + } - token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method") - } - return secretKey, nil - }) + err := "" + success := true - if err != nil { - e := err.Error() - errStr = &e - return auth.PostAuthVerifyToken200JSONResponse{ - Valid: &valid, - UserId: userID, - Error: errStr, + pass, ok := UserDb[req.Body.Nickname] + if !ok || pass != req.Body.Pass { + e := "invalid credentials" + return auth.PostAuthSignIn401JSONResponse{ + Error: &e, }, nil } - if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { - if uid, ok := claims["user_id"].(string); ok { - valid = true - userID = &uid - } else { - e := "user_id not found in token" - errStr = &e - } - } else { - e := "invalid token claims" - errStr = &e - } + accessToken, refreshToken, _ := generateTokens(req.Body.Nickname) - return auth.PostAuthVerifyToken200JSONResponse{ - Valid: &valid, - UserId: userID, - Error: errStr, - }, nil + ginCtx.SetSameSite(http.SameSiteStrictMode) + ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true) + ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true) + + // Return access token; refresh token can be returned in response or HttpOnly cookie + result := auth.PostAuthSignIn200JSONResponse{ + Error: &err, + Success: &success, + UserId: &req.Body.Nickname, + } + return result, nil } + +// func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { +// valid := false +// var userID *string +// var errStr *string + +// token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { +// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { +// return nil, fmt.Errorf("unexpected signing method") +// } +// return accessSecret, nil +// }) + +// if err != nil { +// e := err.Error() +// errStr = &e +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { +// if uid, ok := claims["user_id"].(string); ok { +// valid = true +// userID = &uid +// } else { +// e := "user_id not found in token" +// errStr = &e +// } +// } else { +// e := "invalid token claims" +// errStr = &e +// } + +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// func (s Server) PostAuthRefreshToken(ctx context.Context, req auth.PostAuthRefreshTokenRequestObject) (auth.PostAuthRefreshTokenResponseObject, error) { +// valid := false +// var userID *string +// var errStr *string + +// token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { +// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { +// return nil, fmt.Errorf("unexpected signing method") +// } +// return refreshSecret, nil +// }) + +// if err != nil { +// e := err.Error() +// errStr = &e +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { +// if uid, ok := claims["user_id"].(string); ok { +// // Refresh token is valid, generate new tokens +// newAccessToken, newRefreshToken, _ := generateTokens(uid) +// valid = true +// userID = &uid +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: nil, +// Token: &newAccessToken, // return new access token +// // optionally return newRefreshToken as well +// }, nil +// } else { +// e := "user_id not found in refresh token" +// errStr = &e +// } +// } else { +// e := "invalid refresh token claims" +// errStr = &e +// } + +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } From 2929a6e4bc30613efdfe8eb2a6d469a81f5cf208 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 15 Nov 2025 02:53:25 +0300 Subject: [PATCH 097/224] feat: initial auth service support --- Dockerfiles/Dockerfile_auth | 6 + auth/auth.gen.go | 329 ++++++++++++++++++++++++++++++ auth/auth/auth.gen.go | 329 ++++++++++++++++++++++++++++++ auth/oapi-auth-codegen.yaml | 6 + auth/openapi-auth.yaml | 112 ++++++++++ go.mod | 1 + go.sum | 2 + modules/auth/handlers/handlers.go | 108 ++++++++++ modules/auth/main.go | 38 ++++ modules/auth/types.go | 6 + 10 files changed, 937 insertions(+) create mode 100644 Dockerfiles/Dockerfile_auth create mode 100644 auth/auth.gen.go create mode 100644 auth/auth/auth.gen.go create mode 100644 auth/oapi-auth-codegen.yaml create mode 100644 auth/openapi-auth.yaml create mode 100644 modules/auth/handlers/handlers.go create mode 100644 modules/auth/main.go create mode 100644 modules/auth/types.go diff --git a/Dockerfiles/Dockerfile_auth b/Dockerfiles/Dockerfile_auth new file mode 100644 index 0000000..5280e86 --- /dev/null +++ b/Dockerfiles/Dockerfile_auth @@ -0,0 +1,6 @@ +FROM ubuntu:22.04 + +WORKDIR /app +COPY --chmod=755 modules/auth/auth /app +EXPOSE 8082 +ENTRYPOINT ["/app/auth"] \ No newline at end of file diff --git a/auth/auth.gen.go b/auth/auth.gen.go new file mode 100644 index 0000000..1f16575 --- /dev/null +++ b/auth/auth.gen.go @@ -0,0 +1,329 @@ +// Package auth provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" +) + +// PostAuthSignInJSONBody defines parameters for PostAuthSignIn. +type PostAuthSignInJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthSignUpJSONBody defines parameters for PostAuthSignUp. +type PostAuthSignUpJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthVerifyTokenJSONBody defines parameters for PostAuthVerifyToken. +type PostAuthVerifyTokenJSONBody struct { + // Token JWT token to validate + Token string `json:"token"` +} + +// PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. +type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody + +// PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. +type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody + +// PostAuthVerifyTokenJSONRequestBody defines body for PostAuthVerifyToken for application/json ContentType. +type PostAuthVerifyTokenJSONRequestBody PostAuthVerifyTokenJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(c *gin.Context) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(c *gin.Context) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(c *gin.Context) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// PostAuthSignIn operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignIn(c) +} + +// PostAuthSignUp operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignUp(c) +} + +// PostAuthVerifyToken operation middleware +func (siw *ServerInterfaceWrapper) PostAuthVerifyToken(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthVerifyToken(c) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) + router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) + router.POST(options.BaseURL+"/auth/verify-token", wrapper.PostAuthVerifyToken) +} + +type PostAuthSignInRequestObject struct { + Body *PostAuthSignInJSONRequestBody +} + +type PostAuthSignInResponseObject interface { + VisitPostAuthSignInResponse(w http.ResponseWriter) error +} + +type PostAuthSignIn200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + + // Token JWT token to access protected endpoints + Token *string `json:"token"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthSignUpRequestObject struct { + Body *PostAuthSignUpJSONRequestBody +} + +type PostAuthSignUpResponseObject interface { + VisitPostAuthSignUpResponse(w http.ResponseWriter) error +} + +type PostAuthSignUp200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthVerifyTokenRequestObject struct { + Body *PostAuthVerifyTokenJSONRequestBody +} + +type PostAuthVerifyTokenResponseObject interface { + VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error +} + +type PostAuthVerifyToken200JSONResponse struct { + // Error Error message if token is invalid + Error *string `json:"error"` + + // UserId User ID extracted from token if valid + UserId *string `json:"user_id"` + + // Valid True if token is valid + Valid *bool `json:"valid,omitempty"` +} + +func (response PostAuthVerifyToken200JSONResponse) VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(ctx context.Context, request PostAuthSignInRequestObject) (PostAuthSignInResponseObject, error) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(ctx context.Context, request PostAuthVerifyTokenRequestObject) (PostAuthVerifyTokenResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// PostAuthSignIn operation middleware +func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { + var request PostAuthSignInRequestObject + + var body PostAuthSignInJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignIn(ctx, request.(PostAuthSignInRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignIn") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignInResponseObject); ok { + if err := validResponse.VisitPostAuthSignInResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthSignUp operation middleware +func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { + var request PostAuthSignUpRequestObject + + var body PostAuthSignUpJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignUp(ctx, request.(PostAuthSignUpRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignUp") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignUpResponseObject); ok { + if err := validResponse.VisitPostAuthSignUpResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthVerifyToken operation middleware +func (sh *strictHandler) PostAuthVerifyToken(ctx *gin.Context) { + var request PostAuthVerifyTokenRequestObject + + var body PostAuthVerifyTokenJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthVerifyToken(ctx, request.(PostAuthVerifyTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthVerifyToken") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthVerifyTokenResponseObject); ok { + if err := validResponse.VisitPostAuthVerifyTokenResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/auth/auth/auth.gen.go b/auth/auth/auth.gen.go new file mode 100644 index 0000000..12b6622 --- /dev/null +++ b/auth/auth/auth.gen.go @@ -0,0 +1,329 @@ +// Package oapi_auth provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +package oapi_auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" +) + +// PostAuthSignInJSONBody defines parameters for PostAuthSignIn. +type PostAuthSignInJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthSignUpJSONBody defines parameters for PostAuthSignUp. +type PostAuthSignUpJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthVerifyTokenJSONBody defines parameters for PostAuthVerifyToken. +type PostAuthVerifyTokenJSONBody struct { + // Token JWT token to validate + Token string `json:"token"` +} + +// PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. +type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody + +// PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. +type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody + +// PostAuthVerifyTokenJSONRequestBody defines body for PostAuthVerifyToken for application/json ContentType. +type PostAuthVerifyTokenJSONRequestBody PostAuthVerifyTokenJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(c *gin.Context) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(c *gin.Context) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(c *gin.Context) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// PostAuthSignIn operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignIn(c) +} + +// PostAuthSignUp operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignUp(c) +} + +// PostAuthVerifyToken operation middleware +func (siw *ServerInterfaceWrapper) PostAuthVerifyToken(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthVerifyToken(c) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) + router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) + router.POST(options.BaseURL+"/auth/verify-token", wrapper.PostAuthVerifyToken) +} + +type PostAuthSignInRequestObject struct { + Body *PostAuthSignInJSONRequestBody +} + +type PostAuthSignInResponseObject interface { + VisitPostAuthSignInResponse(w http.ResponseWriter) error +} + +type PostAuthSignIn200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + + // Token JWT token to access protected endpoints + Token *string `json:"token"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthSignUpRequestObject struct { + Body *PostAuthSignUpJSONRequestBody +} + +type PostAuthSignUpResponseObject interface { + VisitPostAuthSignUpResponse(w http.ResponseWriter) error +} + +type PostAuthSignUp200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthVerifyTokenRequestObject struct { + Body *PostAuthVerifyTokenJSONRequestBody +} + +type PostAuthVerifyTokenResponseObject interface { + VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error +} + +type PostAuthVerifyToken200JSONResponse struct { + // Error Error message if token is invalid + Error *string `json:"error"` + + // UserId User ID extracted from token if valid + UserId *string `json:"user_id"` + + // Valid True if token is valid + Valid *bool `json:"valid,omitempty"` +} + +func (response PostAuthVerifyToken200JSONResponse) VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(ctx context.Context, request PostAuthSignInRequestObject) (PostAuthSignInResponseObject, error) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(ctx context.Context, request PostAuthVerifyTokenRequestObject) (PostAuthVerifyTokenResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// PostAuthSignIn operation middleware +func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { + var request PostAuthSignInRequestObject + + var body PostAuthSignInJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignIn(ctx, request.(PostAuthSignInRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignIn") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignInResponseObject); ok { + if err := validResponse.VisitPostAuthSignInResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthSignUp operation middleware +func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { + var request PostAuthSignUpRequestObject + + var body PostAuthSignUpJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignUp(ctx, request.(PostAuthSignUpRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignUp") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignUpResponseObject); ok { + if err := validResponse.VisitPostAuthSignUpResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthVerifyToken operation middleware +func (sh *strictHandler) PostAuthVerifyToken(ctx *gin.Context) { + var request PostAuthVerifyTokenRequestObject + + var body PostAuthVerifyTokenJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthVerifyToken(ctx, request.(PostAuthVerifyTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthVerifyToken") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthVerifyTokenResponseObject); ok { + if err := validResponse.VisitPostAuthVerifyTokenResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/auth/oapi-auth-codegen.yaml b/auth/oapi-auth-codegen.yaml new file mode 100644 index 0000000..6792391 --- /dev/null +++ b/auth/oapi-auth-codegen.yaml @@ -0,0 +1,6 @@ +package: auth +generate: + strict-server: true + gin-server: true + models: true +output: auth/auth.gen.go \ No newline at end of file diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml new file mode 100644 index 0000000..7ffc60e --- /dev/null +++ b/auth/openapi-auth.yaml @@ -0,0 +1,112 @@ +openapi: 3.1.0 +info: + title: Auth Service + version: 1.0.0 + +paths: + /auth/sign-up: + post: + summary: Sign up a new user + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [nickname, pass] + properties: + nickname: + type: string + pass: + type: string + format: password + responses: + "200": + description: Sign-up result + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + error: + type: string + nullable: true + user_id: + type: string + nullable: true + + /auth/sign-in: + post: + summary: Sign in a user and return JWT + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [nickname, pass] + properties: + nickname: + type: string + pass: + type: string + format: password + responses: + "200": + description: Sign-in result with JWT + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + error: + type: string + nullable: true + user_id: + type: string + nullable: true + token: + type: string + description: JWT token to access protected endpoints + nullable: true + + /auth/verify-token: + post: + summary: Verify JWT validity + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: + type: string + description: JWT token to validate + responses: + "200": + description: Token validation result + content: + application/json: + schema: + type: object + properties: + valid: + type: boolean + description: True if token is valid + user_id: + type: string + nullable: true + description: User ID extracted from token if valid + error: + type: string + nullable: true + description: Error message if token is invalid \ No newline at end of file diff --git a/go.mod b/go.mod index 80a9ab1..bf73121 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/jackc/pgx/v5 v5.7.6 github.com/oapi-codegen/runtime v1.1.2 github.com/pelletier/go-toml/v2 v2.2.4 diff --git a/go.sum b/go.sum index 121ca40..8f46514 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go new file mode 100644 index 0000000..ca72192 --- /dev/null +++ b/modules/auth/handlers/handlers.go @@ -0,0 +1,108 @@ +package handlers + +import ( + "context" + "fmt" + auth "nyanimedb/auth" + sqlc "nyanimedb/sql" + "strconv" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var secretKey = []byte("my_secret_key") + +func generateToken(userID string) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(time.Hour * 24).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(secretKey) +} + +var UserDb = make(map[string]string) //TEMP + +type Server struct { + db *sqlc.Queries +} + +func NewServer(db *sqlc.Queries) Server { + return Server{db: db} +} + +func parseInt64(s string) (int32, error) { + i, err := strconv.ParseInt(s, 10, 64) + return int32(i), err +} + +func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { + err := "" + success := true + t, _ := generateToken(req.Body.Nickname) + + UserDb[req.Body.Nickname] = req.Body.Pass + + return auth.PostAuthSignIn200JSONResponse{ + Error: &err, + Success: &success, + UserId: &req.Body.Nickname, + Token: &t, + }, nil +} + +func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { + err := "" + success := true + UserDb[req.Body.Nickname] = req.Body.Pass + + return auth.PostAuthSignUp200JSONResponse{ + Error: &err, + Success: &success, + UserId: &req.Body.Nickname, + }, nil +} + +func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { + valid := false + var userID *string + var errStr *string + + token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return secretKey, nil + }) + + if err != nil { + e := err.Error() + errStr = &e + return auth.PostAuthVerifyToken200JSONResponse{ + Valid: &valid, + UserId: userID, + Error: errStr, + }, nil + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + if uid, ok := claims["user_id"].(string); ok { + valid = true + userID = &uid + } else { + e := "user_id not found in token" + errStr = &e + } + } else { + e := "invalid token claims" + errStr = &e + } + + return auth.PostAuthVerifyToken200JSONResponse{ + Valid: &valid, + UserId: userID, + Error: errStr, + }, nil +} diff --git a/modules/auth/main.go b/modules/auth/main.go new file mode 100644 index 0000000..c001e8b --- /dev/null +++ b/modules/auth/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "time" + + auth "nyanimedb/auth" + handlers "nyanimedb/modules/auth/handlers" + sqlc "nyanimedb/sql" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +var AppConfig Config + +func main() { + r := gin.Default() + + var queries *sqlc.Queries = nil + + server := handlers.NewServer(queries) + + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + auth.RegisterHandlers(r, auth.NewStrictHandler( + server, + []auth.StrictMiddlewareFunc{}, + )) + + r.Run(":8082") +} diff --git a/modules/auth/types.go b/modules/auth/types.go new file mode 100644 index 0000000..038b179 --- /dev/null +++ b/modules/auth/types.go @@ -0,0 +1,6 @@ +package main + +type Config struct { + JwtPrivateKey string + LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` +} From 69e8a8dc79b1d696c548400e5abbb225105356b0 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sun, 23 Nov 2025 03:32:58 +0300 Subject: [PATCH 098/224] feat: use SetCookie for access and refresh tokens --- auth/auth.gen.go | 104 ++------------- auth/openapi-auth.yaml | 121 +++++++++++++----- modules/auth/handlers/handlers.go | 203 +++++++++++++++++++++--------- 3 files changed, 246 insertions(+), 182 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index 1f16575..adb2b06 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -25,21 +25,12 @@ type PostAuthSignUpJSONBody struct { Pass string `json:"pass"` } -// PostAuthVerifyTokenJSONBody defines parameters for PostAuthVerifyToken. -type PostAuthVerifyTokenJSONBody struct { - // Token JWT token to validate - Token string `json:"token"` -} - // PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody // PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody -// PostAuthVerifyTokenJSONRequestBody defines body for PostAuthVerifyToken for application/json ContentType. -type PostAuthVerifyTokenJSONRequestBody PostAuthVerifyTokenJSONBody - // ServerInterface represents all server handlers. type ServerInterface interface { // Sign in a user and return JWT @@ -48,9 +39,6 @@ type ServerInterface interface { // Sign up a new user // (POST /auth/sign-up) PostAuthSignUp(c *gin.Context) - // Verify JWT validity - // (POST /auth/verify-token) - PostAuthVerifyToken(c *gin.Context) } // ServerInterfaceWrapper converts contexts to parameters. @@ -88,19 +76,6 @@ func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { siw.Handler.PostAuthSignUp(c) } -// PostAuthVerifyToken operation middleware -func (siw *ServerInterfaceWrapper) PostAuthVerifyToken(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostAuthVerifyToken(c) -} - // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -130,7 +105,6 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) - router.POST(options.BaseURL+"/auth/verify-token", wrapper.PostAuthVerifyToken) } type PostAuthSignInRequestObject struct { @@ -144,10 +118,7 @@ type PostAuthSignInResponseObject interface { type PostAuthSignIn200JSONResponse struct { Error *string `json:"error"` Success *bool `json:"success,omitempty"` - - // Token JWT token to access protected endpoints - Token *string `json:"token"` - UserId *string `json:"user_id"` + UserId *string `json:"user_id"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { @@ -157,6 +128,17 @@ func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http return json.NewEncoder(w).Encode(response) } +type PostAuthSignIn401JSONResponse struct { + Error *string `json:"error,omitempty"` +} + +func (response PostAuthSignIn401JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + type PostAuthSignUpRequestObject struct { Body *PostAuthSignUpJSONRequestBody } @@ -178,32 +160,6 @@ func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http return json.NewEncoder(w).Encode(response) } -type PostAuthVerifyTokenRequestObject struct { - Body *PostAuthVerifyTokenJSONRequestBody -} - -type PostAuthVerifyTokenResponseObject interface { - VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error -} - -type PostAuthVerifyToken200JSONResponse struct { - // Error Error message if token is invalid - Error *string `json:"error"` - - // UserId User ID extracted from token if valid - UserId *string `json:"user_id"` - - // Valid True if token is valid - Valid *bool `json:"valid,omitempty"` -} - -func (response PostAuthVerifyToken200JSONResponse) VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Sign in a user and return JWT @@ -212,9 +168,6 @@ type StrictServerInterface interface { // Sign up a new user // (POST /auth/sign-up) PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) - // Verify JWT validity - // (POST /auth/verify-token) - PostAuthVerifyToken(ctx context.Context, request PostAuthVerifyTokenRequestObject) (PostAuthVerifyTokenResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -294,36 +247,3 @@ func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { ctx.Error(fmt.Errorf("unexpected response type: %T", response)) } } - -// PostAuthVerifyToken operation middleware -func (sh *strictHandler) PostAuthVerifyToken(ctx *gin.Context) { - var request PostAuthVerifyTokenRequestObject - - var body PostAuthVerifyTokenJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostAuthVerifyToken(ctx, request.(PostAuthVerifyTokenRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostAuthVerifyToken") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostAuthVerifyTokenResponseObject); ok { - if err := validResponse.VisitPostAuthVerifyTokenResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 7ffc60e..b9ce76f 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.0 +openapi: 3.1.1 info: title: Auth Service version: 1.0.0 @@ -58,6 +58,14 @@ paths: responses: "200": description: Sign-in result with JWT + # headers: + # Set-Cookie: + # schema: + # type: array + # items: + # type: string + # explode: true + # style: simple content: application/json: schema: @@ -71,42 +79,89 @@ paths: user_id: type: string nullable: true - token: - type: string - description: JWT token to access protected endpoints - nullable: true - - /auth/verify-token: - post: - summary: Verify JWT validity - tags: [Auth] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [token] - properties: - token: - type: string - description: JWT token to validate - responses: - "200": - description: Token validation result + "401": + description: Access denied due to invalid credentials content: application/json: schema: type: object properties: - valid: - type: boolean - description: True if token is valid - user_id: - type: string - nullable: true - description: User ID extracted from token if valid error: type: string - nullable: true - description: Error message if token is invalid \ No newline at end of file + example: "Access denied" + # /auth/verify-token: + # post: + # summary: Verify JWT validity + # tags: [Auth] + # requestBody: + # required: true + # content: + # application/json: + # schema: + # type: object + # required: [token] + # properties: + # token: + # type: string + # description: JWT token to validate + # responses: + # "200": + # description: Token validation result + # content: + # application/json: + # schema: + # type: object + # properties: + # valid: + # type: boolean + # description: True if token is valid + # user_id: + # type: string + # nullable: true + # description: User ID extracted from token if valid + # error: + # type: string + # nullable: true + # description: Error message if token is invalid + # /auth/refresh-token: + # post: + # summary: Refresh JWT using a refresh token + # tags: [Auth] + # requestBody: + # required: true + # content: + # application/json: + # schema: + # type: object + # required: [refresh_token] + # properties: + # refresh_token: + # type: string + # description: JWT refresh token obtained from sign-in + # responses: + # "200": + # description: New access (and optionally refresh) token + # content: + # application/json: + # schema: + # type: object + # properties: + # valid: + # type: boolean + # description: True if refresh token was valid + # user_id: + # type: string + # nullable: true + # description: User ID extracted from refresh token + # access_token: + # type: string + # description: New access token + # nullable: true + # refresh_token: + # type: string + # description: New refresh token (optional) + # nullable: true + # error: + # type: string + # nullable: true + # description: Error message if refresh token is invalid diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index ca72192..9b9b0d3 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -3,27 +3,21 @@ package handlers import ( "context" "fmt" + "log" + "net/http" auth "nyanimedb/auth" sqlc "nyanimedb/sql" "strconv" "time" + "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" ) -var secretKey = []byte("my_secret_key") +var accessSecret = []byte("my_access_secret_key") +var refreshSecret = []byte("my_refresh_secret_key") -func generateToken(userID string) (string, error) { - claims := jwt.MapClaims{ - "user_id": userID, - "exp": time.Now().Add(time.Hour * 24).Unix(), - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(secretKey) -} - -var UserDb = make(map[string]string) //TEMP +var UserDb = make(map[string]string) // TEMP: stores passwords type Server struct { db *sqlc.Queries @@ -38,19 +32,28 @@ func parseInt64(s string) (int32, error) { return int32(i), err } -func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { - err := "" - success := true - t, _ := generateToken(req.Body.Nickname) +func generateTokens(userID string) (accessToken string, refreshToken string, err error) { + accessClaims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(15 * time.Minute).Unix(), + } + at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + accessToken, err = at.SignedString(accessSecret) + if err != nil { + return "", "", err + } - UserDb[req.Body.Nickname] = req.Body.Pass + refreshClaims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), + } + rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) + refreshToken, err = rt.SignedString(refreshSecret) + if err != nil { + return "", "", err + } - return auth.PostAuthSignIn200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, - Token: &t, - }, nil + return accessToken, refreshToken, nil } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { @@ -65,44 +68,130 @@ func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpReque }, nil } -func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { - valid := false - var userID *string - var errStr *string +func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { + // ctx.SetCookie("122") + ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) + if !ok { + log.Print("failed to get gin context") + // TODO: change to 500 + return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") + } - token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method") - } - return secretKey, nil - }) + err := "" + success := true - if err != nil { - e := err.Error() - errStr = &e - return auth.PostAuthVerifyToken200JSONResponse{ - Valid: &valid, - UserId: userID, - Error: errStr, + pass, ok := UserDb[req.Body.Nickname] + if !ok || pass != req.Body.Pass { + e := "invalid credentials" + return auth.PostAuthSignIn401JSONResponse{ + Error: &e, }, nil } - if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { - if uid, ok := claims["user_id"].(string); ok { - valid = true - userID = &uid - } else { - e := "user_id not found in token" - errStr = &e - } - } else { - e := "invalid token claims" - errStr = &e - } + accessToken, refreshToken, _ := generateTokens(req.Body.Nickname) - return auth.PostAuthVerifyToken200JSONResponse{ - Valid: &valid, - UserId: userID, - Error: errStr, - }, nil + ginCtx.SetSameSite(http.SameSiteStrictMode) + ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true) + ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true) + + // Return access token; refresh token can be returned in response or HttpOnly cookie + result := auth.PostAuthSignIn200JSONResponse{ + Error: &err, + Success: &success, + UserId: &req.Body.Nickname, + } + return result, nil } + +// func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { +// valid := false +// var userID *string +// var errStr *string + +// token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { +// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { +// return nil, fmt.Errorf("unexpected signing method") +// } +// return accessSecret, nil +// }) + +// if err != nil { +// e := err.Error() +// errStr = &e +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { +// if uid, ok := claims["user_id"].(string); ok { +// valid = true +// userID = &uid +// } else { +// e := "user_id not found in token" +// errStr = &e +// } +// } else { +// e := "invalid token claims" +// errStr = &e +// } + +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// func (s Server) PostAuthRefreshToken(ctx context.Context, req auth.PostAuthRefreshTokenRequestObject) (auth.PostAuthRefreshTokenResponseObject, error) { +// valid := false +// var userID *string +// var errStr *string + +// token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { +// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { +// return nil, fmt.Errorf("unexpected signing method") +// } +// return refreshSecret, nil +// }) + +// if err != nil { +// e := err.Error() +// errStr = &e +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { +// if uid, ok := claims["user_id"].(string); ok { +// // Refresh token is valid, generate new tokens +// newAccessToken, newRefreshToken, _ := generateTokens(uid) +// valid = true +// userID = &uid +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: nil, +// Token: &newAccessToken, // return new access token +// // optionally return newRefreshToken as well +// }, nil +// } else { +// e := "user_id not found in refresh token" +// errStr = &e +// } +// } else { +// e := "invalid refresh token claims" +// errStr = &e +// } + +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } From c500116916cc9c3a6299d7c887386575066db829 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sun, 23 Nov 2025 03:57:35 +0300 Subject: [PATCH 099/224] feat: added login page --- auth/openapi-auth.yaml | 3 + modules/frontend/src/App.tsx | 3 + modules/frontend/src/api/core/OpenAPI.ts | 2 +- modules/frontend/src/auth/core/ApiError.ts | 25 ++ .../src/auth/core/ApiRequestOptions.ts | 17 + modules/frontend/src/auth/core/ApiResult.ts | 11 + .../src/auth/core/CancelablePromise.ts | 131 +++++++ modules/frontend/src/auth/core/OpenAPI.ts | 32 ++ modules/frontend/src/auth/core/request.ts | 323 ++++++++++++++++++ modules/frontend/src/auth/index.ts | 10 + .../frontend/src/auth/services/AuthService.ts | 58 ++++ .../src/pages/LoginPage/LoginPage.tsx | 116 +++++++ 12 files changed, 730 insertions(+), 1 deletion(-) create mode 100644 modules/frontend/src/auth/core/ApiError.ts create mode 100644 modules/frontend/src/auth/core/ApiRequestOptions.ts create mode 100644 modules/frontend/src/auth/core/ApiResult.ts create mode 100644 modules/frontend/src/auth/core/CancelablePromise.ts create mode 100644 modules/frontend/src/auth/core/OpenAPI.ts create mode 100644 modules/frontend/src/auth/core/request.ts create mode 100644 modules/frontend/src/auth/index.ts create mode 100644 modules/frontend/src/auth/services/AuthService.ts create mode 100644 modules/frontend/src/pages/LoginPage/LoginPage.tsx diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index b9ce76f..913c000 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -3,6 +3,9 @@ info: title: Auth Service version: 1.0.0 +servers: + - url: /auth + paths: /auth/sign-up: post: diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 909ad6c..5a25313 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -2,6 +2,7 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./pages/UserPage/UserPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; +import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; const App: React.FC = () => { @@ -10,6 +11,8 @@ const App: React.FC = () => { <Router> <Header username={username} /> <Routes> + <Route path="/login" element={<LoginPage />} /> {/* <-- маршрут для логина */} + <Route path="/signup" element={<LoginPage />} /> {/* <-- можно использовать тот же компонент для регистрации */} <Route path="/users/:id" element={<UserPage />} /> <Route path="/titles" element={<TitlesPage />} /> </Routes> diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 185e5c3..6ce873e 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: '/api/v1', + BASE: 'http://10.1.0.65:8081/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/auth/core/ApiError.ts b/modules/frontend/src/auth/core/ApiError.ts new file mode 100644 index 0000000..ec7b16a --- /dev/null +++ b/modules/frontend/src/auth/core/ApiError.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: any; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} diff --git a/modules/frontend/src/auth/core/ApiRequestOptions.ts b/modules/frontend/src/auth/core/ApiRequestOptions.ts new file mode 100644 index 0000000..93143c3 --- /dev/null +++ b/modules/frontend/src/auth/core/ApiRequestOptions.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiRequestOptions = { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record<string, any>; + readonly cookies?: Record<string, any>; + readonly headers?: Record<string, any>; + readonly query?: Record<string, any>; + readonly formData?: Record<string, any>; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record<number, string>; +}; diff --git a/modules/frontend/src/auth/core/ApiResult.ts b/modules/frontend/src/auth/core/ApiResult.ts new file mode 100644 index 0000000..ee1126e --- /dev/null +++ b/modules/frontend/src/auth/core/ApiResult.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiResult = { + readonly url: string; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly body: any; +}; diff --git a/modules/frontend/src/auth/core/CancelablePromise.ts b/modules/frontend/src/auth/core/CancelablePromise.ts new file mode 100644 index 0000000..d70de92 --- /dev/null +++ b/modules/frontend/src/auth/core/CancelablePromise.ts @@ -0,0 +1,131 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export class CancelError extends Error { + + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise<T> implements Promise<T> { + #isResolved: boolean; + #isRejected: boolean; + #isCancelled: boolean; + readonly #cancelHandlers: (() => void)[]; + readonly #promise: Promise<T>; + #resolve?: (value: T | PromiseLike<T>) => void; + #reject?: (reason?: any) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike<T>) => void, + reject: (reason?: any) => void, + onCancel: OnCancel + ) => void + ) { + this.#isResolved = false; + this.#isRejected = false; + this.#isCancelled = false; + this.#cancelHandlers = []; + this.#promise = new Promise<T>((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + + const onResolve = (value: T | PromiseLike<T>): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isResolved = true; + if (this.#resolve) this.#resolve(value); + }; + + const onReject = (reason?: any): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isRejected = true; + if (this.#reject) this.#reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this.#isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this.#isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this.#isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + get [Symbol.toStringTag]() { + return "Cancellable Promise"; + } + + public then<TResult1 = T, TResult2 = never>( + onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null + ): Promise<TResult1 | TResult2> { + return this.#promise.then(onFulfilled, onRejected); + } + + public catch<TResult = never>( + onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null + ): Promise<T | TResult> { + return this.#promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise<T> { + return this.#promise.finally(onFinally); + } + + public cancel(): void { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isCancelled = true; + if (this.#cancelHandlers.length) { + try { + for (const cancelHandler of this.#cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this.#cancelHandlers.length = 0; + if (this.#reject) this.#reject(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this.#isCancelled; + } +} diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts new file mode 100644 index 0000000..27bd73f --- /dev/null +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -0,0 +1,32 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; + +type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; +type Headers = Record<string, string>; + +export type OpenAPIConfig = { + BASE: string; + VERSION: string; + WITH_CREDENTIALS: boolean; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + TOKEN?: string | Resolver<string> | undefined; + USERNAME?: string | Resolver<string> | undefined; + PASSWORD?: string | Resolver<string> | undefined; + HEADERS?: Headers | Resolver<Headers> | undefined; + ENCODE_PATH?: ((path: string) => string) | undefined; +}; + +export const OpenAPI: OpenAPIConfig = { + BASE: 'http://127.0.0.1:8082', + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'include', + TOKEN: undefined, + USERNAME: undefined, + PASSWORD: undefined, + HEADERS: undefined, + ENCODE_PATH: undefined, +}; diff --git a/modules/frontend/src/auth/core/request.ts b/modules/frontend/src/auth/core/request.ts new file mode 100644 index 0000000..1dc6fef --- /dev/null +++ b/modules/frontend/src/auth/core/request.ts @@ -0,0 +1,323 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; +import FormData from 'form-data'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => { + return value !== undefined && value !== null; +}; + +export const isString = (value: any): value is string => { + return typeof value === 'string'; +}; + +export const isStringWithValue = (value: any): value is string => { + return isString(value) && value !== ''; +}; + +export const isBlob = (value: any): value is Blob => { + return ( + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +export const isFormData = (value: any): value is FormData => { + return value instanceof FormData; +}; + +export const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + +export const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +export const getQueryString = (params: Record<string, any>): string => { + const qs: string[] = []; + + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach(v => { + process(key, v); + }); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; + + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); + + if (qs.length > 0) { + return `?${qs.join('&')}`; + } + + return ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; +}; + +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; + +export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => { + if (typeof resolver === 'function') { + return (resolver as Resolver<T>)(options); + } + return resolver; +}; + +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) + .filter(([_, value]) => isDefined(value)) + .reduce((headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), {} as Record<string, string>); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return headers; +}; + +export const getRequestBody = (options: ApiRequestOptions): any => { + if (options.body) { + return options.body; + } + return undefined; +}; + +export const sendRequest = async <T>( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Record<string, string>, + onCancel: OnCancel, + axiosClient: AxiosInstance +): Promise<AxiosResponse<T>> => { + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, + headers, + data: body ?? formData, + method: options.method, + withCredentials: config.WITH_CREDENTIALS, + withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, + cancelToken: source.token, + }; + + onCancel(() => source.cancel('The user aborted a request.')); + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError<T>; + if (axiosError.response) { + return axiosError.response; + } + throw error; + } +}; + +export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; + } + } + return undefined; +}; + +export const getResponseBody = (response: AxiosResponse<any>): any => { + if (response.status !== 204) { + return response.data; + } + return undefined; +}; + +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record<number, string> = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError(options, result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @param axiosClient The axios client instance to use + * @returns CancelablePromise<T> + * @throws ApiError + */ +export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + if (!onCancel.isCancelled) { + const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient); + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/modules/frontend/src/auth/index.ts b/modules/frontend/src/auth/index.ts new file mode 100644 index 0000000..b0989c4 --- /dev/null +++ b/modules/frontend/src/auth/index.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export { ApiError } from './core/ApiError'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI } from './core/OpenAPI'; +export type { OpenAPIConfig } from './core/OpenAPI'; + +export { AuthService } from './services/AuthService'; diff --git a/modules/frontend/src/auth/services/AuthService.ts b/modules/frontend/src/auth/services/AuthService.ts new file mode 100644 index 0000000..bab9c77 --- /dev/null +++ b/modules/frontend/src/auth/services/AuthService.ts @@ -0,0 +1,58 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class AuthService { + /** + * Sign up a new user + * @param requestBody + * @returns any Sign-up result + * @throws ApiError + */ + public static postAuthSignUp( + requestBody: { + nickname: string; + pass: string; + }, + ): CancelablePromise<{ + success?: boolean; + error?: string | null; + user_id?: string | null; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/sign-up', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Sign in a user and return JWT + * @param requestBody + * @returns any Sign-in result with JWT + * @throws ApiError + */ + public static postAuthSignIn( + requestBody: { + nickname: string; + pass: string; + }, + ): CancelablePromise<{ + success?: boolean; + error?: string | null; + user_id?: string | null; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/sign-in', + body: requestBody, + mediaType: 'application/json', + errors: { + 401: `Access denied due to invalid credentials`, + }, + }); + } +} diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx new file mode 100644 index 0000000..dcd6965 --- /dev/null +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -0,0 +1,116 @@ +import React, { useState } from "react"; +import { AuthService } from "../../auth/services/AuthService"; +import { useNavigate } from "react-router-dom"; + +export const LoginPage: React.FC = () => { + const navigate = useNavigate(); + const [isLogin, setIsLogin] = useState(true); // true = login, false = signup + const [nickname, setNickname] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + if (isLogin) { + const res = await AuthService.postAuthSignIn({ nickname, pass: password }); + if (res.success) { + // TODO: сохранить JWT в localStorage/cookie + console.log("Logged in user id:", res.user_id); + navigate("/"); // редирект после успешного входа + } else { + setError(res.error || "Login failed"); + } + } else { + const res = await AuthService.postAuthSignUp({ nickname, pass: password }); + if (res.success) { + console.log("User signed up with id:", res.user_id); + setIsLogin(true); // переключаемся на login после регистрации + } else { + setError(res.error || "Sign up failed"); + } + } + } catch (err: any) { + console.error(err); + setError(err?.message || "Something went wrong"); + } finally { + setLoading(false); + } + }; + + return ( + <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> + <div className="max-w-md w-full bg-white shadow-md rounded-lg p-8"> + <h2 className="text-2xl font-bold mb-6 text-center"> + {isLogin ? "Login" : "Sign Up"} + </h2> + + {error && <div className="text-red-600 mb-4">{error}</div>} + + <form onSubmit={handleSubmit} className="space-y-4"> + <div> + <label className="block text-sm font-medium text-gray-700 mb-1"> + Nickname + </label> + <input + type="text" + value={nickname} + onChange={(e) => setNickname(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> + </div> + + <div> + <label className="block text-sm font-medium text-gray-700 mb-1"> + Password + </label> + <input + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> + </div> + + <button + type="submit" + disabled={loading} + className="w-full bg-blue-600 text-white py-2 rounded-lg font-semibold hover:bg-blue-700 transition disabled:opacity-50" + > + {loading ? "Please wait..." : isLogin ? "Login" : "Sign Up"} + </button> + </form> + + <div className="mt-4 text-center text-sm text-gray-600"> + {isLogin ? ( + <> + Don't have an account?{" "} + <button + onClick={() => setIsLogin(false)} + className="text-blue-600 hover:underline" + > + Sign Up + </button> + </> + ) : ( + <> + Already have an account?{" "} + <button + onClick={() => setIsLogin(true)} + className="text-blue-600 hover:underline" + > + Login + </button> + </> + )} + </div> + </div> + </div> + ); +}; From 0942df1fa404a572d20e0110867a2c2d11493fd9 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sun, 23 Nov 2025 04:01:29 +0300 Subject: [PATCH 100/224] feat: auth container --- deploy/docker-compose.yml | 12 ++++++++++++ modules/frontend/nginx-default.conf | 9 +++++++++ modules/frontend/src/auth/core/OpenAPI.ts | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1a96253..7f53da5 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -37,6 +37,18 @@ services: - "8080:8080" depends_on: - postgres + + nyanimedb-auth: + image: meowgit.nekoea.red/nihonium/nyanimedb-auth:latest + container_name: nyanimedb-auth + restart: always + environment: + LOG_LEVEL: ${LOG_LEVEL} + DATABASE_URL: ${DATABASE_URL} + ports: + - "8082:8082" + depends_on: + - postgres nyanimedb-frontend: image: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest diff --git a/modules/frontend/nginx-default.conf b/modules/frontend/nginx-default.conf index a538968..c3a851f 100644 --- a/modules/frontend/nginx-default.conf +++ b/modules/frontend/nginx-default.conf @@ -19,6 +19,15 @@ server { proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } + location /auth/ { + rewrite ^/auth/(.*)$ /$1 break; + proxy_pass http://nyanimedb-auth:8082/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } #error_page 404 /404.html; error_page 500 502 503 504 /50x.html; diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts index 27bd73f..2d0edf8 100644 --- a/modules/frontend/src/auth/core/OpenAPI.ts +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://127.0.0.1:8082', + BASE: '/auth', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', From 0c94930bca1cbf63623291b43acde17a5a10579e Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sun, 23 Nov 2025 04:02:23 +0300 Subject: [PATCH 101/224] fix: useUnionTypes for TS oapi codegen --- deploy/generate.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/generate.sh b/deploy/generate.sh index d7d94a2..29587cf 100644 --- a/deploy/generate.sh +++ b/deploy/generate.sh @@ -1,3 +1,3 @@ -npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios +npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios --useUnionTypes oapi-codegen --config=api/oapi-codegen.yaml .\api\openapi.yaml sqlc generate -f .\sql\sqlc.yaml \ No newline at end of file From e792d5780b67d210a60e71a2566848c247384042 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 05:14:23 +0300 Subject: [PATCH 102/224] feat: GetUsertitles implemented --- api/_build/openapi.yaml | 25 +++- api/api.gen.go | 36 ++++- api/paths/users-id-titles.yaml | 25 +++- modules/backend/handlers/common.go | 64 +++++---- modules/backend/handlers/titles.go | 110 ++++++++-------- modules/backend/handlers/users.go | 205 ++++++++++++++++++++--------- modules/backend/queries.sql | 72 ++++++---- sql/queries.sql.go | 202 ++++++++++++++-------------- 8 files changed, 456 insertions(+), 283 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 722b7af..b3eacb4 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -146,11 +146,17 @@ paths: summary: Get user titles parameters: - $ref: '#/components/parameters/cursor' + - $ref: '#/components/parameters/title_sort' - in: path name: user_id required: true schema: type: string + - in: query + name: sort_forward + schema: + type: boolean + default: true - in: query name: word schema: @@ -173,6 +179,11 @@ paths: schema: type: number format: double + - in: query + name: my_rate + schema: + type: integer + format: int32 - in: query name: release_year schema: @@ -199,9 +210,17 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/UserTitle' + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/UserTitle' + cursor: + $ref: '#/components/schemas/CursorObj' + required: + - data + - cursor '204': description: No titles found '400': diff --git a/api/api.gen.go b/api/api.gen.go index 54c8fc1..e1f94c2 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -183,13 +183,16 @@ type GetUsersUserIdParams struct { // GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. type GetUsersUserIdTitlesParams struct { - Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` - Word *string `form:"word,omitempty" json:"word,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` + SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` + Word *string `form:"word,omitempty" json:"word,omitempty"` // Status List of title statuses to filter Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"` WatchStatus *UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` + MyRate *int32 `form:"my_rate,omitempty" json:"my_rate,omitempty"` ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` @@ -803,6 +806,22 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { return } + // ------------- Optional query parameter "sort" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort", c.Request.URL.Query(), ¶ms.Sort) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "sort_forward" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort_forward", c.Request.URL.Query(), ¶ms.SortForward) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort_forward: %w", err), http.StatusBadRequest) + return + } + // ------------- Optional query parameter "word" ------------- err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) @@ -835,6 +854,14 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { return } + // ------------- Optional query parameter "my_rate" ------------- + + err = runtime.BindQueryParameter("form", true, false, "my_rate", c.Request.URL.Query(), ¶ms.MyRate) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter my_rate: %w", err), http.StatusBadRequest) + return + } + // ------------- Optional query parameter "release_year" ------------- err = runtime.BindQueryParameter("form", true, false, "release_year", c.Request.URL.Query(), ¶ms.ReleaseYear) @@ -1057,7 +1084,10 @@ type GetUsersUserIdTitlesResponseObject interface { VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error } -type GetUsersUserIdTitles200JSONResponse []UserTitle +type GetUsersUserIdTitles200JSONResponse struct { + Cursor CursorObj `json:"cursor"` + Data []UserTitle `json:"data"` +} func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 0788319..91b212d 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -2,11 +2,17 @@ get: summary: Get user titles parameters: - $ref: '../parameters/cursor.yaml' + - $ref: "../parameters/title_sort.yaml" - in: path name: user_id required: true schema: type: string + - in: query + name: sort_forward + schema: + type: boolean + default: true - in: query name: word schema: @@ -29,6 +35,11 @@ get: schema: type: number format: double + - in: query + name: my_rate + schema: + type: integer + format: int32 - in: query name: release_year schema: @@ -55,9 +66,17 @@ get: content: application/json: schema: - type: array - items: - $ref: '../schemas/UserTitle.yaml' + type: object + properties: + data: + type: array + items: + $ref: '../schemas/UserTitle.yaml' + cursor: + $ref: '../schemas/CursorObj.yaml' + required: + - data + - cursor '204': description: No titles found '400': diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 6618d49..89a3d59 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -17,7 +17,24 @@ func NewServer(db *sqlc.Queries) Server { return Server{db: db} } -func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi.Title, error) { +func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi.Title, error) { + + oapi_title := oapi.Title{ + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAired, + // EpisodesLen: &episodes_lens, + Id: title.ID, + // Poster: &oapi_image, + Rating: title.Rating, + RatingCount: title.RatingCount, + // ReleaseSeason: &release_season, + ReleaseYear: title.ReleaseYear, + // Studio: &oapi_studio, + // Tags: oapi_tag_names, + // TitleNames: title_names, + // TitleStatus: oapi_status, + // AdditionalProperties: + } title_names := make(map[string][]string, 0) err := json.Unmarshal(title.TitleNames, &title_names) @@ -25,10 +42,13 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi. return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) } - episodes_lens := make(map[string]float64, 0) - err = json.Unmarshal(title.EpisodesLen, &episodes_lens) - if err != nil { - return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err) + if title.EpisodesLen != nil && len(title.EpisodesLen) > 0 { + episodes_lens := make(map[string]float64, 0) + err = json.Unmarshal(title.EpisodesLen, &episodes_lens) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err) + } + oapi_title.EpisodesLen = &episodes_lens } oapi_tag_names := make(oapi.Tags, 0) @@ -38,17 +58,19 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi. } var oapi_studio oapi.Studio - - oapi_studio.Id = title.StudioID if title.StudioName != nil { oapi_studio.Name = *title.StudioName } - oapi_studio.Description = title.StudioDesc - if title.StudioIllustID != nil { - oapi_studio.Poster.Id = title.StudioIllustID - oapi_studio.Poster.ImagePath = title.StudioImagePath - oapi_studio.Poster.StorageType = &title.StudioStorageType + if title.StudioID != 0 { + oapi_studio.Id = title.StudioID + oapi_studio.Description = title.StudioDesc + if title.StudioIllustID != nil { + oapi_studio.Poster.Id = title.StudioIllustID + oapi_studio.Poster.ImagePath = title.StudioImagePath + oapi_studio.Poster.StorageType = &title.StudioStorageType + } } + oapi_title.Studio = &oapi_studio var oapi_image oapi.Image @@ -62,27 +84,13 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.SearchTitlesRow) (oapi. if title.ReleaseSeason != nil { release_season = oapi.ReleaseSeason(*title.ReleaseSeason) } + oapi_title.ReleaseSeason = &release_season oapi_status, err := TitleStatus2oapi(&title.TitleStatus) if err != nil { return oapi.Title{}, fmt.Errorf("TitleStatus2oapi: %v", err) } - oapi_title := oapi.Title{ - EpisodesAired: title.EpisodesAired, - EpisodesAll: title.EpisodesAired, - EpisodesLen: &episodes_lens, - Id: title.ID, - Poster: &oapi_image, - Rating: title.Rating, - RatingCount: title.RatingCount, - ReleaseSeason: &release_season, - ReleaseYear: title.ReleaseYear, - Studio: &oapi_studio, - Tags: oapi_tag_names, - TitleNames: title_names, - TitleStatus: oapi_status, - // AdditionalProperties: - } + oapi_title.TitleStatus = oapi_status return oapi_title, nil } diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 78323f6..d593314 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -26,25 +26,25 @@ type SqlcStatus struct { planned string } -func TitleStatus2Sqlc(s *[]oapi.TitleStatus) (*SqlcStatus, error) { - var sqlc_status SqlcStatus - if s == nil { - return &sqlc_status, nil - } - for _, t := range *s { - switch t { - case oapi.TitleStatusFinished: - sqlc_status.finished = "finished" - case oapi.TitleStatusOngoing: - sqlc_status.ongoing = "ongoing" - case oapi.TitleStatusPlanned: - sqlc_status.planned = "planned" - default: - return nil, fmt.Errorf("unexpected tittle status: %s", t) - } - } - return &sqlc_status, nil -} +// func TitleStatus2Sqlc(s *[]oapi.TitleStatus) (*SqlcStatus, error) { +// var sqlc_status SqlcStatus +// if s == nil { +// return &sqlc_status, nil +// } +// for _, t := range *s { +// switch t { +// case oapi.TitleStatusFinished: +// sqlc_status.finished = "finished" +// case oapi.TitleStatusOngoing: +// sqlc_status.ongoing = "ongoing" +// case oapi.TitleStatusPlanned: +// sqlc_status.planned = "planned" +// default: +// return nil, fmt.Errorf("unexpected tittle status: %s", t) +// } +// } +// return &sqlc_status, nil +// } func TitleStatus2oapi(s *sqlc.TitleStatusT) (*oapi.TitleStatus, error) { if s == nil { @@ -169,29 +169,8 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl log.Errorf("%v", err) return oapi.GetTitlesTitleId500Response{}, nil } - _sqlc_title := sqlc.SearchTitlesRow{ - ID: sqlc_title.ID, - StudioID: sqlc_title.StudioID, - PosterID: sqlc_title.PosterID, - TitleStatus: sqlc_title.TitleStatus, - Rating: sqlc_title.Rating, - RatingCount: sqlc_title.RatingCount, - ReleaseYear: sqlc_title.ReleaseYear, - ReleaseSeason: sqlc_title.ReleaseSeason, - Season: sqlc_title.Season, - EpisodesAired: sqlc_title.EpisodesAired, - EpisodesAll: sqlc_title.EpisodesAll, - EpisodesLen: sqlc_title.EpisodesLen, - TitleStorageType: sqlc_title.TitleStorageType, - TitleImagePath: sqlc_title.TitleImagePath, - TagNames: sqlc_title.TitleNames, - StudioName: sqlc_title.StudioName, - StudioIllustID: sqlc_title.StudioIllustID, - StudioDesc: sqlc_title.StudioDesc, - StudioStorageType: sqlc_title.StudioStorageType, - StudioImagePath: sqlc_title.StudioImagePath, - } - oapi_title, err = s.mapTitle(ctx, _sqlc_title) + + oapi_title, err = s.mapTitle(ctx, sqlc_title) if err != nil { log.Errorf("%v", err) return oapi.GetTitlesTitleId500Response{}, nil @@ -204,11 +183,6 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje opai_titles := make([]oapi.Title, 0) word := Word2Sqlc(request.Params.Word) - status, err := TitleStatus2Sqlc(request.Params.Status) - if err != nil { - log.Errorf("%v", err) - return oapi.GetTitles400Response{}, err - } season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) if err != nil { @@ -216,16 +190,22 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje return oapi.GetTitles400Response{}, err } + var statuses_sort []string + if request.Params.Status != nil { + for _, s := range *request.Params.Status { + ss := string(s) // s type is alias for string + statuses_sort = append(statuses_sort, ss) + } + } + params := sqlc.SearchTitlesParams{ Word: word, - Ongoing: status.ongoing, - Finished: status.finished, - Planned: status.planned, + TitleStatuses: statuses_sort, Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, - Forward: true, - SortBy: "id", + Forward: true, // default + SortBy: "id", // default Limit: request.Params.Limit, } @@ -235,6 +215,7 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje if request.Params.Sort != nil { params.SortBy = string(*request.Params.Sort) if request.Params.Cursor != nil { + // here we set CursorYear CursorID CursorRating fields err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) if err != nil { log.Errorf("%v", err) @@ -256,7 +237,30 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje for _, title := range titles { - t, err := s.mapTitle(ctx, title) + _title := sqlc.GetTitleByIDRow{ + ID: title.ID, + // StudioID: title.StudioID, + PosterID: title.PosterID, + TitleStatus: title.TitleStatus, + Rating: title.Rating, + RatingCount: title.RatingCount, + ReleaseYear: title.ReleaseYear, + ReleaseSeason: title.ReleaseSeason, + Season: title.Season, + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAll, + // EpisodesLen: title.EpisodesLen, + TitleStorageType: title.TitleStorageType, + TitleImagePath: title.TitleImagePath, + TagNames: title.TitleNames, + StudioName: title.StudioName, + // StudioIllustID: title.StudioIllustID, + // StudioDesc: title.StudioDesc, + // StudioStorageType: title.StudioStorageType, + // StudioImagePath: title.StudioImagePath, + } + + t, err := s.mapTitle(ctx, _title) if err != nil { log.Errorf("%v", err) return oapi.GetTitles500Response{}, nil diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 1ea2c1a..3223389 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -5,6 +5,7 @@ import ( "fmt" oapi "nyanimedb/api" sqlc "nyanimedb/sql" + "strconv" "time" "github.com/jackc/pgx/v5" @@ -53,8 +54,12 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil } -func sqlDate2oapi(p_date pgtype.Timestamptz) (time.Time, error) { - return time.Time{}, nil +func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { + if p_date.Valid { + t := p_date.Time + return &t + } + return nil } type SqlcUserStatus struct { @@ -64,26 +69,94 @@ type SqlcUserStatus struct { in_progress string } -func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) { - var sqlc_status SqlcUserStatus - if s == nil { - return &sqlc_status, nil +// func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) { +// var sqlc_status SqlcUserStatus +// if s == nil { +// return &sqlc_status, nil +// } +// for _, t := range *s { +// switch t { +// case oapi.UserTitleStatusFinished: +// sqlc_status.finished = "finished" +// case oapi.UserTitleStatusDropped: +// sqlc_status.dropped = "dropped" +// case oapi.UserTitleStatusPlanned: +// sqlc_status.planned = "planned" +// case oapi.UserTitleStatusInProgress: +// sqlc_status.in_progress = "in-progress" +// default: +// return nil, fmt.Errorf("unexpected tittle status: %s", t) +// } +// } +// return &sqlc_status, nil +// } + +func sql2usertitlestatus(s sqlc.UsertitleStatusT) (oapi.UserTitleStatus, error) { + var status oapi.UserTitleStatus + + switch status { + case "finished": + status = oapi.UserTitleStatusFinished + case "dropped": + status = oapi.UserTitleStatusDropped + case "planned": + status = oapi.UserTitleStatusPlanned + case "in-progress": + status = oapi.UserTitleStatusInProgress + default: + return status, fmt.Errorf("unexpected tittle status: %s", s) } - for _, t := range *s { - switch t { - case oapi.UserTitleStatusFinished: - sqlc_status.finished = "finished" - case oapi.UserTitleStatusDropped: - sqlc_status.dropped = "dropped" - case oapi.UserTitleStatusPlanned: - sqlc_status.planned = "planned" - case oapi.UserTitleStatusInProgress: - sqlc_status.in_progress = "in-progress" - default: - return nil, fmt.Errorf("unexpected tittle status: %s", t) - } + + return status, nil +} + +func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (oapi.UserTitle, error) { + + oapi_usertitle := oapi.UserTitle{ + Ctime: sqlDate2oapi(t.UserCtime), + Rate: t.UserRate, + ReviewId: t.ReviewID, + // Status: , + // Title: , + UserId: t.UserID, } - return &sqlc_status, nil + + status, err := sql2usertitlestatus(t.UsertitleStatus) + if err != nil { + return oapi_usertitle, fmt.Errorf("mapUsertitle: %v", err) + } + oapi_usertitle.Status = status + + _title := sqlc.GetTitleByIDRow{ + ID: t.ID, + // StudioID: title.StudioID, + PosterID: t.PosterID, + TitleStatus: t.TitleStatus, + Rating: t.Rating, + RatingCount: t.RatingCount, + ReleaseYear: t.ReleaseYear, + ReleaseSeason: t.ReleaseSeason, + Season: t.Season, + EpisodesAired: t.EpisodesAired, + EpisodesAll: t.EpisodesAll, + // EpisodesLen: title.EpisodesLen, + TitleStorageType: t.TitleStorageType, + TitleImagePath: t.TitleImagePath, + TagNames: t.TitleNames, + StudioName: t.StudioName, + // StudioIllustID: title.StudioIllustID, + // StudioDesc: title.StudioDesc, + // StudioStorageType: title.StudioStorageType, + // StudioImagePath: title.StudioImagePath, + } + + oapi_title, err := s.mapTitle(ctx, _title) + if err != nil { + return oapi_usertitle, fmt.Errorf("mapUsertitle: %v", err) + } + oapi_usertitle.Title = &oapi_title + + return oapi_usertitle, nil } func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) { @@ -91,11 +164,6 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU oapi_usertitles := make([]oapi.UserTitle, 0) word := Word2Sqlc(request.Params.Word) - status, err := TitleStatus2Sqlc(request.Params.Status) - if err != nil { - log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles400Response{}, err - } season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) if err != nil { @@ -103,23 +171,33 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU return oapi.GetUsersUserIdTitles400Response{}, err } + var statuses_sort []string + if request.Params.Status != nil { + for _, s := range *request.Params.Status { + ss := string(s) // s type is alias for string + statuses_sort = append(statuses_sort, ss) + } + } + + var watch_status []string + if request.Params.WatchStatus != nil { + for _, s := range *request.Params.WatchStatus { + ss := string(s) // s type is alias for string + watch_status = append(statuses_sort, ss) + } + } + params := sqlc.SearchUserTitlesParams{ - Forward: true, - SortBy: "id", - // CursorYear : - // CursorID *int64 `json:"cursor_id"` - // CursorRating *float64 `json:"cursor_rating"` - // Word *string `json:"word"` - // Ongoing string `json:"ongoing"` - // Planned string `json:"planned"` - // Dropped string `json:"dropped"` - // InProgress string `json:"in-progress"` - // Finished string `json:"finished"` - // Rate *int32 `json:"rate"` - // Rating *float64 `json:"rating"` - // ReleaseYear *int32 `json:"release_year"` - // ReleaseSeason *ReleaseSeasonT `json:"release_season"` - // Limit *int32 `json:"limit"` + Word: word, + TitleStatuses: statuses_sort, + UsertitleStatuses: watch_status, + Rating: request.Params.Rating, + Rate: request.Params.MyRate, + ReleaseYear: request.Params.ReleaseYear, + ReleaseSeason: season, + Forward: true, // default + SortBy: "id", // default + Limit: request.Params.Limit, } if request.Params.SortForward != nil { @@ -128,6 +206,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU if request.Params.Sort != nil { params.SortBy = string(*request.Params.Sort) if request.Params.Cursor != nil { + // here we set CursorYear CursorID CursorRating fields err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) if err != nil { log.Errorf("%v", err) @@ -144,30 +223,30 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU if len(titles) == 0 { return oapi.GetUsersUserIdTitles204Response{}, nil } + + var new_cursor oapi.CursorObj + for _, title := range titles { - _sqlc_title := sqlc.SearchTitlesRow{ - ID: sqlc_title.ID, - StudioID: sqlc_title.StudioID, - PosterID: sqlc_title.PosterID, - TitleStatus: sqlc_title.TitleStatus, - Rating: sqlc_title.Rating, - RatingCount: sqlc_title.RatingCount, - ReleaseYear: sqlc_title.ReleaseYear, - ReleaseSeason: sqlc_title.ReleaseSeason, - Season: sqlc_title.Season, - EpisodesAired: sqlc_title.EpisodesAired, - EpisodesAll: sqlc_title.EpisodesAll, - EpisodesLen: sqlc_title.EpisodesLen, - TitleStorageType: sqlc_title.TitleStorageType, - TitleImagePath: sqlc_title.TitleImagePath, - TagNames: sqlc_title.TitleNames, - StudioName: sqlc_title.StudioName, - StudioIllustID: sqlc_title.StudioIllustID, - StudioDesc: sqlc_title.StudioDesc, - StudioStorageType: sqlc_title.StudioStorageType, - StudioImagePath: sqlc_title.StudioImagePath, + + t, err := s.mapUsertitle(ctx, title) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles500Response{}, nil + } + oapi_usertitles = append(oapi_usertitles, t) + + new_cursor.Id = t.Title.Id + if request.Params.Sort != nil { + switch string(*request.Params.Sort) { + case "year": + tmp := fmt.Sprint(*t.Title.ReleaseYear) + new_cursor.Param = &tmp + case "rating": + tmp := strconv.FormatFloat(*t.Title.Rating, 'f', -1, 64) + new_cursor.Param = &tmp + } } } - return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil + return oapi.GetUsersUserIdTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil } diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index ee2a84a..9734ff2 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -101,25 +101,30 @@ GROUP BY -- name: SearchTitles :many SELECT - t.*, + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, i.storage_type::text as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), '[]'::jsonb )::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type::text as studio_storage_type, - si.image_path as studio_image_path + s.studio_name as studio_name FROM titles as t LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) WHERE CASE @@ -199,7 +204,7 @@ WHERE AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) GROUP BY - t.id, i.id, s.id, si.id + t.id, i.id, s.id ORDER BY CASE WHEN sqlc.arg('forward')::boolean THEN @@ -223,7 +228,17 @@ LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit -- name: SearchUserTitles :many SELECT - t.*, + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, u.user_id as user_id, u.status as usertitle_status, u.rate as user_rate, @@ -231,20 +246,18 @@ SELECT u.ctime as user_ctime, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type::text as studio_storage_type, - si.image_path as studio_image_path + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name -FROM usertitles as u -LEFT JOIN titles as t ON (u.title_id = t.id) -LEFT JOIN images as i ON (t.poster_id = i.id) -LEFT JOIN title_tags as tt ON (t.id = tt.title_id) -LEFT JOIN tags as g ON (tt.tag_id = g.id) -LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) WHERE CASE @@ -312,15 +325,24 @@ WHERE END ) - AND (u.status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('planned')::text, sqlc.arg('dropped')::text, sqlc.arg('in-progress')::text)) - AND (t.title_status::text IN (sqlc.arg('ongoing')::text, sqlc.arg('finished')::text, sqlc.arg('planned')::text)) + AND ( + -- Если массив пуст (NULL или []) — не фильтруем + cardinality(sqlc.arg('title_statuses')::text[]) = 0 + OR + t.title_status = ANY(sqlc.arg('title_statuses')::text[]) + ) + AND ( + cardinality(sqlc.arg('usertitle_statuses')::text[]) = 0 + OR + u.status = ANY(sqlc.arg('usertitle_statuses')::text[]) + ) AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) GROUP BY - t.id, i.id, s.id, si.id + t.id, i.id, s.id ORDER BY CASE WHEN sqlc.arg('forward')::boolean THEN diff --git a/sql/queries.sql.go b/sql/queries.sql.go index bf2f08a..f7535db 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -342,25 +342,30 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams const searchTitles = `-- name: SearchTitles :many SELECT - t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, i.storage_type::text as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), '[]'::jsonb )::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type::text as studio_storage_type, - si.image_path as studio_image_path + s.studio_name as studio_name FROM titles as t LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) WHERE CASE @@ -440,7 +445,7 @@ WHERE AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) GROUP BY - t.id, i.id, s.id, si.id + t.id, i.id, s.id ORDER BY CASE WHEN $1::boolean THEN @@ -478,27 +483,21 @@ type SearchTitlesParams struct { } type SearchTitlesRow struct { - ID int64 `json:"id"` - TitleNames json.RawMessage `json:"title_names"` - StudioID int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` - TitleStorageType string `json:"title_storage_type"` - TitleImagePath *string `json:"title_image_path"` - TagNames json.RawMessage `json:"tag_names"` - StudioName *string `json:"studio_name"` - StudioIllustID *int64 `json:"studio_illust_id"` - StudioDesc *string `json:"studio_desc"` - StudioStorageType string `json:"studio_storage_type"` - StudioImagePath *string `json:"studio_image_path"` + ID int64 `json:"id"` + TitleNames json.RawMessage `json:"title_names"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + TitleStorageType string `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` } func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]SearchTitlesRow, error) { @@ -525,7 +524,6 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S if err := rows.Scan( &i.ID, &i.TitleNames, - &i.StudioID, &i.PosterID, &i.TitleStatus, &i.Rating, @@ -535,15 +533,10 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S &i.Season, &i.EpisodesAired, &i.EpisodesAll, - &i.EpisodesLen, &i.TitleStorageType, &i.TitleImagePath, &i.TagNames, &i.StudioName, - &i.StudioIllustID, - &i.StudioDesc, - &i.StudioStorageType, - &i.StudioImagePath, ); err != nil { return nil, err } @@ -558,7 +551,17 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S const searchUserTitles = `-- name: SearchUserTitles :many SELECT - t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, u.user_id as user_id, u.status as usertitle_status, u.rate as user_rate, @@ -566,20 +569,18 @@ SELECT u.ctime as user_ctime, i.storage_type::text as title_storage_type, i.image_path as title_image_path, - jsonb_agg(g.tag_name)'[]'::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type::text as studio_storage_type, - si.image_path as studio_image_path + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name FROM usertitles as u -LEFT JOIN titles as t ON (u.title_id = t.id) +JOIN titles as t ON (u.title_id = t.id) LEFT JOIN images as i ON (t.poster_id = i.id) LEFT JOIN title_tags as tt ON (t.id = tt.title_id) LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) WHERE CASE @@ -647,15 +648,24 @@ WHERE END ) - AND (u.status::text IN ($7::text, $8::text, $9::text, $10::text)) - AND (t.title_status::text IN ($7::text, $11::text, $8::text)) - AND ($12::int IS NULL OR u.rate >= $12::int) - AND ($13::float IS NULL OR t.rating >= $13::float) - AND ($14::int IS NULL OR t.release_year = $14::int) - AND ($15::release_season_t IS NULL OR t.release_season = $15::release_season_t) + AND ( + -- Если массив пуст (NULL или []) — не фильтруем + cardinality($7::text[]) = 0 + OR + t.title_status = ANY($7::text[]) + ) + AND ( + cardinality($8::text[]) = 0 + OR + u.status = ANY($8::text[]) + ) + AND ($9::int IS NULL OR u.rate >= $9::int) + AND ($10::float IS NULL OR t.rating >= $10::float) + AND ($11::int IS NULL OR t.release_year = $11::int) + AND ($12::release_season_t IS NULL OR t.release_season = $12::release_season_t) GROUP BY - t.id, i.id, s.id, si.id + t.id, i.id, s.id ORDER BY CASE WHEN $1::boolean THEN @@ -677,55 +687,46 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($16::int, 100) +LIMIT COALESCE($13::int, 100) ` type SearchUserTitlesParams struct { - Forward bool `json:"forward"` - SortBy string `json:"sort_by"` - CursorYear *int32 `json:"cursor_year"` - CursorID *int64 `json:"cursor_id"` - CursorRating *float64 `json:"cursor_rating"` - Word *string `json:"word"` - Ongoing string `json:"ongoing"` - Planned string `json:"planned"` - Dropped string `json:"dropped"` - InProgress string `json:"in-progress"` - Finished string `json:"finished"` - Rate *int32 `json:"rate"` - Rating *float64 `json:"rating"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Limit *int32 `json:"limit"` + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` + Word *string `json:"word"` + TitleStatuses []string `json:"title_statuses"` + UsertitleStatuses []string `json:"usertitle_statuses"` + Rate *int32 `json:"rate"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Limit *int32 `json:"limit"` } type SearchUserTitlesRow struct { - ID *int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID *int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus *TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` - UserID int64 `json:"user_id"` - UsertitleStatus UsertitleStatusT `json:"usertitle_status"` - UserRate *int32 `json:"user_rate"` - ReviewID *int64 `json:"review_id"` - UserCtime pgtype.Timestamptz `json:"user_ctime"` - TitleStorageType string `json:"title_storage_type"` - TitleImagePath *string `json:"title_image_path"` - TagNames json.RawMessage `json:"tag_names"` - StudioName *string `json:"studio_name"` - StudioIllustID *int64 `json:"studio_illust_id"` - StudioDesc *string `json:"studio_desc"` - StudioStorageType string `json:"studio_storage_type"` - StudioImagePath *string `json:"studio_image_path"` + ID int64 `json:"id"` + TitleNames json.RawMessage `json:"title_names"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + UserID int64 `json:"user_id"` + UsertitleStatus UsertitleStatusT `json:"usertitle_status"` + UserRate *int32 `json:"user_rate"` + ReviewID *int64 `json:"review_id"` + UserCtime pgtype.Timestamptz `json:"user_ctime"` + TitleStorageType string `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` } // 100 is default limit @@ -737,11 +738,8 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara arg.CursorID, arg.CursorRating, arg.Word, - arg.Ongoing, - arg.Planned, - arg.Dropped, - arg.InProgress, - arg.Finished, + arg.TitleStatuses, + arg.UsertitleStatuses, arg.Rate, arg.Rating, arg.ReleaseYear, @@ -758,7 +756,6 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara if err := rows.Scan( &i.ID, &i.TitleNames, - &i.StudioID, &i.PosterID, &i.TitleStatus, &i.Rating, @@ -768,7 +765,6 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara &i.Season, &i.EpisodesAired, &i.EpisodesAll, - &i.EpisodesLen, &i.UserID, &i.UsertitleStatus, &i.UserRate, @@ -778,10 +774,6 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara &i.TitleImagePath, &i.TagNames, &i.StudioName, - &i.StudioIllustID, - &i.StudioDesc, - &i.StudioStorageType, - &i.StudioImagePath, ); err != nil { return nil, err } From d1937fcbd7e62a4415cace621a7c8d7491f68b7c Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 05:28:32 +0300 Subject: [PATCH 103/224] fix: bad types in query --- modules/backend/queries.sql | 12 ++++----- sql/queries.sql.go | 52 ++++++++++++++++++------------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 9734ff2..1d5cc45 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -194,10 +194,10 @@ WHERE AND ( -- Если массив пуст (NULL или []) — не фильтруем - cardinality(sqlc.arg('title_statuses')::text[]) = 0 + cardinality(sqlc.arg('title_statuses')::title_status_t[]) = 0 OR -- Иначе: статус есть в списке - t.title_status = ANY(sqlc.arg('title_statuses')::text[]) + t.title_status = ANY(sqlc.arg('title_statuses')::title_status_t[]) ) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) @@ -327,14 +327,14 @@ WHERE AND ( -- Если массив пуст (NULL или []) — не фильтруем - cardinality(sqlc.arg('title_statuses')::text[]) = 0 + cardinality(sqlc.arg('title_statuses')::title_status_t[]) = 0 OR - t.title_status = ANY(sqlc.arg('title_statuses')::text[]) + t.title_status = ANY(sqlc.arg('title_statuses')::title_status_t[]) ) AND ( - cardinality(sqlc.arg('usertitle_statuses')::text[]) = 0 + cardinality(sqlc.arg('usertitle_statuses')::usertitle_status_t[]) = 0 OR - u.status = ANY(sqlc.arg('usertitle_statuses')::text[]) + u.status = ANY(sqlc.arg('usertitle_statuses')::usertitle_status_t[]) ) AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index f7535db..daa2875 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -435,10 +435,10 @@ WHERE AND ( -- Если массив пуст (NULL или []) — не фильтруем - cardinality($7::text[]) = 0 + cardinality($7::title_status_t[]) = 0 OR -- Иначе: статус есть в списке - t.title_status = ANY($7::text[]) + t.title_status = ANY($7::title_status_t[]) ) AND ($8::float IS NULL OR t.rating >= $8::float) AND ($9::int IS NULL OR t.release_year = $9::int) @@ -475,7 +475,7 @@ type SearchTitlesParams struct { CursorID *int64 `json:"cursor_id"` CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` - TitleStatuses []string `json:"title_statuses"` + TitleStatuses []TitleStatusT `json:"title_statuses"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` @@ -575,12 +575,12 @@ SELECT )::jsonb as tag_names, s.studio_name as studio_name -FROM usertitles as u -JOIN titles as t ON (u.title_id = t.id) -LEFT JOIN images as i ON (t.poster_id = i.id) -LEFT JOIN title_tags as tt ON (t.id = tt.title_id) -LEFT JOIN tags as g ON (tt.tag_id = g.id) -LEFT JOIN studios as s ON (t.studio_id = s.id) +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) WHERE CASE @@ -650,14 +650,14 @@ WHERE AND ( -- Если массив пуст (NULL или []) — не фильтруем - cardinality($7::text[]) = 0 + cardinality($7::title_status_t[]) = 0 OR - t.title_status = ANY($7::text[]) + t.title_status = ANY($7::title_status_t[]) ) AND ( - cardinality($8::text[]) = 0 + cardinality($8::usertitle_status_t[]) = 0 OR - u.status = ANY($8::text[]) + u.status = ANY($8::usertitle_status_t[]) ) AND ($9::int IS NULL OR u.rate >= $9::int) AND ($10::float IS NULL OR t.rating >= $10::float) @@ -691,19 +691,19 @@ LIMIT COALESCE($13::int, 100) ` type SearchUserTitlesParams struct { - Forward bool `json:"forward"` - SortBy string `json:"sort_by"` - CursorYear *int32 `json:"cursor_year"` - CursorID *int64 `json:"cursor_id"` - CursorRating *float64 `json:"cursor_rating"` - Word *string `json:"word"` - TitleStatuses []string `json:"title_statuses"` - UsertitleStatuses []string `json:"usertitle_statuses"` - Rate *int32 `json:"rate"` - Rating *float64 `json:"rating"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Limit *int32 `json:"limit"` + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` + Word *string `json:"word"` + TitleStatuses []TitleStatusT `json:"title_statuses"` + UsertitleStatuses []UsertitleStatusT `json:"usertitle_statuses"` + Rate *int32 `json:"rate"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Limit *int32 `json:"limit"` } type SearchUserTitlesRow struct { From b42fb34903955cc82414737b238da063db5bceb3 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 05:51:45 +0300 Subject: [PATCH 104/224] fix: type cast fixed --- api/_build/openapi.yaml | 6 ++- api/api.gen.go | 18 ++++----- api/paths/users-id-titles.yaml | 6 ++- modules/backend/handlers/common.go | 22 ++++++++++- modules/backend/handlers/titles.go | 38 +++---------------- modules/backend/handlers/users.go | 59 ++++++++++++++++++++---------- 6 files changed, 84 insertions(+), 65 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index b3eacb4..215eabc 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -173,7 +173,11 @@ paths: - in: query name: watch_status schema: - $ref: '#/components/schemas/UserTitleStatus' + type: array + items: + $ref: '#/components/schemas/UserTitleStatus' + style: form + explode: false - in: query name: rating schema: diff --git a/api/api.gen.go b/api/api.gen.go index e1f94c2..dcc2f89 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -189,14 +189,14 @@ type GetUsersUserIdTitlesParams struct { Word *string `form:"word,omitempty" json:"word,omitempty"` // Status List of title statuses to filter - Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"` - WatchStatus *UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` - Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` - MyRate *int32 `form:"my_rate,omitempty" json:"my_rate,omitempty"` - ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` - ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` - Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` + Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"` + WatchStatus *[]UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` + Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` + MyRate *int32 `form:"my_rate,omitempty" json:"my_rate,omitempty"` + ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` + ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } // Getter for additional properties for Title. Returns the specified @@ -840,7 +840,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { // ------------- Optional query parameter "watch_status" ------------- - err = runtime.BindQueryParameter("form", true, false, "watch_status", c.Request.URL.Query(), ¶ms.WatchStatus) + err = runtime.BindQueryParameter("form", false, false, "watch_status", c.Request.URL.Query(), ¶ms.WatchStatus) if err != nil { siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter watch_status: %w", err), http.StatusBadRequest) return diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 91b212d..a76cc40 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -29,7 +29,11 @@ get: - in: query name: watch_status schema: - $ref: '../schemas/enums/UserTitleStatus.yaml' + type: array + items: + $ref: '../schemas/enums/UserTitleStatus.yaml' + style: form + explode: false - in: query name: rating schema: diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 89a3d59..dc3a4ba 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -42,7 +42,7 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) } - if title.EpisodesLen != nil && len(title.EpisodesLen) > 0 { + if len(title.EpisodesLen) > 0 { episodes_lens := make(map[string]float64, 0) err = json.Unmarshal(title.EpisodesLen, &episodes_lens) if err != nil { @@ -99,3 +99,23 @@ func parseInt64(s string) (int32, error) { i, err := strconv.ParseInt(s, 10, 64) return int32(i), err } + +func TitleStatus2Sqlc(s *[]oapi.TitleStatus) ([]sqlc.TitleStatusT, error) { + var sqlc_status []sqlc.TitleStatusT + if s == nil { + return nil, nil + } + for _, t := range *s { + switch t { + case oapi.TitleStatusFinished: + sqlc_status = append(sqlc_status, sqlc.TitleStatusTFinished) + case oapi.TitleStatusOngoing: + sqlc_status = append(sqlc_status, sqlc.TitleStatusTOngoing) + case oapi.TitleStatusPlanned: + sqlc_status = append(sqlc_status, sqlc.TitleStatusTPlanned) + default: + return nil, fmt.Errorf("unexpected tittle status: %s", t) + } + } + return sqlc_status, nil +} diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index d593314..054b745 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -20,32 +20,6 @@ func Word2Sqlc(s *string) *string { return s } -type SqlcStatus struct { - ongoing string - finished string - planned string -} - -// func TitleStatus2Sqlc(s *[]oapi.TitleStatus) (*SqlcStatus, error) { -// var sqlc_status SqlcStatus -// if s == nil { -// return &sqlc_status, nil -// } -// for _, t := range *s { -// switch t { -// case oapi.TitleStatusFinished: -// sqlc_status.finished = "finished" -// case oapi.TitleStatusOngoing: -// sqlc_status.ongoing = "ongoing" -// case oapi.TitleStatusPlanned: -// sqlc_status.planned = "planned" -// default: -// return nil, fmt.Errorf("unexpected tittle status: %s", t) -// } -// } -// return &sqlc_status, nil -// } - func TitleStatus2oapi(s *sqlc.TitleStatusT) (*oapi.TitleStatus, error) { if s == nil { return nil, nil @@ -190,17 +164,15 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje return oapi.GetTitles400Response{}, err } - var statuses_sort []string - if request.Params.Status != nil { - for _, s := range *request.Params.Status { - ss := string(s) // s type is alias for string - statuses_sort = append(statuses_sort, ss) - } + title_statuses, err := TitleStatus2Sqlc(request.Params.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles400Response{}, err } params := sqlc.SearchTitlesParams{ Word: word, - TitleStatuses: statuses_sort, + TitleStatuses: title_statuses, Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 3223389..3a271d7 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -62,13 +62,6 @@ func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { return nil } -type SqlcUserStatus struct { - dropped string - finished string - planned string - in_progress string -} - // func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) { // var sqlc_status SqlcUserStatus // if s == nil { @@ -110,6 +103,28 @@ func sql2usertitlestatus(s sqlc.UsertitleStatusT) (oapi.UserTitleStatus, error) return status, nil } +func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) ([]sqlc.UsertitleStatusT, error) { + var sqlc_status []sqlc.UsertitleStatusT + if s == nil { + return nil, nil + } + for _, t := range *s { + switch t { + case oapi.UserTitleStatusFinished: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTFinished) + case oapi.UserTitleStatusInProgress: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTInProgress) + case oapi.UserTitleStatusDropped: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTDropped) + case oapi.UserTitleStatusPlanned: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTPlanned) + default: + return nil, fmt.Errorf("unexpected tittle status: %s", t) + } + } + return sqlc_status, nil +} + func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (oapi.UserTitle, error) { oapi_usertitle := oapi.UserTitle{ @@ -171,25 +186,29 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU return oapi.GetUsersUserIdTitles400Response{}, err } - var statuses_sort []string - if request.Params.Status != nil { - for _, s := range *request.Params.Status { - ss := string(s) // s type is alias for string - statuses_sort = append(statuses_sort, ss) - } + // var statuses_sort []string + // if request.Params.Status != nil { + // for _, s := range *request.Params.Status { + // ss := string(s) // s type is alias for string + // statuses_sort = append(statuses_sort, ss) + // } + // } + + watch_status, err := UserTitleStatus2Sqlc(request.Params.WatchStatus) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles400Response{}, err } - var watch_status []string - if request.Params.WatchStatus != nil { - for _, s := range *request.Params.WatchStatus { - ss := string(s) // s type is alias for string - watch_status = append(statuses_sort, ss) - } + title_statuses, err := TitleStatus2Sqlc(request.Params.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserIdTitles400Response{}, err } params := sqlc.SearchUserTitlesParams{ Word: word, - TitleStatuses: statuses_sort, + TitleStatuses: title_statuses, UsertitleStatuses: watch_status, Rating: request.Params.Rating, Rate: request.Params.MyRate, From 4e732e15423e03d6d48ab643249a225af166dd2b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 06:26:23 +0300 Subject: [PATCH 105/224] fix: bad NULL handling in where clause --- modules/backend/handlers/common.go | 1 + modules/backend/queries.sql | 26 +++++------ sql/queries.sql.go | 72 ++++++++++++++---------------- 3 files changed, 47 insertions(+), 52 deletions(-) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index dc3a4ba..e22ce3f 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -65,6 +65,7 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. oapi_studio.Id = title.StudioID oapi_studio.Description = title.StudioDesc if title.StudioIllustID != nil { + oapi_studio.Poster = &oapi.Image{} oapi_studio.Poster.Id = title.StudioIllustID oapi_studio.Poster.ImagePath = title.StudioImagePath oapi_studio.Poster.StorageType = &title.StudioStorageType diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 1d5cc45..d064660 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -193,12 +193,11 @@ WHERE ) AND ( - -- Если массив пуст (NULL или []) — не фильтруем - cardinality(sqlc.arg('title_statuses')::title_status_t[]) = 0 - OR - -- Иначе: статус есть в списке - t.title_status = ANY(sqlc.arg('title_statuses')::title_status_t[]) -) + 'title_statuses'::title_status_t[] IS NULL + OR array_length('title_statuses'::title_status_t[], 1) IS NULL + OR array_length('title_statuses'::title_status_t[], 1) = 0 + OR t.title_status = ANY('title_statuses'::title_status_t[]) + ) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) @@ -326,15 +325,16 @@ WHERE ) AND ( - -- Если массив пуст (NULL или []) — не фильтруем - cardinality(sqlc.arg('title_statuses')::title_status_t[]) = 0 - OR - t.title_status = ANY(sqlc.arg('title_statuses')::title_status_t[]) + 'title_statuses'::title_status_t[] IS NULL + OR array_length('title_statuses'::title_status_t[], 1) IS NULL + OR array_length('title_statuses'::title_status_t[], 1) = 0 + OR t.title_status = ANY('title_statuses'::title_status_t[]) ) AND ( - cardinality(sqlc.arg('usertitle_statuses')::usertitle_status_t[]) = 0 - OR - u.status = ANY(sqlc.arg('usertitle_statuses')::usertitle_status_t[]) + 'usertitle_statuses'::title_status_t[] IS NULL + OR array_length('usertitle_statuses'::title_status_t[], 1) IS NULL + OR array_length('usertitle_statuses'::title_status_t[], 1) = 0 + OR t.title_status = ANY('usertitle_statuses'::title_status_t[]) ) AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index daa2875..daa2b56 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -434,15 +434,14 @@ WHERE ) AND ( - -- Если массив пуст (NULL или []) — не фильтруем - cardinality($7::title_status_t[]) = 0 - OR - -- Иначе: статус есть в списке - t.title_status = ANY($7::title_status_t[]) -) - AND ($8::float IS NULL OR t.rating >= $8::float) - AND ($9::int IS NULL OR t.release_year = $9::int) - AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) + 'title_statuses'::title_status_t[] IS NULL + OR array_length('title_statuses'::title_status_t[], 1) IS NULL + OR array_length('title_statuses'::title_status_t[], 1) = 0 + OR t.title_status = ANY('title_statuses'::title_status_t[]) + ) + AND ($7::float IS NULL OR t.rating >= $7::float) + AND ($8::int IS NULL OR t.release_year = $8::int) + AND ($9::release_season_t IS NULL OR t.release_season = $9::release_season_t) GROUP BY t.id, i.id, s.id @@ -465,7 +464,7 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($11::int, 100) +LIMIT COALESCE($10::int, 100) ` type SearchTitlesParams struct { @@ -475,7 +474,6 @@ type SearchTitlesParams struct { CursorID *int64 `json:"cursor_id"` CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` - TitleStatuses []TitleStatusT `json:"title_statuses"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` @@ -508,7 +506,6 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S arg.CursorID, arg.CursorRating, arg.Word, - arg.TitleStatuses, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, @@ -649,20 +646,21 @@ WHERE ) AND ( - -- Если массив пуст (NULL или []) — не фильтруем - cardinality($7::title_status_t[]) = 0 - OR - t.title_status = ANY($7::title_status_t[]) + 'title_statuses'::title_status_t[] IS NULL + OR array_length('title_statuses'::title_status_t[], 1) IS NULL + OR array_length('title_statuses'::title_status_t[], 1) = 0 + OR t.title_status = ANY('title_statuses'::title_status_t[]) ) AND ( - cardinality($8::usertitle_status_t[]) = 0 - OR - u.status = ANY($8::usertitle_status_t[]) + 'usertitle_statuses'::title_status_t[] IS NULL + OR array_length('usertitle_statuses'::title_status_t[], 1) IS NULL + OR array_length('usertitle_statuses'::title_status_t[], 1) = 0 + OR t.title_status = ANY('usertitle_statuses'::title_status_t[]) ) - AND ($9::int IS NULL OR u.rate >= $9::int) - AND ($10::float IS NULL OR t.rating >= $10::float) - AND ($11::int IS NULL OR t.release_year = $11::int) - AND ($12::release_season_t IS NULL OR t.release_season = $12::release_season_t) + AND ($7::int IS NULL OR u.rate >= $7::int) + AND ($8::float IS NULL OR t.rating >= $8::float) + AND ($9::int IS NULL OR t.release_year = $9::int) + AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) GROUP BY t.id, i.id, s.id @@ -687,23 +685,21 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($13::int, 100) +LIMIT COALESCE($11::int, 100) ` type SearchUserTitlesParams struct { - Forward bool `json:"forward"` - SortBy string `json:"sort_by"` - CursorYear *int32 `json:"cursor_year"` - CursorID *int64 `json:"cursor_id"` - CursorRating *float64 `json:"cursor_rating"` - Word *string `json:"word"` - TitleStatuses []TitleStatusT `json:"title_statuses"` - UsertitleStatuses []UsertitleStatusT `json:"usertitle_statuses"` - Rate *int32 `json:"rate"` - Rating *float64 `json:"rating"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Limit *int32 `json:"limit"` + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` + Word *string `json:"word"` + Rate *int32 `json:"rate"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Limit *int32 `json:"limit"` } type SearchUserTitlesRow struct { @@ -738,8 +734,6 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara arg.CursorID, arg.CursorRating, arg.Word, - arg.TitleStatuses, - arg.UsertitleStatuses, arg.Rate, arg.Rating, arg.ReleaseYear, From 20e9c5bf23c42c4cd397caad7c64b47aa084514d Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 06:33:11 +0300 Subject: [PATCH 106/224] fix --- modules/backend/queries.sql | 24 ++++++------- sql/queries.sql.go | 70 ++++++++++++++++++++----------------- 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index d064660..dc81da9 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -193,10 +193,10 @@ WHERE ) AND ( - 'title_statuses'::title_status_t[] IS NULL - OR array_length('title_statuses'::title_status_t[], 1) IS NULL - OR array_length('title_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('title_statuses'::title_status_t[]) + sqlc.narg('title_statuses')::title_status_t[] IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) = 0 + OR t.title_status = ANY(sqlc.narg('title_statuses')::title_status_t[]) ) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) @@ -325,16 +325,16 @@ WHERE ) AND ( - 'title_statuses'::title_status_t[] IS NULL - OR array_length('title_statuses'::title_status_t[], 1) IS NULL - OR array_length('title_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('title_statuses'::title_status_t[]) + sqlc.narg('title_statuses')::title_status_t[] IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) = 0 + OR t.title_status = ANY(sqlc.narg('title_statuses')::title_status_t[]) ) AND ( - 'usertitle_statuses'::title_status_t[] IS NULL - OR array_length('usertitle_statuses'::title_status_t[], 1) IS NULL - OR array_length('usertitle_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('usertitle_statuses'::title_status_t[]) + sqlc.narg('usertitle_statuses')::usertitle_status_t[] IS NULL + OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) IS NULL + OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) = 0 + OR t.title_status = ANY(sqlc.narg('usertitle_statuses')::usertitle_status_t[]) ) AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index daa2b56..2df4da8 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -434,14 +434,14 @@ WHERE ) AND ( - 'title_statuses'::title_status_t[] IS NULL - OR array_length('title_statuses'::title_status_t[], 1) IS NULL - OR array_length('title_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('title_statuses'::title_status_t[]) + $7::title_status_t[] IS NULL + OR array_length($7::title_status_t[], 1) IS NULL + OR array_length($7::title_status_t[], 1) = 0 + OR t.title_status = ANY($7::title_status_t[]) ) - AND ($7::float IS NULL OR t.rating >= $7::float) - AND ($8::int IS NULL OR t.release_year = $8::int) - AND ($9::release_season_t IS NULL OR t.release_season = $9::release_season_t) + AND ($8::float IS NULL OR t.rating >= $8::float) + AND ($9::int IS NULL OR t.release_year = $9::int) + AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) GROUP BY t.id, i.id, s.id @@ -464,7 +464,7 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($10::int, 100) +LIMIT COALESCE($11::int, 100) ` type SearchTitlesParams struct { @@ -474,6 +474,7 @@ type SearchTitlesParams struct { CursorID *int64 `json:"cursor_id"` CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` + TitleStatuses []TitleStatusT `json:"title_statuses"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` @@ -506,6 +507,7 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S arg.CursorID, arg.CursorRating, arg.Word, + arg.TitleStatuses, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, @@ -646,21 +648,21 @@ WHERE ) AND ( - 'title_statuses'::title_status_t[] IS NULL - OR array_length('title_statuses'::title_status_t[], 1) IS NULL - OR array_length('title_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('title_statuses'::title_status_t[]) + $7::title_status_t[] IS NULL + OR array_length($7::title_status_t[], 1) IS NULL + OR array_length($7::title_status_t[], 1) = 0 + OR t.title_status = ANY($7::title_status_t[]) ) AND ( - 'usertitle_statuses'::title_status_t[] IS NULL - OR array_length('usertitle_statuses'::title_status_t[], 1) IS NULL - OR array_length('usertitle_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('usertitle_statuses'::title_status_t[]) + $8::usertitle_status_t[] IS NULL + OR array_length($8::usertitle_status_t[], 1) IS NULL + OR array_length($8::usertitle_status_t[], 1) = 0 + OR t.title_status = ANY($8::usertitle_status_t[]) ) - AND ($7::int IS NULL OR u.rate >= $7::int) - AND ($8::float IS NULL OR t.rating >= $8::float) - AND ($9::int IS NULL OR t.release_year = $9::int) - AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) + AND ($9::int IS NULL OR u.rate >= $9::int) + AND ($10::float IS NULL OR t.rating >= $10::float) + AND ($11::int IS NULL OR t.release_year = $11::int) + AND ($12::release_season_t IS NULL OR t.release_season = $12::release_season_t) GROUP BY t.id, i.id, s.id @@ -685,21 +687,23 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($11::int, 100) +LIMIT COALESCE($13::int, 100) ` type SearchUserTitlesParams struct { - Forward bool `json:"forward"` - SortBy string `json:"sort_by"` - CursorYear *int32 `json:"cursor_year"` - CursorID *int64 `json:"cursor_id"` - CursorRating *float64 `json:"cursor_rating"` - Word *string `json:"word"` - Rate *int32 `json:"rate"` - Rating *float64 `json:"rating"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Limit *int32 `json:"limit"` + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` + Word *string `json:"word"` + TitleStatuses []TitleStatusT `json:"title_statuses"` + UsertitleStatuses []UsertitleStatusT `json:"usertitle_statuses"` + Rate *int32 `json:"rate"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Limit *int32 `json:"limit"` } type SearchUserTitlesRow struct { @@ -734,6 +738,8 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara arg.CursorID, arg.CursorRating, arg.Word, + arg.TitleStatuses, + arg.UsertitleStatuses, arg.Rate, arg.Rating, arg.ReleaseYear, From 1d65833b8a14c7de044bcf82aaed15d3a2303ee6 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 06:56:42 +0300 Subject: [PATCH 107/224] fix --- modules/backend/handlers/common.go | 5 ++++- modules/backend/handlers/titles.go | 3 ++- modules/backend/handlers/users.go | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index e22ce3f..e233f98 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -21,7 +21,7 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. oapi_title := oapi.Title{ EpisodesAired: title.EpisodesAired, - EpisodesAll: title.EpisodesAired, + EpisodesAll: title.EpisodesAll, // EpisodesLen: &episodes_lens, Id: title.ID, // Poster: &oapi_image, @@ -41,6 +41,7 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. if err != nil { return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) } + oapi_title.TitleNames = title_names if len(title.EpisodesLen) > 0 { episodes_lens := make(map[string]float64, 0) @@ -56,6 +57,7 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. if err != nil { return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err) } + oapi_title.Tags = oapi_tag_names var oapi_studio oapi.Studio if title.StudioName != nil { @@ -80,6 +82,7 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. oapi_image.ImagePath = title.TitleImagePath oapi_image.StorageType = &title.TitleStorageType } + oapi_title.Poster = &oapi_image var release_season oapi.ReleaseSeason if title.ReleaseSeason != nil { diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 054b745..ec9426c 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -224,7 +224,8 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje // EpisodesLen: title.EpisodesLen, TitleStorageType: title.TitleStorageType, TitleImagePath: title.TitleImagePath, - TagNames: title.TitleNames, + TitleNames: title.TitleNames, + TagNames: title.TagNames, StudioName: title.StudioName, // StudioIllustID: title.StudioIllustID, // StudioDesc: title.StudioDesc, diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 3a271d7..d3848f4 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -157,8 +157,9 @@ func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (o // EpisodesLen: title.EpisodesLen, TitleStorageType: t.TitleStorageType, TitleImagePath: t.TitleImagePath, - TagNames: t.TitleNames, StudioName: t.StudioName, + TitleNames: t.TitleNames, + TagNames: t.TagNames, // StudioIllustID: title.StudioIllustID, // StudioDesc: title.StudioDesc, // StudioStorageType: title.StudioStorageType, From e999534b3f0dc3a5a83180e037c256cb80160837 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 08:31:55 +0300 Subject: [PATCH 108/224] feat: UpdateUser implemented --- api/paths/users-id.yaml | 76 ++++++++++++++++++++++++++++ modules/backend/handlers/users.go | 48 +++++++++++++++++- modules/backend/queries.sql | 18 +++---- sql/queries.sql.go | 84 ++++++++++++++++++++++--------- 4 files changed, 193 insertions(+), 33 deletions(-) diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index 0acdb81..06f4a19 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -24,3 +24,79 @@ get: description: Request params are not correct '500': description: Unknown server error + +patch: + summary: Partially update a user account + description: | + Update selected user profile fields (excluding password). + Password updates must be done via the dedicated auth-service (`/auth/`). + Fields not provided in the request body remain unchanged. + operationId: updateUser + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: User ID (primary key) + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + avatar_id: + type: integer + format: int64 + nullable: true + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + example: 42 + mail: + type: string + format: email + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + description: User email (must be unique and valid) + example: john.doe.updated@example.com + nickname: + type: string + pattern: '^[a-zA-Z0-9_-]{3,16}$' + description: Username (alphanumeric + `_` or `-`, 3–16 chars) + maxLength: 16 + minLength: 3 + example: john_doe_43 + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Smith + user_desc: + type: string + description: User description / bio + maxLength: 512 + example: Just a curious developer. + additionalProperties: false + description: Only provided fields are updated. Omitted fields remain unchanged. + responses: + '200': + description: User updated successfully. Returns updated user representation (excluding sensitive fields). + content: + application/json: + schema: + $ref: '../schemas/User.yaml' + '400': + description: Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON) + '401': + description: Unauthorized — missing or invalid authentication token + '403': + description: Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights) + '404': + description: User not found + '409': + description: Conflict — e.g., requested `nickname` or `mail` already taken by another user + '422': + description: Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`) + '500': + description: Unknown server error diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index d3848f4..781911f 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -33,7 +33,7 @@ func mapUser(u sqlc.GetUserByIDRow) oapi.User { CreationDate: &u.CreationDate, DispName: u.DispName, Id: &u.ID, - Mail: (*types.Email)(u.Mail), + Mail: StringToEmail(u.Mail), Nickname: u.Nickname, UserDesc: u.UserDesc, } @@ -270,3 +270,49 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU return oapi.GetUsersUserIdTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil } + +func EmailToStringPtr(e *types.Email) *string { + if e == nil { + return nil + } + s := string(*e) + return &s +} + +func StringToEmail(e *string) *types.Email { + if e == nil { + return nil + } + s := types.Email(*e) + return &s +} + +// UpdateUser implements oapi.StrictServerInterface. +func (s Server) UpdateUser(ctx context.Context, request oapi.UpdateUserRequestObject) (oapi.UpdateUserResponseObject, error) { + + params := sqlc.UpdateUserParams{ + AvatarID: request.Body.AvatarId, + DispName: request.Body.DispName, + UserDesc: request.Body.UserDesc, + Mail: EmailToStringPtr(request.Body.Mail), + UserID: request.UserId, + } + + user, err := s.db.UpdateUser(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.UpdateUser500Response{}, nil + } + + oapi_user := oapi.User{ // maybe its possible to make one sqlc type and use one map func iinstead of this shit + AvatarId: user.AvatarID, + CreationDate: &user.CreationDate, + DispName: user.DispName, + Id: &user.ID, + Mail: StringToEmail(user.Mail), + Nickname: user.Nickname, + UserDesc: user.UserDesc, + } + + return oapi.UpdateUser200JSONResponse(oapi_user), nil +} diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index dc81da9..0bf1b86 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -58,15 +58,15 @@ RETURNING id, tag_names; -- VALUES ($1, $2, $3, $4, $5, $6, $7) -- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; --- -- name: UpdateUser :one --- UPDATE users --- SET --- avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), --- disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), --- user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), --- passhash = COALESCE(sqlc.narg('passhash'), passhash) --- WHERE user_id = sqlc.arg('user_id') --- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +-- name: UpdateUser :one +UPDATE users +SET + avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), + disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), + user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), + mail = COALESCE(sqlc.narg('mail'), mail) +WHERE id = sqlc.arg('user_id') +RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail; -- -- name: DeleteUser :exec -- DELETE FROM users diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 2df4da8..cac5543 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -114,9 +114,6 @@ func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, er const getTitleByID = `-- name: GetTitleByID :one - - - SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type::text as title_storage_type, @@ -167,26 +164,6 @@ type GetTitleByIDRow struct { StudioImagePath *string `json:"studio_image_path"` } -// -- name: ListUsers :many -// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -// FROM users -// ORDER BY user_id -// LIMIT $1 OFFSET $2; -// -- name: CreateUser :one -// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) -// VALUES ($1, $2, $3, $4, $5, $6, $7) -// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; -// -- name: UpdateUser :one -// UPDATE users -// SET -// -// avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), -// disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), -// user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), -// passhash = COALESCE(sqlc.narg('passhash'), passhash) -// -// WHERE user_id = sqlc.arg('user_id') -// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; // -- name: DeleteUser :exec // DELETE FROM users // WHERE user_id = $1; @@ -784,3 +761,64 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara } return items, nil } + +const updateUser = `-- name: UpdateUser :one + + +UPDATE users +SET + avatar_id = COALESCE($1, avatar_id), + disp_name = COALESCE($2, disp_name), + user_desc = COALESCE($3, user_desc), + mail = COALESCE($4, mail) +WHERE id = $5 +RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail +` + +type UpdateUserParams struct { + AvatarID *int64 `json:"avatar_id"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + Mail *string `json:"mail"` + UserID int64 `json:"user_id"` +} + +type UpdateUserRow struct { + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + Mail *string `json:"mail"` +} + +// -- name: ListUsers :many +// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +// FROM users +// ORDER BY user_id +// LIMIT $1 OFFSET $2; +// -- name: CreateUser :one +// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) +// VALUES ($1, $2, $3, $4, $5, $6, $7) +// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) { + row := q.db.QueryRow(ctx, updateUser, + arg.AvatarID, + arg.DispName, + arg.UserDesc, + arg.Mail, + arg.UserID, + ) + var i UpdateUserRow + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.Mail, + ) + return i, err +} From 15a681c62275a6281cb62fec949c7a214b638baa Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 09:04:40 +0300 Subject: [PATCH 109/224] feat: trigger for ctime on usertitle update --- sql/migrations/000001_init.up.sql | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index e6ed628..0a2fd71 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -87,7 +87,7 @@ CREATE TABLE usertitles ( status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), review_id bigint REFERENCES reviews (id), - ctime timestamptz + ctime timestamptz NOT NULL DEFAULT now() -- // TODO: series status ); @@ -169,4 +169,17 @@ EXECUTE FUNCTION update_title_rating(); CREATE TRIGGER trg_notify_new_signal AFTER INSERT ON signals FOR EACH ROW -EXECUTE FUNCTION notify_new_signal(); \ No newline at end of file +EXECUTE FUNCTION notify_new_signal(); + +CREATE OR REPLACE FUNCTION set_ctime() +RETURNS TRIGGER AS $$ +BEGIN + NEW.ctime = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_ctime_on_update +BEFORE UPDATE ON usertitles +FOR EACH ROW +EXECUTE FUNCTION set_ctime(); \ No newline at end of file From 76df4d859295666041239d4766332dc87cc50194 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Mon, 24 Nov 2025 09:34:05 +0300 Subject: [PATCH 110/224] feat: AddUserTitle implemented --- api/_build/openapi.yaml | 141 +++++++++++++- api/api.gen.go | 313 +++++++++++++++++++++++++++++- api/openapi.yaml | 3 +- api/paths/users-id-titles.yaml | 52 +++++ api/schemas/UserTitleMini.yaml | 24 +++ api/schemas/updateUser.yaml | 26 +++ modules/backend/handlers/users.go | 72 ++++++- modules/backend/queries.sql | 16 +- sql/models.go | 12 +- sql/queries.sql.go | 129 +++++++++--- 10 files changed, 749 insertions(+), 39 deletions(-) create mode 100644 api/schemas/UserTitleMini.yaml create mode 100644 api/schemas/updateUser.yaml diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 215eabc..aa96593 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -141,7 +141,82 @@ paths: description: User not found '500': description: Unknown server error - '/users/{user_id}/titles/': + patch: + summary: Partially update a user account + description: | + Update selected user profile fields (excluding password). + Password updates must be done via the dedicated auth-service (`/auth/`). + Fields not provided in the request body remain unchanged. + operationId: updateUser + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: User ID (primary key) + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + avatar_id: + type: integer + format: int64 + nullable: true + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + example: 42 + mail: + type: string + format: email + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + description: User email (must be unique and valid) + example: john.doe.updated@example.com + nickname: + type: string + pattern: '^[a-zA-Z0-9_-]{3,16}$' + description: 'Username (alphanumeric + `_` or `-`, 3–16 chars)' + maxLength: 16 + minLength: 3 + example: john_doe_43 + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Smith + user_desc: + type: string + description: User description / bio + maxLength: 512 + example: Just a curious developer. + additionalProperties: false + description: Only provided fields are updated. Omitted fields remain unchanged. + responses: + '200': + description: User updated successfully. Returns updated user representation (excluding sensitive fields). + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: 'Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)' + '401': + description: Unauthorized — missing or invalid authentication token + '403': + description: 'Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)' + '404': + description: User not found + '409': + description: 'Conflict — e.g., requested `nickname` or `mail` already taken by another user' + '422': + description: 'Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`)' + '500': + description: Unknown server error + '/users/{user_id}/titles': get: summary: Get user titles parameters: @@ -231,6 +306,70 @@ paths: description: Request params are not correct '500': description: Unknown server error + post: + summary: Add a title to a user + description: 'User adding title to list af watched, status required' + operationId: addUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserTitle' + responses: + '200': + description: Title successfully added to user + content: + application/json: + schema: + type: object + properties: + data: + type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + additionalProperties: false + '400': + description: 'Invalid request body (missing fields, invalid types, etc.)' + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to assign titles to this user + '404': + description: User or Title not found + '409': + description: Conflict — title already assigned to user (if applicable) + '500': + description: Internal server error components: parameters: cursor: diff --git a/api/api.gen.go b/api/api.gen.go index dcc2f89..b092742 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -181,6 +181,24 @@ type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// UpdateUserJSONBody defines parameters for UpdateUser. +type UpdateUserJSONBody struct { + // AvatarId ID of the user avatar (references `images.id`); set to `null` to remove avatar + AvatarId *int64 `json:"avatar_id"` + + // DispName Display name + DispName *string `json:"disp_name,omitempty"` + + // Mail User email (must be unique and valid) + Mail *openapi_types.Email `json:"mail,omitempty"` + + // Nickname Username (alphanumeric + `_` or `-`, 3–16 chars) + Nickname *string `json:"nickname,omitempty"` + + // UserDesc User description / bio + UserDesc *string `json:"user_desc,omitempty"` +} + // GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. type GetUsersUserIdTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -199,6 +217,12 @@ type GetUsersUserIdTitlesParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. +type UpdateUserJSONRequestBody UpdateUserJSONBody + +// AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. +type AddUserTitleJSONRequestBody = UserTitle + // Getter for additional properties for Title. Returns the specified // element and whether it was found func (a Title) Get(fieldName string) (value interface{}, found bool) { @@ -591,9 +615,15 @@ type ServerInterface interface { // Get user info // (GET /users/{user_id}) GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) + // Partially update a user account + // (PATCH /users/{user_id}) + UpdateUser(c *gin.Context, userId int64) // Get user titles - // (GET /users/{user_id}/titles/) + // (GET /users/{user_id}/titles) GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) + // Add a title to a user + // (POST /users/{user_id}/titles) + AddUserTitle(c *gin.Context, userId int64) } // ServerInterfaceWrapper converts contexts to parameters. @@ -781,6 +811,30 @@ func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { siw.Handler.GetUsersUserId(c, userId, params) } +// UpdateUser operation middleware +func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.UpdateUser(c, userId) +} + // GetUsersUserIdTitles operation middleware func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { @@ -904,6 +958,30 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { siw.Handler.GetUsersUserIdTitles(c, userId, params) } +// AddUserTitle operation middleware +func (siw *ServerInterfaceWrapper) AddUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.AddUserTitle(c, userId) +} + // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -934,7 +1012,9 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/titles", wrapper.GetTitles) router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitlesTitleId) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) - router.GET(options.BaseURL+"/users/:user_id/titles/", wrapper.GetUsersUserIdTitles) + router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) + router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) + router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.AddUserTitle) } type GetTitlesRequestObject struct { @@ -1075,6 +1155,80 @@ func (response GetUsersUserId500Response) VisitGetUsersUserIdResponse(w http.Res return nil } +type UpdateUserRequestObject struct { + UserId int64 `json:"user_id"` + Body *UpdateUserJSONRequestBody +} + +type UpdateUserResponseObject interface { + VisitUpdateUserResponse(w http.ResponseWriter) error +} + +type UpdateUser200JSONResponse User + +func (response UpdateUser200JSONResponse) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateUser400Response struct { +} + +func (response UpdateUser400Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type UpdateUser401Response struct { +} + +func (response UpdateUser401Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type UpdateUser403Response struct { +} + +func (response UpdateUser403Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type UpdateUser404Response struct { +} + +func (response UpdateUser404Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type UpdateUser409Response struct { +} + +func (response UpdateUser409Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(409) + return nil +} + +type UpdateUser422Response struct { +} + +func (response UpdateUser422Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(422) + return nil +} + +type UpdateUser500Response struct { +} + +func (response UpdateUser500Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type GetUsersUserIdTitlesRequestObject struct { UserId string `json:"user_id"` Params GetUsersUserIdTitlesParams @@ -1120,6 +1274,83 @@ func (response GetUsersUserIdTitles500Response) VisitGetUsersUserIdTitlesRespons return nil } +type AddUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + Body *AddUserTitleJSONRequestBody +} + +type AddUserTitleResponseObject interface { + VisitAddUserTitleResponse(w http.ResponseWriter) error +} + +type AddUserTitle200JSONResponse struct { + Data *struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` + } `json:"data,omitempty"` +} + +func (response AddUserTitle200JSONResponse) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type AddUserTitle400Response struct { +} + +func (response AddUserTitle400Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type AddUserTitle401Response struct { +} + +func (response AddUserTitle401Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type AddUserTitle403Response struct { +} + +func (response AddUserTitle403Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type AddUserTitle404Response struct { +} + +func (response AddUserTitle404Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type AddUserTitle409Response struct { +} + +func (response AddUserTitle409Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(409) + return nil +} + +type AddUserTitle500Response struct { +} + +func (response AddUserTitle500Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Get titles @@ -1131,9 +1362,15 @@ type StrictServerInterface interface { // Get user info // (GET /users/{user_id}) GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) + // Partially update a user account + // (PATCH /users/{user_id}) + UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error) // Get user titles - // (GET /users/{user_id}/titles/) + // (GET /users/{user_id}/titles) GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) + // Add a title to a user + // (POST /users/{user_id}/titles) + AddUserTitle(ctx context.Context, request AddUserTitleRequestObject) (AddUserTitleResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -1231,6 +1468,41 @@ func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params } } +// UpdateUser operation middleware +func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { + var request UpdateUserRequestObject + + request.UserId = userId + + var body UpdateUserJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.UpdateUser(ctx, request.(UpdateUserRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateUser") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(UpdateUserResponseObject); ok { + if err := validResponse.VisitUpdateUserResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetUsersUserIdTitles operation middleware func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { var request GetUsersUserIdTitlesRequestObject @@ -1258,3 +1530,38 @@ func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, p ctx.Error(fmt.Errorf("unexpected response type: %T", response)) } } + +// AddUserTitle operation middleware +func (sh *strictHandler) AddUserTitle(ctx *gin.Context, userId int64) { + var request AddUserTitleRequestObject + + request.UserId = userId + + var body AddUserTitleJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.AddUserTitle(ctx, request.(AddUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "AddUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(AddUserTitleResponseObject); ok { + if err := validResponse.VisitAddUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/api/openapi.yaml b/api/openapi.yaml index c8bdbc4..7da26f8 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -13,8 +13,9 @@ paths: $ref: "./paths/titles-id.yaml" /users/{user_id}: $ref: "./paths/users-id.yaml" - /users/{user_id}/titles/: + /users/{user_id}/titles: $ref: "./paths/users-id-titles.yaml" + components: parameters: $ref: "./parameters/_index.yaml" diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index a76cc40..7e6ac5e 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -87,3 +87,55 @@ get: description: Request params are not correct '500': description: Unknown server error + +post: + summary: Add a title to a user + description: User adding title to list af watched, status required + operationId: addUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/UserTitle.yaml' + responses: + '200': + description: Title successfully added to user + content: + application/json: + schema: + type: object + properties: + # success: + # type: boolean + # example: true + # error: + # type: string + # nullable: true + # example: null + data: + $ref: '../schemas/UserTitleMini.yaml' + # required: + # - success + # - error + '400': + description: Invalid request body (missing fields, invalid types, etc.) + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to assign titles to this user + '404': + description: User or Title not found + '409': + description: Conflict — title already assigned to user (if applicable) + '500': + description: Internal server error \ No newline at end of file diff --git a/api/schemas/UserTitleMini.yaml b/api/schemas/UserTitleMini.yaml new file mode 100644 index 0000000..9e45e95 --- /dev/null +++ b/api/schemas/UserTitleMini.yaml @@ -0,0 +1,24 @@ +type: object +required: + - user_id + - title_id + - status +properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: ./enums/UserTitleStatus.yaml + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time +additionalProperties: false diff --git a/api/schemas/updateUser.yaml b/api/schemas/updateUser.yaml new file mode 100644 index 0000000..e1d77af --- /dev/null +++ b/api/schemas/updateUser.yaml @@ -0,0 +1,26 @@ +type: object +properties: + avatar_id: + type: integer + format: int64 + nullable: true + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + example: 42 + mail: + type: string + format: email + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + description: User email (must be unique and valid) + example: john.doe.updated@example.com + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Smith + user_desc: + type: string + description: User description / bio + maxLength: 512 + example: Just a curious developer. +additionalProperties: false +description: Only provided fields are updated. Omitted fields remain unchanged. \ No newline at end of file diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 781911f..23fda62 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -125,10 +125,32 @@ func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) ([]sqlc.UsertitleStatusT, e return sqlc_status, nil } +func UserTitleStatus2Sqlc1(s *oapi.UserTitleStatus) (*sqlc.UsertitleStatusT, error) { + var sqlc_status sqlc.UsertitleStatusT + if s == nil { + return nil, nil + } + + switch *s { + case oapi.UserTitleStatusFinished: + sqlc_status = sqlc.UsertitleStatusTFinished + case oapi.UserTitleStatusInProgress: + sqlc_status = sqlc.UsertitleStatusTInProgress + case oapi.UserTitleStatusDropped: + sqlc_status = sqlc.UsertitleStatusTDropped + case oapi.UserTitleStatusPlanned: + sqlc_status = sqlc.UsertitleStatusTPlanned + default: + return nil, fmt.Errorf("unexpected tittle status: %s", s) + } + + return &sqlc_status, nil +} + func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (oapi.UserTitle, error) { oapi_usertitle := oapi.UserTitle{ - Ctime: sqlDate2oapi(t.UserCtime), + Ctime: &t.UserCtime, Rate: t.UserRate, ReviewId: t.ReviewID, // Status: , @@ -316,3 +338,51 @@ func (s Server) UpdateUser(ctx context.Context, request oapi.UpdateUserRequestOb return oapi.UpdateUser200JSONResponse(oapi_user), nil } + +func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleRequestObject) (oapi.AddUserTitleResponseObject, error) { + + status, err := UserTitleStatus2Sqlc1(&request.Body.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.AddUserTitle400Response{}, nil + } + + params := sqlc.InsertUserTitleParams{ + UserID: request.UserId, + TitleID: request.Body.Title.Id, + Status: *status, + Rate: request.Body.Rate, + ReviewID: request.Body.ReviewId, + } + + user_title, err := s.db.InsertUserTitle(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.AddUserTitle500Response{}, nil + } + + oapi_status, err := sql2usertitlestatus(user_title.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.AddUserTitle500Response{}, nil + } + oapi_usertitle := struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status oapi.UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` + }{ + Ctime: &user_title.Ctime, + Rate: user_title.Rate, + ReviewId: user_title.ReviewID, + Status: oapi_status, + TitleId: user_title.TitleID, + UserId: user_title.UserID, + } + + return oapi.AddUserTitle200JSONResponse{Data: &oapi_usertitle}, nil +} diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 0bf1b86..450e0a7 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -412,7 +412,7 @@ WHERE review_id = sqlc.arg('review_id')::bigint; -- DELETE FROM reviews -- WHERE review_id = $1; --- name: ListReviewsByTitle :many +-- -- name: ListReviewsByTitle :many -- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -- FROM reviews -- WHERE title_id = $1 @@ -438,10 +438,16 @@ WHERE review_id = sqlc.arg('review_id')::bigint; -- ORDER BY usertitle_id -- LIMIT $2 OFFSET $3; --- -- name: CreateUserTitle :one --- INSERT INTO usertitles (user_id, title_id, status, rate, review_id) --- VALUES ($1, $2, $3, $4, $5) --- RETURNING usertitle_id, user_id, title_id, status, rate, review_id; +-- name: InsertUserTitle :one +INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +VALUES ( + sqlc.arg('user_id')::bigint, + sqlc.arg('title_id')::bigint, + sqlc.arg('status')::usertitle_status_t, + sqlc.narg('rate')::int, + sqlc.narg('review_id')::bigint +) +RETURNING user_id, title_id, status, rate, review_id, ctime; -- -- name: UpdateUserTitle :one -- UPDATE usertitles diff --git a/sql/models.go b/sql/models.go index 36d4c07..842d58c 100644 --- a/sql/models.go +++ b/sql/models.go @@ -277,10 +277,10 @@ type User struct { } type Usertitle struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` - Ctime pgtype.Timestamptz `json:"ctime"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime time.Time `json:"ctime"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index cac5543..fa44808 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -9,8 +9,6 @@ import ( "context" "encoding/json" "time" - - "github.com/jackc/pgx/v5/pgtype" ) const createImage = `-- name: CreateImage :one @@ -317,6 +315,93 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams return i, err } +const insertUserTitle = `-- name: InsertUserTitle :one + + + + + + + +INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +VALUES ( + $1::bigint, + $2::bigint, + $3::usertitle_status_t, + $4::int, + $5::bigint +) +RETURNING user_id, title_id, status, rate, review_id, ctime +` + +type InsertUserTitleParams struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` +} + +// -- name: CreateReview :one +// INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) +// VALUES ($1, $2, $3, $4, $5) +// RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date; +// -- name: UpdateReview :one +// UPDATE reviews +// SET +// +// image_ids = COALESCE(sqlc.narg('image_ids'), image_ids), +// review_text = COALESCE(sqlc.narg('review_text'), review_text) +// +// WHERE review_id = sqlc.arg('review_id') +// RETURNING *; +// -- name: DeleteReview :exec +// DELETE FROM reviews +// WHERE review_id = $1; +// +// -- name: ListReviewsByTitle :many +// +// SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +// FROM reviews +// WHERE title_id = $1 +// ORDER BY creation_date DESC +// LIMIT $2 OFFSET $3; +// -- name: ListReviewsByUser :many +// SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +// FROM reviews +// WHERE user_id = $1 +// ORDER BY creation_date DESC +// LIMIT $2 OFFSET $3; +// -- name: GetUserTitle :one +// SELECT usertitle_id, user_id, title_id, status, rate, review_id +// FROM usertitles +// WHERE user_id = $1 AND title_id = $2; +// -- name: ListUserTitles :many +// SELECT usertitle_id, user_id, title_id, status, rate, review_id +// FROM usertitles +// WHERE user_id = $1 +// ORDER BY usertitle_id +// LIMIT $2 OFFSET $3; +func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams) (Usertitle, error) { + row := q.db.QueryRow(ctx, insertUserTitle, + arg.UserID, + arg.TitleID, + arg.Status, + arg.Rate, + arg.ReviewID, + ) + var i Usertitle + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + ) + return i, err +} + const searchTitles = `-- name: SearchTitles :many SELECT t.id as id, @@ -684,26 +769,26 @@ type SearchUserTitlesParams struct { } type SearchUserTitlesRow struct { - ID int64 `json:"id"` - TitleNames json.RawMessage `json:"title_names"` - PosterID *int64 `json:"poster_id"` - TitleStatus TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - UserID int64 `json:"user_id"` - UsertitleStatus UsertitleStatusT `json:"usertitle_status"` - UserRate *int32 `json:"user_rate"` - ReviewID *int64 `json:"review_id"` - UserCtime pgtype.Timestamptz `json:"user_ctime"` - TitleStorageType string `json:"title_storage_type"` - TitleImagePath *string `json:"title_image_path"` - TagNames json.RawMessage `json:"tag_names"` - StudioName *string `json:"studio_name"` + ID int64 `json:"id"` + TitleNames json.RawMessage `json:"title_names"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + UserID int64 `json:"user_id"` + UsertitleStatus UsertitleStatusT `json:"usertitle_status"` + UserRate *int32 `json:"user_rate"` + ReviewID *int64 `json:"review_id"` + UserCtime time.Time `json:"user_ctime"` + TitleStorageType string `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` } // 100 is default limit From cea7cd3cd89585787e8e8a2a189035ac30a33b20 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 01:56:48 +0300 Subject: [PATCH 111/224] fix: delete logic improved --- sql/migrations/000001_init.up.sql | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 0a2fd71..437a99f 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -28,8 +28,8 @@ CREATE TABLE reviews ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, data text NOT NULL, rating int CHECK (rating >= 0 AND rating <= 10), - user_id bigint REFERENCES users (id), - title_id bigint REFERENCES titles (id), + user_id bigint REFERENCES users (id) ON DELETE SET NULL, + title_id bigint REFERENCES titles (id) ON DELETE CASCADE, created_at timestamptz DEFAULT NOW() ); @@ -41,20 +41,20 @@ CREATE TABLE review_images ( CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - avatar_id bigint REFERENCES images (id), + avatar_id bigint REFERENCES images (id) ON DELETE SET NULL, passhash text NOT NULL, mail text CHECK (mail ~ '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+$'), nickname text UNIQUE NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]{3,}$'), disp_name text, user_desc text, - creation_date timestamptz NOT NULL, + creation_date timestamptz NOT NULL DEFAULT NOW(), last_login timestamptz ); CREATE TABLE studios ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, studio_name text NOT NULL UNIQUE, - illust_id bigint REFERENCES images (id), + illust_id bigint REFERENCES images (id) ON DELETE SET NULL, studio_desc text ); @@ -64,7 +64,7 @@ CREATE TABLE titles ( -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, studio_id bigint NOT NULL REFERENCES studios (id), - poster_id bigint REFERENCES images (id), + poster_id bigint REFERENCES images (id) ON DELETE SET NULL, title_status title_status_t NOT NULL, rating float CHECK (rating >= 0 AND rating <= 10), rating_count int CHECK (rating_count >= 0), @@ -82,19 +82,19 @@ CREATE TABLE titles ( CREATE TABLE usertitles ( PRIMARY KEY (user_id, title_id), - user_id bigint NOT NULL REFERENCES users (id), - title_id bigint NOT NULL REFERENCES titles (id), + user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, + title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE, status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), - review_id bigint REFERENCES reviews (id), + review_id bigint REFERENCES reviews (id) ON DELETE SET NULL, ctime timestamptz NOT NULL DEFAULT now() -- // TODO: series status ); CREATE TABLE title_tags ( PRIMARY KEY (title_id, tag_id), - title_id bigint NOT NULL REFERENCES titles (id), - tag_id bigint NOT NULL REFERENCES tags (id) + title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE, + tag_id bigint NOT NULL REFERENCES tags (id) ON DELETE CASCADE ); CREATE TABLE signals ( @@ -180,6 +180,6 @@ END; $$ LANGUAGE plpgsql; CREATE TRIGGER set_ctime_on_update -BEFORE UPDATE ON usertitles +AFTER UPDATE ON usertitles FOR EACH ROW EXECUTE FUNCTION set_ctime(); \ No newline at end of file From f3fa41382ae63e5e2583081549dc4f62a4959ce4 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 02:19:30 +0300 Subject: [PATCH 112/224] fix: topology sort --- sql/migrations/000001_init.up.sql | 32 ++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 437a99f..392dcde 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -24,21 +24,6 @@ CREATE TABLE images ( image_path text UNIQUE NOT NULL ); -CREATE TABLE reviews ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - data text NOT NULL, - rating int CHECK (rating >= 0 AND rating <= 10), - user_id bigint REFERENCES users (id) ON DELETE SET NULL, - title_id bigint REFERENCES titles (id) ON DELETE CASCADE, - created_at timestamptz DEFAULT NOW() -); - -CREATE TABLE review_images ( - PRIMARY KEY (review_id, image_id), - review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, - image_id bigint NOT NULL REFERENCES images(id) ON DELETE CASCADE -); - CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, avatar_id bigint REFERENCES images (id) ON DELETE SET NULL, @@ -51,6 +36,8 @@ CREATE TABLE users ( last_login timestamptz ); + + CREATE TABLE studios ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, studio_name text NOT NULL UNIQUE, @@ -80,6 +67,21 @@ CREATE TABLE titles ( AND episodes_aired <= episodes_all)) ); +CREATE TABLE reviews ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + data text NOT NULL, + rating int CHECK (rating >= 0 AND rating <= 10), + user_id bigint REFERENCES users (id) ON DELETE SET NULL, + title_id bigint REFERENCES titles (id) ON DELETE CASCADE, + created_at timestamptz DEFAULT NOW() +); + +CREATE TABLE review_images ( + PRIMARY KEY (review_id, image_id), + review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, + image_id bigint NOT NULL REFERENCES images(id) ON DELETE CASCADE +); + CREATE TABLE usertitles ( PRIMARY KEY (user_id, title_id), user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, From ed79c71273628bc3e3498af53270cd543277e32b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 02:24:17 +0300 Subject: [PATCH 113/224] fix: topology sort. again --- sql/migrations/000001_init.up.sql | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 392dcde..18087cd 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -1,10 +1,10 @@ -- TODO: -- maybe jsonb constraints --- clean unused images +-- clea('finished', 'ongoing', 'planned'); +CREATE TYPE release_seasn unused images CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); CREATE TYPE storage_type_t AS ENUM ('local', 's3'); -CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned'); -CREATE TYPE release_season_t AS ENUM ('winter', 'spring', 'summer', 'fall'); +CREATE TYPE title_status_t AS ENUM on_t AS ENUM ('winter', 'spring', 'summer', 'fall'); CREATE TABLE providers ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, @@ -107,17 +107,17 @@ CREATE TABLE signals ( pending boolean NOT NULL ); +CREATE TABLE external_services ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text UNIQUE NOT NULL +); + CREATE TABLE external_ids ( user_id bigint NOT NULL REFERENCES users (id), service_id bigint REFERENCES external_services (id), external_id text NOT NULL ); -CREATE TABLE external_services ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name text UNIQUE NOT NULL -); - -- Functions CREATE OR REPLACE FUNCTION update_title_rating() RETURNS TRIGGER AS $$ From 9f74c9eeb62077062620b59e40cc195ed6929173 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 02:27:22 +0300 Subject: [PATCH 114/224] fix --- sql/migrations/000001_init.up.sql | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 18087cd..f8781de 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -1,10 +1,7 @@ --- TODO: --- maybe jsonb constraints --- clea('finished', 'ongoing', 'planned'); -CREATE TYPE release_seasn unused images CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); CREATE TYPE storage_type_t AS ENUM ('local', 's3'); -CREATE TYPE title_status_t AS ENUM on_t AS ENUM ('winter', 'spring', 'summer', 'fall'); +CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned'); +CREATE TYPE release_season_t AS ENUM ('winter', 'spring', 'summer', 'fall'); CREATE TABLE providers ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, From 4c7d03cddc2d24ccf231d2cc2f31330f291a9e4f Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 03:20:39 +0300 Subject: [PATCH 115/224] feat: --- api/_build/openapi.yaml | 4 ++++ api/api.gen.go | 17 ++++++++++++++--- api/schemas/Image.yaml | 2 +- api/schemas/enums/StorageType.yaml | 5 +++++ modules/backend/handlers/users.go | 2 +- modules/backend/queries.sql | 2 +- sql/sqlc.yaml | 2 ++ 7 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 api/schemas/enums/StorageType.yaml diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index aa96593..d2f231d 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -412,6 +412,10 @@ components: format: int64 storage_type: type: string + description: Image storage type + enum: + - s3 + - local image_path: type: string TitleStatus: diff --git a/api/api.gen.go b/api/api.gen.go index b092742..5e0ddb5 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -16,6 +16,12 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +// Defines values for ImageStorageType. +const ( + Local ImageStorageType = "local" + S3 ImageStorageType = "s3" +) + // Defines values for ReleaseSeason. const ( Fall ReleaseSeason = "fall" @@ -55,11 +61,16 @@ type CursorObj struct { // Image defines model for Image. type Image struct { - Id *int64 `json:"id,omitempty"` - ImagePath *string `json:"image_path,omitempty"` - StorageType *string `json:"storage_type,omitempty"` + Id *int64 `json:"id,omitempty"` + ImagePath *string `json:"image_path,omitempty"` + + // StorageType Image storage type + StorageType *ImageStorageType `json:"storage_type,omitempty"` } +// ImageStorageType Image storage type +type ImageStorageType string + // ReleaseSeason Title release season type ReleaseSeason string diff --git a/api/schemas/Image.yaml b/api/schemas/Image.yaml index 4ae3cb7..3fb520b 100644 --- a/api/schemas/Image.yaml +++ b/api/schemas/Image.yaml @@ -5,6 +5,6 @@ properties: type: integer format: int64 storage_type: - type: string + $ref: './enums/StorageType.yaml' image_path: type: string diff --git a/api/schemas/enums/StorageType.yaml b/api/schemas/enums/StorageType.yaml new file mode 100644 index 0000000..1984a87 --- /dev/null +++ b/api/schemas/enums/StorageType.yaml @@ -0,0 +1,5 @@ +type: string +description: Image storage type +enum: + - s3 + - local \ No newline at end of file diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 23fda62..9204eb9 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -141,7 +141,7 @@ func UserTitleStatus2Sqlc1(s *oapi.UserTitleStatus) (*sqlc.UsertitleStatusT, err case oapi.UserTitleStatusPlanned: sqlc_status = sqlc.UsertitleStatusTPlanned default: - return nil, fmt.Errorf("unexpected tittle status: %s", s) + return nil, fmt.Errorf("unexpected tittle status: %s", *s) } return &sqlc_status, nil diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 450e0a7..b9e05c1 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -334,7 +334,7 @@ WHERE sqlc.narg('usertitle_statuses')::usertitle_status_t[] IS NULL OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) IS NULL OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) = 0 - OR t.title_status = ANY(sqlc.narg('usertitle_statuses')::usertitle_status_t[]) + OR u.status = ANY(sqlc.narg('usertitle_statuses')::usertitle_status_t[]) ) AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index 3338c35..f26cf92 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -14,6 +14,8 @@ sql: emit_pointers_for_null_types: true emit_empty_slices: true #slices returned by :many queries will be empty instead of nil overrides: + - column: "titles.title_storage_type" + go_type: "*string" - db_type: "jsonb" go_type: "encoding/json.RawMessage" - db_type: "uuid" From 673ce48fac5c17f4aab157344cc8fce86e45e124 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 03:55:23 +0300 Subject: [PATCH 116/224] fix: bad types from sql --- modules/backend/handlers/common.go | 30 ++++++++++- modules/backend/handlers/titles.go | 85 ++++++++++++++++-------------- modules/backend/queries.sql | 8 +-- sql/queries.sql.go | 18 +++---- sql/sqlc.yaml | 7 ++- 5 files changed, 91 insertions(+), 57 deletions(-) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index e233f98..73efc42 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -17,6 +17,22 @@ func NewServer(db *sqlc.Queries) Server { return Server{db: db} } +func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.ImageStorageType, error) { + if s == nil { + return nil, nil + } + var t oapi.ImageStorageType + switch *s { + case sqlc.StorageTypeTLocal: + t = oapi.Local + case sqlc.StorageTypeTS3: + t = oapi.S3 + default: + return nil, fmt.Errorf("unexpected storage type: %s", *s) + } + return &t, nil +} + func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi.Title, error) { oapi_title := oapi.Title{ @@ -70,7 +86,13 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. oapi_studio.Poster = &oapi.Image{} oapi_studio.Poster.Id = title.StudioIllustID oapi_studio.Poster.ImagePath = title.StudioImagePath - oapi_studio.Poster.StorageType = &title.StudioStorageType + + s, err := sql2StorageType(title.StudioStorageType) + if err != nil { + return oapi.Title{}, fmt.Errorf("mapTitle, studio storage type: %v", err) + } + oapi_studio.Poster.StorageType = s + } } oapi_title.Studio = &oapi_studio @@ -80,7 +102,11 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. if title.PosterID != nil { oapi_image.Id = title.PosterID oapi_image.ImagePath = title.TitleImagePath - oapi_image.StorageType = &title.TitleStorageType + s, err := sql2StorageType(title.TitleStorageType) + if err != nil { + return oapi.Title{}, fmt.Errorf("mapTitle, title starage type: %v", err) + } + oapi_image.StorageType = s } oapi_title.Poster = &oapi_image diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index ec9426c..c67177f 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -81,56 +81,56 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro return oapi_tag_names, nil } -func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { +// func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { - var oapi_image oapi.Image +// var oapi_image oapi.Image - sqlc_image, err := s.db.GetImageByID(ctx, id) - if err != nil { - if err == pgx.ErrNoRows { - return nil, nil //todo: error reference in db - } - return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) - } +// sqlc_image, err := s.db.GetImageByID(ctx, id) +// if err != nil { +// if err == pgx.ErrNoRows { +// return nil, nil //todo: error reference in db +// } +// return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) +// } - //can cast and dont use brain cause all this fields required in image table - oapi_image.Id = &sqlc_image.ID - oapi_image.ImagePath = &sqlc_image.ImagePath - storageTypeStr := string(sqlc_image.StorageType) - oapi_image.StorageType = &storageTypeStr +// //can cast and dont use brain cause all this fields required in image table +// oapi_image.Id = &sqlc_image.ID +// oapi_image.ImagePath = &sqlc_image.ImagePath +// storageTypeStr := string(sqlc_image.StorageType) +// oapi_image.StorageType = string(storageTypeStr) - return &oapi_image, nil -} +// return &oapi_image, nil +// } -func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { +// func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { - var oapi_studio oapi.Studio +// var oapi_studio oapi.Studio - sqlc_studio, err := s.db.GetStudioByID(ctx, id) - if err != nil { - if err == pgx.ErrNoRows { - return nil, nil - } - return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) - } +// sqlc_studio, err := s.db.GetStudioByID(ctx, id) +// if err != nil { +// if err == pgx.ErrNoRows { +// return nil, nil +// } +// return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) +// } - oapi_studio.Id = sqlc_studio.ID - oapi_studio.Name = sqlc_studio.StudioName - oapi_studio.Description = sqlc_studio.StudioDesc +// oapi_studio.Id = sqlc_studio.ID +// oapi_studio.Name = sqlc_studio.StudioName +// oapi_studio.Description = sqlc_studio.StudioDesc - if sqlc_studio.IllustID == nil { - return &oapi_studio, nil - } - oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) - if err != nil { - return &oapi_studio, fmt.Errorf("GetImage: %v", err) - } - if oapi_illust != nil { - oapi_studio.Poster = oapi_illust - } +// if sqlc_studio.IllustID == nil { +// return &oapi_studio, nil +// } +// oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) +// if err != nil { +// return &oapi_studio, fmt.Errorf("GetImage: %v", err) +// } +// if oapi_illust != nil { +// oapi_studio.Poster = oapi_illust +// } - return &oapi_studio, nil -} +// return &oapi_studio, nil +// } func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitleIdRequestObject) (oapi.GetTitlesTitleIdResponseObject, error) { var oapi_title oapi.Title @@ -233,6 +233,11 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje // StudioImagePath: title.StudioImagePath, } + // if title.TitleStorageType != nil { + // s := *title.TitleStorageType + // _title.TitleStorageType = string(s) + // } + t, err := s.mapTitle(ctx, _title) if err != nil { log.Errorf("%v", err) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index b9e05c1..87d7755 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -76,7 +76,7 @@ RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail; -- sqlc.struct: TitlesFull SELECT t.*, - i.storage_type::text as title_storage_type, + i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), @@ -85,7 +85,7 @@ SELECT s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, - si.storage_type::text as studio_storage_type, + si.storage_type as studio_storage_type, si.image_path as studio_image_path FROM titles as t @@ -112,7 +112,7 @@ SELECT t.season as season, t.episodes_aired as episodes_aired, t.episodes_all as episodes_all, - i.storage_type::text as title_storage_type, + i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), @@ -243,7 +243,7 @@ SELECT u.rate as user_rate, u.review_id as review_id, u.ctime as user_ctime, - i.storage_type::text as title_storage_type, + i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), diff --git a/sql/queries.sql.go b/sql/queries.sql.go index fa44808..5236f1f 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -114,7 +114,7 @@ const getTitleByID = `-- name: GetTitleByID :one SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, - i.storage_type::text as title_storage_type, + i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), @@ -123,7 +123,7 @@ SELECT s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, - si.storage_type::text as studio_storage_type, + si.storage_type as studio_storage_type, si.image_path as studio_image_path FROM titles as t @@ -152,13 +152,13 @@ type GetTitleByIDRow struct { EpisodesAired *int32 `json:"episodes_aired"` EpisodesAll *int32 `json:"episodes_all"` EpisodesLen []byte `json:"episodes_len"` - TitleStorageType string `json:"title_storage_type"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` TagNames json.RawMessage `json:"tag_names"` StudioName *string `json:"studio_name"` StudioIllustID *int64 `json:"studio_illust_id"` StudioDesc *string `json:"studio_desc"` - StudioStorageType string `json:"studio_storage_type"` + StudioStorageType *StorageTypeT `json:"studio_storage_type"` StudioImagePath *string `json:"studio_image_path"` } @@ -415,7 +415,7 @@ SELECT t.season as season, t.episodes_aired as episodes_aired, t.episodes_all as episodes_all, - i.storage_type::text as title_storage_type, + i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), @@ -555,7 +555,7 @@ type SearchTitlesRow struct { Season *int32 `json:"season"` EpisodesAired *int32 `json:"episodes_aired"` EpisodesAll *int32 `json:"episodes_all"` - TitleStorageType string `json:"title_storage_type"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` TagNames json.RawMessage `json:"tag_names"` StudioName *string `json:"studio_name"` @@ -628,7 +628,7 @@ SELECT u.rate as user_rate, u.review_id as review_id, u.ctime as user_ctime, - i.storage_type::text as title_storage_type, + i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), @@ -719,7 +719,7 @@ WHERE $8::usertitle_status_t[] IS NULL OR array_length($8::usertitle_status_t[], 1) IS NULL OR array_length($8::usertitle_status_t[], 1) = 0 - OR t.title_status = ANY($8::usertitle_status_t[]) + OR u.status = ANY($8::usertitle_status_t[]) ) AND ($9::int IS NULL OR u.rate >= $9::int) AND ($10::float IS NULL OR t.rating >= $10::float) @@ -785,7 +785,7 @@ type SearchUserTitlesRow struct { UserRate *int32 `json:"user_rate"` ReviewID *int64 `json:"review_id"` UserCtime time.Time `json:"user_ctime"` - TitleStorageType string `json:"title_storage_type"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` TagNames json.RawMessage `json:"tag_names"` StudioName *string `json:"studio_name"` diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index f26cf92..de67bcf 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -14,8 +14,11 @@ sql: emit_pointers_for_null_types: true emit_empty_slices: true #slices returned by :many queries will be empty instead of nil overrides: - - column: "titles.title_storage_type" - go_type: "*string" + - db_type: "storage_type_t" + nullable: true + go_type: + type: "StorageTypeT" + pointer: true - db_type: "jsonb" go_type: "encoding/json.RawMessage" - db_type: "uuid" From a225d1fb6071622a5f293b40c2585ebc339b41a1 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 25 Nov 2025 04:13:52 +0300 Subject: [PATCH 117/224] feat: signup return username --- auth/auth.gen.go | 6 +++--- auth/openapi-auth.yaml | 14 ++++---------- modules/auth/handlers/handlers.go | 7 +++---- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index adb2b06..b24deb5 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -116,9 +116,9 @@ type PostAuthSignInResponseObject interface { } type PostAuthSignIn200JSONResponse struct { - Error *string `json:"error"` - Success *bool `json:"success,omitempty"` - UserId *string `json:"user_id"` + Error *string `json:"error"` + UserId *string `json:"user_id"` + UserName *string `json:"user_name"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index b9ce76f..b1b10ca 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -56,29 +56,23 @@ paths: type: string format: password responses: + # This one also sets two cookies: access_token and refresh_token "200": description: Sign-in result with JWT - # headers: - # Set-Cookie: - # schema: - # type: array - # items: - # type: string - # explode: true - # style: simple content: application/json: schema: type: object properties: - success: - type: boolean error: type: string nullable: true user_id: type: string nullable: true + user_name: + type: string + nullable: true "401": description: Access denied due to invalid credentials content: diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 9b9b0d3..7f675aa 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -78,7 +78,6 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque } err := "" - success := true pass, ok := UserDb[req.Body.Nickname] if !ok || pass != req.Body.Pass { @@ -96,9 +95,9 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque // Return access token; refresh token can be returned in response or HttpOnly cookie result := auth.PostAuthSignIn200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, + Error: &err, + UserId: &req.Body.Nickname, + UserName: &req.Body.Nickname, } return result, nil } From 3aafab36c266b22272981de050d37b58e80ec240 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 04:15:46 +0300 Subject: [PATCH 118/224] feat: now GetUser returnes all the image info --- api/_build/openapi.yaml | 7 ++----- api/api.gen.go | 6 ++---- api/schemas/User.yaml | 7 ++----- modules/backend/handlers/users.go | 26 +++++++++++++++++------ modules/backend/queries.sql | 16 ++++++++++++--- sql/queries.sql.go | 34 ++++++++++++++++++++++--------- 6 files changed, 63 insertions(+), 33 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index d2f231d..10cb7c7 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -552,11 +552,8 @@ components: format: int64 description: Unique user ID (primary key) example: 1 - avatar_id: - type: integer - format: int64 - description: ID of the user avatar (references images table) - example: null + image: + $ref: '#/components/schemas/Image' mail: type: string format: email diff --git a/api/api.gen.go b/api/api.gen.go index 5e0ddb5..02d389e 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -124,9 +124,6 @@ type TitleStatus string // User defines model for User. type User struct { - // AvatarId ID of the user avatar (references images table) - AvatarId *int64 `json:"avatar_id,omitempty"` - // CreationDate Timestamp when the user was created CreationDate *time.Time `json:"creation_date,omitempty"` @@ -134,7 +131,8 @@ type User struct { DispName *string `json:"disp_name,omitempty"` // Id Unique user ID (primary key) - Id *int64 `json:"id,omitempty"` + Id *int64 `json:"id,omitempty"` + Image *Image `json:"image,omitempty"` // Mail User email Mail *openapi_types.Email `json:"mail,omitempty"` diff --git a/api/schemas/User.yaml b/api/schemas/User.yaml index 8b4d88d..4e53534 100644 --- a/api/schemas/User.yaml +++ b/api/schemas/User.yaml @@ -5,11 +5,8 @@ properties: format: int64 description: Unique user ID (primary key) example: 1 - avatar_id: - type: integer - format: int64 - description: ID of the user avatar (references images table) - example: null + image: + $ref: '../schemas/Image.yaml' mail: type: string format: email diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 9204eb9..96e7251 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -27,16 +27,25 @@ import ( // return int32(i), err // } -func mapUser(u sqlc.GetUserByIDRow) oapi.User { +func mapUser(u sqlc.GetUserByIDRow) (oapi.User, error) { + i := oapi.Image{ + Id: u.AvatarID, + ImagePath: u.ImagePath, + } + s, err := sql2StorageType(u.StorageType) + if err != nil { + return oapi.User{}, fmt.Errorf("mapUser, storage type: %v", err) + } + i.StorageType = s return oapi.User{ - AvatarId: u.AvatarID, + Image: &i, CreationDate: &u.CreationDate, DispName: u.DispName, Id: &u.ID, Mail: StringToEmail(u.Mail), Nickname: u.Nickname, UserDesc: u.UserDesc, - } + }, nil } func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdRequestObject) (oapi.GetUsersUserIdResponseObject, error) { @@ -44,14 +53,19 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque if err != nil { return oapi.GetUsersUserId404Response{}, nil } - user, err := s.db.GetUserByID(context.TODO(), int64(userID)) + _user, err := s.db.GetUserByID(context.TODO(), int64(userID)) if err != nil { if err == pgx.ErrNoRows { return oapi.GetUsersUserId404Response{}, nil } return nil, err } - return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil + user, err := mapUser(_user) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserId500Response{}, err + } + return oapi.GetUsersUserId200JSONResponse(user), nil } func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { @@ -327,7 +341,7 @@ func (s Server) UpdateUser(ctx context.Context, request oapi.UpdateUserRequestOb } oapi_user := oapi.User{ // maybe its possible to make one sqlc type and use one map func iinstead of this shit - AvatarId: user.AvatarID, + // AvatarId: user.AvatarID, CreationDate: &user.CreationDate, DispName: user.DispName, Id: &user.ID, diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 87d7755..90484db 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -9,9 +9,19 @@ VALUES ($1, $2) RETURNING id, storage_type, image_path; -- name: GetUserByID :one -SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date -FROM users -WHERE id = $1; +SELECT + t.id as id, + t.avatar_id as avatar_id, + t.mail as mail, + t.nickname as nickname, + t.disp_name as disp_name, + t.user_desc as user_desc, + t.creation_date as creation_date, + i.storage_type as storage_type, + i.image_path as image_path +FROM users as t +LEFT JOIN images as i ON (t.avatar_id = i.id) +WHERE id = sqlc.arg('id')::bigint; -- name: GetStudioByID :one diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 5236f1f..d88a041 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -224,19 +224,31 @@ func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([]json.RawMe } const getUserByID = `-- name: GetUserByID :one -SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date -FROM users -WHERE id = $1 +SELECT + t.id as id, + t.avatar_id as avatar_id, + t.mail as mail, + t.nickname as nickname, + t.disp_name as disp_name, + t.user_desc as user_desc, + t.creation_date as creation_date, + i.storage_type as storage_type, + i.image_path as image_path +FROM users as t +LEFT JOIN images as i ON (t.avatar_id = i.id) +WHERE id = $1::bigint ` type GetUserByIDRow struct { - ID int64 `json:"id"` - AvatarID *int64 `json:"avatar_id"` - Mail *string `json:"mail"` - Nickname string `json:"nickname"` - DispName *string `json:"disp_name"` - UserDesc *string `json:"user_desc"` - CreationDate time.Time `json:"creation_date"` + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Mail *string `json:"mail"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + StorageType *StorageTypeT `json:"storage_type"` + ImagePath *string `json:"image_path"` } func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) { @@ -250,6 +262,8 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er &i.DispName, &i.UserDesc, &i.CreationDate, + &i.StorageType, + &i.ImagePath, ) return i, err } From 87eb6a6b12e66efd9f31ba461d5d60d7bba41b78 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 25 Nov 2025 04:13:52 +0300 Subject: [PATCH 119/224] feat: signup return username --- auth/auth.gen.go | 6 +++--- auth/openapi-auth.yaml | 14 ++++---------- modules/auth/handlers/handlers.go | 7 +++---- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index adb2b06..b24deb5 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -116,9 +116,9 @@ type PostAuthSignInResponseObject interface { } type PostAuthSignIn200JSONResponse struct { - Error *string `json:"error"` - Success *bool `json:"success,omitempty"` - UserId *string `json:"user_id"` + Error *string `json:"error"` + UserId *string `json:"user_id"` + UserName *string `json:"user_name"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 913c000..0fe308c 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -59,29 +59,23 @@ paths: type: string format: password responses: + # This one also sets two cookies: access_token and refresh_token "200": description: Sign-in result with JWT - # headers: - # Set-Cookie: - # schema: - # type: array - # items: - # type: string - # explode: true - # style: simple content: application/json: schema: type: object properties: - success: - type: boolean error: type: string nullable: true user_id: type: string nullable: true + user_name: + type: string + nullable: true "401": description: Access denied due to invalid credentials content: diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 9b9b0d3..7f675aa 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -78,7 +78,6 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque } err := "" - success := true pass, ok := UserDb[req.Body.Nickname] if !ok || pass != req.Body.Pass { @@ -96,9 +95,9 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque // Return access token; refresh token can be returned in response or HttpOnly cookie result := auth.PostAuthSignIn200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, + Error: &err, + UserId: &req.Body.Nickname, + UserName: &req.Body.Nickname, } return result, nil } From 354c577f7db7e6f82e2478aeb46b1e90bae74efa Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 25 Nov 2025 04:33:54 +0300 Subject: [PATCH 120/224] feat: reworked user and login page --- modules/frontend/src/App.tsx | 21 +- modules/frontend/src/api/core/OpenAPI.ts | 2 +- .../src/api/services/DefaultService.ts | 122 +++++++++++- .../frontend/src/auth/services/AuthService.ts | 2 +- .../src/pages/LoginPage/LoginPage.tsx | 14 +- .../src/pages/UsersIdPage/UsersIdPage.tsx | 183 ++++++++++++++++++ 6 files changed, 323 insertions(+), 21 deletions(-) create mode 100644 modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 5a25313..3ecfa2d 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,23 +1,34 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; -import UserPage from "./pages/UserPage/UserPage"; +import UsersIdPage from "./pages/UsersIdPage/UsersIdPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; const App: React.FC = () => { - const username = "nihonium"; + // Получаем username из localStorage + const username = localStorage.getItem("username") || undefined; + const userId = localStorage.getItem("userId"); + return ( <Router> <Header username={username} /> <Routes> - <Route path="/login" element={<LoginPage />} /> {/* <-- маршрут для логина */} - <Route path="/signup" element={<LoginPage />} /> {/* <-- можно использовать тот же компонент для регистрации */} - <Route path="/users/:id" element={<UserPage />} /> + <Route path="/login" element={<LoginPage />} /> + <Route path="/signup" element={<LoginPage />} /> + + {/* /profile рендерит UsersIdPage с id из localStorage */} + <Route + path="/profile" + element={userId ? <UsersIdPage userId={userId} /> : <LoginPage />} + /> + + <Route path="/users/:id" element={<UsersIdPage />} /> <Route path="/titles" element={<TitlesPage />} /> </Routes> </Router> ); }; + export default App; \ No newline at end of file diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 6ce873e..185e5c3 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://10.1.0.65:8081/api/v1', + BASE: '/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 52321b8..bb42012 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -20,7 +20,7 @@ export class DefaultService { * @param sort * @param sortForward * @param word - * @param status + * @param status List of title statuses to filter * @param rating * @param releaseYear * @param releaseSeason @@ -35,7 +35,7 @@ export class DefaultService { sort?: TitleSort, sortForward: boolean = true, word?: string, - status?: TitleStatus, + status?: Array<TitleStatus>, rating?: number, releaseYear?: number, releaseSeason?: ReleaseSeason, @@ -125,45 +125,112 @@ export class DefaultService { }, }); } + /** + * Partially update a user account + * Update selected user profile fields (excluding password). + * Password updates must be done via the dedicated auth-service (`/auth/`). + * Fields not provided in the request body remain unchanged. + * + * @param userId User ID (primary key) + * @param requestBody + * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). + * @throws ApiError + */ + public static updateUser( + userId: number, + requestBody: { + /** + * ID of the user avatar (references `images.id`); set to `null` to remove avatar + */ + avatar_id?: number | null; + /** + * User email (must be unique and valid) + */ + mail?: string; + /** + * Username (alphanumeric + `_` or `-`, 3–16 chars) + */ + nickname?: string; + /** + * Display name + */ + disp_name?: string; + /** + * User description / bio + */ + user_desc?: string; + }, + ): CancelablePromise<User> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{user_id}', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)`, + 401: `Unauthorized — missing or invalid authentication token`, + 403: `Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)`, + 404: `User not found`, + 409: `Conflict — e.g., requested \`nickname\` or \`mail\` already taken by another user`, + 422: `Unprocessable Entity — semantic errors not caught by schema (e.g., invalid \`avatar_id\`)`, + 500: `Unknown server error`, + }, + }); + } /** * Get user titles * @param userId * @param cursor + * @param sort + * @param sortForward * @param word - * @param status + * @param status List of title statuses to filter * @param watchStatus * @param rating + * @param myRate * @param releaseYear * @param releaseSeason * @param limit * @param fields - * @returns UserTitle List of user titles + * @returns any List of user titles * @throws ApiError */ public static getUsersTitles( userId: string, cursor?: string, + sort?: TitleSort, + sortForward: boolean = true, word?: string, - status?: TitleStatus, - watchStatus?: UserTitleStatus, + status?: Array<TitleStatus>, + watchStatus?: Array<UserTitleStatus>, rating?: number, + myRate?: number, releaseYear?: number, releaseSeason?: ReleaseSeason, limit: number = 10, fields: string = 'all', - ): CancelablePromise<Array<UserTitle>> { + ): CancelablePromise<{ + data: Array<UserTitle>; + cursor: CursorObj; + }> { return __request(OpenAPI, { method: 'GET', - url: '/users/{user_id}/titles/', + url: '/users/{user_id}/titles', path: { 'user_id': userId, }, query: { 'cursor': cursor, + 'sort': sort, + 'sort_forward': sortForward, 'word': word, 'status': status, 'watch_status': watchStatus, 'rating': rating, + 'my_rate': myRate, 'release_year': releaseYear, 'release_season': releaseSeason, 'limit': limit, @@ -175,4 +242,43 @@ export class DefaultService { }, }); } + /** + * Add a title to a user + * User adding title to list af watched, status required + * @param userId ID of the user to assign the title to + * @param requestBody + * @returns any Title successfully added to user + * @throws ApiError + */ + public static addUserTitle( + userId: number, + requestBody: UserTitle, + ): CancelablePromise<{ + data?: { + user_id: number; + title_id: number; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; + }; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid request body (missing fields, invalid types, etc.)`, + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to assign titles to this user`, + 404: `User or Title not found`, + 409: `Conflict — title already assigned to user (if applicable)`, + 500: `Internal server error`, + }, + }); + } } diff --git a/modules/frontend/src/auth/services/AuthService.ts b/modules/frontend/src/auth/services/AuthService.ts index bab9c77..94578d8 100644 --- a/modules/frontend/src/auth/services/AuthService.ts +++ b/modules/frontend/src/auth/services/AuthService.ts @@ -41,9 +41,9 @@ export class AuthService { pass: string; }, ): CancelablePromise<{ - success?: boolean; error?: string | null; user_id?: string | null; + user_name?: string | null; }> { return __request(OpenAPI, { method: 'POST', diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx index dcd6965..89ee88c 100644 --- a/modules/frontend/src/pages/LoginPage/LoginPage.tsx +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -18,17 +18,19 @@ export const LoginPage: React.FC = () => { try { if (isLogin) { const res = await AuthService.postAuthSignIn({ nickname, pass: password }); - if (res.success) { - // TODO: сохранить JWT в localStorage/cookie - console.log("Logged in user id:", res.user_id); - navigate("/"); // редирект после успешного входа + if (res.user_id && res.user_name) { + // Сохраняем user_id и username в localStorage + localStorage.setItem("userId", res.user_id); + localStorage.setItem("username", res.user_name); + + navigate("/profile"); // редирект на профиль } else { setError(res.error || "Login failed"); } } else { + // SignUp оставляем без сохранения данных const res = await AuthService.postAuthSignUp({ nickname, pass: password }); - if (res.success) { - console.log("User signed up with id:", res.user_id); + if (res.user_id) { setIsLogin(true); // переключаемся на login после регистрации } else { setError(res.error || "Sign up failed"); diff --git a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx new file mode 100644 index 0000000..b5a8336 --- /dev/null +++ b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx @@ -0,0 +1,183 @@ +// pages/UserPage/UserPage.tsx +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { DefaultService } from "../../api/services/DefaultService"; +import { SearchBar } from "../../components/SearchBar/SearchBar"; +import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; +import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; +import { ListView } from "../../components/ListView/ListView"; +import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; +import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; +import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; + +const PAGE_SIZE = 10; + +type UsersIdPageProps = { + userId?: string; +}; + +export default function UsersIdPage({ userId }: UsersIdPageProps) { + const params = useParams(); + const id = userId || params?.id; + + const [user, setUser] = useState<User | null>(null); + const [loadingUser, setLoadingUser] = useState(true); + const [errorUser, setErrorUser] = useState<string | null>(null); + + // Для списка тайтлов + const [titles, setTitles] = useState<UserTitle[]>([]); + const [nextPage, setNextPage] = useState<UserTitle[]>([]); + const [cursor, setCursor] = useState<CursorObj | null>(null); + const [loadingTitles, setLoadingTitles] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [search, setSearch] = useState(""); + const [sort, setSort] = useState<TitleSort>("id"); + const [sortForward, setSortForward] = useState(true); + const [layout, setLayout] = useState<"square" | "horizontal">("square"); + + // --- Получение данных пользователя --- + useEffect(() => { + const fetchUser = async () => { + if (!id) return; + setLoadingUser(true); + try { + const result = await DefaultService.getUsers(id, "all"); + setUser(result); + setErrorUser(null); + } catch (err: any) { + console.error(err); + setErrorUser(err?.message || "Failed to fetch user data"); + } finally { + setLoadingUser(false); + } + }; + fetchUser(); + }, [id]); + + // --- Получение списка тайтлов пользователя --- + const fetchPage = async (cursorObj: CursorObj | null) => { + if (!id) return { items: [], nextCursor: null }; + const cursorStr = cursorObj + ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") + : ""; + + try { + const result = await DefaultService.getUsersTitles( + id, + cursorStr, + sort, + sortForward, + search.trim() || undefined, + undefined, // status фильтр, можно добавить + undefined, // watchStatus + undefined, // rating + undefined, // myRate + undefined, // releaseYear + undefined, // releaseSeason + PAGE_SIZE, + "all" + ); + + if (!result?.data?.length) return { items: [], nextCursor: null }; + + return { items: result.data, nextCursor: result.cursor ?? null }; + } catch (err: any) { + if (err.status === 204) return { items: [], nextCursor: null }; + throw err; + } + }; + + // Инициализация: загружаем сразу две страницы + useEffect(() => { + const initLoad = async () => { + setLoadingTitles(true); + setTitles([]); + setNextPage([]); + setCursor(null); + + const firstPage = await fetchPage(null); + const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null }; + + setTitles(firstPage.items); + setNextPage(secondPage.items); + setCursor(secondPage.nextCursor); + setLoadingTitles(false); + }; + initLoad(); + }, [id, search, sort, sortForward]); + + const handleLoadMore = async () => { + if (nextPage.length === 0) { + setLoadingMore(false); + return; + } + setLoadingMore(true); + + setTitles(prev => [...prev, ...nextPage]); + setNextPage([]); + + if (cursor) { + try { + const next = await fetchPage(cursor); + if (next.items.length > 0) setNextPage(next.items); + setCursor(next.nextCursor); + } catch (err) { + console.error(err); + } + } + + setLoadingMore(false); + }; + + const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> + + {/* --- Карточка пользователя --- */} + {loadingUser && <div className="mt-10 text-xl font-medium">Loading user...</div>} + {errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>} + {user && ( + <div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8"> + <img src={getAvatarUrl(user.avatar_id)} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" /> + <h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2> + {user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>} + {user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>} + {user.creation_date && <p className="text-gray-400 mt-4 text-sm">Registered: {new Date(user.creation_date).toLocaleDateString()}</p>} + </div> + )} + + {/* --- Панель поиска, сортировки и лейаута --- */} + <div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center"> + <SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} /> + <LayoutSwitch layout={layout} setLayout={setLayout} /> + <TitlesSortBox sort={sort} setSort={setSort} sortForward={sortForward} setSortForward={setSortForward} /> + </div> + + {/* --- Список тайтлов --- */} + {loadingTitles && <div className="mt-6 font-medium text-black">Loading titles...</div>} + {!loadingTitles && titles.length === 0 && <div className="mt-6 font-medium text-black">No titles found.</div>} + + {titles.length > 0 && ( + <> + <ListView<UserTitle> + items={titles} + layout={layout} + hasMore={!!cursor || nextPage.length > 1} + loadingMore={loadingMore} + onLoadMore={handleLoadMore} + renderItem={(title, layout) => + layout === "square" ? <TitleCardSquare title={title} /> : <TitleCardHorizontal title={title} /> + } + /> + + {!cursor && nextPage.length === 0 && ( + <div className="mt-6 font-medium text-black"> + Результатов больше нет, было найдено {titles.length} тайтлов. + </div> + )} + </> + )} + </div> + ); +} From dbdb52269a5cc1d3055dfca52e0d1db0e3930f26 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 04:42:56 +0300 Subject: [PATCH 121/224] fix --- api/_build/openapi.yaml | 2 + api/api.gen.go | 8 +++ api/paths/users-id-titles.yaml | 2 + modules/backend/handlers/common.go | 4 +- modules/backend/handlers/users.go | 8 ++- modules/backend/queries.sql | 6 +- sql/queries.sql.go | 98 ++++++++++++++++-------------- 7 files changed, 76 insertions(+), 52 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 10cb7c7..6b39558 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -304,6 +304,8 @@ paths: description: No titles found '400': description: Request params are not correct + '404': + description: User not found '500': description: Unknown server error post: diff --git a/api/api.gen.go b/api/api.gen.go index 02d389e..f3e935c 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -1275,6 +1275,14 @@ func (response GetUsersUserIdTitles400Response) VisitGetUsersUserIdTitlesRespons return nil } +type GetUsersUserIdTitles404Response struct { +} + +func (response GetUsersUserIdTitles404Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + type GetUsersUserIdTitles500Response struct { } diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 7e6ac5e..23ea761 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -85,6 +85,8 @@ get: description: No titles found '400': description: Request params are not correct + '404': + description: User not found '500': description: Unknown server error diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 73efc42..2cf2283 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -125,9 +125,9 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. return oapi_title, nil } -func parseInt64(s string) (int32, error) { +func parseInt64(s string) (int64, error) { i, err := strconv.ParseInt(s, 10, 64) - return int32(i), err + return i, err } func TitleStatus2Sqlc(s *[]oapi.TitleStatus) ([]sqlc.TitleStatusT, error) { diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 96e7251..927c1c1 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -53,7 +53,7 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque if err != nil { return oapi.GetUsersUserId404Response{}, nil } - _user, err := s.db.GetUserByID(context.TODO(), int64(userID)) + _user, err := s.db.GetUserByID(context.TODO(), userID) if err != nil { if err == pgx.ErrNoRows { return oapi.GetUsersUserId404Response{}, nil @@ -243,7 +243,13 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU return oapi.GetUsersUserIdTitles400Response{}, err } + userID, err := parseInt64(request.UserId) + if err != nil { + log.Errorf("get user titles: %v", err) + return oapi.GetUsersUserIdTitles404Response{}, err + } params := sqlc.SearchUserTitlesParams{ + UserID: userID, Word: word, TitleStatuses: title_statuses, UsertitleStatuses: watch_status, diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 90484db..0146b25 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -21,7 +21,7 @@ SELECT i.image_path as image_path FROM users as t LEFT JOIN images as i ON (t.avatar_id = i.id) -WHERE id = sqlc.arg('id')::bigint; +WHERE t.id = sqlc.arg('id')::bigint; -- name: GetStudioByID :one @@ -269,6 +269,8 @@ LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) WHERE + u.user_id = sqlc.arg('user_id')::bigint + AND CASE WHEN sqlc.arg('forward')::boolean THEN -- forward: greater than cursor (next page) @@ -352,7 +354,7 @@ WHERE AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) GROUP BY - t.id, i.id, s.id + t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id ORDER BY CASE WHEN sqlc.arg('forward')::boolean THEN diff --git a/sql/queries.sql.go b/sql/queries.sql.go index d88a041..a46da86 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -236,7 +236,7 @@ SELECT i.image_path as image_path FROM users as t LEFT JOIN images as i ON (t.avatar_id = i.id) -WHERE id = $1::bigint +WHERE t.id = $1::bigint ` type GetUserByIDRow struct { @@ -658,43 +658,45 @@ LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) WHERE + u.user_id = $1::bigint + AND CASE - WHEN $1::boolean THEN + WHEN $2::boolean THEN -- forward: greater than cursor (next page) - CASE $2::text + CASE $3::text WHEN 'year' THEN - ($3::int IS NULL) OR - (t.release_year > $3::int) OR - (t.release_year = $3::int AND t.id > $4::bigint) + ($4::int IS NULL) OR + (t.release_year > $4::int) OR + (t.release_year = $4::int AND t.id > $5::bigint) WHEN 'rating' THEN - ($5::float IS NULL) OR - (t.rating > $5::float) OR - (t.rating = $5::float AND t.id > $4::bigint) + ($6::float IS NULL) OR + (t.rating > $6::float) OR + (t.rating = $6::float AND t.id > $5::bigint) WHEN 'id' THEN - ($4::bigint IS NULL) OR - (t.id > $4::bigint) + ($5::bigint IS NULL) OR + (t.id > $5::bigint) ELSE true -- fallback END ELSE -- backward: less than cursor (prev page) - CASE $2::text + CASE $3::text WHEN 'year' THEN - ($3::int IS NULL) OR - (t.release_year < $3::int) OR - (t.release_year = $3::int AND t.id < $4::bigint) + ($4::int IS NULL) OR + (t.release_year < $4::int) OR + (t.release_year = $4::int AND t.id < $5::bigint) WHEN 'rating' THEN - ($5::float IS NULL) OR - (t.rating < $5::float) OR - (t.rating = $5::float AND t.id < $4::bigint) + ($6::float IS NULL) OR + (t.rating < $6::float) OR + (t.rating = $6::float AND t.id < $5::bigint) WHEN 'id' THEN - ($4::bigint IS NULL) OR - (t.id < $4::bigint) + ($5::bigint IS NULL) OR + (t.id < $5::bigint) ELSE true END @@ -702,7 +704,7 @@ WHERE AND ( CASE - WHEN $6::text IS NOT NULL THEN + WHEN $7::text IS NOT NULL THEN ( SELECT bool_and( EXISTS ( @@ -714,7 +716,7 @@ WHERE FROM unnest( ARRAY( SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array($6::text, ' ')) AS w + FROM unnest(string_to_array($7::text, ' ')) AS w WHERE trim(w) <> '' ) ) AS pattern @@ -724,49 +726,50 @@ WHERE ) AND ( - $7::title_status_t[] IS NULL - OR array_length($7::title_status_t[], 1) IS NULL - OR array_length($7::title_status_t[], 1) = 0 - OR t.title_status = ANY($7::title_status_t[]) + $8::title_status_t[] IS NULL + OR array_length($8::title_status_t[], 1) IS NULL + OR array_length($8::title_status_t[], 1) = 0 + OR t.title_status = ANY($8::title_status_t[]) ) AND ( - $8::usertitle_status_t[] IS NULL - OR array_length($8::usertitle_status_t[], 1) IS NULL - OR array_length($8::usertitle_status_t[], 1) = 0 - OR u.status = ANY($8::usertitle_status_t[]) + $9::usertitle_status_t[] IS NULL + OR array_length($9::usertitle_status_t[], 1) IS NULL + OR array_length($9::usertitle_status_t[], 1) = 0 + OR u.status = ANY($9::usertitle_status_t[]) ) - AND ($9::int IS NULL OR u.rate >= $9::int) - AND ($10::float IS NULL OR t.rating >= $10::float) - AND ($11::int IS NULL OR t.release_year = $11::int) - AND ($12::release_season_t IS NULL OR t.release_season = $12::release_season_t) + AND ($10::int IS NULL OR u.rate >= $10::int) + AND ($11::float IS NULL OR t.rating >= $11::float) + AND ($12::int IS NULL OR t.release_year = $12::int) + AND ($13::release_season_t IS NULL OR t.release_season = $13::release_season_t) GROUP BY - t.id, i.id, s.id + t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id ORDER BY - CASE WHEN $1::boolean THEN + CASE WHEN $2::boolean THEN CASE - WHEN $2::text = 'id' THEN t.id - WHEN $2::text = 'year' THEN t.release_year - WHEN $2::text = 'rating' THEN t.rating - WHEN $2::text = 'rate' THEN u.rate + WHEN $3::text = 'id' THEN t.id + WHEN $3::text = 'year' THEN t.release_year + WHEN $3::text = 'rating' THEN t.rating + WHEN $3::text = 'rate' THEN u.rate END END ASC, - CASE WHEN NOT $1::boolean THEN + CASE WHEN NOT $2::boolean THEN CASE - WHEN $2::text = 'id' THEN t.id - WHEN $2::text = 'year' THEN t.release_year - WHEN $2::text = 'rating' THEN t.rating - WHEN $2::text = 'rate' THEN u.rate + WHEN $3::text = 'id' THEN t.id + WHEN $3::text = 'year' THEN t.release_year + WHEN $3::text = 'rating' THEN t.rating + WHEN $3::text = 'rate' THEN u.rate END END DESC, - CASE WHEN $2::text <> 'id' THEN t.id END ASC + CASE WHEN $3::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($13::int, 100) +LIMIT COALESCE($14::int, 100) ` type SearchUserTitlesParams struct { + UserID int64 `json:"user_id"` Forward bool `json:"forward"` SortBy string `json:"sort_by"` CursorYear *int32 `json:"cursor_year"` @@ -808,6 +811,7 @@ type SearchUserTitlesRow struct { // 100 is default limit func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { rows, err := q.db.Query(ctx, searchUserTitles, + arg.UserID, arg.Forward, arg.SortBy, arg.CursorYear, From b8bfe01ef578f3db8338de54f2898888d9889fd9 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Tue, 25 Nov 2025 04:57:07 +0300 Subject: [PATCH 122/224] fix: usertitles status mapping --- modules/backend/handlers/users.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 927c1c1..d800e7a 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -101,14 +101,14 @@ func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { func sql2usertitlestatus(s sqlc.UsertitleStatusT) (oapi.UserTitleStatus, error) { var status oapi.UserTitleStatus - switch status { - case "finished": + switch s { + case sqlc.UsertitleStatusTFinished: status = oapi.UserTitleStatusFinished - case "dropped": + case sqlc.UsertitleStatusTDropped: status = oapi.UserTitleStatusDropped - case "planned": + case sqlc.UsertitleStatusTPlanned: status = oapi.UserTitleStatusPlanned - case "in-progress": + case sqlc.UsertitleStatusTInProgress: status = oapi.UserTitleStatusInProgress default: return status, fmt.Errorf("unexpected tittle status: %s", s) From 51bf7b6f7e4c2bcbd4266f207280aecf05bbb2f9 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Tue, 25 Nov 2025 05:36:57 +0300 Subject: [PATCH 123/224] fix: UserTitle cards --- api/schemas/UserTitle.yaml | 1 - modules/frontend/src/api/models/Image.ts | 5 ++++- modules/frontend/src/api/models/User.ts | 6 ++--- modules/frontend/src/api/models/UserTitle.ts | 12 +++++++++- .../src/api/services/DefaultService.ts | 1 + .../frontend/src/components/Header/Header.tsx | 2 +- .../cards/UserTitleCardHorizontal.tsx | 22 +++++++++++++++++++ .../components/cards/UserTitleCardSquare.tsx | 22 +++++++++++++++++++ .../frontend/src/pages/UserPage/UserPage.tsx | 4 ++-- .../src/pages/UsersIdPage/UsersIdPage.tsx | 10 ++++----- 10 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx create mode 100644 modules/frontend/src/components/cards/UserTitleCardSquare.tsx diff --git a/api/schemas/UserTitle.yaml b/api/schemas/UserTitle.yaml index 3beaec6..ef619cb 100644 --- a/api/schemas/UserTitle.yaml +++ b/api/schemas/UserTitle.yaml @@ -20,4 +20,3 @@ properties: ctime: type: string format: date-time -additionalProperties: true diff --git a/modules/frontend/src/api/models/Image.ts b/modules/frontend/src/api/models/Image.ts index 1317db7..a94de74 100644 --- a/modules/frontend/src/api/models/Image.ts +++ b/modules/frontend/src/api/models/Image.ts @@ -4,7 +4,10 @@ /* eslint-disable */ export type Image = { id?: number; - storage_type?: string; + /** + * Image storage type + */ + storage_type?: 's3' | 'local'; image_path?: string; }; diff --git a/modules/frontend/src/api/models/User.ts b/modules/frontend/src/api/models/User.ts index cd76dbe..969023f 100644 --- a/modules/frontend/src/api/models/User.ts +++ b/modules/frontend/src/api/models/User.ts @@ -2,15 +2,13 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { Image } from './Image'; export type User = { /** * Unique user ID (primary key) */ id?: number; - /** - * ID of the user avatar (references images table) - */ - avatar_id?: number; + image?: Image; /** * User email */ diff --git a/modules/frontend/src/api/models/UserTitle.ts b/modules/frontend/src/api/models/UserTitle.ts index 26d5ddc..42b7919 100644 --- a/modules/frontend/src/api/models/UserTitle.ts +++ b/modules/frontend/src/api/models/UserTitle.ts @@ -2,4 +2,14 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type UserTitle = Record<string, any>; +import type { Title } from './Title'; +import type { UserTitleStatus } from './UserTitleStatus'; +export type UserTitle = { + user_id: number; + title?: Title; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; +}; + diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index bb42012..874971e 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -238,6 +238,7 @@ export class DefaultService { }, errors: { 400: `Request params are not correct`, + 404: `User not found`, 500: `Unknown server error`, }, }); diff --git a/modules/frontend/src/components/Header/Header.tsx b/modules/frontend/src/components/Header/Header.tsx index 98b1295..26f1658 100644 --- a/modules/frontend/src/components/Header/Header.tsx +++ b/modules/frontend/src/components/Header/Header.tsx @@ -12,7 +12,7 @@ export const Header: React.FC<HeaderProps> = ({ username }) => { const toggleMenu = () => setMenuOpen(!menuOpen); return ( - <header className="w-full bg-white shadow-md fixed top-0 left-0 z-50"> + <header className="w-full bg-white shadow-md sticky top-0 left-0 z-50"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="flex justify-between h-16 items-center"> diff --git a/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx b/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx new file mode 100644 index 0000000..ad7d5df --- /dev/null +++ b/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx @@ -0,0 +1,22 @@ +import type { UserTitle } from "../../api"; + +export function UserTitleCardHorizontal({ title }: { title: UserTitle }) { + return ( + <div style={{ + display: "flex", + gap: 12, + padding: 12, + border: "1px solid #ddd", + borderRadius: 8 + }}> + {title.title?.poster?.image_path && ( + <img src={title.title?.poster.image_path} width={80} /> + )} + <div> + <h3>{title.title?.title_names["en"]}</h3> + <p>{title.title?.release_year} · {title.title?.release_season} · Rating: {title.title?.rating}</p> + <p>Status: {title.title?.title_status}</p> + </div> + </div> + ); +} diff --git a/modules/frontend/src/components/cards/UserTitleCardSquare.tsx b/modules/frontend/src/components/cards/UserTitleCardSquare.tsx new file mode 100644 index 0000000..edcf1d5 --- /dev/null +++ b/modules/frontend/src/components/cards/UserTitleCardSquare.tsx @@ -0,0 +1,22 @@ +import type { UserTitle } from "../../api"; + +export function UserTitleCardSquare({ title }: { title: UserTitle }) { + return ( + <div style={{ + width: 160, + border: "1px solid #ddd", + padding: 8, + borderRadius: 8, + textAlign: "center" + }}> + {title.title?.poster?.image_path && ( + <img src={title.title?.poster.image_path} width={140} /> + )} + <div> + <h4>{title.title?.title_names["en"]}</h4> + <h5>{title.status}</h5> + <small>{title.title?.release_year} • {title.title?.rating}</small> + </div> + </div> + ); +} diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 52c5574..2e39e6b 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -35,9 +35,9 @@ const UserPage: React.FC = () => { <div className={styles.container}> <div className={styles.header}> <div className={styles.avatarWrapper}> - {user.avatar_id ? ( + {user.image?.image_path ? ( <img - src={`/images/${user.avatar_id}.png`} + src={`/images/${user.image.image_path}.png`} alt="User Avatar" className={styles.avatarImg} /> diff --git a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx index b5a8336..342f22c 100644 --- a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx +++ b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx @@ -6,8 +6,8 @@ import { SearchBar } from "../../components/SearchBar/SearchBar"; import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; import { ListView } from "../../components/ListView/ListView"; -import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; -import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; +import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; +import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; const PAGE_SIZE = 10; @@ -129,7 +129,7 @@ export default function UsersIdPage({ userId }: UsersIdPageProps) { setLoadingMore(false); }; - const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); + // const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); return ( <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> @@ -139,7 +139,7 @@ export default function UsersIdPage({ userId }: UsersIdPageProps) { {errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>} {user && ( <div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8"> - <img src={getAvatarUrl(user.avatar_id)} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" /> + <img src={user.image?.image_path} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" /> <h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2> {user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>} {user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>} @@ -167,7 +167,7 @@ export default function UsersIdPage({ userId }: UsersIdPageProps) { loadingMore={loadingMore} onLoadMore={handleLoadMore} renderItem={(title, layout) => - layout === "square" ? <TitleCardSquare title={title} /> : <TitleCardHorizontal title={title} /> + layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} /> } /> From 65b76d58c3936c84e9bedba00008046dc090e961 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 27 Nov 2025 03:19:53 +0300 Subject: [PATCH 124/224] fix: now post usertitle dont need title body --- api/_build/openapi.yaml | 24 +++++++++++++++++++++++- api/api.gen.go | 14 +++++++++++++- api/paths/users-id-titles.yaml | 25 ++++++++++++++++++++++++- modules/backend/handlers/users.go | 8 ++++---- 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 6b39558..d816a3a 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -326,7 +326,29 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserTitle' + type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time responses: '200': description: Title successfully added to user diff --git a/api/api.gen.go b/api/api.gen.go index f3e935c..5c49f12 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -226,11 +226,23 @@ type GetUsersUserIdTitlesParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// AddUserTitleJSONBody defines parameters for AddUserTitle. +type AddUserTitleJSONBody struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` +} + // UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. type UpdateUserJSONRequestBody UpdateUserJSONBody // AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. -type AddUserTitleJSONRequestBody = UserTitle +type AddUserTitleJSONRequestBody AddUserTitleJSONBody // Getter for additional properties for Title. Returns the specified // element and whether it was found diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 23ea761..80b9916 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -108,7 +108,30 @@ post: content: application/json: schema: - $ref: '../schemas/UserTitle.yaml' + type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: ../schemas/enums/UserTitleStatus.yaml + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + responses: '200': description: Title successfully added to user diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index d800e7a..89b77e0 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -140,9 +140,9 @@ func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) ([]sqlc.UsertitleStatusT, e } func UserTitleStatus2Sqlc1(s *oapi.UserTitleStatus) (*sqlc.UsertitleStatusT, error) { - var sqlc_status sqlc.UsertitleStatusT + var sqlc_status sqlc.UsertitleStatusT = sqlc.UsertitleStatusTFinished if s == nil { - return nil, nil + return &sqlc_status, nil } switch *s { @@ -304,7 +304,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU tmp := fmt.Sprint(*t.Title.ReleaseYear) new_cursor.Param = &tmp case "rating": - tmp := strconv.FormatFloat(*t.Title.Rating, 'f', -1, 64) + tmp := strconv.FormatFloat(*t.Title.Rating, 'f', -1, 64) // падает new_cursor.Param = &tmp } } @@ -369,7 +369,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque params := sqlc.InsertUserTitleParams{ UserID: request.UserId, - TitleID: request.Body.Title.Id, + TitleID: request.Body.TitleId, Status: *status, Rate: request.Body.Rate, ReviewID: request.Body.ReviewId, From cb9fba6fbc5f2ec5e9b581bdea6cddde9508b071 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 27 Nov 2025 03:46:40 +0300 Subject: [PATCH 125/224] feat: patch usertitle described --- api/_build/openapi.yaml | 105 ++++++++++++------- api/api.gen.go | 162 ++---------------------------- api/paths/users-id-titles.yaml | 76 +++++++++----- modules/backend/handlers/users.go | 4 +- 4 files changed, 133 insertions(+), 214 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index d816a3a..e7482c1 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -328,13 +328,9 @@ paths: schema: type: object required: - - user_id - title_id - status properties: - user_id: - type: integer - format: int64 title_id: type: integer format: int64 @@ -343,12 +339,6 @@ paths: rate: type: integer format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time responses: '200': description: Title successfully added to user @@ -356,32 +346,29 @@ paths: application/json: schema: type: object + required: + - user_id + - title_id + - status properties: - data: - type: object - required: - - user_id - - title_id - - status - properties: - user_id: - type: integer - format: int64 - title_id: - type: integer - format: int64 - status: - $ref: '#/components/schemas/UserTitleStatus' - rate: - type: integer - format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time - additionalProperties: false + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + additionalProperties: false '400': description: 'Invalid request body (missing fields, invalid types, etc.)' '401': @@ -394,6 +381,53 @@ paths: description: Conflict — title already assigned to user (if applicable) '500': description: Internal server error + patch: + summary: Update a usertitle + description: User updating title list of watched + operationId: updateUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title_id + properties: + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + responses: + '200': + description: Title successfully updated + content: + application/json: + schema: + $ref: '#/paths/~1users~1%7Buser_id%7D~1titles/post/responses/200/content/application~1json/schema' + '400': + description: 'Invalid request body (missing fields, invalid types, etc.)' + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to update title + '404': + description: User or Title not found + '500': + description: Internal server error components: parameters: cursor: @@ -629,4 +663,3 @@ components: ctime: type: string format: date-time - additionalProperties: true diff --git a/api/api.gen.go b/api/api.gen.go index 5c49f12..cb5c1ae 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -151,10 +151,9 @@ type UserTitle struct { ReviewId *int64 `json:"review_id,omitempty"` // Status User's title status - Status UserTitleStatus `json:"status"` - Title *Title `json:"title,omitempty"` - UserId int64 `json:"user_id"` - AdditionalProperties map[string]interface{} `json:"-"` + Status UserTitleStatus `json:"status"` + Title *Title `json:"title,omitempty"` + UserId int64 `json:"user_id"` } // UserTitleStatus User's title status @@ -486,145 +485,6 @@ func (a Title) MarshalJSON() ([]byte, error) { return json.Marshal(object) } -// Getter for additional properties for UserTitle. Returns the specified -// element and whether it was found -func (a UserTitle) Get(fieldName string) (value interface{}, found bool) { - if a.AdditionalProperties != nil { - value, found = a.AdditionalProperties[fieldName] - } - return -} - -// Setter for additional properties for UserTitle -func (a *UserTitle) Set(fieldName string, value interface{}) { - if a.AdditionalProperties == nil { - a.AdditionalProperties = make(map[string]interface{}) - } - a.AdditionalProperties[fieldName] = value -} - -// Override default JSON handling for UserTitle to handle AdditionalProperties -func (a *UserTitle) UnmarshalJSON(b []byte) error { - object := make(map[string]json.RawMessage) - err := json.Unmarshal(b, &object) - if err != nil { - return err - } - - if raw, found := object["ctime"]; found { - err = json.Unmarshal(raw, &a.Ctime) - if err != nil { - return fmt.Errorf("error reading 'ctime': %w", err) - } - delete(object, "ctime") - } - - if raw, found := object["rate"]; found { - err = json.Unmarshal(raw, &a.Rate) - if err != nil { - return fmt.Errorf("error reading 'rate': %w", err) - } - delete(object, "rate") - } - - if raw, found := object["review_id"]; found { - err = json.Unmarshal(raw, &a.ReviewId) - if err != nil { - return fmt.Errorf("error reading 'review_id': %w", err) - } - delete(object, "review_id") - } - - if raw, found := object["status"]; found { - err = json.Unmarshal(raw, &a.Status) - if err != nil { - return fmt.Errorf("error reading 'status': %w", err) - } - delete(object, "status") - } - - if raw, found := object["title"]; found { - err = json.Unmarshal(raw, &a.Title) - if err != nil { - return fmt.Errorf("error reading 'title': %w", err) - } - delete(object, "title") - } - - if raw, found := object["user_id"]; found { - err = json.Unmarshal(raw, &a.UserId) - if err != nil { - return fmt.Errorf("error reading 'user_id': %w", err) - } - delete(object, "user_id") - } - - if len(object) != 0 { - a.AdditionalProperties = make(map[string]interface{}) - for fieldName, fieldBuf := range object { - var fieldVal interface{} - err := json.Unmarshal(fieldBuf, &fieldVal) - if err != nil { - return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) - } - a.AdditionalProperties[fieldName] = fieldVal - } - } - return nil -} - -// Override default JSON handling for UserTitle to handle AdditionalProperties -func (a UserTitle) MarshalJSON() ([]byte, error) { - var err error - object := make(map[string]json.RawMessage) - - if a.Ctime != nil { - object["ctime"], err = json.Marshal(a.Ctime) - if err != nil { - return nil, fmt.Errorf("error marshaling 'ctime': %w", err) - } - } - - if a.Rate != nil { - object["rate"], err = json.Marshal(a.Rate) - if err != nil { - return nil, fmt.Errorf("error marshaling 'rate': %w", err) - } - } - - if a.ReviewId != nil { - object["review_id"], err = json.Marshal(a.ReviewId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'review_id': %w", err) - } - } - - object["status"], err = json.Marshal(a.Status) - if err != nil { - return nil, fmt.Errorf("error marshaling 'status': %w", err) - } - - if a.Title != nil { - object["title"], err = json.Marshal(a.Title) - if err != nil { - return nil, fmt.Errorf("error marshaling 'title': %w", err) - } - } - - object["user_id"], err = json.Marshal(a.UserId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'user_id': %w", err) - } - - for fieldName, field := range a.AdditionalProperties { - object[fieldName], err = json.Marshal(field) - if err != nil { - return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) - } - } - return json.Marshal(object) -} - // ServerInterface represents all server handlers. type ServerInterface interface { // Get titles @@ -1313,16 +1173,14 @@ type AddUserTitleResponseObject interface { } type AddUserTitle200JSONResponse struct { - Data *struct { - Ctime *time.Time `json:"ctime,omitempty"` - Rate *int32 `json:"rate,omitempty"` - ReviewId *int64 `json:"review_id,omitempty"` + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` - // Status User's title status - Status UserTitleStatus `json:"status"` - TitleId int64 `json:"title_id"` - UserId int64 `json:"user_id"` - } `json:"data,omitempty"` + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` } func (response AddUserTitle200JSONResponse) VisitAddUserTitleResponse(w http.ResponseWriter) error { diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 80b9916..1580cc1 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -110,13 +110,9 @@ post: schema: type: object required: - - user_id - title_id - status properties: - user_id: - type: integer - format: int64 title_id: type: integer format: int64 @@ -125,12 +121,6 @@ post: rate: type: integer format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time responses: '200': @@ -138,20 +128,8 @@ post: content: application/json: schema: - type: object - properties: - # success: - # type: boolean - # example: true - # error: - # type: string - # nullable: true - # example: null - data: - $ref: '../schemas/UserTitleMini.yaml' - # required: - # - success - # - error + $ref: '../schemas/UserTitleMini.yaml' + '400': description: Invalid request body (missing fields, invalid types, etc.) '401': @@ -162,5 +140,55 @@ post: description: User or Title not found '409': description: Conflict — title already assigned to user (if applicable) + '500': + description: Internal server error + +patch: + summary: Update a usertitle + description: User updating title list of watched + operationId: updateUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title_id + properties: + title_id: + type: integer + format: int64 + status: + $ref: ../schemas/enums/UserTitleStatus.yaml + rate: + type: integer + format: int32 + + responses: + '200': + description: Title successfully updated + content: + application/json: + schema: + $ref: '../schemas/UserTitleMini.yaml' + + '400': + description: Invalid request body (missing fields, invalid types, etc.) + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to update title + '404': + description: User or Title not found '500': description: Internal server error \ No newline at end of file diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 89b77e0..1881f36 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -360,7 +360,7 @@ func (s Server) UpdateUser(ctx context.Context, request oapi.UpdateUserRequestOb } func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleRequestObject) (oapi.AddUserTitleResponseObject, error) { - + //TODO: add review if exists status, err := UserTitleStatus2Sqlc1(&request.Body.Status) if err != nil { log.Errorf("%v", err) @@ -404,5 +404,5 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque UserId: user_title.UserID, } - return oapi.AddUserTitle200JSONResponse{Data: &oapi_usertitle}, nil + return oapi.AddUserTitle200JSONResponse(oapi_usertitle), nil } From e0a68d7d0f9c4cd834ace7bc721a9d6dbe51aaba Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 27 Nov 2025 05:48:13 +0300 Subject: [PATCH 126/224] feat: delete usertitle described --- api/_build/openapi.yaml | 423 ++++++++++++++++++--------------- api/api.gen.go | 290 ++++++++++++++++++++-- api/openapi.yaml | 1 - api/paths/users-id-titles.yaml | 33 ++- api/schemas/UserTitleMini.yaml | 3 +- deploy/api_gen.ps1 | 4 + 6 files changed, 526 insertions(+), 228 deletions(-) create mode 100644 deploy/api_gen.ps1 diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index e7482c1..403a45c 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -11,52 +11,52 @@ paths: parameters: - $ref: '#/components/parameters/cursor' - $ref: '#/components/parameters/title_sort' - - in: query - name: sort_forward + - name: sort_forward + in: query schema: type: boolean default: true - - in: query - name: word + - name: word + in: query schema: type: string - - in: query - name: status + - name: status + in: query + description: List of title statuses to filter schema: type: array items: $ref: '#/components/schemas/TitleStatus' - description: List of title statuses to filter - style: form explode: false - - in: query - name: rating + style: form + - name: rating + in: query schema: type: number format: double - - in: query - name: release_year + - name: release_year + in: query schema: type: integer format: int32 - - in: query - name: release_season + - name: release_season + in: query schema: $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit + - name: limit + in: query schema: type: integer format: int32 default: 10 - - in: query - name: offset + - name: offset + in: query schema: type: integer format: int32 default: 0 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -69,10 +69,10 @@ paths: type: object properties: data: + description: List of titles type: array items: $ref: '#/components/schemas/Title' - description: List of titles cursor: $ref: '#/components/schemas/CursorObj' required: @@ -88,14 +88,14 @@ paths: get: summary: Get title description parameters: - - in: path - name: title_id + - name: title_id + in: path required: true schema: type: integer format: int64 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -118,13 +118,13 @@ paths: get: summary: Get user info parameters: - - in: path - name: user_id + - name: user_id + in: path required: true schema: type: string - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -142,59 +142,59 @@ paths: '500': description: Unknown server error patch: + operationId: updateUser summary: Partially update a user account description: | Update selected user profile fields (excluding password). Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. - operationId: updateUser parameters: - name: user_id in: path + description: User ID (primary key) required: true schema: type: integer format: int64 - description: User ID (primary key) example: 123 requestBody: required: true content: application/json: schema: + description: Only provided fields are updated. Omitted fields remain unchanged. type: object properties: avatar_id: + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar type: integer format: int64 - nullable: true - description: ID of the user avatar (references `images.id`); set to `null` to remove avatar example: 42 + nullable: true mail: + description: User email (must be unique and valid) type: string format: email - pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' - description: User email (must be unique and valid) example: john.doe.updated@example.com + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' nickname: - type: string - pattern: '^[a-zA-Z0-9_-]{3,16}$' description: 'Username (alphanumeric + `_` or `-`, 3–16 chars)' + type: string + example: john_doe_43 maxLength: 16 minLength: 3 - example: john_doe_43 + pattern: '^[a-zA-Z0-9_-]{3,16}$' disp_name: - type: string description: Display name - maxLength: 32 - example: John Smith - user_desc: type: string + example: John Smith + maxLength: 32 + user_desc: description: User description / bio - maxLength: 512 + type: string example: Just a curious developer. + maxLength: 512 additionalProperties: false - description: Only provided fields are updated. Omitted fields remain unchanged. responses: '200': description: User updated successfully. Returns updated user representation (excluding sensitive fields). @@ -222,64 +222,64 @@ paths: parameters: - $ref: '#/components/parameters/cursor' - $ref: '#/components/parameters/title_sort' - - in: path - name: user_id + - name: user_id + in: path required: true schema: type: string - - in: query - name: sort_forward + - name: sort_forward + in: query schema: type: boolean default: true - - in: query - name: word + - name: word + in: query schema: type: string - - in: query - name: status + - name: status + in: query + description: List of title statuses to filter schema: type: array items: $ref: '#/components/schemas/TitleStatus' - description: List of title statuses to filter - style: form explode: false - - in: query - name: watch_status + style: form + - name: watch_status + in: query schema: type: array items: $ref: '#/components/schemas/UserTitleStatus' - style: form explode: false - - in: query - name: rating + style: form + - name: rating + in: query schema: type: number format: double - - in: query - name: my_rate + - name: my_rate + in: query schema: type: integer format: int32 - - in: query - name: release_year + - name: release_year + in: query schema: type: integer format: int32 - - in: query - name: release_season + - name: release_season + in: query schema: $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit + - name: limit + in: query schema: type: integer format: int32 default: 10 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -309,17 +309,17 @@ paths: '500': description: Unknown server error post: + operationId: addUserTitle summary: Add a title to a user description: 'User adding title to list af watched, status required' - operationId: addUserTitle parameters: - name: user_id in: path + description: ID of the user to assign the title to required: true schema: type: integer format: int64 - description: ID of the user to assign the title to example: 123 requestBody: required: true @@ -327,9 +327,6 @@ paths: application/json: schema: type: object - required: - - title_id - - status properties: title_id: type: integer @@ -339,36 +336,16 @@ paths: rate: type: integer format: int32 + required: + - title_id + - status responses: '200': description: Title successfully added to user content: application/json: schema: - type: object - required: - - user_id - - title_id - - status - properties: - user_id: - type: integer - format: int64 - title_id: - type: integer - format: int64 - status: - $ref: '#/components/schemas/UserTitleStatus' - rate: - type: integer - format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time - additionalProperties: false + $ref: '#/components/schemas/UserTitleMini' '400': description: 'Invalid request body (missing fields, invalid types, etc.)' '401': @@ -382,17 +359,17 @@ paths: '500': description: Internal server error patch: + operationId: updateUserTitle summary: Update a usertitle description: User updating title list of watched - operationId: updateUserTitle parameters: - name: user_id in: path + description: ID of the user to assign the title to required: true schema: type: integer format: int64 - description: ID of the user to assign the title to example: 123 requestBody: required: true @@ -400,8 +377,6 @@ paths: application/json: schema: type: object - required: - - title_id properties: title_id: type: integer @@ -411,13 +386,15 @@ paths: rate: type: integer format: int32 + required: + - title_id responses: '200': description: Title successfully updated content: application/json: schema: - $ref: '#/paths/~1users~1%7Buser_id%7D~1titles/post/responses/200/content/application~1json/schema' + $ref: '#/components/schemas/UserTitleMini' '400': description: 'Invalid request body (missing fields, invalid types, etc.)' '401': @@ -428,6 +405,30 @@ paths: description: User or Title not found '500': description: Internal server error + delete: + operationId: deleteUserTitle + summary: Delete a usertitle + description: User deleting title from list of watched + parameters: + - name: user_id + in: path + description: ID of the user to assign the title to + required: true + schema: + type: integer + format: int64 + example: 123 + responses: + '200': + description: Title successfully deleted + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to delete title + '404': + description: User or Title not found + '500': + description: Internal server error components: parameters: cursor: @@ -443,25 +444,36 @@ components: schema: $ref: '#/components/schemas/TitleSort' schemas: - CursorObj: - type: object - required: - - id - properties: - id: - type: integer - format: int64 - param: - type: string TitleSort: - type: string description: Title sort order + type: string default: id enum: - id - year - rating - views + TitleStatus: + description: Title status + type: string + enum: + - finished + - ongoing + - planned + ReleaseSeason: + description: Title release season + type: string + enum: + - winter + - spring + - summer + - fall + StorageType: + description: Image storage type + type: string + enum: + - s3 + - local Image: type: object properties: @@ -469,65 +481,11 @@ components: type: integer format: int64 storage_type: - type: string - description: Image storage type - enum: - - s3 - - local + $ref: '#/components/schemas/StorageType' image_path: type: string - TitleStatus: - type: string - description: Title status - enum: - - finished - - ongoing - - planned - ReleaseSeason: - type: string - description: Title release season - enum: - - winter - - spring - - summer - - fall - UserTitleStatus: - type: string - description: User's title status - enum: - - finished - - planned - - dropped - - in-progress - Review: - type: object - additionalProperties: true - Tag: - type: object - description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' - additionalProperties: - type: string - example: - en: Shojo - ru: Сёдзё - ja: 少女 - Tags: - type: array - description: Array of localized tags - items: - $ref: '#/components/schemas/Tag' - example: - - en: Shojo - ru: Сёдзё - ja: 少女 - - en: Shounen - ru: Сёнен - ja: 少年 Studio: type: object - required: - - id - - name properties: id: type: integer @@ -538,30 +496,41 @@ components: $ref: '#/components/schemas/Image' description: type: string - Title: - type: object required: - id - - title_names - - tags + - name + Tag: + description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' + type: object + example: + en: Shojo + ru: Сёдзё + ja: 少女 + additionalProperties: + type: string + Tags: + description: Array of localized tags + type: array + items: + $ref: '#/components/schemas/Tag' + example: + - en: Shojo + ru: Сёдзё + ja: 少女 + - en: Shounen + ru: Сёнен + ja: 少年 + Title: + type: object properties: id: + description: Unique title ID (primary key) type: integer format: int64 - description: Unique title ID (primary key) example: 1 title_names: - type: object description: 'Localized titles. Key = language (ISO 639-1), value = list of names' - additionalProperties: - type: array - items: - type: string - example: Attack on Titan - minItems: 1 - example: - - Attack on Titan - - AoT + type: object example: en: - Attack on Titan @@ -571,6 +540,15 @@ components: - Титаны ja: - 進撃の巨人 + additionalProperties: + type: array + items: + type: string + example: Attack on Titan + minItems: 1 + example: + - Attack on Titan + - AoT studio: $ref: '#/components/schemas/Studio' tags: @@ -602,50 +580,68 @@ components: type: number format: double additionalProperties: true - User: + required: + - id + - title_names + - tags + CursorObj: type: object properties: id: type: integer format: int64 + param: + type: string + required: + - id + User: + type: object + properties: + id: description: Unique user ID (primary key) + type: integer + format: int64 example: 1 image: $ref: '#/components/schemas/Image' mail: + description: User email type: string format: email - description: User email example: john.doe@example.com nickname: - type: string description: Username (alphanumeric + _ or -) - maxLength: 16 + type: string example: john_doe_42 + maxLength: 16 disp_name: - type: string description: Display name - maxLength: 32 - example: John Doe - user_desc: type: string + example: John Doe + maxLength: 32 + user_desc: description: User description - maxLength: 512 + type: string example: Just a regular user. + maxLength: 512 creation_date: + description: Timestamp when the user was created type: string format: date-time - description: Timestamp when the user was created example: '2025-10-10T23:45:47.908073Z' required: - user_id - nickname + UserTitleStatus: + description: User's title status + type: string + enum: + - finished + - planned + - dropped + - in-progress UserTitle: type: object - required: - - user_id - - title_id - - status properties: user_id: type: integer @@ -663,3 +659,34 @@ components: ctime: type: string format: date-time + required: + - user_id + - title_id + - status + UserTitleMini: + type: object + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + required: + - user_id + - title_id + - status + Review: + type: object + additionalProperties: true diff --git a/api/api.gen.go b/api/api.gen.go index cb5c1ae..6af01d0 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -16,12 +16,6 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) -// Defines values for ImageStorageType. -const ( - Local ImageStorageType = "local" - S3 ImageStorageType = "s3" -) - // Defines values for ReleaseSeason. const ( Fall ReleaseSeason = "fall" @@ -30,6 +24,12 @@ const ( Winter ReleaseSeason = "winter" ) +// Defines values for StorageType. +const ( + Local StorageType = "local" + S3 StorageType = "s3" +) + // Defines values for TitleSort. const ( Id TitleSort = "id" @@ -65,15 +65,15 @@ type Image struct { ImagePath *string `json:"image_path,omitempty"` // StorageType Image storage type - StorageType *ImageStorageType `json:"storage_type,omitempty"` + StorageType *StorageType `json:"storage_type,omitempty"` } -// ImageStorageType Image storage type -type ImageStorageType string - // ReleaseSeason Title release season type ReleaseSeason string +// StorageType Image storage type +type StorageType string + // Studio defines model for Studio. type Studio struct { Description *string `json:"description,omitempty"` @@ -156,6 +156,18 @@ type UserTitle struct { UserId int64 `json:"user_id"` } +// UserTitleMini defines model for UserTitleMini. +type UserTitleMini struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` +} + // UserTitleStatus User's title status type UserTitleStatus string @@ -225,21 +237,30 @@ type GetUsersUserIdTitlesParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// UpdateUserTitleJSONBody defines parameters for UpdateUserTitle. +type UpdateUserTitleJSONBody struct { + Rate *int32 `json:"rate,omitempty"` + + // Status User's title status + Status *UserTitleStatus `json:"status,omitempty"` + TitleId int64 `json:"title_id"` +} + // AddUserTitleJSONBody defines parameters for AddUserTitle. type AddUserTitleJSONBody struct { - Ctime *time.Time `json:"ctime,omitempty"` - Rate *int32 `json:"rate,omitempty"` - ReviewId *int64 `json:"review_id,omitempty"` + Rate *int32 `json:"rate,omitempty"` // Status User's title status Status UserTitleStatus `json:"status"` TitleId int64 `json:"title_id"` - UserId int64 `json:"user_id"` } // UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. type UpdateUserJSONRequestBody UpdateUserJSONBody +// UpdateUserTitleJSONRequestBody defines body for UpdateUserTitle for application/json ContentType. +type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody + // AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. type AddUserTitleJSONRequestBody AddUserTitleJSONBody @@ -499,9 +520,15 @@ type ServerInterface interface { // Partially update a user account // (PATCH /users/{user_id}) UpdateUser(c *gin.Context, userId int64) + // Delete a usertitle + // (DELETE /users/{user_id}/titles) + DeleteUserTitle(c *gin.Context, userId int64) // Get user titles // (GET /users/{user_id}/titles) GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) + // Update a usertitle + // (PATCH /users/{user_id}/titles) + UpdateUserTitle(c *gin.Context, userId int64) // Add a title to a user // (POST /users/{user_id}/titles) AddUserTitle(c *gin.Context, userId int64) @@ -716,6 +743,30 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { siw.Handler.UpdateUser(c, userId) } +// DeleteUserTitle operation middleware +func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteUserTitle(c, userId) +} + // GetUsersUserIdTitles operation middleware func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { @@ -839,6 +890,30 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { siw.Handler.GetUsersUserIdTitles(c, userId, params) } +// UpdateUserTitle operation middleware +func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.UpdateUserTitle(c, userId) +} + // AddUserTitle operation middleware func (siw *ServerInterfaceWrapper) AddUserTitle(c *gin.Context) { @@ -894,7 +969,9 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitlesTitleId) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) + router.DELETE(options.BaseURL+"/users/:user_id/titles", wrapper.DeleteUserTitle) router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) + router.PATCH(options.BaseURL+"/users/:user_id/titles", wrapper.UpdateUserTitle) router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.AddUserTitle) } @@ -1110,6 +1187,54 @@ func (response UpdateUser500Response) VisitUpdateUserResponse(w http.ResponseWri return nil } +type DeleteUserTitleRequestObject struct { + UserId int64 `json:"user_id"` +} + +type DeleteUserTitleResponseObject interface { + VisitDeleteUserTitleResponse(w http.ResponseWriter) error +} + +type DeleteUserTitle200Response struct { +} + +func (response DeleteUserTitle200Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type DeleteUserTitle401Response struct { +} + +func (response DeleteUserTitle401Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type DeleteUserTitle403Response struct { +} + +func (response DeleteUserTitle403Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type DeleteUserTitle404Response struct { +} + +func (response DeleteUserTitle404Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type DeleteUserTitle500Response struct { +} + +func (response DeleteUserTitle500Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type GetUsersUserIdTitlesRequestObject struct { UserId string `json:"user_id"` Params GetUsersUserIdTitlesParams @@ -1163,6 +1288,64 @@ func (response GetUsersUserIdTitles500Response) VisitGetUsersUserIdTitlesRespons return nil } +type UpdateUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + Body *UpdateUserTitleJSONRequestBody +} + +type UpdateUserTitleResponseObject interface { + VisitUpdateUserTitleResponse(w http.ResponseWriter) error +} + +type UpdateUserTitle200JSONResponse UserTitleMini + +func (response UpdateUserTitle200JSONResponse) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateUserTitle400Response struct { +} + +func (response UpdateUserTitle400Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type UpdateUserTitle401Response struct { +} + +func (response UpdateUserTitle401Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type UpdateUserTitle403Response struct { +} + +func (response UpdateUserTitle403Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type UpdateUserTitle404Response struct { +} + +func (response UpdateUserTitle404Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type UpdateUserTitle500Response struct { +} + +func (response UpdateUserTitle500Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type AddUserTitleRequestObject struct { UserId int64 `json:"user_id"` Body *AddUserTitleJSONRequestBody @@ -1172,16 +1355,7 @@ type AddUserTitleResponseObject interface { VisitAddUserTitleResponse(w http.ResponseWriter) error } -type AddUserTitle200JSONResponse struct { - Ctime *time.Time `json:"ctime,omitempty"` - Rate *int32 `json:"rate,omitempty"` - ReviewId *int64 `json:"review_id,omitempty"` - - // Status User's title status - Status UserTitleStatus `json:"status"` - TitleId int64 `json:"title_id"` - UserId int64 `json:"user_id"` -} +type AddUserTitle200JSONResponse UserTitleMini func (response AddUserTitle200JSONResponse) VisitAddUserTitleResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") @@ -1252,9 +1426,15 @@ type StrictServerInterface interface { // Partially update a user account // (PATCH /users/{user_id}) UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error) + // Delete a usertitle + // (DELETE /users/{user_id}/titles) + DeleteUserTitle(ctx context.Context, request DeleteUserTitleRequestObject) (DeleteUserTitleResponseObject, error) // Get user titles // (GET /users/{user_id}/titles) GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) + // Update a usertitle + // (PATCH /users/{user_id}/titles) + UpdateUserTitle(ctx context.Context, request UpdateUserTitleRequestObject) (UpdateUserTitleResponseObject, error) // Add a title to a user // (POST /users/{user_id}/titles) AddUserTitle(ctx context.Context, request AddUserTitleRequestObject) (AddUserTitleResponseObject, error) @@ -1390,6 +1570,33 @@ func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { } } +// DeleteUserTitle operation middleware +func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64) { + var request DeleteUserTitleRequestObject + + request.UserId = userId + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteUserTitle(ctx, request.(DeleteUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(DeleteUserTitleResponseObject); ok { + if err := validResponse.VisitDeleteUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetUsersUserIdTitles operation middleware func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { var request GetUsersUserIdTitlesRequestObject @@ -1418,6 +1625,41 @@ func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, p } } +// UpdateUserTitle operation middleware +func (sh *strictHandler) UpdateUserTitle(ctx *gin.Context, userId int64) { + var request UpdateUserTitleRequestObject + + request.UserId = userId + + var body UpdateUserTitleJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.UpdateUserTitle(ctx, request.(UpdateUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(UpdateUserTitleResponseObject); ok { + if err := validResponse.VisitUpdateUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // AddUserTitle operation middleware func (sh *strictHandler) AddUserTitle(ctx *gin.Context, userId int64) { var request AddUserTitleRequestObject diff --git a/api/openapi.yaml b/api/openapi.yaml index 7da26f8..23f2058 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -21,4 +21,3 @@ components: $ref: "./parameters/_index.yaml" schemas: $ref: "./schemas/_index.yaml" - \ No newline at end of file diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 1580cc1..18c805e 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -117,11 +117,10 @@ post: type: integer format: int64 status: - $ref: ../schemas/enums/UserTitleStatus.yaml + $ref: '../schemas/enums/UserTitleStatus.yaml' rate: type: integer format: int32 - responses: '200': description: Title successfully added to user @@ -169,7 +168,7 @@ patch: type: integer format: int64 status: - $ref: ../schemas/enums/UserTitleStatus.yaml + $ref: '../schemas/enums/UserTitleStatus.yaml' rate: type: integer format: int32 @@ -190,5 +189,33 @@ patch: description: Forbidden — user not allowed to update title '404': description: User or Title not found + '500': + description: Internal server error + +delete: + summary: Delete a usertitle + description: User deleting title from list of watched + operationId: deleteUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + + responses: + '200': + description: Title successfully deleted + # '400': + # description: Invalid request body (missing fields, invalid types, etc.) + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to delete title + '404': + description: User or Title not found '500': description: Internal server error \ No newline at end of file diff --git a/api/schemas/UserTitleMini.yaml b/api/schemas/UserTitleMini.yaml index 9e45e95..e20bcbf 100644 --- a/api/schemas/UserTitleMini.yaml +++ b/api/schemas/UserTitleMini.yaml @@ -20,5 +20,4 @@ properties: format: int64 ctime: type: string - format: date-time -additionalProperties: false + format: date-time \ No newline at end of file diff --git a/deploy/api_gen.ps1 b/deploy/api_gen.ps1 new file mode 100644 index 0000000..c8966b7 --- /dev/null +++ b/deploy/api_gen.ps1 @@ -0,0 +1,4 @@ +cd ./api +openapi-format .\openapi.yaml --output .\_build\openapi.yaml --yaml +cd .. +oapi-codegen --config=api\oapi-codegen.yaml api\_build\openapi.yaml From 68294dd13c3bd02e57929260ecab044d3f63fd38 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 06:11:55 +0300 Subject: [PATCH 127/224] fix: oapi shitty generation --- api/_build/openapi.yaml | 402 +++++++++--------- api/paths/titles-id.yaml | 1 + api/paths/users-id-titles.yaml | 7 +- api/paths/users-id.yaml | 1 + api/schemas/Title.yaml | 1 - api/schemas/UserTitleMini.yaml | 1 - modules/frontend/src/api/index.ts | 2 + modules/frontend/src/api/models/Image.ts | 6 +- .../frontend/src/api/models/StorageType.ts | 8 + modules/frontend/src/api/models/Title.ts | 28 +- .../frontend/src/api/models/UserTitleMini.ts | 14 + .../src/api/services/DefaultService.ts | 51 ++- .../src/pages/TitlePage/TitlePage.module.css | 0 .../src/pages/TitlePage/TitlePage.tsx | 64 --- .../frontend/src/pages/UserPage/UserPage.tsx | 2 +- .../src/pages/UsersIdPage/UsersIdPage.tsx | 2 +- 16 files changed, 302 insertions(+), 288 deletions(-) create mode 100644 modules/frontend/src/api/models/StorageType.ts create mode 100644 modules/frontend/src/api/models/UserTitleMini.ts delete mode 100644 modules/frontend/src/pages/TitlePage/TitlePage.module.css diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index e7482c1..720b686 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -11,52 +11,52 @@ paths: parameters: - $ref: '#/components/parameters/cursor' - $ref: '#/components/parameters/title_sort' - - in: query - name: sort_forward + - name: sort_forward + in: query schema: type: boolean default: true - - in: query - name: word + - name: word + in: query schema: type: string - - in: query - name: status + - name: status + in: query + description: List of title statuses to filter schema: type: array items: $ref: '#/components/schemas/TitleStatus' - description: List of title statuses to filter - style: form explode: false - - in: query - name: rating + style: form + - name: rating + in: query schema: type: number format: double - - in: query - name: release_year + - name: release_year + in: query schema: type: integer format: int32 - - in: query - name: release_season + - name: release_season + in: query schema: $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit + - name: limit + in: query schema: type: integer format: int32 default: 10 - - in: query - name: offset + - name: offset + in: query schema: type: integer format: int32 default: 0 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -69,10 +69,10 @@ paths: type: object properties: data: + description: List of titles type: array items: $ref: '#/components/schemas/Title' - description: List of titles cursor: $ref: '#/components/schemas/CursorObj' required: @@ -86,16 +86,17 @@ paths: description: Unknown server error '/titles/{title_id}': get: + operationId: getTitle summary: Get title description parameters: - - in: path - name: title_id + - name: title_id + in: path required: true schema: type: integer format: int64 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -116,15 +117,16 @@ paths: description: Unknown server error '/users/{user_id}': get: + operationId: getUsersId summary: Get user info parameters: - - in: path - name: user_id + - name: user_id + in: path required: true schema: type: string - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -142,59 +144,59 @@ paths: '500': description: Unknown server error patch: + operationId: updateUser summary: Partially update a user account description: | Update selected user profile fields (excluding password). Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. - operationId: updateUser parameters: - name: user_id in: path + description: User ID (primary key) required: true schema: type: integer format: int64 - description: User ID (primary key) example: 123 requestBody: required: true content: application/json: schema: + description: Only provided fields are updated. Omitted fields remain unchanged. type: object properties: avatar_id: + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar type: integer format: int64 - nullable: true - description: ID of the user avatar (references `images.id`); set to `null` to remove avatar example: 42 + nullable: true mail: + description: User email (must be unique and valid) type: string format: email - pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' - description: User email (must be unique and valid) example: john.doe.updated@example.com + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' nickname: - type: string - pattern: '^[a-zA-Z0-9_-]{3,16}$' description: 'Username (alphanumeric + `_` or `-`, 3–16 chars)' + type: string + example: john_doe_43 maxLength: 16 minLength: 3 - example: john_doe_43 + pattern: '^[a-zA-Z0-9_-]{3,16}$' disp_name: - type: string description: Display name - maxLength: 32 - example: John Smith - user_desc: type: string + example: John Smith + maxLength: 32 + user_desc: description: User description / bio - maxLength: 512 + type: string example: Just a curious developer. + maxLength: 512 additionalProperties: false - description: Only provided fields are updated. Omitted fields remain unchanged. responses: '200': description: User updated successfully. Returns updated user representation (excluding sensitive fields). @@ -222,64 +224,64 @@ paths: parameters: - $ref: '#/components/parameters/cursor' - $ref: '#/components/parameters/title_sort' - - in: path - name: user_id + - name: user_id + in: path required: true schema: type: string - - in: query - name: sort_forward + - name: sort_forward + in: query schema: type: boolean default: true - - in: query - name: word + - name: word + in: query schema: type: string - - in: query - name: status + - name: status + in: query + description: List of title statuses to filter schema: type: array items: $ref: '#/components/schemas/TitleStatus' - description: List of title statuses to filter - style: form explode: false - - in: query - name: watch_status + style: form + - name: watch_status + in: query schema: type: array items: $ref: '#/components/schemas/UserTitleStatus' - style: form explode: false - - in: query - name: rating + style: form + - name: rating + in: query schema: type: number format: double - - in: query - name: my_rate + - name: my_rate + in: query schema: type: integer format: int32 - - in: query - name: release_year + - name: release_year + in: query schema: type: integer format: int32 - - in: query - name: release_season + - name: release_season + in: query schema: $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit + - name: limit + in: query schema: type: integer format: int32 default: 10 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -309,17 +311,17 @@ paths: '500': description: Unknown server error post: + operationId: addUserTitle summary: Add a title to a user description: 'User adding title to list af watched, status required' - operationId: addUserTitle parameters: - name: user_id in: path + description: ID of the user to assign the title to required: true schema: type: integer format: int64 - description: ID of the user to assign the title to example: 123 requestBody: required: true @@ -327,9 +329,6 @@ paths: application/json: schema: type: object - required: - - title_id - - status properties: title_id: type: integer @@ -339,36 +338,16 @@ paths: rate: type: integer format: int32 + required: + - title_id + - status responses: '200': description: Title successfully added to user content: application/json: schema: - type: object - required: - - user_id - - title_id - - status - properties: - user_id: - type: integer - format: int64 - title_id: - type: integer - format: int64 - status: - $ref: '#/components/schemas/UserTitleStatus' - rate: - type: integer - format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time - additionalProperties: false + $ref: '#/components/schemas/UserTitleMini' '400': description: 'Invalid request body (missing fields, invalid types, etc.)' '401': @@ -382,17 +361,17 @@ paths: '500': description: Internal server error patch: + operationId: updateUserTitle summary: Update a usertitle description: User updating title list of watched - operationId: updateUserTitle parameters: - name: user_id in: path + description: ID of the user to assign the title to required: true schema: type: integer format: int64 - description: ID of the user to assign the title to example: 123 requestBody: required: true @@ -400,8 +379,6 @@ paths: application/json: schema: type: object - required: - - title_id properties: title_id: type: integer @@ -411,13 +388,15 @@ paths: rate: type: integer format: int32 + required: + - title_id responses: '200': description: Title successfully updated content: application/json: schema: - $ref: '#/paths/~1users~1%7Buser_id%7D~1titles/post/responses/200/content/application~1json/schema' + $ref: '#/components/schemas/UserTitleMini' '400': description: 'Invalid request body (missing fields, invalid types, etc.)' '401': @@ -443,25 +422,36 @@ components: schema: $ref: '#/components/schemas/TitleSort' schemas: - CursorObj: - type: object - required: - - id - properties: - id: - type: integer - format: int64 - param: - type: string TitleSort: - type: string description: Title sort order + type: string default: id enum: - id - year - rating - views + TitleStatus: + description: Title status + type: string + enum: + - finished + - ongoing + - planned + ReleaseSeason: + description: Title release season + type: string + enum: + - winter + - spring + - summer + - fall + StorageType: + description: Image storage type + type: string + enum: + - s3 + - local Image: type: object properties: @@ -469,65 +459,11 @@ components: type: integer format: int64 storage_type: - type: string - description: Image storage type - enum: - - s3 - - local + $ref: '#/components/schemas/StorageType' image_path: type: string - TitleStatus: - type: string - description: Title status - enum: - - finished - - ongoing - - planned - ReleaseSeason: - type: string - description: Title release season - enum: - - winter - - spring - - summer - - fall - UserTitleStatus: - type: string - description: User's title status - enum: - - finished - - planned - - dropped - - in-progress - Review: - type: object - additionalProperties: true - Tag: - type: object - description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' - additionalProperties: - type: string - example: - en: Shojo - ru: Сёдзё - ja: 少女 - Tags: - type: array - description: Array of localized tags - items: - $ref: '#/components/schemas/Tag' - example: - - en: Shojo - ru: Сёдзё - ja: 少女 - - en: Shounen - ru: Сёнен - ja: 少年 Studio: type: object - required: - - id - - name properties: id: type: integer @@ -538,30 +474,41 @@ components: $ref: '#/components/schemas/Image' description: type: string - Title: - type: object required: - id - - title_names - - tags + - name + Tag: + description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' + type: object + example: + en: Shojo + ru: Сёдзё + ja: 少女 + additionalProperties: + type: string + Tags: + description: Array of localized tags + type: array + items: + $ref: '#/components/schemas/Tag' + example: + - en: Shojo + ru: Сёдзё + ja: 少女 + - en: Shounen + ru: Сёнен + ja: 少年 + Title: + type: object properties: id: + description: Unique title ID (primary key) type: integer format: int64 - description: Unique title ID (primary key) example: 1 title_names: - type: object description: 'Localized titles. Key = language (ISO 639-1), value = list of names' - additionalProperties: - type: array - items: - type: string - example: Attack on Titan - minItems: 1 - example: - - Attack on Titan - - AoT + type: object example: en: - Attack on Titan @@ -571,6 +518,15 @@ components: - Титаны ja: - 進撃の巨人 + additionalProperties: + type: array + items: + type: string + example: Attack on Titan + minItems: 1 + example: + - Attack on Titan + - AoT studio: $ref: '#/components/schemas/Studio' tags: @@ -601,51 +557,68 @@ components: additionalProperties: type: number format: double - additionalProperties: true - User: + required: + - id + - title_names + - tags + CursorObj: type: object properties: id: type: integer format: int64 + param: + type: string + required: + - id + User: + type: object + properties: + id: description: Unique user ID (primary key) + type: integer + format: int64 example: 1 image: $ref: '#/components/schemas/Image' mail: + description: User email type: string format: email - description: User email example: john.doe@example.com nickname: - type: string description: Username (alphanumeric + _ or -) - maxLength: 16 + type: string example: john_doe_42 + maxLength: 16 disp_name: - type: string description: Display name - maxLength: 32 - example: John Doe - user_desc: type: string + example: John Doe + maxLength: 32 + user_desc: description: User description - maxLength: 512 + type: string example: Just a regular user. + maxLength: 512 creation_date: + description: Timestamp when the user was created type: string format: date-time - description: Timestamp when the user was created example: '2025-10-10T23:45:47.908073Z' required: - user_id - nickname + UserTitleStatus: + description: User's title status + type: string + enum: + - finished + - planned + - dropped + - in-progress UserTitle: type: object - required: - - user_id - - title_id - - status properties: user_id: type: integer @@ -663,3 +636,34 @@ components: ctime: type: string format: date-time + required: + - user_id + - title_id + - status + UserTitleMini: + type: object + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + required: + - user_id + - title_id + - status + Review: + type: object + additionalProperties: true diff --git a/api/paths/titles-id.yaml b/api/paths/titles-id.yaml index 01fa504..235743f 100644 --- a/api/paths/titles-id.yaml +++ b/api/paths/titles-id.yaml @@ -1,5 +1,6 @@ get: summary: Get title description + operationId: getTitle parameters: - in: path name: title_id diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 1580cc1..4f11ab6 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -117,11 +117,10 @@ post: type: integer format: int64 status: - $ref: ../schemas/enums/UserTitleStatus.yaml + $ref: '../schemas/enums/UserTitleStatus.yaml' rate: type: integer format: int32 - responses: '200': description: Title successfully added to user @@ -129,7 +128,6 @@ post: application/json: schema: $ref: '../schemas/UserTitleMini.yaml' - '400': description: Invalid request body (missing fields, invalid types, etc.) '401': @@ -169,7 +167,7 @@ patch: type: integer format: int64 status: - $ref: ../schemas/enums/UserTitleStatus.yaml + $ref: '../schemas/enums/UserTitleStatus.yaml' rate: type: integer format: int32 @@ -181,7 +179,6 @@ patch: application/json: schema: $ref: '../schemas/UserTitleMini.yaml' - '400': description: Invalid request body (missing fields, invalid types, etc.) '401': diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index 06f4a19..fe62e46 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -1,5 +1,6 @@ get: summary: Get user info + operationId: getUsersId parameters: - in: path name: user_id diff --git a/api/schemas/Title.yaml b/api/schemas/Title.yaml index 7497d1f..877ee24 100644 --- a/api/schemas/Title.yaml +++ b/api/schemas/Title.yaml @@ -60,4 +60,3 @@ properties: additionalProperties: type: number format: double -additionalProperties: true diff --git a/api/schemas/UserTitleMini.yaml b/api/schemas/UserTitleMini.yaml index 9e45e95..e1a5a74 100644 --- a/api/schemas/UserTitleMini.yaml +++ b/api/schemas/UserTitleMini.yaml @@ -21,4 +21,3 @@ properties: ctime: type: string format: date-time -additionalProperties: false diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index 80ae491..9013fc7 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -12,6 +12,7 @@ export type { CursorObj } from './models/CursorObj'; export type { Image } from './models/Image'; export type { ReleaseSeason } from './models/ReleaseSeason'; export type { Review } from './models/Review'; +export type { StorageType } from './models/StorageType'; export type { Studio } from './models/Studio'; export type { Tag } from './models/Tag'; export type { Tags } from './models/Tags'; @@ -21,6 +22,7 @@ export type { TitleSort } from './models/TitleSort'; export type { TitleStatus } from './models/TitleStatus'; export type { User } from './models/User'; export type { UserTitle } from './models/UserTitle'; +export type { UserTitleMini } from './models/UserTitleMini'; export type { UserTitleStatus } from './models/UserTitleStatus'; export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api/models/Image.ts b/modules/frontend/src/api/models/Image.ts index a94de74..887bf2f 100644 --- a/modules/frontend/src/api/models/Image.ts +++ b/modules/frontend/src/api/models/Image.ts @@ -2,12 +2,10 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { StorageType } from './StorageType'; export type Image = { id?: number; - /** - * Image storage type - */ - storage_type?: 's3' | 'local'; + storage_type?: StorageType; image_path?: string; }; diff --git a/modules/frontend/src/api/models/StorageType.ts b/modules/frontend/src/api/models/StorageType.ts new file mode 100644 index 0000000..f6d086b --- /dev/null +++ b/modules/frontend/src/api/models/StorageType.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Image storage type + */ +export type StorageType = 's3' | 'local'; diff --git a/modules/frontend/src/api/models/Title.ts b/modules/frontend/src/api/models/Title.ts index 4da7aa3..9ffdeb6 100644 --- a/modules/frontend/src/api/models/Title.ts +++ b/modules/frontend/src/api/models/Title.ts @@ -2,4 +2,30 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Title = Record<string, any>; +import type { Image } from './Image'; +import type { ReleaseSeason } from './ReleaseSeason'; +import type { Studio } from './Studio'; +import type { Tags } from './Tags'; +import type { TitleStatus } from './TitleStatus'; +export type Title = { + /** + * Unique title ID (primary key) + */ + id: number; + /** + * Localized titles. Key = language (ISO 639-1), value = list of names + */ + title_names: Record<string, Array<string>>; + studio?: Studio; + tags: Tags; + poster?: Image; + title_status?: TitleStatus; + rating?: number; + rating_count?: number; + release_year?: number; + release_season?: ReleaseSeason; + episodes_aired?: number; + episodes_all?: number; + episodes_len?: Record<string, number>; +}; + diff --git a/modules/frontend/src/api/models/UserTitleMini.ts b/modules/frontend/src/api/models/UserTitleMini.ts new file mode 100644 index 0000000..2b223ce --- /dev/null +++ b/modules/frontend/src/api/models/UserTitleMini.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { UserTitleStatus } from './UserTitleStatus'; +export type UserTitleMini = { + user_id: number; + title_id: number; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; +}; + diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 874971e..5070fae 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -9,6 +9,7 @@ import type { TitleSort } from '../models/TitleSort'; import type { TitleStatus } from '../models/TitleStatus'; import type { User } from '../models/User'; import type { UserTitle } from '../models/UserTitle'; +import type { UserTitleMini } from '../models/UserTitleMini'; import type { UserTitleStatus } from '../models/UserTitleStatus'; import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; @@ -78,7 +79,7 @@ export class DefaultService { * @returns Title Title description * @throws ApiError */ - public static getTitles1( + public static getTitle( titleId: number, fields: string = 'all', ): CancelablePromise<Title> { @@ -105,7 +106,7 @@ export class DefaultService { * @returns User User info * @throws ApiError */ - public static getUsers( + public static getUsersId( userId: string, fields: string = 'all', ): CancelablePromise<User> { @@ -248,22 +249,17 @@ export class DefaultService { * User adding title to list af watched, status required * @param userId ID of the user to assign the title to * @param requestBody - * @returns any Title successfully added to user + * @returns UserTitleMini Title successfully added to user * @throws ApiError */ public static addUserTitle( userId: number, - requestBody: UserTitle, - ): CancelablePromise<{ - data?: { - user_id: number; + requestBody: { title_id: number; status: UserTitleStatus; rate?: number; - review_id?: number; - ctime?: string; - }; - }> { + }, + ): CancelablePromise<UserTitleMini> { return __request(OpenAPI, { method: 'POST', url: '/users/{user_id}/titles', @@ -282,4 +278,37 @@ export class DefaultService { }, }); } + /** + * Update a usertitle + * User updating title list of watched + * @param userId ID of the user to assign the title to + * @param requestBody + * @returns UserTitleMini Title successfully updated + * @throws ApiError + */ + public static updateUserTitle( + userId: number, + requestBody: { + title_id: number; + status?: UserTitleStatus; + rate?: number; + }, + ): CancelablePromise<UserTitleMini> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid request body (missing fields, invalid types, etc.)`, + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to update title`, + 404: `User or Title not found`, + 500: `Internal server error`, + }, + }); + } } diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.module.css b/modules/frontend/src/pages/TitlePage/TitlePage.module.css deleted file mode 100644 index e69de29..0000000 diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index 7fe9de7..e69de29 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -1,64 +0,0 @@ -// import React, { useEffect, useState } from "react"; -// import { useParams } from "react-router-dom"; -// import { DefaultService } from "../../api/services/DefaultService"; -// import type { User } from "../../api/models/User"; -// import styles from "./UserPage.module.css"; - -// const UserPage: React.FC = () => { -// const { id } = useParams<{ id: string }>(); -// const [user, setUser] = useState<User | null>(null); -// const [loading, setLoading] = useState(true); -// const [error, setError] = useState<string | null>(null); - -// useEffect(() => { -// if (!id) return; - -// const getTitleInfo = async () => { -// try { -// const userInfo = await DefaultService.getTitle(id, "all"); -// setUser(userInfo); -// } catch (err) { -// console.error(err); -// setError("Failed to fetch user info."); -// } finally { -// setLoading(false); -// } -// }; -// getTitleInfo(); -// }, [id]); - -// if (loading) return <div className={styles.loader}>Loading...</div>; -// if (error) return <div className={styles.error}>{error}</div>; -// if (!user) return <div className={styles.error}>User not found.</div>; - -// return ( -// <div className={styles.container}> -// <div className={styles.card}> -// <div className={styles.avatar}> -// {user.avatar_id ? ( -// <img -// src={`/images/${user.avatar_id}.png`} -// alt="User Avatar" -// className={styles.avatarImg} -// /> -// ) : ( -// <div className={styles.avatarPlaceholder}> -// {user.disp_name?.[0] || "U"} -// </div> -// )} -// </div> - -// <div className={styles.info}> -// <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> -// <p className={styles.nickname}>@{user.nickname}</p> -// {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} -// <p className={styles.created}> -// Joined: {new Date(user.creation_date).toLocaleDateString()} -// </p> -// </div> -// </div> -// </div> -// ); -// }; - -// export default UserPage; diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 2e39e6b..eafdf6b 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -15,7 +15,7 @@ const UserPage: React.FC = () => { const getUserInfo = async () => { try { - const userInfo = await DefaultService.getUsers(id, "all"); // <-- use dynamic id + const userInfo = await DefaultService.getUsersId(id, "all"); // <-- use dynamic id setUser(userInfo); } catch (err) { console.error(err); diff --git a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx index 342f22c..729da20 100644 --- a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx +++ b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx @@ -41,7 +41,7 @@ export default function UsersIdPage({ userId }: UsersIdPageProps) { if (!id) return; setLoadingUser(true); try { - const result = await DefaultService.getUsers(id, "all"); + const result = await DefaultService.getUsersId(id, "all"); setUser(result); setErrorUser(null); } catch (err: any) { From 4c643d80bb35cff875e4e5d2fad9eec2fc4e0bcc Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 06:29:36 +0300 Subject: [PATCH 128/224] feat: added title page --- modules/frontend/src/App.tsx | 3 + modules/frontend/src/api/core/OpenAPI.ts | 2 +- modules/frontend/src/auth/core/OpenAPI.ts | 2 +- .../src/pages/TitlePage/TitlePage.tsx | 140 ++++++++++++++++++ 4 files changed, 145 insertions(+), 2 deletions(-) diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 3ecfa2d..e2c909f 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -2,6 +2,7 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UsersIdPage from "./pages/UsersIdPage/UsersIdPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; +import TitlePage from "./pages/TitlePage/TitlePage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; @@ -24,7 +25,9 @@ const App: React.FC = () => { /> <Route path="/users/:id" element={<UsersIdPage />} /> + <Route path="/titles" element={<TitlesPage />} /> + <Route path="/titles/:id" element={<TitlePage />} /> </Routes> </Router> ); diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 185e5c3..6ce873e 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: '/api/v1', + BASE: 'http://10.1.0.65:8081/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts index 2d0edf8..79aa305 100644 --- a/modules/frontend/src/auth/core/OpenAPI.ts +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: '/auth', + BASE: 'http://10.1.0.65:8081/auth', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index e69de29..5ea0e3d 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { DefaultService } from "../../api/services/DefaultService"; +import type { Title, UserTitleStatus } from "../../api"; +import { + ClockIcon, + CheckCircleIcon, + PlayCircleIcon, + XCircleIcon, +} from "@heroicons/react/24/solid"; + +const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [ + { status: "planned", icon: <ClockIcon className="w-6 h-6" />, label: "Planned" }, + { status: "finished", icon: <CheckCircleIcon className="w-6 h-6" />, label: "Finished" }, + { status: "in-progress", icon: <PlayCircleIcon className="w-6 h-6" />, label: "In Progress" }, + { status: "dropped", icon: <XCircleIcon className="w-6 h-6" />, label: "Dropped" }, +]; + +export default function TitlePage() { + const params = useParams(); + const titleId = Number(params.id); + + const [title, setTitle] = useState<Title | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const [userStatus, setUserStatus] = useState<UserTitleStatus | null>(null); + const [updatingStatus, setUpdatingStatus] = useState(false); + + useEffect(() => { + const fetchTitle = async () => { + setLoading(true); + try { + const data = await DefaultService.getTitle(titleId, "all"); + setTitle(data); + setError(null); + } catch (err: any) { + console.error(err); + setError(err?.message || "Failed to fetch title"); + } finally { + setLoading(false); + } + }; + fetchTitle(); + }, [titleId]); + + const handleStatusClick = async (status: UserTitleStatus) => { + if (updatingStatus || userStatus === status) return; + + const userId = Number(localStorage.getItem("userId")); + if (!userId) { + alert("You must be logged in to set status."); + return; + } + + setUpdatingStatus(true); + try { + await DefaultService.addUserTitle(userId, { + title_id: titleId, + status, + }); + setUserStatus(status); + } catch (err: any) { + console.error(err); + alert(err?.message || "Failed to set status"); + } finally { + setUpdatingStatus(false); + } + }; + + const getTagsString = () => + title?.tags?.map(tag => tag.en).filter(Boolean).join(", "); + + if (loading) return <div className="mt-20 font-medium text-black">Loading title...</div>; + if (error) return <div className="mt-20 text-red-600 font-medium">{error}</div>; + if (!title) return null; + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex justify-center"> + <div className="flex flex-col md:flex-row bg-white shadow-lg rounded-xl max-w-4xl w-full p-6 gap-6"> + {/* Постер */} + <div className="flex flex-col items-center"> + <img + src={title.poster?.image_path || "/default-poster.png"} + alt={title.title_names?.en?.[0] || "Title poster"} + className="w-48 h-72 object-cover rounded-lg mb-4" + /> + + {/* Статус кнопки с иконками */} + <div className="flex gap-2 mt-2 flex-wrap justify-center"> + {STATUS_BUTTONS.map(btn => ( + <button + key={btn.status} + onClick={() => handleStatusClick(btn.status)} + disabled={updatingStatus} + className={`p-2 rounded-lg transition flex items-center justify-center ${ + userStatus === btn.status + ? "bg-blue-600 text-white" + : "bg-gray-200 text-gray-700 hover:bg-gray-300" + }`} + title={btn.label} + > + {btn.icon} + </button> + ))} + </div> + </div> + + {/* Информация о тайтле */} + <div className="flex-1 flex flex-col"> + <h1 className="text-3xl font-bold mb-2"> + {title.title_names?.en?.[0] || "Untitled"} + </h1> + {title.studio && <p className="text-gray-700 mb-1">Studio: {title.studio.name}</p>} + {title.title_status && <p className="text-gray-700 mb-1">Status: {title.title_status}</p>} + {title.rating !== undefined && ( + <p className="text-gray-700 mb-1"> + Rating: {title.rating} ({title.rating_count} votes) + </p> + )} + {title.release_year && ( + <p className="text-gray-700 mb-1"> + Released: {title.release_year} {title.release_season || ""} + </p> + )} + {title.episodes_aired !== undefined && ( + <p className="text-gray-700 mb-1"> + Episodes: {title.episodes_aired}/{title.episodes_all} + </p> + )} + {title.tags && title.tags.length > 0 && ( + <p className="text-gray-700 mb-1"> + Tags: {getTagsString()} + </p> + )} + </div> + </div> + </div> + ); +} From e98d2c65094efa8a5bb52b70102905287b1c5e1e Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 06:35:43 +0300 Subject: [PATCH 129/224] cicd: build auth using actions --- .forgejo/workflows/build-and-deploy.yml | 25 +++++++++++++++++++++++-- go.mod | 3 --- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index e7d0a83..0338440 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -20,9 +20,9 @@ jobs: go-version: '^1.25' check-latest: false cache-dependency-path: | - modules/backend/go.sum + go.sum - - name: Build Go app + - name: Build backend run: | cd modules/backend go mod tidy @@ -35,6 +35,19 @@ jobs: name: nyanimedb-backend.tar.gz path: modules/backend/nyanimedb-backend.tar.gz + - name: Build auth + run: | + cd modules/auth + go mod tidy + go build -o auth . + tar -czvf nyanimedb-auth.tar.gz auth + + - name: Upload built auth to artifactory + uses: actions/upload-artifact@v3 + with: + name: nyanimedb-auth.tar.gz + path: modules/auth/nyanimedb-auth.tar.gz + # Build frontend - uses: actions/setup-node@v5 with: @@ -76,6 +89,14 @@ jobs: push: true tags: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest + - name: Build and push auth image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfiles/Dockerfile_auth + push: true + tags: meowgit.nekoea.red/nihonium/nyanimedb-auth:latest + - name: Build and push frontend image uses: docker/build-push-action@v6 with: diff --git a/go.mod b/go.mod index 72df275..bf73121 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,7 @@ require ( github.com/jackc/pgx/v5 v5.7.6 github.com/oapi-codegen/runtime v1.1.2 github.com/pelletier/go-toml/v2 v2.2.4 -<<<<<<< HEAD -======= github.com/sirupsen/logrus v1.9.3 ->>>>>>> front ) require ( From 79e8ece9482b5f19f00d3aee9227668f81053e57 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 06:42:07 +0300 Subject: [PATCH 130/224] cicd: removed go mod tidy for go builds --- .forgejo/workflows/build-and-deploy.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 0338440..87f3655 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -25,7 +25,6 @@ jobs: - name: Build backend run: | cd modules/backend - go mod tidy go build -o nyanimedb . tar -czvf nyanimedb-backend.tar.gz nyanimedb @@ -38,7 +37,6 @@ jobs: - name: Build auth run: | cd modules/auth - go mod tidy go build -o auth . tar -czvf nyanimedb-auth.tar.gz auth From f2589e05e8ebef3bfbe6342310757dca3dafe983 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 27 Nov 2025 07:06:18 +0300 Subject: [PATCH 131/224] fix: now 409 on try to add existing usertitle --- modules/backend/handlers/common.go | 7 ++--- modules/backend/handlers/titles.go | 4 +-- modules/backend/handlers/users.go | 50 +++++++++++++++++------------- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 2cf2283..f820db6 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -1,7 +1,6 @@ package handlers import ( - "context" "encoding/json" "fmt" oapi "nyanimedb/api" @@ -17,11 +16,11 @@ func NewServer(db *sqlc.Queries) Server { return Server{db: db} } -func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.ImageStorageType, error) { +func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.StorageType, error) { if s == nil { return nil, nil } - var t oapi.ImageStorageType + var t oapi.StorageType switch *s { case sqlc.StorageTypeTLocal: t = oapi.Local @@ -33,7 +32,7 @@ func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.ImageStorageType, error) { return &t, nil } -func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi.Title, error) { +func (s Server) mapTitle(title sqlc.GetTitleByIDRow) (oapi.Title, error) { oapi_title := oapi.Title{ EpisodesAired: title.EpisodesAired, diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index c67177f..03553fd 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -144,7 +144,7 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl return oapi.GetTitlesTitleId500Response{}, nil } - oapi_title, err = s.mapTitle(ctx, sqlc_title) + oapi_title, err = s.mapTitle(sqlc_title) if err != nil { log.Errorf("%v", err) return oapi.GetTitlesTitleId500Response{}, nil @@ -238,7 +238,7 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje // _title.TitleStorageType = string(s) // } - t, err := s.mapTitle(ctx, _title) + t, err := s.mapTitle(_title) if err != nil { log.Errorf("%v", err) return oapi.GetTitles500Response{}, nil diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 1881f36..7af705e 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "errors" "fmt" oapi "nyanimedb/api" sqlc "nyanimedb/sql" @@ -9,24 +10,12 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/oapi-codegen/runtime/types" log "github.com/sirupsen/logrus" ) -// type Server struct { -// db *sqlc.Queries -// } - -// func NewServer(db *sqlc.Queries) Server { -// return Server{db: db} -// } - -// func parseInt64(s string) (int32, error) { -// i, err := strconv.ParseInt(s, 10, 64) -// return int32(i), err -// } - func mapUser(u sqlc.GetUserByIDRow) (oapi.User, error) { i := oapi.Image{ Id: u.AvatarID, @@ -202,7 +191,7 @@ func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (o // StudioImagePath: title.StudioImagePath, } - oapi_title, err := s.mapTitle(ctx, _title) + oapi_title, err := s.mapTitle(_title) if err != nil { return oapi_usertitle, fmt.Errorf("mapUsertitle: %v", err) } @@ -368,19 +357,26 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque } params := sqlc.InsertUserTitleParams{ - UserID: request.UserId, - TitleID: request.Body.TitleId, - Status: *status, - Rate: request.Body.Rate, - ReviewID: request.Body.ReviewId, + UserID: request.UserId, + TitleID: request.Body.TitleId, + Status: *status, + Rate: request.Body.Rate, } user_title, err := s.db.InsertUserTitle(ctx, params) if err != nil { - log.Errorf("%v", err) - return oapi.AddUserTitle500Response{}, nil + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + // fmt.Println(pgErr.Message) // => syntax error at end of input + // fmt.Println(pgErr.Code) // => 42601 + if pgErr.Code == "23505" { //duplicate key value + return oapi.AddUserTitle409Response{}, nil + } + } else { + log.Errorf("%v", err) + return oapi.AddUserTitle500Response{}, nil + } } - oapi_status, err := sql2usertitlestatus(user_title.Status) if err != nil { log.Errorf("%v", err) @@ -406,3 +402,13 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque return oapi.AddUserTitle200JSONResponse(oapi_usertitle), nil } + +// DeleteUserTitle implements oapi.StrictServerInterface. +func (s Server) DeleteUserTitle(ctx context.Context, request oapi.DeleteUserTitleRequestObject) (oapi.DeleteUserTitleResponseObject, error) { + panic("unimplemented") +} + +// UpdateUserTitle implements oapi.StrictServerInterface. +func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitleRequestObject) (oapi.UpdateUserTitleResponseObject, error) { + panic("unimplemented") +} From 658d666fec71c165de4492111bfa9d29f42cef18 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 27 Nov 2025 07:08:06 +0300 Subject: [PATCH 132/224] feat: query for update usertitle --- modules/backend/queries.sql | 28 ++++++++--------------- sql/migrations/000001_init.up.sql | 2 +- sql/queries.sql.go | 38 +++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 0146b25..ef6e26d 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -461,21 +461,13 @@ VALUES ( ) RETURNING user_id, title_id, status, rate, review_id, ctime; --- -- name: UpdateUserTitle :one --- UPDATE usertitles --- SET --- status = COALESCE(sqlc.narg('status'), status), --- rate = COALESCE(sqlc.narg('rate'), rate), --- review_id = COALESCE(sqlc.narg('review_id'), review_id) --- WHERE user_id = $1 AND title_id = $2 --- RETURNING *; - --- -- name: DeleteUserTitle :exec --- DELETE FROM usertitles --- WHERE user_id = $1 AND ($2::int IS NULL OR title_id = $2); - --- -- name: ListTags :many --- SELECT tag_id, tag_names --- FROM tags --- ORDER BY tag_id --- LIMIT $1 OFFSET $2; \ No newline at end of file +-- name: UpdateUserTitle :one +-- Fails with sql.ErrNoRows if (user_id, title_id) not found +UPDATE usertitles +SET + status = COALESCE(sqlc.narg('status')::usertitle_status_t, status), + rate = COALESCE(sqlc.narg('rate')::int, rate) +WHERE + user_id = sqlc.arg('user_id') + AND title_id = sqlc.arg('title_id') +RETURNING *; \ No newline at end of file diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index f8781de..3499fe2 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -179,6 +179,6 @@ END; $$ LANGUAGE plpgsql; CREATE TRIGGER set_ctime_on_update -AFTER UPDATE ON usertitles +BEFORE UPDATE ON usertitles FOR EACH ROW EXECUTE FUNCTION set_ctime(); \ No newline at end of file diff --git a/sql/queries.sql.go b/sql/queries.sql.go index a46da86..89b16c9 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -925,3 +925,41 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateU ) return i, err } + +const updateUserTitle = `-- name: UpdateUserTitle :one +UPDATE usertitles +SET + status = COALESCE($1::usertitle_status_t, status), + rate = COALESCE($2::int, rate) +WHERE + user_id = $3 + AND title_id = $4 +RETURNING user_id, title_id, status, rate, review_id, ctime +` + +type UpdateUserTitleParams struct { + Status NullUsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` +} + +// Fails with sql.ErrNoRows if (user_id, title_id) not found +func (q *Queries) UpdateUserTitle(ctx context.Context, arg UpdateUserTitleParams) (Usertitle, error) { + row := q.db.QueryRow(ctx, updateUserTitle, + arg.Status, + arg.Rate, + arg.UserID, + arg.TitleID, + ) + var i Usertitle + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + ) + return i, err +} From 451df61127709b3c9a557e87745e8d5827b6be3a Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 27 Nov 2025 08:00:29 +0300 Subject: [PATCH 133/224] feat: delete usertitle implemented --- api/_build/openapi.yaml | 8 +- api/api.gen.go | 392 +++++++---------------------- api/paths/users-id-titles.yaml | 8 +- modules/backend/handlers/titles.go | 10 +- modules/backend/handlers/users.go | 30 ++- modules/backend/queries.sql | 88 +------ sql/queries.sql.go | 116 ++------- 7 files changed, 160 insertions(+), 492 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index e2c7409..2ee6cdc 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -419,7 +419,12 @@ paths: schema: type: integer format: int64 - example: 123 + - name: title_id + in: query + required: true + schema: + type: integer + format: int64 responses: '200': description: Title successfully deleted @@ -581,7 +586,6 @@ components: additionalProperties: type: number format: double - additionalProperties: true required: - id - title_names diff --git a/api/api.gen.go b/api/api.gen.go index 6af01d0..6208050 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -112,8 +112,7 @@ type Title struct { TitleNames map[string][]string `json:"title_names"` // TitleStatus Title status - TitleStatus *TitleStatus `json:"title_status,omitempty"` - AdditionalProperties map[string]interface{} `json:"-"` + TitleStatus *TitleStatus `json:"title_status,omitempty"` } // TitleSort Title sort order @@ -191,13 +190,13 @@ type GetTitlesParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// GetTitlesTitleIdParams defines parameters for GetTitlesTitleId. -type GetTitlesTitleIdParams struct { +// GetTitleParams defines parameters for GetTitle. +type GetTitleParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// GetUsersUserIdParams defines parameters for GetUsersUserId. -type GetUsersUserIdParams struct { +// GetUsersIdParams defines parameters for GetUsersId. +type GetUsersIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } @@ -219,6 +218,11 @@ type UpdateUserJSONBody struct { UserDesc *string `json:"user_desc,omitempty"` } +// DeleteUserTitleParams defines parameters for DeleteUserTitle. +type DeleteUserTitleParams struct { + TitleId int64 `form:"title_id" json:"title_id"` +} + // GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. type GetUsersUserIdTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -264,248 +268,6 @@ type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody // AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. type AddUserTitleJSONRequestBody AddUserTitleJSONBody -// Getter for additional properties for Title. Returns the specified -// element and whether it was found -func (a Title) Get(fieldName string) (value interface{}, found bool) { - if a.AdditionalProperties != nil { - value, found = a.AdditionalProperties[fieldName] - } - return -} - -// Setter for additional properties for Title -func (a *Title) Set(fieldName string, value interface{}) { - if a.AdditionalProperties == nil { - a.AdditionalProperties = make(map[string]interface{}) - } - a.AdditionalProperties[fieldName] = value -} - -// Override default JSON handling for Title to handle AdditionalProperties -func (a *Title) UnmarshalJSON(b []byte) error { - object := make(map[string]json.RawMessage) - err := json.Unmarshal(b, &object) - if err != nil { - return err - } - - if raw, found := object["episodes_aired"]; found { - err = json.Unmarshal(raw, &a.EpisodesAired) - if err != nil { - return fmt.Errorf("error reading 'episodes_aired': %w", err) - } - delete(object, "episodes_aired") - } - - if raw, found := object["episodes_all"]; found { - err = json.Unmarshal(raw, &a.EpisodesAll) - if err != nil { - return fmt.Errorf("error reading 'episodes_all': %w", err) - } - delete(object, "episodes_all") - } - - if raw, found := object["episodes_len"]; found { - err = json.Unmarshal(raw, &a.EpisodesLen) - if err != nil { - return fmt.Errorf("error reading 'episodes_len': %w", err) - } - delete(object, "episodes_len") - } - - if raw, found := object["id"]; found { - err = json.Unmarshal(raw, &a.Id) - if err != nil { - return fmt.Errorf("error reading 'id': %w", err) - } - delete(object, "id") - } - - if raw, found := object["poster"]; found { - err = json.Unmarshal(raw, &a.Poster) - if err != nil { - return fmt.Errorf("error reading 'poster': %w", err) - } - delete(object, "poster") - } - - if raw, found := object["rating"]; found { - err = json.Unmarshal(raw, &a.Rating) - if err != nil { - return fmt.Errorf("error reading 'rating': %w", err) - } - delete(object, "rating") - } - - if raw, found := object["rating_count"]; found { - err = json.Unmarshal(raw, &a.RatingCount) - if err != nil { - return fmt.Errorf("error reading 'rating_count': %w", err) - } - delete(object, "rating_count") - } - - if raw, found := object["release_season"]; found { - err = json.Unmarshal(raw, &a.ReleaseSeason) - if err != nil { - return fmt.Errorf("error reading 'release_season': %w", err) - } - delete(object, "release_season") - } - - if raw, found := object["release_year"]; found { - err = json.Unmarshal(raw, &a.ReleaseYear) - if err != nil { - return fmt.Errorf("error reading 'release_year': %w", err) - } - delete(object, "release_year") - } - - if raw, found := object["studio"]; found { - err = json.Unmarshal(raw, &a.Studio) - if err != nil { - return fmt.Errorf("error reading 'studio': %w", err) - } - delete(object, "studio") - } - - if raw, found := object["tags"]; found { - err = json.Unmarshal(raw, &a.Tags) - if err != nil { - return fmt.Errorf("error reading 'tags': %w", err) - } - delete(object, "tags") - } - - if raw, found := object["title_names"]; found { - err = json.Unmarshal(raw, &a.TitleNames) - if err != nil { - return fmt.Errorf("error reading 'title_names': %w", err) - } - delete(object, "title_names") - } - - if raw, found := object["title_status"]; found { - err = json.Unmarshal(raw, &a.TitleStatus) - if err != nil { - return fmt.Errorf("error reading 'title_status': %w", err) - } - delete(object, "title_status") - } - - if len(object) != 0 { - a.AdditionalProperties = make(map[string]interface{}) - for fieldName, fieldBuf := range object { - var fieldVal interface{} - err := json.Unmarshal(fieldBuf, &fieldVal) - if err != nil { - return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) - } - a.AdditionalProperties[fieldName] = fieldVal - } - } - return nil -} - -// Override default JSON handling for Title to handle AdditionalProperties -func (a Title) MarshalJSON() ([]byte, error) { - var err error - object := make(map[string]json.RawMessage) - - if a.EpisodesAired != nil { - object["episodes_aired"], err = json.Marshal(a.EpisodesAired) - if err != nil { - return nil, fmt.Errorf("error marshaling 'episodes_aired': %w", err) - } - } - - if a.EpisodesAll != nil { - object["episodes_all"], err = json.Marshal(a.EpisodesAll) - if err != nil { - return nil, fmt.Errorf("error marshaling 'episodes_all': %w", err) - } - } - - if a.EpisodesLen != nil { - object["episodes_len"], err = json.Marshal(a.EpisodesLen) - if err != nil { - return nil, fmt.Errorf("error marshaling 'episodes_len': %w", err) - } - } - - object["id"], err = json.Marshal(a.Id) - if err != nil { - return nil, fmt.Errorf("error marshaling 'id': %w", err) - } - - if a.Poster != nil { - object["poster"], err = json.Marshal(a.Poster) - if err != nil { - return nil, fmt.Errorf("error marshaling 'poster': %w", err) - } - } - - if a.Rating != nil { - object["rating"], err = json.Marshal(a.Rating) - if err != nil { - return nil, fmt.Errorf("error marshaling 'rating': %w", err) - } - } - - if a.RatingCount != nil { - object["rating_count"], err = json.Marshal(a.RatingCount) - if err != nil { - return nil, fmt.Errorf("error marshaling 'rating_count': %w", err) - } - } - - if a.ReleaseSeason != nil { - object["release_season"], err = json.Marshal(a.ReleaseSeason) - if err != nil { - return nil, fmt.Errorf("error marshaling 'release_season': %w", err) - } - } - - if a.ReleaseYear != nil { - object["release_year"], err = json.Marshal(a.ReleaseYear) - if err != nil { - return nil, fmt.Errorf("error marshaling 'release_year': %w", err) - } - } - - if a.Studio != nil { - object["studio"], err = json.Marshal(a.Studio) - if err != nil { - return nil, fmt.Errorf("error marshaling 'studio': %w", err) - } - } - - object["tags"], err = json.Marshal(a.Tags) - if err != nil { - return nil, fmt.Errorf("error marshaling 'tags': %w", err) - } - - object["title_names"], err = json.Marshal(a.TitleNames) - if err != nil { - return nil, fmt.Errorf("error marshaling 'title_names': %w", err) - } - - if a.TitleStatus != nil { - object["title_status"], err = json.Marshal(a.TitleStatus) - if err != nil { - return nil, fmt.Errorf("error marshaling 'title_status': %w", err) - } - } - - for fieldName, field := range a.AdditionalProperties { - object[fieldName], err = json.Marshal(field) - if err != nil { - return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) - } - } - return json.Marshal(object) -} - // ServerInterface represents all server handlers. type ServerInterface interface { // Get titles @@ -513,16 +275,16 @@ type ServerInterface interface { GetTitles(c *gin.Context, params GetTitlesParams) // Get title description // (GET /titles/{title_id}) - GetTitlesTitleId(c *gin.Context, titleId int64, params GetTitlesTitleIdParams) + GetTitle(c *gin.Context, titleId int64, params GetTitleParams) // Get user info // (GET /users/{user_id}) - GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) + GetUsersId(c *gin.Context, userId string, params GetUsersIdParams) // Partially update a user account // (PATCH /users/{user_id}) UpdateUser(c *gin.Context, userId int64) // Delete a usertitle // (DELETE /users/{user_id}/titles) - DeleteUserTitle(c *gin.Context, userId int64) + DeleteUserTitle(c *gin.Context, userId int64, params DeleteUserTitleParams) // Get user titles // (GET /users/{user_id}/titles) GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) @@ -649,8 +411,8 @@ func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { siw.Handler.GetTitles(c, params) } -// GetTitlesTitleId operation middleware -func (siw *ServerInterfaceWrapper) GetTitlesTitleId(c *gin.Context) { +// GetTitle operation middleware +func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { var err error @@ -664,7 +426,7 @@ func (siw *ServerInterfaceWrapper) GetTitlesTitleId(c *gin.Context) { } // Parameter object where we will unmarshal all parameters from the context - var params GetTitlesTitleIdParams + var params GetTitleParams // ------------- Optional query parameter "fields" ------------- @@ -681,11 +443,11 @@ func (siw *ServerInterfaceWrapper) GetTitlesTitleId(c *gin.Context) { } } - siw.Handler.GetTitlesTitleId(c, titleId, params) + siw.Handler.GetTitle(c, titleId, params) } -// GetUsersUserId operation middleware -func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { +// GetUsersId operation middleware +func (siw *ServerInterfaceWrapper) GetUsersId(c *gin.Context) { var err error @@ -699,7 +461,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { } // Parameter object where we will unmarshal all parameters from the context - var params GetUsersUserIdParams + var params GetUsersIdParams // ------------- Optional query parameter "fields" ------------- @@ -716,7 +478,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { } } - siw.Handler.GetUsersUserId(c, userId, params) + siw.Handler.GetUsersId(c, userId, params) } // UpdateUser operation middleware @@ -757,6 +519,24 @@ func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { return } + // Parameter object where we will unmarshal all parameters from the context + var params DeleteUserTitleParams + + // ------------- Required query parameter "title_id" ------------- + + if paramValue := c.Query("title_id"); paramValue != "" { + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument title_id is required, but not found"), http.StatusBadRequest) + return + } + + err = runtime.BindQueryParameter("form", true, true, "title_id", c.Request.URL.Query(), ¶ms.TitleId) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -764,7 +544,7 @@ func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { } } - siw.Handler.DeleteUserTitle(c, userId) + siw.Handler.DeleteUserTitle(c, userId, params) } // GetUsersUserIdTitles operation middleware @@ -966,8 +746,8 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options } router.GET(options.BaseURL+"/titles", wrapper.GetTitles) - router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitlesTitleId) - router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) + router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitle) + router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersId) router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) router.DELETE(options.BaseURL+"/users/:user_id/titles", wrapper.DeleteUserTitle) router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) @@ -1021,94 +801,94 @@ func (response GetTitles500Response) VisitGetTitlesResponse(w http.ResponseWrite return nil } -type GetTitlesTitleIdRequestObject struct { +type GetTitleRequestObject struct { TitleId int64 `json:"title_id"` - Params GetTitlesTitleIdParams + Params GetTitleParams } -type GetTitlesTitleIdResponseObject interface { - VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error +type GetTitleResponseObject interface { + VisitGetTitleResponse(w http.ResponseWriter) error } -type GetTitlesTitleId200JSONResponse Title +type GetTitle200JSONResponse Title -func (response GetTitlesTitleId200JSONResponse) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetTitlesTitleId204Response struct { +type GetTitle204Response struct { } -func (response GetTitlesTitleId204Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type GetTitlesTitleId400Response struct { +type GetTitle400Response struct { } -func (response GetTitlesTitleId400Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle400Response) VisitGetTitleResponse(w http.ResponseWriter) error { w.WriteHeader(400) return nil } -type GetTitlesTitleId404Response struct { +type GetTitle404Response struct { } -func (response GetTitlesTitleId404Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle404Response) VisitGetTitleResponse(w http.ResponseWriter) error { w.WriteHeader(404) return nil } -type GetTitlesTitleId500Response struct { +type GetTitle500Response struct { } -func (response GetTitlesTitleId500Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) error { w.WriteHeader(500) return nil } -type GetUsersUserIdRequestObject struct { +type GetUsersIdRequestObject struct { UserId string `json:"user_id"` - Params GetUsersUserIdParams + Params GetUsersIdParams } -type GetUsersUserIdResponseObject interface { - VisitGetUsersUserIdResponse(w http.ResponseWriter) error +type GetUsersIdResponseObject interface { + VisitGetUsersIdResponse(w http.ResponseWriter) error } -type GetUsersUserId200JSONResponse User +type GetUsersId200JSONResponse User -func (response GetUsersUserId200JSONResponse) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { +func (response GetUsersId200JSONResponse) VisitGetUsersIdResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetUsersUserId400Response struct { +type GetUsersId400Response struct { } -func (response GetUsersUserId400Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { +func (response GetUsersId400Response) VisitGetUsersIdResponse(w http.ResponseWriter) error { w.WriteHeader(400) return nil } -type GetUsersUserId404Response struct { +type GetUsersId404Response struct { } -func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { +func (response GetUsersId404Response) VisitGetUsersIdResponse(w http.ResponseWriter) error { w.WriteHeader(404) return nil } -type GetUsersUserId500Response struct { +type GetUsersId500Response struct { } -func (response GetUsersUserId500Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { +func (response GetUsersId500Response) VisitGetUsersIdResponse(w http.ResponseWriter) error { w.WriteHeader(500) return nil } @@ -1189,6 +969,7 @@ func (response UpdateUser500Response) VisitUpdateUserResponse(w http.ResponseWri type DeleteUserTitleRequestObject struct { UserId int64 `json:"user_id"` + Params DeleteUserTitleParams } type DeleteUserTitleResponseObject interface { @@ -1419,10 +1200,10 @@ type StrictServerInterface interface { GetTitles(ctx context.Context, request GetTitlesRequestObject) (GetTitlesResponseObject, error) // Get title description // (GET /titles/{title_id}) - GetTitlesTitleId(ctx context.Context, request GetTitlesTitleIdRequestObject) (GetTitlesTitleIdResponseObject, error) + GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) // Get user info // (GET /users/{user_id}) - GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) + GetUsersId(ctx context.Context, request GetUsersIdRequestObject) (GetUsersIdResponseObject, error) // Partially update a user account // (PATCH /users/{user_id}) UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error) @@ -1479,18 +1260,18 @@ func (sh *strictHandler) GetTitles(ctx *gin.Context, params GetTitlesParams) { } } -// GetTitlesTitleId operation middleware -func (sh *strictHandler) GetTitlesTitleId(ctx *gin.Context, titleId int64, params GetTitlesTitleIdParams) { - var request GetTitlesTitleIdRequestObject +// GetTitle operation middleware +func (sh *strictHandler) GetTitle(ctx *gin.Context, titleId int64, params GetTitleParams) { + var request GetTitleRequestObject request.TitleId = titleId request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitlesTitleId(ctx, request.(GetTitlesTitleIdRequestObject)) + return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitlesTitleId") + handler = middleware(handler, "GetTitle") } response, err := handler(ctx, request) @@ -1498,8 +1279,8 @@ func (sh *strictHandler) GetTitlesTitleId(ctx *gin.Context, titleId int64, param if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitlesTitleIdResponseObject); ok { - if err := validResponse.VisitGetTitlesTitleIdResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetTitleResponseObject); ok { + if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -1507,18 +1288,18 @@ func (sh *strictHandler) GetTitlesTitleId(ctx *gin.Context, titleId int64, param } } -// GetUsersUserId operation middleware -func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { - var request GetUsersUserIdRequestObject +// GetUsersId operation middleware +func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetUsersIdParams) { + var request GetUsersIdRequestObject request.UserId = userId request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsersUserId(ctx, request.(GetUsersUserIdRequestObject)) + return sh.ssi.GetUsersId(ctx, request.(GetUsersIdRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsersUserId") + handler = middleware(handler, "GetUsersId") } response, err := handler(ctx, request) @@ -1526,8 +1307,8 @@ func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersUserIdResponseObject); ok { - if err := validResponse.VisitGetUsersUserIdResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetUsersIdResponseObject); ok { + if err := validResponse.VisitGetUsersIdResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -1571,10 +1352,11 @@ func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { } // DeleteUserTitle operation middleware -func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64) { +func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, params DeleteUserTitleParams) { var request DeleteUserTitleRequestObject request.UserId = userId + request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { return sh.ssi.DeleteUserTitle(ctx, request.(DeleteUserTitleRequestObject)) diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 2cff448..0cb7092 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -202,7 +202,13 @@ delete: type: integer format: int64 description: ID of the user to assign the title to - example: 123 + - in: query + name: title_id + required: true + schema: + type: integer + format: int64 + responses: '200': diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 03553fd..77af7e4 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -132,25 +132,25 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro // return &oapi_studio, nil // } -func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitleIdRequestObject) (oapi.GetTitlesTitleIdResponseObject, error) { +func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { var oapi_title oapi.Title sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId) if err != nil { if err == pgx.ErrNoRows { - return oapi.GetTitlesTitleId204Response{}, nil + return oapi.GetTitle204Response{}, nil } log.Errorf("%v", err) - return oapi.GetTitlesTitleId500Response{}, nil + return oapi.GetTitle500Response{}, nil } oapi_title, err = s.mapTitle(sqlc_title) if err != nil { log.Errorf("%v", err) - return oapi.GetTitlesTitleId500Response{}, nil + return oapi.GetTitle500Response{}, nil } - return oapi.GetTitlesTitleId200JSONResponse(oapi_title), nil + return oapi.GetTitle200JSONResponse(oapi_title), nil } func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 7af705e..48f80d8 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -16,6 +16,10 @@ import ( log "github.com/sirupsen/logrus" ) +const ( + pgErrDuplicateKey = "23505" +) + func mapUser(u sqlc.GetUserByIDRow) (oapi.User, error) { i := oapi.Image{ Id: u.AvatarID, @@ -37,24 +41,24 @@ func mapUser(u sqlc.GetUserByIDRow) (oapi.User, error) { }, nil } -func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdRequestObject) (oapi.GetUsersUserIdResponseObject, error) { +func (s Server) GetUsersId(ctx context.Context, req oapi.GetUsersIdRequestObject) (oapi.GetUsersIdResponseObject, error) { userID, err := parseInt64(req.UserId) if err != nil { - return oapi.GetUsersUserId404Response{}, nil + return oapi.GetUsersId404Response{}, nil } _user, err := s.db.GetUserByID(context.TODO(), userID) if err != nil { if err == pgx.ErrNoRows { - return oapi.GetUsersUserId404Response{}, nil + return oapi.GetUsersId404Response{}, nil } return nil, err } user, err := mapUser(_user) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserId500Response{}, err + return oapi.GetUsersId500Response{}, err } - return oapi.GetUsersUserId200JSONResponse(user), nil + return oapi.GetUsersId200JSONResponse(user), nil } func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { @@ -369,7 +373,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque if errors.As(err, &pgErr) { // fmt.Println(pgErr.Message) // => syntax error at end of input // fmt.Println(pgErr.Code) // => 42601 - if pgErr.Code == "23505" { //duplicate key value + if pgErr.Code == pgErrDuplicateKey { //duplicate key value return oapi.AddUserTitle409Response{}, nil } } else { @@ -405,7 +409,19 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque // DeleteUserTitle implements oapi.StrictServerInterface. func (s Server) DeleteUserTitle(ctx context.Context, request oapi.DeleteUserTitleRequestObject) (oapi.DeleteUserTitleResponseObject, error) { - panic("unimplemented") + params := sqlc.DeleteUserTitleParams{ + UserID: request.UserId, + TitleID: request.Params.TitleId, + } + _, err := s.db.DeleteUserTitle(ctx, params) + if err != nil { + if err == pgx.ErrNoRows { + return oapi.DeleteUserTitle404Response{}, nil + } + log.Errorf("%v", err) + return oapi.DeleteUserTitle500Response{}, nil + } + return oapi.DeleteUserTitle200Response{}, nil } // UpdateUserTitle implements oapi.StrictServerInterface. diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index ef6e26d..5ac2c5c 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -57,17 +57,6 @@ VALUES ( sqlc.arg('tag_names')::jsonb) RETURNING id, tag_names; --- -- name: ListUsers :many --- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date --- FROM users --- ORDER BY user_id --- LIMIT $1 OFFSET $2; - --- -- name: CreateUser :one --- INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) --- VALUES ($1, $2, $3, $4, $5, $6, $7) --- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; - -- name: UpdateUser :one UPDATE users SET @@ -78,10 +67,6 @@ SET WHERE id = sqlc.arg('user_id') RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail; --- -- name: DeleteUser :exec --- DELETE FROM users --- WHERE user_id = $1; - -- name: GetTitleByID :one -- sqlc.struct: TitlesFull SELECT @@ -378,78 +363,11 @@ ORDER BY LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit --- -- name: ListTitles :many --- SELECT title_id, title_names, studio_id, poster_id, signal_ids, --- title_status, rating, rating_count, release_year, release_season, --- season, episodes_aired, episodes_all, episodes_len --- FROM titles --- ORDER BY title_id --- LIMIT $1 OFFSET $2; - --- -- name: UpdateTitle :one --- UPDATE titles --- SET --- title_names = COALESCE(sqlc.narg('title_names'), title_names), --- studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), --- poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), --- signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), --- title_status = COALESCE(sqlc.narg('title_status'), title_status), --- release_year = COALESCE(sqlc.narg('release_year'), release_year), --- release_season = COALESCE(sqlc.narg('release_season'), release_season), --- episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), --- episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), --- episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) --- WHERE title_id = sqlc.arg('title_id') --- RETURNING *; - -- name: GetReviewByID :one SELECT * FROM reviews WHERE review_id = sqlc.arg('review_id')::bigint; --- -- name: CreateReview :one --- INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) --- VALUES ($1, $2, $3, $4, $5) --- RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date; - --- -- name: UpdateReview :one --- UPDATE reviews --- SET --- image_ids = COALESCE(sqlc.narg('image_ids'), image_ids), --- review_text = COALESCE(sqlc.narg('review_text'), review_text) --- WHERE review_id = sqlc.arg('review_id') --- RETURNING *; - --- -- name: DeleteReview :exec --- DELETE FROM reviews --- WHERE review_id = $1; - --- -- name: ListReviewsByTitle :many --- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date --- FROM reviews --- WHERE title_id = $1 --- ORDER BY creation_date DESC --- LIMIT $2 OFFSET $3; - --- -- name: ListReviewsByUser :many --- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date --- FROM reviews --- WHERE user_id = $1 --- ORDER BY creation_date DESC --- LIMIT $2 OFFSET $3; - --- -- name: GetUserTitle :one --- SELECT usertitle_id, user_id, title_id, status, rate, review_id --- FROM usertitles --- WHERE user_id = $1 AND title_id = $2; - --- -- name: ListUserTitles :many --- SELECT usertitle_id, user_id, title_id, status, rate, review_id --- FROM usertitles --- WHERE user_id = $1 --- ORDER BY usertitle_id --- LIMIT $2 OFFSET $3; - -- name: InsertUserTitle :one INSERT INTO usertitles (user_id, title_id, status, rate, review_id) VALUES ( @@ -470,4 +388,10 @@ SET WHERE user_id = sqlc.arg('user_id') AND title_id = sqlc.arg('title_id') +RETURNING *; + +-- name: DeleteUserTitle :one +DELETE FROM usertitles +WHERE user_id = sqlc.arg('user_id') + AND title_id = sqlc.arg('title_id') RETURNING *; \ No newline at end of file diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 89b16c9..24f77b4 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -29,6 +29,32 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image return i, err } +const deleteUserTitle = `-- name: DeleteUserTitle :one +DELETE FROM usertitles +WHERE user_id = $1 + AND title_id = $2 +RETURNING user_id, title_id, status, rate, review_id, ctime +` + +type DeleteUserTitleParams struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` +} + +func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams) (Usertitle, error) { + row := q.db.QueryRow(ctx, deleteUserTitle, arg.UserID, arg.TitleID) + var i Usertitle + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + ) + return i, err +} + const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images @@ -44,40 +70,12 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro const getReviewByID = `-- name: GetReviewByID :one - - SELECT id, data, rating, user_id, title_id, created_at FROM reviews WHERE review_id = $1::bigint ` // 100 is default limit -// -- name: ListTitles :many -// SELECT title_id, title_names, studio_id, poster_id, signal_ids, -// -// title_status, rating, rating_count, release_year, release_season, -// season, episodes_aired, episodes_all, episodes_len -// -// FROM titles -// ORDER BY title_id -// LIMIT $1 OFFSET $2; -// -- name: UpdateTitle :one -// UPDATE titles -// SET -// -// title_names = COALESCE(sqlc.narg('title_names'), title_names), -// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), -// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), -// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), -// title_status = COALESCE(sqlc.narg('title_status'), title_status), -// release_year = COALESCE(sqlc.narg('release_year'), release_year), -// release_season = COALESCE(sqlc.narg('release_season'), release_season), -// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), -// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), -// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) -// -// WHERE title_id = sqlc.arg('title_id') -// RETURNING *; func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, error) { row := q.db.QueryRow(ctx, getReviewByID, reviewID) var i Review @@ -111,7 +109,6 @@ func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, er } const getTitleByID = `-- name: GetTitleByID :one - SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type as title_storage_type, @@ -162,9 +159,6 @@ type GetTitleByIDRow struct { StudioImagePath *string `json:"studio_image_path"` } -// -- name: DeleteUser :exec -// DELETE FROM users -// WHERE user_id = $1; // sqlc.struct: TitlesFull func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (GetTitleByIDRow, error) { row := q.db.QueryRow(ctx, getTitleByID, titleID) @@ -330,13 +324,6 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams } const insertUserTitle = `-- name: InsertUserTitle :one - - - - - - - INSERT INTO usertitles (user_id, title_id, status, rate, review_id) VALUES ( $1::bigint, @@ -356,46 +343,6 @@ type InsertUserTitleParams struct { ReviewID *int64 `json:"review_id"` } -// -- name: CreateReview :one -// INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) -// VALUES ($1, $2, $3, $4, $5) -// RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date; -// -- name: UpdateReview :one -// UPDATE reviews -// SET -// -// image_ids = COALESCE(sqlc.narg('image_ids'), image_ids), -// review_text = COALESCE(sqlc.narg('review_text'), review_text) -// -// WHERE review_id = sqlc.arg('review_id') -// RETURNING *; -// -- name: DeleteReview :exec -// DELETE FROM reviews -// WHERE review_id = $1; -// -// -- name: ListReviewsByTitle :many -// -// SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -// FROM reviews -// WHERE title_id = $1 -// ORDER BY creation_date DESC -// LIMIT $2 OFFSET $3; -// -- name: ListReviewsByUser :many -// SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -// FROM reviews -// WHERE user_id = $1 -// ORDER BY creation_date DESC -// LIMIT $2 OFFSET $3; -// -- name: GetUserTitle :one -// SELECT usertitle_id, user_id, title_id, status, rate, review_id -// FROM usertitles -// WHERE user_id = $1 AND title_id = $2; -// -- name: ListUserTitles :many -// SELECT usertitle_id, user_id, title_id, status, rate, review_id -// FROM usertitles -// WHERE user_id = $1 -// ORDER BY usertitle_id -// LIMIT $2 OFFSET $3; func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams) (Usertitle, error) { row := q.db.QueryRow(ctx, insertUserTitle, arg.UserID, @@ -866,8 +813,6 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara } const updateUser = `-- name: UpdateUser :one - - UPDATE users SET avatar_id = COALESCE($1, avatar_id), @@ -896,15 +841,6 @@ type UpdateUserRow struct { Mail *string `json:"mail"` } -// -- name: ListUsers :many -// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -// FROM users -// ORDER BY user_id -// LIMIT $1 OFFSET $2; -// -- name: CreateUser :one -// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) -// VALUES ($1, $2, $3, $4, $5, $6, $7) -// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) { row := q.db.QueryRow(ctx, updateUser, arg.AvatarID, From a25a912ead2e5ff2b81edd67191afe3ec3ce4b13 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 27 Nov 2025 08:16:12 +0300 Subject: [PATCH 134/224] feat: Update UserTitle implemented --- modules/backend/handlers/users.go | 49 ++++++++++++++++++++++++------- sql/queries.sql.go | 8 ++--- sql/sqlc.yaml | 5 ++++ 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 48f80d8..563a244 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -386,16 +386,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque log.Errorf("%v", err) return oapi.AddUserTitle500Response{}, nil } - oapi_usertitle := struct { - Ctime *time.Time `json:"ctime,omitempty"` - Rate *int32 `json:"rate,omitempty"` - ReviewId *int64 `json:"review_id,omitempty"` - - // Status User's title status - Status oapi.UserTitleStatus `json:"status"` - TitleId int64 `json:"title_id"` - UserId int64 `json:"user_id"` - }{ + oapi_usertitle := oapi.UserTitleMini{ Ctime: &user_title.Ctime, Rate: user_title.Rate, ReviewId: user_title.ReviewID, @@ -426,5 +417,41 @@ func (s Server) DeleteUserTitle(ctx context.Context, request oapi.DeleteUserTitl // UpdateUserTitle implements oapi.StrictServerInterface. func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitleRequestObject) (oapi.UpdateUserTitleResponseObject, error) { - panic("unimplemented") + + status, err := UserTitleStatus2Sqlc1(request.Body.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.UpdateUserTitle400Response{}, nil + } + params := sqlc.UpdateUserTitleParams{ + Status: status, + Rate: request.Body.Rate, + UserID: request.UserId, + TitleID: request.Body.TitleId, + } + + user_title, err := s.db.UpdateUserTitle(ctx, params) + if err != nil { + if err == pgx.ErrNoRows { + return oapi.UpdateUserTitle404Response{}, nil + } + log.Errorf("%v", err) + return oapi.UpdateUserTitle500Response{}, nil + } + oapi_status, err := sql2usertitlestatus(user_title.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.UpdateUserTitle500Response{}, nil + } + + oapi_usertitle := oapi.UserTitleMini{ + Ctime: &user_title.Ctime, + Rate: user_title.Rate, + ReviewId: user_title.ReviewID, + Status: oapi_status, + TitleId: user_title.TitleID, + UserId: user_title.UserID, + } + + return oapi.UpdateUserTitle200JSONResponse(oapi_usertitle), nil } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 24f77b4..9338717 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -874,10 +874,10 @@ RETURNING user_id, title_id, status, rate, review_id, ctime ` type UpdateUserTitleParams struct { - Status NullUsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` + Status *UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` } // Fails with sql.ErrNoRows if (user_id, title_id) not found diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index de67bcf..8f8626a 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -14,6 +14,11 @@ sql: emit_pointers_for_null_types: true emit_empty_slices: true #slices returned by :many queries will be empty instead of nil overrides: + - db_type: "usertitle_status_t" + nullable: true + go_type: + type: "UsertitleStatusT" + pointer: true - db_type: "storage_type_t" nullable: true go_type: From 6cbf0afb33e73939ec54b5785964d90168ffca33 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 09:42:05 +0300 Subject: [PATCH 135/224] feat: use postgres to fetch and store user info --- auth/auth.gen.go | 9 ++-- auth/openapi-auth.yaml | 22 ++++----- go.mod | 1 + go.sum | 40 ++++++++++++++++ modules/auth/handlers/handlers.go | 78 ++++++++++++++++++++++--------- modules/auth/main.go | 13 +++++- modules/auth/queries.sql | 11 +++++ sql/queries.sql.go | 42 +++++++++++++++++ sql/sqlc.yaml | 1 + 9 files changed, 175 insertions(+), 42 deletions(-) create mode 100644 modules/auth/queries.sql diff --git a/auth/auth.gen.go b/auth/auth.gen.go index b24deb5..7276545 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -116,9 +116,8 @@ type PostAuthSignInResponseObject interface { } type PostAuthSignIn200JSONResponse struct { - Error *string `json:"error"` - UserId *string `json:"user_id"` - UserName *string `json:"user_name"` + UserId int64 `json:"user_id"` + UserName string `json:"user_name"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { @@ -148,9 +147,7 @@ type PostAuthSignUpResponseObject interface { } type PostAuthSignUp200JSONResponse struct { - Error *string `json:"error"` - Success *bool `json:"success,omitempty"` - UserId *string `json:"user_id"` + UserId int64 `json:"user_id"` } func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 0fe308c..239b03b 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -30,16 +30,13 @@ paths: content: application/json: schema: + required: + - user_id type: object properties: - success: - type: boolean - error: - type: string - nullable: true user_id: - type: string - nullable: true + type: integer + format: int64 /auth/sign-in: post: @@ -65,17 +62,16 @@ paths: content: application/json: schema: + required: + - user_id + - user_name type: object properties: - error: - type: string - nullable: true user_id: - type: string - nullable: true + type: integer + format: int64 user_name: type: string - nullable: true "401": description: Access denied due to invalid credentials content: diff --git a/go.mod b/go.mod index bf73121..7b7cc71 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module nyanimedb go 1.25.0 require ( + github.com/alexedwards/argon2id v1.0.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.0 diff --git a/go.sum b/go.sum index 8f46514..cd197e6 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= +github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -87,26 +89,64 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 7f675aa..261826c 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -3,22 +3,21 @@ package handlers import ( "context" "fmt" - "log" "net/http" auth "nyanimedb/auth" sqlc "nyanimedb/sql" "strconv" "time" + "github.com/alexedwards/argon2id" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + log "github.com/sirupsen/logrus" ) var accessSecret = []byte("my_access_secret_key") var refreshSecret = []byte("my_refresh_secret_key") -var UserDb = make(map[string]string) // TEMP: stores passwords - type Server struct { db *sqlc.Queries } @@ -32,6 +31,22 @@ func parseInt64(s string) (int32, error) { return int32(i), err } +func HashPassword(password string) (string, error) { + params := &argon2id.Params{ + Memory: 64 * 1024, + Iterations: 3, + Parallelism: 2, + SaltLength: 16, + KeyLength: 32, + } + + return argon2id.CreateHash(password, params) +} + +func CheckPassword(password, hash string) (bool, error) { + return argon2id.ComparePasswordAndHash(password, hash) +} + func generateTokens(userID string) (accessToken string, refreshToken string, err error) { accessClaims := jwt.MapClaims{ "user_id": userID, @@ -57,19 +72,27 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { - err := "" - success := true - UserDb[req.Body.Nickname] = req.Body.Pass + passhash, err := HashPassword(req.Body.Pass) + if err != nil { + log.Errorf("failed to hash password: %v", err) + // TODO: return 500 + } + + user_id, err := s.db.CreateNewUser(context.Background(), sqlc.CreateNewUserParams{ + Passhash: passhash, + Nickname: req.Body.Nickname, + }) + if err != nil { + log.Errorf("failed to create user %s: %v", req.Body.Nickname, err) + // TODO: check err and retyrn 400/500 + } return auth.PostAuthSignUp200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, + UserId: user_id, }, nil } func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { - // ctx.SetCookie("122") ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) if !ok { log.Print("failed to get gin context") @@ -77,27 +100,38 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") } - err := "" + user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname) + if err != nil { + log.Errorf("failed to get user by nickname %s: %v", req.Body.Nickname, err) + // TODO: return 400/500 + } - pass, ok := UserDb[req.Body.Nickname] - if !ok || pass != req.Body.Pass { - e := "invalid credentials" + ok, err = CheckPassword(req.Body.Pass, user.Passhash) + if err != nil { + log.Errorf("failed to check password for user %s: %v", req.Body.Nickname, err) + // TODO: return 500 + } + if !ok { + err_msg := "invalid credentials" return auth.PostAuthSignIn401JSONResponse{ - Error: &e, + Error: &err_msg, }, nil } - accessToken, refreshToken, _ := generateTokens(req.Body.Nickname) + accessToken, refreshToken, err := generateTokens(req.Body.Nickname) + if err != nil { + log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err) + // TODO: return 500 + } + // TODO: check cookie settings carefully ginCtx.SetSameSite(http.SameSiteStrictMode) - ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true) - ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true) + ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true) + ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true) - // Return access token; refresh token can be returned in response or HttpOnly cookie result := auth.PostAuthSignIn200JSONResponse{ - Error: &err, - UserId: &req.Body.Nickname, - UserName: &req.Body.Nickname, + UserId: user.ID, + UserName: user.Nickname, } return result, nil } diff --git a/modules/auth/main.go b/modules/auth/main.go index c001e8b..7554f42 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -1,6 +1,9 @@ package main import ( + "context" + "fmt" + "os" "time" auth "nyanimedb/auth" @@ -9,14 +12,22 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" ) var AppConfig Config func main() { + // TODO: env args r := gin.Default() - var queries *sqlc.Queries = nil + pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) + os.Exit(1) + } + + var queries *sqlc.Queries = sqlc.New(pool) server := handlers.NewServer(queries) diff --git a/modules/auth/queries.sql b/modules/auth/queries.sql new file mode 100644 index 0000000..828d2af --- /dev/null +++ b/modules/auth/queries.sql @@ -0,0 +1,11 @@ +-- name: GetUserByNickname :one +SELECT * +FROM users +WHERE nickname = sqlc.arg('nickname'); + +-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES (sqlc.arg(passhash), sqlc.arg(nickname)) +RETURNING id; + diff --git a/sql/queries.sql.go b/sql/queries.sql.go index a46da86..371337f 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -29,6 +29,25 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image return i, err } +const createNewUser = `-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES ($1, $2) +RETURNING id +` + +type CreateNewUserParams struct { + Passhash string `json:"passhash"` + Nickname string `json:"nickname"` +} + +func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) { + row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname) + var id int64 + err := row.Scan(&id) + return id, err +} + const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images @@ -268,6 +287,29 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er return i, err } +const getUserByNickname = `-- name: GetUserByNickname :one +SELECT id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date, last_login +FROM users +WHERE nickname = $1 +` + +func (q *Queries) GetUserByNickname(ctx context.Context, nickname string) (User, error) { + row := q.db.QueryRow(ctx, getUserByNickname, nickname) + var i User + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Passhash, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.LastLogin, + ) + return i, err +} + const insertStudio = `-- name: InsertStudio :one INSERT INTO studios (studio_name, illust_id, studio_desc) VALUES ( diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index de67bcf..a4d8875 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -3,6 +3,7 @@ sql: - engine: "postgresql" queries: - "../modules/backend/queries.sql" + - "../modules/auth/queries.sql" schema: "migrations" gen: go: From 3528ea7d344b471fec6923d9fa2ba3ec8b7c7fa9 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 06:42:07 +0300 Subject: [PATCH 136/224] cicd: removed go mod tidy for go builds --- .forgejo/workflows/build-and-deploy.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 0338440..87f3655 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -25,7 +25,6 @@ jobs: - name: Build backend run: | cd modules/backend - go mod tidy go build -o nyanimedb . tar -czvf nyanimedb-backend.tar.gz nyanimedb @@ -38,7 +37,6 @@ jobs: - name: Build auth run: | cd modules/auth - go mod tidy go build -o auth . tar -czvf nyanimedb-auth.tar.gz auth From 40e0b14f2a909f6dfe779c779446c22cf7558176 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 09:42:05 +0300 Subject: [PATCH 137/224] feat: use postgres to fetch and store user info --- auth/auth.gen.go | 9 ++-- auth/openapi-auth.yaml | 22 ++++----- go.mod | 1 + go.sum | 40 ++++++++++++++++ modules/auth/handlers/handlers.go | 78 ++++++++++++++++++++++--------- modules/auth/main.go | 13 +++++- modules/auth/queries.sql | 11 +++++ sql/queries.sql.go | 42 +++++++++++++++++ sql/sqlc.yaml | 1 + 9 files changed, 175 insertions(+), 42 deletions(-) create mode 100644 modules/auth/queries.sql diff --git a/auth/auth.gen.go b/auth/auth.gen.go index b24deb5..7276545 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -116,9 +116,8 @@ type PostAuthSignInResponseObject interface { } type PostAuthSignIn200JSONResponse struct { - Error *string `json:"error"` - UserId *string `json:"user_id"` - UserName *string `json:"user_name"` + UserId int64 `json:"user_id"` + UserName string `json:"user_name"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { @@ -148,9 +147,7 @@ type PostAuthSignUpResponseObject interface { } type PostAuthSignUp200JSONResponse struct { - Error *string `json:"error"` - Success *bool `json:"success,omitempty"` - UserId *string `json:"user_id"` + UserId int64 `json:"user_id"` } func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 0fe308c..239b03b 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -30,16 +30,13 @@ paths: content: application/json: schema: + required: + - user_id type: object properties: - success: - type: boolean - error: - type: string - nullable: true user_id: - type: string - nullable: true + type: integer + format: int64 /auth/sign-in: post: @@ -65,17 +62,16 @@ paths: content: application/json: schema: + required: + - user_id + - user_name type: object properties: - error: - type: string - nullable: true user_id: - type: string - nullable: true + type: integer + format: int64 user_name: type: string - nullable: true "401": description: Access denied due to invalid credentials content: diff --git a/go.mod b/go.mod index bf73121..7b7cc71 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module nyanimedb go 1.25.0 require ( + github.com/alexedwards/argon2id v1.0.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.0 diff --git a/go.sum b/go.sum index 8f46514..cd197e6 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= +github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -87,26 +89,64 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 7f675aa..261826c 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -3,22 +3,21 @@ package handlers import ( "context" "fmt" - "log" "net/http" auth "nyanimedb/auth" sqlc "nyanimedb/sql" "strconv" "time" + "github.com/alexedwards/argon2id" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + log "github.com/sirupsen/logrus" ) var accessSecret = []byte("my_access_secret_key") var refreshSecret = []byte("my_refresh_secret_key") -var UserDb = make(map[string]string) // TEMP: stores passwords - type Server struct { db *sqlc.Queries } @@ -32,6 +31,22 @@ func parseInt64(s string) (int32, error) { return int32(i), err } +func HashPassword(password string) (string, error) { + params := &argon2id.Params{ + Memory: 64 * 1024, + Iterations: 3, + Parallelism: 2, + SaltLength: 16, + KeyLength: 32, + } + + return argon2id.CreateHash(password, params) +} + +func CheckPassword(password, hash string) (bool, error) { + return argon2id.ComparePasswordAndHash(password, hash) +} + func generateTokens(userID string) (accessToken string, refreshToken string, err error) { accessClaims := jwt.MapClaims{ "user_id": userID, @@ -57,19 +72,27 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { - err := "" - success := true - UserDb[req.Body.Nickname] = req.Body.Pass + passhash, err := HashPassword(req.Body.Pass) + if err != nil { + log.Errorf("failed to hash password: %v", err) + // TODO: return 500 + } + + user_id, err := s.db.CreateNewUser(context.Background(), sqlc.CreateNewUserParams{ + Passhash: passhash, + Nickname: req.Body.Nickname, + }) + if err != nil { + log.Errorf("failed to create user %s: %v", req.Body.Nickname, err) + // TODO: check err and retyrn 400/500 + } return auth.PostAuthSignUp200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, + UserId: user_id, }, nil } func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { - // ctx.SetCookie("122") ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) if !ok { log.Print("failed to get gin context") @@ -77,27 +100,38 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") } - err := "" + user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname) + if err != nil { + log.Errorf("failed to get user by nickname %s: %v", req.Body.Nickname, err) + // TODO: return 400/500 + } - pass, ok := UserDb[req.Body.Nickname] - if !ok || pass != req.Body.Pass { - e := "invalid credentials" + ok, err = CheckPassword(req.Body.Pass, user.Passhash) + if err != nil { + log.Errorf("failed to check password for user %s: %v", req.Body.Nickname, err) + // TODO: return 500 + } + if !ok { + err_msg := "invalid credentials" return auth.PostAuthSignIn401JSONResponse{ - Error: &e, + Error: &err_msg, }, nil } - accessToken, refreshToken, _ := generateTokens(req.Body.Nickname) + accessToken, refreshToken, err := generateTokens(req.Body.Nickname) + if err != nil { + log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err) + // TODO: return 500 + } + // TODO: check cookie settings carefully ginCtx.SetSameSite(http.SameSiteStrictMode) - ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true) - ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true) + ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true) + ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true) - // Return access token; refresh token can be returned in response or HttpOnly cookie result := auth.PostAuthSignIn200JSONResponse{ - Error: &err, - UserId: &req.Body.Nickname, - UserName: &req.Body.Nickname, + UserId: user.ID, + UserName: user.Nickname, } return result, nil } diff --git a/modules/auth/main.go b/modules/auth/main.go index c001e8b..7554f42 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -1,6 +1,9 @@ package main import ( + "context" + "fmt" + "os" "time" auth "nyanimedb/auth" @@ -9,14 +12,22 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" ) var AppConfig Config func main() { + // TODO: env args r := gin.Default() - var queries *sqlc.Queries = nil + pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) + os.Exit(1) + } + + var queries *sqlc.Queries = sqlc.New(pool) server := handlers.NewServer(queries) diff --git a/modules/auth/queries.sql b/modules/auth/queries.sql new file mode 100644 index 0000000..828d2af --- /dev/null +++ b/modules/auth/queries.sql @@ -0,0 +1,11 @@ +-- name: GetUserByNickname :one +SELECT * +FROM users +WHERE nickname = sqlc.arg('nickname'); + +-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES (sqlc.arg(passhash), sqlc.arg(nickname)) +RETURNING id; + diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 9338717..3318a14 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -55,6 +55,25 @@ func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams return i, err } +const createNewUser = `-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES ($1, $2) +RETURNING id +` + +type CreateNewUserParams struct { + Passhash string `json:"passhash"` + Nickname string `json:"nickname"` +} + +func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) { + row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname) + var id int64 + err := row.Scan(&id) + return id, err +} + const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images @@ -262,6 +281,29 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er return i, err } +const getUserByNickname = `-- name: GetUserByNickname :one +SELECT id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date, last_login +FROM users +WHERE nickname = $1 +` + +func (q *Queries) GetUserByNickname(ctx context.Context, nickname string) (User, error) { + row := q.db.QueryRow(ctx, getUserByNickname, nickname) + var i User + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Passhash, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.LastLogin, + ) + return i, err +} + const insertStudio = `-- name: InsertStudio :one INSERT INTO studios (studio_name, illust_id, studio_desc) VALUES ( diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index 8f8626a..904abaf 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -3,6 +3,7 @@ sql: - engine: "postgresql" queries: - "../modules/backend/queries.sql" + - "../modules/auth/queries.sql" schema: "migrations" gen: go: From 9338c6504051462f362f0ccf26085f2d108b7c05 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 09:44:41 +0300 Subject: [PATCH 138/224] chore: updated sqlc generated code --- sql/queries.sql.go | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 3318a14..c1186b5 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -29,6 +29,25 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image return i, err } +const createNewUser = `-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES ($1, $2) +RETURNING id +` + +type CreateNewUserParams struct { + Passhash string `json:"passhash"` + Nickname string `json:"nickname"` +} + +func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) { + row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname) + var id int64 + err := row.Scan(&id) + return id, err +} + const deleteUserTitle = `-- name: DeleteUserTitle :one DELETE FROM usertitles WHERE user_id = $1 @@ -55,25 +74,6 @@ func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams return i, err } -const createNewUser = `-- name: CreateNewUser :one -INSERT -INTO users (passhash, nickname) -VALUES ($1, $2) -RETURNING id -` - -type CreateNewUserParams struct { - Passhash string `json:"passhash"` - Nickname string `json:"nickname"` -} - -func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) { - row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname) - var id int64 - err := row.Scan(&id) - return id, err -} - const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images From 98178731b9f6a03a9cd1a31b8005be70fc14492e Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 09:51:49 +0300 Subject: [PATCH 139/224] refact: UsersIdPage -> UserPage --- modules/frontend/src/App.tsx | 16 +- .../src/pages/UserPage/UserPage.module.css | 103 -------- .../frontend/src/pages/UserPage/UserPage.tsx | 240 +++++++++++++----- .../src/pages/UsersIdPage/UsersIdPage.tsx | 183 ------------- 4 files changed, 187 insertions(+), 355 deletions(-) delete mode 100644 modules/frontend/src/pages/UserPage/UserPage.module.css delete mode 100644 modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index e2c909f..95b59e3 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,13 +1,12 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; -import UsersIdPage from "./pages/UsersIdPage/UsersIdPage"; +import UserPage from "./pages/UserPage/UserPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; import TitlePage from "./pages/TitlePage/TitlePage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; const App: React.FC = () => { - // Получаем username из localStorage const username = localStorage.getItem("username") || undefined; const userId = localStorage.getItem("userId"); @@ -15,17 +14,20 @@ const App: React.FC = () => { <Router> <Header username={username} /> <Routes> + {/* auth */} <Route path="/login" element={<LoginPage />} /> <Route path="/signup" element={<LoginPage />} /> - - {/* /profile рендерит UsersIdPage с id из localStorage */} + {/*<Route path="/signup" element={<LoginPage />} />*/} + + {/* users */} + {/*<Route path="/users" element={<UsersPage />} />*/} + <Route path="/users/:id" element={<UserPage />} /> <Route path="/profile" - element={userId ? <UsersIdPage userId={userId} /> : <LoginPage />} + element={userId ? <UserPage userId={userId} /> : <LoginPage />} /> - <Route path="/users/:id" element={<UsersIdPage />} /> - + {/* titles */} <Route path="/titles" element={<TitlesPage />} /> <Route path="/titles/:id" element={<TitlePage />} /> </Routes> diff --git a/modules/frontend/src/pages/UserPage/UserPage.module.css b/modules/frontend/src/pages/UserPage/UserPage.module.css deleted file mode 100644 index 7f350c8..0000000 --- a/modules/frontend/src/pages/UserPage/UserPage.module.css +++ /dev/null @@ -1,103 +0,0 @@ -body, -html { - width: 100%; - margin: 0; - background-color: #777; - color: #fff; -} - -html, -body, -#root { - height: 100%; -} - -.header { - width: 100vw; - padding: 30px 40px; - background: #f7f7f7; - display: flex; - align-items: center; - gap: 25px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - border-bottom: 1px solid #e5e5e5; - color: #000000; -} - -.avatarWrapper { - width: 120px; - height: 120px; - min-width: 120px; - border-radius: 50%; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - background: #ddd; -} - -.avatarImg { - width: 100%; - height: 100%; - object-fit: cover; -} - -.avatarPlaceholder { - width: 100%; - height: 100%; - border-radius: 50%; - background: #ccc; - font-size: 42px; - font-weight: bold; - color: #555; - display: flex; - align-items: center; - justify-content: center; -} - -.userInfo { - display: flex; - flex-direction: column; -} - -.name { - font-size: 32px; - font-weight: 700; - margin: 0; -} - -.nickname { - font-size: 18px; - color: #666; - margin-top: 6px; -} - -.container { - max-width: 100vw; - width: 100%; - position: absolute; - top: 0%; - /* margin: 25px auto; */ - /* padding: 0 20px; */ -} - -.content { - margin-top: 20px; -} - -.desc { - font-size: 18px; - margin-bottom: 10px; -} - -.created { - font-size: 16px; - color: #888; -} - -.loader, -.error { - text-align: center; - margin-top: 40px; - font-size: 18px; -} diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index eafdf6b..5fbd6b8 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -1,67 +1,183 @@ -import React, { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; // <-- import +// pages/UserPage/UserPage.tsx +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; import { DefaultService } from "../../api/services/DefaultService"; -import type { User } from "../../api/models/User"; -import styles from "./UserPage.module.css"; +import { SearchBar } from "../../components/SearchBar/SearchBar"; +import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; +import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; +import { ListView } from "../../components/ListView/ListView"; +import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; +import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; +import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; -const UserPage: React.FC = () => { - const { id } = useParams<{ id: string }>(); // <-- get user id from URL - const [user, setUser] = useState<User | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); +const PAGE_SIZE = 10; - useEffect(() => { - if (!id) return; - - const getUserInfo = async () => { - try { - const userInfo = await DefaultService.getUsersId(id, "all"); // <-- use dynamic id - setUser(userInfo); - } catch (err) { - console.error(err); - setError("Failed to fetch user info."); - } finally { - setLoading(false); - } - }; - getUserInfo(); - }, [id]); - - if (loading) return <div className={styles.loader}>Loading...</div>; - if (error) return <div className={styles.error}>{error}</div>; - if (!user) return <div className={styles.error}>User not found.</div>; - - return ( - <div className={styles.container}> - <div className={styles.header}> - <div className={styles.avatarWrapper}> - {user.image?.image_path ? ( - <img - src={`/images/${user.image.image_path}.png`} - alt="User Avatar" - className={styles.avatarImg} - /> - ) : ( - <div className={styles.avatarPlaceholder}> - {user.disp_name?.[0] || "U"} - </div> - )} - </div> - - <div className={styles.userInfo}> - <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> - <p className={styles.nickname}>@{user.nickname}</p> - {/* <p className={styles.created}> - Joined: {new Date(user.creation_date).toLocaleDateString()} - </p> */} - </div> - - <div className={styles.content}> - {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} - </div> - </div> - </div> - ); +type UserPageProps = { + userId?: string; }; -export default UserPage; +export default function UserPage({ userId }: UserPageProps) { + const params = useParams(); + const id = userId || params?.id; + + const [user, setUser] = useState<User | null>(null); + const [loadingUser, setLoadingUser] = useState(true); + const [errorUser, setErrorUser] = useState<string | null>(null); + + // Для списка тайтлов + const [titles, setTitles] = useState<UserTitle[]>([]); + const [nextPage, setNextPage] = useState<UserTitle[]>([]); + const [cursor, setCursor] = useState<CursorObj | null>(null); + const [loadingTitles, setLoadingTitles] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [search, setSearch] = useState(""); + const [sort, setSort] = useState<TitleSort>("id"); + const [sortForward, setSortForward] = useState(true); + const [layout, setLayout] = useState<"square" | "horizontal">("square"); + + // --- Получение данных пользователя --- + useEffect(() => { + const fetchUser = async () => { + if (!id) return; + setLoadingUser(true); + try { + const result = await DefaultService.getUsersId(id, "all"); + setUser(result); + setErrorUser(null); + } catch (err: any) { + console.error(err); + setErrorUser(err?.message || "Failed to fetch user data"); + } finally { + setLoadingUser(false); + } + }; + fetchUser(); + }, [id]); + + // --- Получение списка тайтлов пользователя --- + const fetchPage = async (cursorObj: CursorObj | null) => { + if (!id) return { items: [], nextCursor: null }; + const cursorStr = cursorObj + ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") + : ""; + + try { + const result = await DefaultService.getUsersTitles( + id, + cursorStr, + sort, + sortForward, + search.trim() || undefined, + undefined, // status фильтр, можно добавить + undefined, // watchStatus + undefined, // rating + undefined, // myRate + undefined, // releaseYear + undefined, // releaseSeason + PAGE_SIZE, + "all" + ); + + if (!result?.data?.length) return { items: [], nextCursor: null }; + + return { items: result.data, nextCursor: result.cursor ?? null }; + } catch (err: any) { + if (err.status === 204) return { items: [], nextCursor: null }; + throw err; + } + }; + + // Инициализация: загружаем сразу две страницы + useEffect(() => { + const initLoad = async () => { + setLoadingTitles(true); + setTitles([]); + setNextPage([]); + setCursor(null); + + const firstPage = await fetchPage(null); + const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null }; + + setTitles(firstPage.items); + setNextPage(secondPage.items); + setCursor(secondPage.nextCursor); + setLoadingTitles(false); + }; + initLoad(); + }, [id, search, sort, sortForward]); + + const handleLoadMore = async () => { + if (nextPage.length === 0) { + setLoadingMore(false); + return; + } + setLoadingMore(true); + + setTitles(prev => [...prev, ...nextPage]); + setNextPage([]); + + if (cursor) { + try { + const next = await fetchPage(cursor); + if (next.items.length > 0) setNextPage(next.items); + setCursor(next.nextCursor); + } catch (err) { + console.error(err); + } + } + + setLoadingMore(false); + }; + + // const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> + + {/* --- Карточка пользователя --- */} + {loadingUser && <div className="mt-10 text-xl font-medium">Loading user...</div>} + {errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>} + {user && ( + <div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8"> + <img src={user.image?.image_path} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" /> + <h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2> + {user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>} + {user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>} + {user.creation_date && <p className="text-gray-400 mt-4 text-sm">Registered: {new Date(user.creation_date).toLocaleDateString()}</p>} + </div> + )} + + {/* --- Панель поиска, сортировки и лейаута --- */} + <div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center"> + <SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} /> + <LayoutSwitch layout={layout} setLayout={setLayout} /> + <TitlesSortBox sort={sort} setSort={setSort} sortForward={sortForward} setSortForward={setSortForward} /> + </div> + + {/* --- Список тайтлов --- */} + {loadingTitles && <div className="mt-6 font-medium text-black">Loading titles...</div>} + {!loadingTitles && titles.length === 0 && <div className="mt-6 font-medium text-black">No titles found.</div>} + + {titles.length > 0 && ( + <> + <ListView<UserTitle> + items={titles} + layout={layout} + hasMore={!!cursor || nextPage.length > 1} + loadingMore={loadingMore} + onLoadMore={handleLoadMore} + renderItem={(title, layout) => + layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} /> + } + /> + + {!cursor && nextPage.length === 0 && ( + <div className="mt-6 font-medium text-black"> + Результатов больше нет, было найдено {titles.length} тайтлов. + </div> + )} + </> + )} + </div> + ); +} diff --git a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx deleted file mode 100644 index 729da20..0000000 --- a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx +++ /dev/null @@ -1,183 +0,0 @@ -// pages/UserPage/UserPage.tsx -import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; -import { DefaultService } from "../../api/services/DefaultService"; -import { SearchBar } from "../../components/SearchBar/SearchBar"; -import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; -import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; -import { ListView } from "../../components/ListView/ListView"; -import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; -import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; -import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; - -const PAGE_SIZE = 10; - -type UsersIdPageProps = { - userId?: string; -}; - -export default function UsersIdPage({ userId }: UsersIdPageProps) { - const params = useParams(); - const id = userId || params?.id; - - const [user, setUser] = useState<User | null>(null); - const [loadingUser, setLoadingUser] = useState(true); - const [errorUser, setErrorUser] = useState<string | null>(null); - - // Для списка тайтлов - const [titles, setTitles] = useState<UserTitle[]>([]); - const [nextPage, setNextPage] = useState<UserTitle[]>([]); - const [cursor, setCursor] = useState<CursorObj | null>(null); - const [loadingTitles, setLoadingTitles] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [search, setSearch] = useState(""); - const [sort, setSort] = useState<TitleSort>("id"); - const [sortForward, setSortForward] = useState(true); - const [layout, setLayout] = useState<"square" | "horizontal">("square"); - - // --- Получение данных пользователя --- - useEffect(() => { - const fetchUser = async () => { - if (!id) return; - setLoadingUser(true); - try { - const result = await DefaultService.getUsersId(id, "all"); - setUser(result); - setErrorUser(null); - } catch (err: any) { - console.error(err); - setErrorUser(err?.message || "Failed to fetch user data"); - } finally { - setLoadingUser(false); - } - }; - fetchUser(); - }, [id]); - - // --- Получение списка тайтлов пользователя --- - const fetchPage = async (cursorObj: CursorObj | null) => { - if (!id) return { items: [], nextCursor: null }; - const cursorStr = cursorObj - ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") - : ""; - - try { - const result = await DefaultService.getUsersTitles( - id, - cursorStr, - sort, - sortForward, - search.trim() || undefined, - undefined, // status фильтр, можно добавить - undefined, // watchStatus - undefined, // rating - undefined, // myRate - undefined, // releaseYear - undefined, // releaseSeason - PAGE_SIZE, - "all" - ); - - if (!result?.data?.length) return { items: [], nextCursor: null }; - - return { items: result.data, nextCursor: result.cursor ?? null }; - } catch (err: any) { - if (err.status === 204) return { items: [], nextCursor: null }; - throw err; - } - }; - - // Инициализация: загружаем сразу две страницы - useEffect(() => { - const initLoad = async () => { - setLoadingTitles(true); - setTitles([]); - setNextPage([]); - setCursor(null); - - const firstPage = await fetchPage(null); - const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null }; - - setTitles(firstPage.items); - setNextPage(secondPage.items); - setCursor(secondPage.nextCursor); - setLoadingTitles(false); - }; - initLoad(); - }, [id, search, sort, sortForward]); - - const handleLoadMore = async () => { - if (nextPage.length === 0) { - setLoadingMore(false); - return; - } - setLoadingMore(true); - - setTitles(prev => [...prev, ...nextPage]); - setNextPage([]); - - if (cursor) { - try { - const next = await fetchPage(cursor); - if (next.items.length > 0) setNextPage(next.items); - setCursor(next.nextCursor); - } catch (err) { - console.error(err); - } - } - - setLoadingMore(false); - }; - - // const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); - - return ( - <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> - - {/* --- Карточка пользователя --- */} - {loadingUser && <div className="mt-10 text-xl font-medium">Loading user...</div>} - {errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>} - {user && ( - <div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8"> - <img src={user.image?.image_path} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" /> - <h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2> - {user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>} - {user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>} - {user.creation_date && <p className="text-gray-400 mt-4 text-sm">Registered: {new Date(user.creation_date).toLocaleDateString()}</p>} - </div> - )} - - {/* --- Панель поиска, сортировки и лейаута --- */} - <div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center"> - <SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} /> - <LayoutSwitch layout={layout} setLayout={setLayout} /> - <TitlesSortBox sort={sort} setSort={setSort} sortForward={sortForward} setSortForward={setSortForward} /> - </div> - - {/* --- Список тайтлов --- */} - {loadingTitles && <div className="mt-6 font-medium text-black">Loading titles...</div>} - {!loadingTitles && titles.length === 0 && <div className="mt-6 font-medium text-black">No titles found.</div>} - - {titles.length > 0 && ( - <> - <ListView<UserTitle> - items={titles} - layout={layout} - hasMore={!!cursor || nextPage.length > 1} - loadingMore={loadingMore} - onLoadMore={handleLoadMore} - renderItem={(title, layout) => - layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} /> - } - /> - - {!cursor && nextPage.length === 0 && ( - <div className="mt-6 font-medium text-black"> - Результатов больше нет, было найдено {titles.length} тайтлов. - </div> - )} - </> - )} - </div> - ); -} From de22dbfb504897da78c1ef60479708ee183530c7 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 10:01:52 +0300 Subject: [PATCH 140/224] feat: title cards linked to title pages --- modules/frontend/src/api/core/OpenAPI.ts | 2 +- .../src/pages/TitlesPage/TitlesPage.module.css | 1 - modules/frontend/src/pages/TitlesPage/TitlesPage.tsx | 11 ++++++----- modules/frontend/src/pages/UserPage/UserPage.tsx | 11 ++++++----- 4 files changed, 13 insertions(+), 12 deletions(-) delete mode 100644 modules/frontend/src/pages/TitlesPage/TitlesPage.module.css diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 6ce873e..185e5c3 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://10.1.0.65:8081/api/v1', + BASE: '/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css deleted file mode 100644 index f1d8c73..0000000 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css +++ /dev/null @@ -1 +0,0 @@ -@import "tailwindcss"; diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 0fec3c8..c9911b9 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -7,6 +7,7 @@ import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; import type { CursorObj, Title, TitleSort } from "../../api"; import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; +import { Link } from "react-router-dom"; const PAGE_SIZE = 10; @@ -135,11 +136,11 @@ const handleLoadMore = async () => { hasMore={!!cursor || nextPage.length > 1} loadingMore={loadingMore} onLoadMore={handleLoadMore} - renderItem={(title, layout) => - layout === "square" - ? <TitleCardSquare title={title} /> - : <TitleCardHorizontal title={title} /> - } + renderItem={(title, layout) => ( + <Link to={`/titles/${title.id}`} key={title.id} className="block"> + {layout === "square" ? <TitleCardSquare title={title} /> : <TitleCardHorizontal title={title} />} + </Link> + )} /> {!cursor && nextPage.length == 0 && ( diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 5fbd6b8..494ba99 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -9,6 +9,7 @@ import { ListView } from "../../components/ListView/ListView"; import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; +import { Link } from "react-router-dom"; const PAGE_SIZE = 10; @@ -129,8 +130,6 @@ export default function UserPage({ userId }: UserPageProps) { setLoadingMore(false); }; - // const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); - return ( <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> @@ -166,9 +165,11 @@ export default function UserPage({ userId }: UserPageProps) { hasMore={!!cursor || nextPage.length > 1} loadingMore={loadingMore} onLoadMore={handleLoadMore} - renderItem={(title, layout) => - layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} /> - } + renderItem={(title, layout) => ( + <Link to={`/titles/${title.title?.id}`} key={title.title?.id} className="block"> + {layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} />} + </Link> + )} /> {!cursor && nextPage.length === 0 && ( From ad1c567b42793e743dad1268aac27cec7263508d Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 11:59:49 +0300 Subject: [PATCH 141/224] feat: added GetUserTitle route --- api/_build/openapi.yaml | 49 ++- api/api.gen.go | 708 +++++++++++++++++------------- api/openapi.yaml | 2 + api/paths/users-id-titles-id.yaml | 107 +++++ api/paths/users-id-titles.yaml | 84 +--- modules/backend/handlers/users.go | 54 ++- modules/backend/queries.sql | 31 +- sql/queries.sql.go | 100 +++++ 8 files changed, 733 insertions(+), 402 deletions(-) create mode 100644 api/paths/users-id-titles-id.yaml diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 2ee6cdc..424e893 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -220,6 +220,7 @@ paths: description: Unknown server error '/users/{user_id}/titles': get: + operationId: getUserTitles summary: Get user titles parameters: - $ref: '#/components/parameters/cursor' @@ -360,6 +361,38 @@ paths: description: Conflict — title already assigned to user (if applicable) '500': description: Internal server error + '/users/{user_id}/titles/{title_id}': + get: + operationId: getUserTitle + summary: Get user title + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + - name: title_id + in: path + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: User titles + content: + application/json: + schema: + $ref: '#/components/schemas/UserTitleMini' + '204': + description: No user title found + '400': + description: Request params are not correct + '404': + description: User or title not found + '500': + description: Unknown server error patch: operationId: updateUserTitle summary: Update a usertitle @@ -367,12 +400,16 @@ paths: parameters: - name: user_id in: path - description: ID of the user to assign the title to required: true schema: type: integer format: int64 - example: 123 + - name: title_id + in: path + required: true + schema: + type: integer + format: int64 requestBody: required: true content: @@ -380,16 +417,11 @@ paths: schema: type: object properties: - title_id: - type: integer - format: int64 status: $ref: '#/components/schemas/UserTitleStatus' rate: type: integer format: int32 - required: - - title_id responses: '200': description: Title successfully updated @@ -414,13 +446,12 @@ paths: parameters: - name: user_id in: path - description: ID of the user to assign the title to required: true schema: type: integer format: int64 - name: title_id - in: query + in: path required: true schema: type: integer diff --git a/api/api.gen.go b/api/api.gen.go index 6208050..32ab199 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -218,13 +218,8 @@ type UpdateUserJSONBody struct { UserDesc *string `json:"user_desc,omitempty"` } -// DeleteUserTitleParams defines parameters for DeleteUserTitle. -type DeleteUserTitleParams struct { - TitleId int64 `form:"title_id" json:"title_id"` -} - -// GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. -type GetUsersUserIdTitlesParams struct { +// GetUserTitlesParams defines parameters for GetUserTitles. +type GetUserTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` @@ -241,15 +236,6 @@ type GetUsersUserIdTitlesParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// UpdateUserTitleJSONBody defines parameters for UpdateUserTitle. -type UpdateUserTitleJSONBody struct { - Rate *int32 `json:"rate,omitempty"` - - // Status User's title status - Status *UserTitleStatus `json:"status,omitempty"` - TitleId int64 `json:"title_id"` -} - // AddUserTitleJSONBody defines parameters for AddUserTitle. type AddUserTitleJSONBody struct { Rate *int32 `json:"rate,omitempty"` @@ -259,15 +245,23 @@ type AddUserTitleJSONBody struct { TitleId int64 `json:"title_id"` } +// UpdateUserTitleJSONBody defines parameters for UpdateUserTitle. +type UpdateUserTitleJSONBody struct { + Rate *int32 `json:"rate,omitempty"` + + // Status User's title status + Status *UserTitleStatus `json:"status,omitempty"` +} + // UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. type UpdateUserJSONRequestBody UpdateUserJSONBody -// UpdateUserTitleJSONRequestBody defines body for UpdateUserTitle for application/json ContentType. -type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody - // AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. type AddUserTitleJSONRequestBody AddUserTitleJSONBody +// UpdateUserTitleJSONRequestBody defines body for UpdateUserTitle for application/json ContentType. +type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody + // ServerInterface represents all server handlers. type ServerInterface interface { // Get titles @@ -282,18 +276,21 @@ type ServerInterface interface { // Partially update a user account // (PATCH /users/{user_id}) UpdateUser(c *gin.Context, userId int64) - // Delete a usertitle - // (DELETE /users/{user_id}/titles) - DeleteUserTitle(c *gin.Context, userId int64, params DeleteUserTitleParams) // Get user titles // (GET /users/{user_id}/titles) - GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) - // Update a usertitle - // (PATCH /users/{user_id}/titles) - UpdateUserTitle(c *gin.Context, userId int64) + GetUserTitles(c *gin.Context, userId string, params GetUserTitlesParams) // Add a title to a user // (POST /users/{user_id}/titles) AddUserTitle(c *gin.Context, userId int64) + // Delete a usertitle + // (DELETE /users/{user_id}/titles/{title_id}) + DeleteUserTitle(c *gin.Context, userId int64, titleId int64) + // Get user title + // (GET /users/{user_id}/titles/{title_id}) + GetUserTitle(c *gin.Context, userId int64, titleId int64) + // Update a usertitle + // (PATCH /users/{user_id}/titles/{title_id}) + UpdateUserTitle(c *gin.Context, userId int64, titleId int64) } // ServerInterfaceWrapper converts contexts to parameters. @@ -505,50 +502,8 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { siw.Handler.UpdateUser(c, userId) } -// DeleteUserTitle operation middleware -func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId int64 - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params DeleteUserTitleParams - - // ------------- Required query parameter "title_id" ------------- - - if paramValue := c.Query("title_id"); paramValue != "" { - - } else { - siw.ErrorHandler(c, fmt.Errorf("Query argument title_id is required, but not found"), http.StatusBadRequest) - return - } - - err = runtime.BindQueryParameter("form", true, true, "title_id", c.Request.URL.Query(), ¶ms.TitleId) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.DeleteUserTitle(c, userId, params) -} - -// GetUsersUserIdTitles operation middleware -func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { +// GetUserTitles operation middleware +func (siw *ServerInterfaceWrapper) GetUserTitles(c *gin.Context) { var err error @@ -562,7 +517,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { } // Parameter object where we will unmarshal all parameters from the context - var params GetUsersUserIdTitlesParams + var params GetUserTitlesParams // ------------- Optional query parameter "cursor" ------------- @@ -667,31 +622,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { } } - siw.Handler.GetUsersUserIdTitles(c, userId, params) -} - -// UpdateUserTitle operation middleware -func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId int64 - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.UpdateUserTitle(c, userId) + siw.Handler.GetUserTitles(c, userId, params) } // AddUserTitle operation middleware @@ -718,6 +649,105 @@ func (siw *ServerInterfaceWrapper) AddUserTitle(c *gin.Context) { siw.Handler.AddUserTitle(c, userId) } +// DeleteUserTitle operation middleware +func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteUserTitle(c, userId, titleId) +} + +// GetUserTitle operation middleware +func (siw *ServerInterfaceWrapper) GetUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUserTitle(c, userId, titleId) +} + +// UpdateUserTitle operation middleware +func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.UpdateUserTitle(c, userId, titleId) +} + // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -749,10 +779,11 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitle) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersId) router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) - router.DELETE(options.BaseURL+"/users/:user_id/titles", wrapper.DeleteUserTitle) - router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) - router.PATCH(options.BaseURL+"/users/:user_id/titles", wrapper.UpdateUserTitle) + router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUserTitles) router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.AddUserTitle) + router.DELETE(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.DeleteUserTitle) + router.GET(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.GetUserTitle) + router.PATCH(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.UpdateUserTitle) } type GetTitlesRequestObject struct { @@ -967,162 +998,55 @@ func (response UpdateUser500Response) VisitUpdateUserResponse(w http.ResponseWri return nil } -type DeleteUserTitleRequestObject struct { - UserId int64 `json:"user_id"` - Params DeleteUserTitleParams -} - -type DeleteUserTitleResponseObject interface { - VisitDeleteUserTitleResponse(w http.ResponseWriter) error -} - -type DeleteUserTitle200Response struct { -} - -func (response DeleteUserTitle200Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(200) - return nil -} - -type DeleteUserTitle401Response struct { -} - -func (response DeleteUserTitle401Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(401) - return nil -} - -type DeleteUserTitle403Response struct { -} - -func (response DeleteUserTitle403Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(403) - return nil -} - -type DeleteUserTitle404Response struct { -} - -func (response DeleteUserTitle404Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(404) - return nil -} - -type DeleteUserTitle500Response struct { -} - -func (response DeleteUserTitle500Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(500) - return nil -} - -type GetUsersUserIdTitlesRequestObject struct { +type GetUserTitlesRequestObject struct { UserId string `json:"user_id"` - Params GetUsersUserIdTitlesParams + Params GetUserTitlesParams } -type GetUsersUserIdTitlesResponseObject interface { - VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error +type GetUserTitlesResponseObject interface { + VisitGetUserTitlesResponse(w http.ResponseWriter) error } -type GetUsersUserIdTitles200JSONResponse struct { +type GetUserTitles200JSONResponse struct { Cursor CursorObj `json:"cursor"` Data []UserTitle `json:"data"` } -func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response GetUserTitles200JSONResponse) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetUsersUserIdTitles204Response struct { +type GetUserTitles204Response struct { } -func (response GetUsersUserIdTitles204Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response GetUserTitles204Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type GetUsersUserIdTitles400Response struct { +type GetUserTitles400Response struct { } -func (response GetUsersUserIdTitles400Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response GetUserTitles400Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(400) return nil } -type GetUsersUserIdTitles404Response struct { +type GetUserTitles404Response struct { } -func (response GetUsersUserIdTitles404Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response GetUserTitles404Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(404) return nil } -type GetUsersUserIdTitles500Response struct { +type GetUserTitles500Response struct { } -func (response GetUsersUserIdTitles500Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { - w.WriteHeader(500) - return nil -} - -type UpdateUserTitleRequestObject struct { - UserId int64 `json:"user_id"` - Body *UpdateUserTitleJSONRequestBody -} - -type UpdateUserTitleResponseObject interface { - VisitUpdateUserTitleResponse(w http.ResponseWriter) error -} - -type UpdateUserTitle200JSONResponse UserTitleMini - -func (response UpdateUserTitle200JSONResponse) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type UpdateUserTitle400Response struct { -} - -func (response UpdateUserTitle400Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(400) - return nil -} - -type UpdateUserTitle401Response struct { -} - -func (response UpdateUserTitle401Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(401) - return nil -} - -type UpdateUserTitle403Response struct { -} - -func (response UpdateUserTitle403Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(403) - return nil -} - -type UpdateUserTitle404Response struct { -} - -func (response UpdateUserTitle404Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(404) - return nil -} - -type UpdateUserTitle500Response struct { -} - -func (response UpdateUserTitle500Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { +func (response GetUserTitles500Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(500) return nil } @@ -1193,6 +1117,164 @@ func (response AddUserTitle500Response) VisitAddUserTitleResponse(w http.Respons return nil } +type DeleteUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + TitleId int64 `json:"title_id"` +} + +type DeleteUserTitleResponseObject interface { + VisitDeleteUserTitleResponse(w http.ResponseWriter) error +} + +type DeleteUserTitle200Response struct { +} + +func (response DeleteUserTitle200Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type DeleteUserTitle401Response struct { +} + +func (response DeleteUserTitle401Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type DeleteUserTitle403Response struct { +} + +func (response DeleteUserTitle403Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type DeleteUserTitle404Response struct { +} + +func (response DeleteUserTitle404Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type DeleteUserTitle500Response struct { +} + +func (response DeleteUserTitle500Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type GetUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + TitleId int64 `json:"title_id"` +} + +type GetUserTitleResponseObject interface { + VisitGetUserTitleResponse(w http.ResponseWriter) error +} + +type GetUserTitle200JSONResponse UserTitleMini + +func (response GetUserTitle200JSONResponse) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUserTitle204Response struct { +} + +func (response GetUserTitle204Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetUserTitle400Response struct { +} + +func (response GetUserTitle400Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetUserTitle404Response struct { +} + +func (response GetUserTitle404Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type GetUserTitle500Response struct { +} + +func (response GetUserTitle500Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type UpdateUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + TitleId int64 `json:"title_id"` + Body *UpdateUserTitleJSONRequestBody +} + +type UpdateUserTitleResponseObject interface { + VisitUpdateUserTitleResponse(w http.ResponseWriter) error +} + +type UpdateUserTitle200JSONResponse UserTitleMini + +func (response UpdateUserTitle200JSONResponse) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateUserTitle400Response struct { +} + +func (response UpdateUserTitle400Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type UpdateUserTitle401Response struct { +} + +func (response UpdateUserTitle401Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type UpdateUserTitle403Response struct { +} + +func (response UpdateUserTitle403Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type UpdateUserTitle404Response struct { +} + +func (response UpdateUserTitle404Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type UpdateUserTitle500Response struct { +} + +func (response UpdateUserTitle500Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Get titles @@ -1207,18 +1289,21 @@ type StrictServerInterface interface { // Partially update a user account // (PATCH /users/{user_id}) UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error) - // Delete a usertitle - // (DELETE /users/{user_id}/titles) - DeleteUserTitle(ctx context.Context, request DeleteUserTitleRequestObject) (DeleteUserTitleResponseObject, error) // Get user titles // (GET /users/{user_id}/titles) - GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) - // Update a usertitle - // (PATCH /users/{user_id}/titles) - UpdateUserTitle(ctx context.Context, request UpdateUserTitleRequestObject) (UpdateUserTitleResponseObject, error) + GetUserTitles(ctx context.Context, request GetUserTitlesRequestObject) (GetUserTitlesResponseObject, error) // Add a title to a user // (POST /users/{user_id}/titles) AddUserTitle(ctx context.Context, request AddUserTitleRequestObject) (AddUserTitleResponseObject, error) + // Delete a usertitle + // (DELETE /users/{user_id}/titles/{title_id}) + DeleteUserTitle(ctx context.Context, request DeleteUserTitleRequestObject) (DeleteUserTitleResponseObject, error) + // Get user title + // (GET /users/{user_id}/titles/{title_id}) + GetUserTitle(ctx context.Context, request GetUserTitleRequestObject) (GetUserTitleResponseObject, error) + // Update a usertitle + // (PATCH /users/{user_id}/titles/{title_id}) + UpdateUserTitle(ctx context.Context, request UpdateUserTitleRequestObject) (UpdateUserTitleResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -1351,18 +1436,18 @@ func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { } } -// DeleteUserTitle operation middleware -func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, params DeleteUserTitleParams) { - var request DeleteUserTitleRequestObject +// GetUserTitles operation middleware +func (sh *strictHandler) GetUserTitles(ctx *gin.Context, userId string, params GetUserTitlesParams) { + var request GetUserTitlesRequestObject request.UserId = userId request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.DeleteUserTitle(ctx, request.(DeleteUserTitleRequestObject)) + return sh.ssi.GetUserTitles(ctx, request.(GetUserTitlesRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "DeleteUserTitle") + handler = middleware(handler, "GetUserTitles") } response, err := handler(ctx, request) @@ -1370,71 +1455,8 @@ func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, params if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(DeleteUserTitleResponseObject); ok { - if err := validResponse.VisitDeleteUserTitleResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetUsersUserIdTitles operation middleware -func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { - var request GetUsersUserIdTitlesRequestObject - - request.UserId = userId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsersUserIdTitles(ctx, request.(GetUsersUserIdTitlesRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsersUserIdTitles") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersUserIdTitlesResponseObject); ok { - if err := validResponse.VisitGetUsersUserIdTitlesResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// UpdateUserTitle operation middleware -func (sh *strictHandler) UpdateUserTitle(ctx *gin.Context, userId int64) { - var request UpdateUserTitleRequestObject - - request.UserId = userId - - var body UpdateUserTitleJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.UpdateUserTitle(ctx, request.(UpdateUserTitleRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "UpdateUserTitle") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(UpdateUserTitleResponseObject); ok { - if err := validResponse.VisitUpdateUserTitleResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetUserTitlesResponseObject); ok { + if err := validResponse.VisitGetUserTitlesResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -1476,3 +1498,95 @@ func (sh *strictHandler) AddUserTitle(ctx *gin.Context, userId int64) { ctx.Error(fmt.Errorf("unexpected response type: %T", response)) } } + +// DeleteUserTitle operation middleware +func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, titleId int64) { + var request DeleteUserTitleRequestObject + + request.UserId = userId + request.TitleId = titleId + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteUserTitle(ctx, request.(DeleteUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(DeleteUserTitleResponseObject); ok { + if err := validResponse.VisitDeleteUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUserTitle operation middleware +func (sh *strictHandler) GetUserTitle(ctx *gin.Context, userId int64, titleId int64) { + var request GetUserTitleRequestObject + + request.UserId = userId + request.TitleId = titleId + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUserTitle(ctx, request.(GetUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUserTitleResponseObject); ok { + if err := validResponse.VisitGetUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// UpdateUserTitle operation middleware +func (sh *strictHandler) UpdateUserTitle(ctx *gin.Context, userId int64, titleId int64) { + var request UpdateUserTitleRequestObject + + request.UserId = userId + request.TitleId = titleId + + var body UpdateUserTitleJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.UpdateUserTitle(ctx, request.(UpdateUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(UpdateUserTitleResponseObject); ok { + if err := validResponse.VisitUpdateUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/api/openapi.yaml b/api/openapi.yaml index 23f2058..08a4d54 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -15,6 +15,8 @@ paths: $ref: "./paths/users-id.yaml" /users/{user_id}/titles: $ref: "./paths/users-id-titles.yaml" + /users/{user_id}/titles/{title_id}: + $ref: "./paths/users-id-titles-id.yaml" components: parameters: diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml new file mode 100644 index 0000000..b4ad884 --- /dev/null +++ b/api/paths/users-id-titles-id.yaml @@ -0,0 +1,107 @@ +get: + summary: Get user title + operationId: getUserTitle + parameters: + - in: path + name: user_id + required: true + schema: + type: integer + format: int64 + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: User titles + content: + application/json: + schema: + $ref: '../schemas/UserTitleMini.yaml' + '204': + description: No user title found + '400': + description: Request params are not correct + '404': + description: User or title not found + '500': + description: Unknown server error + +patch: + summary: Update a usertitle + description: User updating title list of watched + operationId: updateUserTitle + parameters: + - in: path + name: user_id + required: true + schema: + type: integer + format: int64 + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + status: + $ref: '../schemas/enums/UserTitleStatus.yaml' + rate: + type: integer + format: int32 + responses: + '200': + description: Title successfully updated + content: + application/json: + schema: + $ref: '../schemas/UserTitleMini.yaml' + '400': + description: Invalid request body (missing fields, invalid types, etc.) + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to update title + '404': + description: User or Title not found + '500': + description: Internal server error + +delete: + summary: Delete a usertitle + description: User deleting title from list of watched + operationId: deleteUserTitle + parameters: + - in: path + name: user_id + required: true + schema: + type: integer + format: int64 + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Title successfully deleted + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to delete title + '404': + description: User or Title not found + '500': + description: Internal server error \ No newline at end of file diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 0cb7092..75f5461 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -1,5 +1,6 @@ get: summary: Get user titles + operationId: getUserTitles parameters: - $ref: '../parameters/cursor.yaml' - $ref: "../parameters/title_sort.yaml" @@ -138,88 +139,5 @@ post: description: User or Title not found '409': description: Conflict — title already assigned to user (if applicable) - '500': - description: Internal server error - -patch: - summary: Update a usertitle - description: User updating title list of watched - operationId: updateUserTitle - parameters: - - name: user_id - in: path - required: true - schema: - type: integer - format: int64 - description: ID of the user to assign the title to - example: 123 - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - title_id - properties: - title_id: - type: integer - format: int64 - status: - $ref: '../schemas/enums/UserTitleStatus.yaml' - rate: - type: integer - format: int32 - - responses: - '200': - description: Title successfully updated - content: - application/json: - schema: - $ref: '../schemas/UserTitleMini.yaml' - '400': - description: Invalid request body (missing fields, invalid types, etc.) - '401': - description: Unauthorized — missing or invalid auth token - '403': - description: Forbidden — user not allowed to update title - '404': - description: User or Title not found - '500': - description: Internal server error - -delete: - summary: Delete a usertitle - description: User deleting title from list of watched - operationId: deleteUserTitle - parameters: - - name: user_id - in: path - required: true - schema: - type: integer - format: int64 - description: ID of the user to assign the title to - - in: query - name: title_id - required: true - schema: - type: integer - format: int64 - - - responses: - '200': - description: Title successfully deleted - # '400': - # description: Invalid request body (missing fields, invalid types, etc.) - '401': - description: Unauthorized — missing or invalid auth token - '403': - description: Forbidden — user not allowed to delete title - '404': - description: User or Title not found '500': description: Internal server error \ No newline at end of file diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 563a244..8723d16 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -204,7 +204,7 @@ func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (o return oapi_usertitle, nil } -func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) { +func (s Server) GetUserTitles(ctx context.Context, request oapi.GetUserTitlesRequestObject) (oapi.GetUserTitlesResponseObject, error) { oapi_usertitles := make([]oapi.UserTitle, 0) @@ -213,7 +213,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles400Response{}, err + return oapi.GetUserTitles400Response{}, err } // var statuses_sort []string @@ -227,19 +227,19 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU watch_status, err := UserTitleStatus2Sqlc(request.Params.WatchStatus) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles400Response{}, err + return oapi.GetUserTitles400Response{}, err } title_statuses, err := TitleStatus2Sqlc(request.Params.Status) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles400Response{}, err + return oapi.GetUserTitles400Response{}, err } userID, err := parseInt64(request.UserId) if err != nil { log.Errorf("get user titles: %v", err) - return oapi.GetUsersUserIdTitles404Response{}, err + return oapi.GetUserTitles404Response{}, err } params := sqlc.SearchUserTitlesParams{ UserID: userID, @@ -265,7 +265,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles400Response{}, nil + return oapi.GetUserTitles400Response{}, nil } } } @@ -273,10 +273,10 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU titles, err := s.db.SearchUserTitles(ctx, params) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles500Response{}, nil + return oapi.GetUserTitles500Response{}, nil } if len(titles) == 0 { - return oapi.GetUsersUserIdTitles204Response{}, nil + return oapi.GetUserTitles204Response{}, nil } var new_cursor oapi.CursorObj @@ -286,7 +286,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU t, err := s.mapUsertitle(ctx, title) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles500Response{}, nil + return oapi.GetUserTitles500Response{}, nil } oapi_usertitles = append(oapi_usertitles, t) @@ -303,7 +303,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU } } - return oapi.GetUsersUserIdTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil + return oapi.GetUserTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil } func EmailToStringPtr(e *types.Email) *string { @@ -402,7 +402,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque func (s Server) DeleteUserTitle(ctx context.Context, request oapi.DeleteUserTitleRequestObject) (oapi.DeleteUserTitleResponseObject, error) { params := sqlc.DeleteUserTitleParams{ UserID: request.UserId, - TitleID: request.Params.TitleId, + TitleID: request.TitleId, } _, err := s.db.DeleteUserTitle(ctx, params) if err != nil { @@ -427,7 +427,7 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl Status: status, Rate: request.Body.Rate, UserID: request.UserId, - TitleID: request.Body.TitleId, + TitleID: request.TitleId, } user_title, err := s.db.UpdateUserTitle(ctx, params) @@ -455,3 +455,33 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl return oapi.UpdateUserTitle200JSONResponse(oapi_usertitle), nil } + +func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleRequestObject) (oapi.GetUserTitleResponseObject, error) { + user_title, err := s.db.GetUserTitleByID(ctx, sqlc.GetUserTitleByIDParams{ + TitleID: request.TitleId, + UserID: request.UserId, + }) + if err != nil { + if err == pgx.ErrNoRows { + return oapi.GetUserTitle404Response{}, nil + } else { + log.Errorf("%v", err) + return oapi.GetUserTitle500Response{}, nil + } + } + oapi_status, err := sql2usertitlestatus(user_title.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUserTitle500Response{}, nil + } + oapi_usertitle := oapi.UserTitleMini{ + Ctime: &user_title.Ctime, + Rate: user_title.Rate, + ReviewId: user_title.ReviewID, + Status: oapi_status, + TitleId: *user_title.ID, + UserId: user_title.UserID, + } + + return oapi.GetUserTitle200JSONResponse(oapi_usertitle), nil +} diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 5ac2c5c..1a90cde 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -394,4 +394,33 @@ RETURNING *; DELETE FROM usertitles WHERE user_id = sqlc.arg('user_id') AND title_id = sqlc.arg('title_id') -RETURNING *; \ No newline at end of file +RETURNING *; + +-- name: GetUserTitleByID :one +SELECT + ut.*, + t.*, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type as studio_storage_type, + si.image_path as studio_image_path + +FROM usertitles as ut +LEFT JOIN users as u ON (ut.user_id = u.id) +LEFT JOIN titles as t ON (ut.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE t.id = sqlc.arg('title_id')::bigint AND u.id = sqlc.arg('user_id')::bigint +GROUP BY + t.id, i.id, s.id, si.id; \ No newline at end of file diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 9338717..f35007d 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -262,6 +262,106 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er return i, err } +const getUserTitleByID = `-- name: GetUserTitleByID :one +SELECT + ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime, + t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type as studio_storage_type, + si.image_path as studio_image_path + +FROM usertitles as ut +LEFT JOIN users as u ON (ut.user_id = u.id) +LEFT JOIN titles as t ON (ut.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE t.id = $1::bigint AND u.id = $2::bigint +GROUP BY + t.id, i.id, s.id, si.id +` + +type GetUserTitleByIDParams struct { + TitleID int64 `json:"title_id"` + UserID int64 `json:"user_id"` +} + +type GetUserTitleByIDRow struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime time.Time `json:"ctime"` + ID *int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID *int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus *TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` + StudioIllustID *int64 `json:"studio_illust_id"` + StudioDesc *string `json:"studio_desc"` + StudioStorageType *StorageTypeT `json:"studio_storage_type"` + StudioImagePath *string `json:"studio_image_path"` +} + +func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (GetUserTitleByIDRow, error) { + row := q.db.QueryRow(ctx, getUserTitleByID, arg.TitleID, arg.UserID) + var i GetUserTitleByIDRow + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, + &i.StudioIllustID, + &i.StudioDesc, + &i.StudioStorageType, + &i.StudioImagePath, + ) + return i, err +} + const insertStudio = `-- name: InsertStudio :one INSERT INTO studios (studio_name, illust_id, studio_desc) VALUES ( From 13342d5613e20c9bf4ece9700ff085de8029b090 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 13:18:19 +0300 Subject: [PATCH 142/224] cicd: updated --- .forgejo/workflows/build-and-deploy.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 87f3655..adbe61e 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -18,9 +18,6 @@ jobs: - uses: actions/setup-go@v6 with: go-version: '^1.25' - check-latest: false - cache-dependency-path: | - go.sum - name: Build backend run: | From 3f0456ba01b20b016e0bf9142eb44a605a770f98 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 14:09:13 +0300 Subject: [PATCH 143/224] cicd: updated --- .forgejo/workflows/build-and-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index adbe61e..3c473d2 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -101,7 +101,7 @@ jobs: tags: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest deploy: - runs-on: self-hosted + runs-on: debian-test needs: build env: POSTGRES_USER: ${{ secrets.POSTGRES_USER }} From 8a3e14a5e5c0495be790ab1dbcd4832fd0f41fb0 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 16:26:03 +0300 Subject: [PATCH 144/224] feat: TitleStatusControls --- .../src/api/services/DefaultService.ts | 62 +++++++++++- modules/frontend/src/auth/core/OpenAPI.ts | 2 +- .../TitleStatusControls.tsx | 88 +++++++++++++++++ .../src/pages/TitlePage/TitlePage.tsx | 94 ++++++------------- .../frontend/src/pages/UserPage/UserPage.tsx | 2 +- 5 files changed, 179 insertions(+), 69 deletions(-) create mode 100644 modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 5070fae..218b461 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -199,7 +199,7 @@ export class DefaultService { * @returns any List of user titles * @throws ApiError */ - public static getUsersTitles( + public static getUserTitles( userId: string, cursor?: string, sort?: TitleSort, @@ -278,27 +278,54 @@ export class DefaultService { }, }); } + /** + * Get user title + * @param userId + * @param titleId + * @returns UserTitleMini User titles + * @throws ApiError + */ + public static getUserTitle( + userId: number, + titleId: number, + ): CancelablePromise<UserTitleMini> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}/titles/{title_id}', + path: { + 'user_id': userId, + 'title_id': titleId, + }, + errors: { + 400: `Request params are not correct`, + 404: `User or title not found`, + 500: `Unknown server error`, + }, + }); + } /** * Update a usertitle * User updating title list of watched - * @param userId ID of the user to assign the title to + * @param userId + * @param titleId * @param requestBody * @returns UserTitleMini Title successfully updated * @throws ApiError */ public static updateUserTitle( userId: number, + titleId: number, requestBody: { - title_id: number; status?: UserTitleStatus; rate?: number; }, ): CancelablePromise<UserTitleMini> { return __request(OpenAPI, { method: 'PATCH', - url: '/users/{user_id}/titles', + url: '/users/{user_id}/titles/{title_id}', path: { 'user_id': userId, + 'title_id': titleId, }, body: requestBody, mediaType: 'application/json', @@ -311,4 +338,31 @@ export class DefaultService { }, }); } + /** + * Delete a usertitle + * User deleting title from list of watched + * @param userId + * @param titleId + * @returns any Title successfully deleted + * @throws ApiError + */ + public static deleteUserTitle( + userId: number, + titleId: number, + ): CancelablePromise<any> { + return __request(OpenAPI, { + method: 'DELETE', + url: '/users/{user_id}/titles/{title_id}', + path: { + 'user_id': userId, + 'title_id': titleId, + }, + errors: { + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to delete title`, + 404: `User or Title not found`, + 500: `Internal server error`, + }, + }); + } } diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts index 79aa305..2d0edf8 100644 --- a/modules/frontend/src/auth/core/OpenAPI.ts +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://10.1.0.65:8081/auth', + BASE: '/auth', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx new file mode 100644 index 0000000..0c9c741 --- /dev/null +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { DefaultService } from "../../api"; +import type { UserTitleStatus } from "../../api"; +import { + ClockIcon, + CheckCircleIcon, + PlayCircleIcon, + XCircleIcon, +} from "@heroicons/react/24/solid"; + +// Статусы с иконками и подписью +const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [ + { status: "planned", icon: <ClockIcon className="w-5 h-5" />, label: "Planned" }, + { status: "finished", icon: <CheckCircleIcon className="w-5 h-5" />, label: "Finished" }, + { status: "in-progress", icon: <PlayCircleIcon className="w-5 h-5" />, label: "In Progress" }, + { status: "dropped", icon: <XCircleIcon className="w-5 h-5" />, label: "Dropped" }, +]; + +export function TitleStatusControls({ titleId }: { titleId: number }) { + const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); + const [loading, setLoading] = useState(false); + + const userIdStr = localStorage.getItem("userId"); + const userId = userIdStr ? Number(userIdStr) : null; + + // --- Load initial status --- + useEffect(() => { + if (!userId) return; + + DefaultService.getUserTitle(userId, titleId) + .then((res) => setCurrentStatus(res.status)) + .catch(() => setCurrentStatus(null)); // 404 = user title does not exist + }, [titleId, userId]); + + // --- Handle click --- + const handleStatusClick = async (status: UserTitleStatus) => { + if (!userId || loading) return; + + setLoading(true); + + try { + // 1) Если кликнули на текущий статус — DELETE + if (currentStatus === status) { + await DefaultService.deleteUserTitle(userId, titleId); + setCurrentStatus(null); + return; + } + + // 2) Если другой статус — POST или PATCH + if (!currentStatus) { + // ещё нет записи — POST + const added = await DefaultService.addUserTitle(userId, { + title_id: titleId, + status, + }); + setCurrentStatus(added.status); + } else { + // уже есть запись — PATCH + const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); + setCurrentStatus(updated.status); + } + } finally { + setLoading(false); + } + }; + + return ( + <div className="flex gap-2 flex-wrap justify-center mt-2"> + {STATUS_BUTTONS.map(btn => ( + <button + key={btn.status} + onClick={() => handleStatusClick(btn.status)} + disabled={loading} + className={` + px-3 py-1 rounded-md border flex items-center gap-1 transition + ${currentStatus === btn.status + ? "bg-blue-600 text-white border-blue-700" + : "bg-gray-200 text-black border-gray-300 hover:bg-gray-300"} + `} + title={btn.label} + > + {btn.icon} + <span>{btn.label}</span> + </button> + ))} + </div> + ); +} diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index 5ea0e3d..01f9c49 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -1,20 +1,8 @@ import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useParams, Link } from "react-router-dom"; import { DefaultService } from "../../api/services/DefaultService"; -import type { Title, UserTitleStatus } from "../../api"; -import { - ClockIcon, - CheckCircleIcon, - PlayCircleIcon, - XCircleIcon, -} from "@heroicons/react/24/solid"; - -const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [ - { status: "planned", icon: <ClockIcon className="w-6 h-6" />, label: "Planned" }, - { status: "finished", icon: <CheckCircleIcon className="w-6 h-6" />, label: "Finished" }, - { status: "in-progress", icon: <PlayCircleIcon className="w-6 h-6" />, label: "In Progress" }, - { status: "dropped", icon: <XCircleIcon className="w-6 h-6" />, label: "Dropped" }, -]; +import type { Title } from "../../api"; +import { TitleStatusControls } from "../../components/TitleStatusControls/TitleStatusControls"; export default function TitlePage() { const params = useParams(); @@ -24,9 +12,9 @@ export default function TitlePage() { const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); - const [userStatus, setUserStatus] = useState<UserTitleStatus | null>(null); - const [updatingStatus, setUpdatingStatus] = useState(false); - + // --------------------------- + // LOAD TITLE INFO + // --------------------------- useEffect(() => { const fetchTitle = async () => { setLoading(true); @@ -44,30 +32,6 @@ export default function TitlePage() { fetchTitle(); }, [titleId]); - const handleStatusClick = async (status: UserTitleStatus) => { - if (updatingStatus || userStatus === status) return; - - const userId = Number(localStorage.getItem("userId")); - if (!userId) { - alert("You must be logged in to set status."); - return; - } - - setUpdatingStatus(true); - try { - await DefaultService.addUserTitle(userId, { - title_id: titleId, - status, - }); - setUserStatus(status); - } catch (err: any) { - console.error(err); - alert(err?.message || "Failed to set status"); - } finally { - setUpdatingStatus(false); - } - }; - const getTagsString = () => title?.tags?.map(tag => tag.en).filter(Boolean).join(", "); @@ -78,7 +42,7 @@ export default function TitlePage() { return ( <div className="w-full min-h-screen bg-gray-50 p-6 flex justify-center"> <div className="flex flex-col md:flex-row bg-white shadow-lg rounded-xl max-w-4xl w-full p-6 gap-6"> - {/* Постер */} + {/* Poster + status buttons */} <div className="flex flex-col items-center"> <img src={title.poster?.image_path || "/default-poster.png"} @@ -86,48 +50,52 @@ export default function TitlePage() { className="w-48 h-72 object-cover rounded-lg mb-4" /> - {/* Статус кнопки с иконками */} - <div className="flex gap-2 mt-2 flex-wrap justify-center"> - {STATUS_BUTTONS.map(btn => ( - <button - key={btn.status} - onClick={() => handleStatusClick(btn.status)} - disabled={updatingStatus} - className={`p-2 rounded-lg transition flex items-center justify-center ${ - userStatus === btn.status - ? "bg-blue-600 text-white" - : "bg-gray-200 text-gray-700 hover:bg-gray-300" - }`} - title={btn.label} - > - {btn.icon} - </button> - ))} - </div> + {/* Status buttons */} + <TitleStatusControls titleId={titleId} /> </div> - {/* Информация о тайтле */} + {/* Title info */} <div className="flex-1 flex flex-col"> <h1 className="text-3xl font-bold mb-2"> {title.title_names?.en?.[0] || "Untitled"} </h1> - {title.studio && <p className="text-gray-700 mb-1">Studio: {title.studio.name}</p>} + + {title.studio && ( + <p className="text-gray-700 mb-1"> + Studio:{" "} + {title.studio.id ? ( + <Link + to={`/studios/${title.studio.id}`} + className="text-blue-600 hover:underline" + > + {title.studio.name} + </Link> + ) : ( + title.studio.name + )} + </p> + )} + {title.title_status && <p className="text-gray-700 mb-1">Status: {title.title_status}</p>} + {title.rating !== undefined && ( <p className="text-gray-700 mb-1"> Rating: {title.rating} ({title.rating_count} votes) </p> )} + {title.release_year && ( <p className="text-gray-700 mb-1"> Released: {title.release_year} {title.release_season || ""} </p> )} + {title.episodes_aired !== undefined && ( <p className="text-gray-700 mb-1"> Episodes: {title.episodes_aired}/{title.episodes_all} </p> )} + {title.tags && title.tags.length > 0 && ( <p className="text-gray-700 mb-1"> Tags: {getTagsString()} diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 494ba99..7cc0db5 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -63,7 +63,7 @@ export default function UserPage({ userId }: UserPageProps) { : ""; try { - const result = await DefaultService.getUsersTitles( + const result = await DefaultService.getUserTitles( id, cursorStr, sort, From 37cdc32d5da55d620cc82eb2caf3b6de28dcab57 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 16:28:09 +0300 Subject: [PATCH 145/224] fix: fix GetUserTitleByID --- modules/backend/handlers/users.go | 2 +- modules/backend/main.go | 2 +- modules/backend/queries.sql | 27 +--------- sql/queries.sql.go | 82 ++----------------------------- 4 files changed, 8 insertions(+), 105 deletions(-) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 8723d16..d6faade 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -479,7 +479,7 @@ func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleReque Rate: user_title.Rate, ReviewId: user_title.ReviewID, Status: oapi_status, - TitleId: *user_title.ID, + TitleId: user_title.TitleID, UserId: user_title.UserID, } diff --git a/modules/backend/main.go b/modules/backend/main.go index 3ac6603..8f58ffe 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -48,7 +48,7 @@ func main() { r.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production - AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 1a90cde..ff41cb1 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -398,29 +398,6 @@ RETURNING *; -- name: GetUserTitleByID :one SELECT - ut.*, - t.*, - i.storage_type as title_storage_type, - i.image_path as title_image_path, - COALESCE( - jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), - '[]'::jsonb - )::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type as studio_storage_type, - si.image_path as studio_image_path - + ut.* FROM usertitles as ut -LEFT JOIN users as u ON (ut.user_id = u.id) -LEFT JOIN titles as t ON (ut.title_id = t.id) -LEFT JOIN images as i ON (t.poster_id = i.id) -LEFT JOIN title_tags as tt ON (t.id = tt.title_id) -LEFT JOIN tags as g ON (tt.tag_id = g.id) -LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) - -WHERE t.id = sqlc.arg('title_id')::bigint AND u.id = sqlc.arg('user_id')::bigint -GROUP BY - t.id, i.id, s.id, si.id; \ No newline at end of file +WHERE ut.title_id = sqlc.arg('title_id')::bigint AND ut.user_id = sqlc.arg('user_id')::bigint; \ No newline at end of file diff --git a/sql/queries.sql.go b/sql/queries.sql.go index ddf6f6b..1cca986 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -306,32 +306,9 @@ func (q *Queries) GetUserByNickname(ctx context.Context, nickname string) (User, const getUserTitleByID = `-- name: GetUserTitleByID :one SELECT - ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime, - t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, - i.storage_type as title_storage_type, - i.image_path as title_image_path, - COALESCE( - jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), - '[]'::jsonb - )::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type as studio_storage_type, - si.image_path as studio_image_path - + ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime FROM usertitles as ut -LEFT JOIN users as u ON (ut.user_id = u.id) -LEFT JOIN titles as t ON (ut.title_id = t.id) -LEFT JOIN images as i ON (t.poster_id = i.id) -LEFT JOIN title_tags as tt ON (t.id = tt.title_id) -LEFT JOIN tags as g ON (tt.tag_id = g.id) -LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) - -WHERE t.id = $1::bigint AND u.id = $2::bigint -GROUP BY - t.id, i.id, s.id, si.id +WHERE ut.title_id = $1::bigint AND ut.user_id = $2::bigint ` type GetUserTitleByIDParams struct { @@ -339,39 +316,9 @@ type GetUserTitleByIDParams struct { UserID int64 `json:"user_id"` } -type GetUserTitleByIDRow struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` - Ctime time.Time `json:"ctime"` - ID *int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID *int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus *TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` - TitleStorageType *StorageTypeT `json:"title_storage_type"` - TitleImagePath *string `json:"title_image_path"` - TagNames json.RawMessage `json:"tag_names"` - StudioName *string `json:"studio_name"` - StudioIllustID *int64 `json:"studio_illust_id"` - StudioDesc *string `json:"studio_desc"` - StudioStorageType *StorageTypeT `json:"studio_storage_type"` - StudioImagePath *string `json:"studio_image_path"` -} - -func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (GetUserTitleByIDRow, error) { +func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (Usertitle, error) { row := q.db.QueryRow(ctx, getUserTitleByID, arg.TitleID, arg.UserID) - var i GetUserTitleByIDRow + var i Usertitle err := row.Scan( &i.UserID, &i.TitleID, @@ -379,27 +326,6 @@ func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDPara &i.Rate, &i.ReviewID, &i.Ctime, - &i.ID, - &i.TitleNames, - &i.StudioID, - &i.PosterID, - &i.TitleStatus, - &i.Rating, - &i.RatingCount, - &i.ReleaseYear, - &i.ReleaseSeason, - &i.Season, - &i.EpisodesAired, - &i.EpisodesAll, - &i.EpisodesLen, - &i.TitleStorageType, - &i.TitleImagePath, - &i.TagNames, - &i.StudioName, - &i.StudioIllustID, - &i.StudioDesc, - &i.StudioStorageType, - &i.StudioImagePath, ) return i, err } From df188cb531e09d031e6ddcd38e1c18af330e6d84 Mon Sep 17 00:00:00 2001 From: garaev kamil <garaev.kr@phystech.edu> Date: Thu, 27 Nov 2025 18:26:47 +0300 Subject: [PATCH 146/224] rate_limiter impl --- modules/anime_etl/rate_limiter.py | 121 ++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 modules/anime_etl/rate_limiter.py diff --git a/modules/anime_etl/rate_limiter.py b/modules/anime_etl/rate_limiter.py new file mode 100644 index 0000000..c2b06ff --- /dev/null +++ b/modules/anime_etl/rate_limiter.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import asyncio +import time +from abc import ABC, abstractmethod +from typing import Awaitable, Callable, TypeVar, Optional + +T = TypeVar("T") + +NowFn = Callable[[], float] +SleepFn = Callable[[float], Awaitable[None]] + + +class RateLimiter(ABC): + """ + Базовый интерфейс rate-limiter'а. + """ + + @abstractmethod + async def acquire(self) -> None: + """ + Дождаться слота для выполнения запроса. + """ + raise NotImplementedError + + +class TokenBucketRateLimiter(RateLimiter): + """ + Token-bucket rate limiter. + + rate — сколько токенов выдаём за период `per` секунд. + per — длина окна в секундах. + capacity — максимальное количество токенов в корзине (burst). По умолчанию = rate. + + Пример: + limiter = TokenBucketRateLimiter(rate=30, per=60.0) + await limiter.acquire() # перед каждым запросом к AniList + """ + + def __init__( + self, + rate: int, + per: float, + *, + capacity: Optional[int] = None, + now_fn: NowFn | None = None, + sleep_fn: SleepFn | None = None, + ) -> None: + self.rate = float(rate) + self.per = float(per) + self.capacity = float(capacity if capacity is not None else rate) + + self._now: NowFn = now_fn or time.monotonic + self._sleep: SleepFn = sleep_fn or asyncio.sleep + + # начальное состояние: полный бак + self._tokens: float = self.capacity + self._updated_at: float = self._now() + self._lock = asyncio.Lock() + + async def acquire(self) -> None: + """ + Подождать, пока не будет доступен хотя бы один токен. + Важно: ожидание (sleep) происходит ВНЕ lock'а, чтобы несколько корутин + могли "делить" ожидание. + """ + refill_rate_per_second = self.rate / self.per + + while True: + async with self._lock: + now = self._now() + elapsed = now - self._updated_at + + if elapsed > 0: + self._tokens = min( + self.capacity, + self._tokens + elapsed * refill_rate_per_second, + ) + self._updated_at = now + + if self._tokens >= 1.0: + self._tokens -= 1.0 + return + + # токенов нет — считаем, сколько нужно ждать + missing = 1.0 - self._tokens + wait_for = missing / refill_rate_per_second + if wait_for <= 0: + wait_for = 0.01 + + # спим уже без lock'а, чтобы другие могли проверить состояние + await self._sleep(wait_for) + + +def wrap_with_rate_limiter( + limiter: RateLimiter, +) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]: + """ + Декоратор для функций, которые делают запросы к внешнему API. + + Пример: + limiter = TokenBucketRateLimiter(rate=30, per=60.0) + + @wrap_with_rate_limiter(limiter) + async def fetch_something(...): + ... + """ + + def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]: + async def wrapper(*args, **kwargs) -> T: + await limiter.acquire() + return await func(*args, **kwargs) + + return wrapper + + return decorator + + +# Глобальные инстансы per-process для источников +ANILIST_RATE_LIMITER = TokenBucketRateLimiter(rate=30, per=60.0) +SHIKIMORI_RATE_LIMITER = TokenBucketRateLimiter(rate=90, per=60.0) From f71c1f4f082bfd6914cfcf2d3f879e3b3b7b05db Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Fri, 28 Nov 2025 11:43:10 +0300 Subject: [PATCH 147/224] feat: added rabbitmq --- deploy/docker-compose.yml | 52 ++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 7f53da5..79ad2f5 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -11,20 +11,34 @@ services: - "${POSTGRES_PORT}:5432" volumes: - postgres_data:/var/lib/postgresql + networks: + - nyanimedb-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s - # pgadmin: - # image: dpage/pgadmin4:${PGADMIN_VERSION} - # container_name: pgadmin - # restart: always - # environment: - # PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL} - # PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD} - # ports: - # - "${PGADMIN_PORT}:80" - # depends_on: - # - postgres - # volumes: - # - pgadmin_data:/var/lib/pgadmin + rabbitmq: + image: rabbitmq:3-management + container_name: rabbitmq + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} + volumes: + - rabbitmq_data:/var/lib/rabbitmq + networks: + - nyanimedb-network + healthcheck: + test: ["CMD", "rabbitmqctl", "status"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s nyanimedb-backend: image: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest @@ -37,6 +51,9 @@ services: - "8080:8080" depends_on: - postgres + - rabbitmq + networks: + - nyanimedb-network nyanimedb-auth: image: meowgit.nekoea.red/nihonium/nyanimedb-auth:latest @@ -49,6 +66,8 @@ services: - "8082:8082" depends_on: - postgres + networks: + - nyanimedb-network nyanimedb-frontend: image: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest @@ -58,7 +77,12 @@ services: - "8081:80" depends_on: - nyanimedb-backend + networks: + - nyanimedb-network volumes: postgres_data: - pgadmin_data: + rabbitmq_data: + +networks: + nyanimedb-network: From 1756d61da466a70fdbe0f3dce4b963f197fc92c9 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 30 Nov 2025 00:51:38 +0300 Subject: [PATCH 148/224] lib for rabbitMQ --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 7b7cc71..fe4f31e 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/streadway/amqp v1.1.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect diff --git a/go.sum b/go.sum index cd197e6..6704a5a 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,8 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/streadway/amqp v1.1.0 h1:py12iX8XSyI7aN/3dUT8DFIDJazNJsVJdxNVEpnQTZM= +github.com/streadway/amqp v1.1.0/go.mod h1:WYSrTEYHOXHd0nwFeUXAe2G2hRnQT+deZJJf88uS9Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= From c6cebb0ed24e2c26b2ce72ac0d618db0c7df0c7c Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 30 Nov 2025 01:34:59 +0300 Subject: [PATCH 149/224] feat: rabbitMQ request --- api/_build/openapi.yaml | 5 ++ api/api.gen.go | 9 ++++ api/paths/titles.yaml | 6 ++- go.mod | 1 + go.sum | 2 + modules/backend/rabbit.go | 103 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 modules/backend/rabbit.go diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 2ee6cdc..f875ba2 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -16,6 +16,11 @@ paths: schema: type: boolean default: true + - name: ext_search + in: query + schema: + type: boolean + default: false - name: word in: query schema: diff --git a/api/api.gen.go b/api/api.gen.go index 6208050..2294d74 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -178,6 +178,7 @@ type GetTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` + ExtSearch *bool `form:"ext_search,omitempty" json:"ext_search,omitempty"` Word *string `form:"word,omitempty" json:"word,omitempty"` // Status List of title statuses to filter @@ -337,6 +338,14 @@ func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { return } + // ------------- Optional query parameter "ext_search" ------------- + + err = runtime.BindQueryParameter("form", true, false, "ext_search", c.Request.URL.Query(), ¶ms.ExtSearch) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter ext_search: %w", err), http.StatusBadRequest) + return + } + // ------------- Optional query parameter "word" ------------- err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) diff --git a/api/paths/titles.yaml b/api/paths/titles.yaml index af2d17b..4288417 100644 --- a/api/paths/titles.yaml +++ b/api/paths/titles.yaml @@ -8,6 +8,11 @@ get: schema: type: boolean default: true + - in: query + name: ext_search + schema: + type: boolean + default: false - in: query name: word schema: @@ -21,7 +26,6 @@ get: description: List of title statuses to filter style: form explode: false - - in: query name: rating schema: diff --git a/go.mod b/go.mod index bf73121..7fc0e5f 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/streadway/amqp v1.1.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect diff --git a/go.sum b/go.sum index 8f46514..e52e5c9 100644 --- a/go.sum +++ b/go.sum @@ -73,6 +73,8 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/streadway/amqp v1.1.0 h1:py12iX8XSyI7aN/3dUT8DFIDJazNJsVJdxNVEpnQTZM= +github.com/streadway/amqp v1.1.0/go.mod h1:WYSrTEYHOXHd0nwFeUXAe2G2hRnQT+deZJJf88uS9Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/modules/backend/rabbit.go b/modules/backend/rabbit.go new file mode 100644 index 0000000..f08bf39 --- /dev/null +++ b/modules/backend/rabbit.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + oapi "nyanimedb/api" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "github.com/streadway/amqp" +) + +type RabbitRequest struct { + Name string `json:"name"` + Status oapi.TitleStatus `json:"titlestatus,omitempty"` + Rating float64 `json:"titleraring,omitempty"` + Year int32 `json:"year,omitempty"` + Season oapi.ReleaseSeason `json:"season,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// PublishAndAwaitReply отправляет запрос и ждёт ответа от worker’а. +// Возвращает раскодированный ответ или ошибку. +func PublishAndAwaitReply( + ctx context.Context, + ch *amqp.Channel, + requestQueue string, // например: "svc.media.process.requests" + request RabbitRequest, // ваша структура запроса + replyCh chan<- any, // куда положить ответ (вы читаете извне) +) error { + // 1. Создаём временную очередь для ответов + replyQueue, err := ch.QueueDeclare( + "", // auto-generated name + false, // not durable + true, // exclusive + true, // auto-delete + false, // no-wait + nil, + ) + if err != nil { + return fmt.Errorf("failed to declare reply queue: %w", err) + } + + // 2. Готовим корреляционный ID + corrID := uuid.New().String() // ← используйте github.com/google/uuid + logrus.Infof("New CorrID: %s", corrID) + + // 3. Сериализуем запрос + body, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + // 4. Публикуем запрос + err = ch.Publish( + "", // default exchange (или свой, если используете) + requestQueue, + false, + false, + amqp.Publishing{ + ContentType: "application/json", + CorrelationId: corrID, + ReplyTo: replyQueue.Name, + DeliveryMode: amqp.Persistent, + Timestamp: time.Now(), + Body: body, + }, + ) + if err != nil { + return fmt.Errorf("failed to publish request, corrID: %s : %w", corrID, err) + } + + // 5. Подписываемся на ответы + msgs, err := ch.Consume( + replyQueue.Name, + "", // consumer tag + true, // auto-ack + true, // exclusive + false, // no-local + false, // no-wait + nil, // args + ) + if err != nil { + return fmt.Errorf("failed to consume from reply queue: %w", err) + } + + // 6. Ожидаем ответ с таймаутом + select { + case msg := <-msgs: + if msg.CorrelationId != corrID { + return fmt.Errorf("correlation ID mismatch: got %s, expected %s", msg.CorrelationId, corrID) + } + // Десериализуем — тут можно передать target-структуру или использовать interface{} + // В данном случае просто возвращаем байты или пусть вызывающая сторона парсит + replyCh <- msg.Body // или json.Unmarshal → и отправить структуру в канал + return nil + + case <-ctx.Done(): + return ctx.Err() + } +} From 77a63a1c748e073470ed19b355e5fd1860955597 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 30 Nov 2025 02:57:11 +0300 Subject: [PATCH 150/224] feat: rabbitMQ is now calling from seatchtitles --- go.mod | 1 + go.sum | 2 + modules/backend/handlers/common.go | 21 +++- modules/backend/handlers/titles.go | 25 +++++ modules/backend/main.go | 43 +++++--- modules/backend/rabbit.go | 103 ------------------ modules/backend/rmq/rabbit.go | 166 +++++++++++++++++++++++++++++ 7 files changed, 237 insertions(+), 124 deletions(-) delete mode 100644 modules/backend/rabbit.go create mode 100644 modules/backend/rmq/rabbit.go diff --git a/go.mod b/go.mod index 7fc0e5f..f29cbb1 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/rabbitmq/amqp091-go v1.10.0 // indirect github.com/streadway/amqp v1.1.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect diff --git a/go.sum b/go.sum index e52e5c9..59cc7ba 100644 --- a/go.sum +++ b/go.sum @@ -70,6 +70,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index f820db6..aece414 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -4,16 +4,29 @@ import ( "encoding/json" "fmt" oapi "nyanimedb/api" + "nyanimedb/modules/backend/rmq" sqlc "nyanimedb/sql" "strconv" ) -type Server struct { - db *sqlc.Queries +type Handler struct { + publisher *rmq.Publisher } -func NewServer(db *sqlc.Queries) Server { - return Server{db: db} +func New(publisher *rmq.Publisher) *Handler { + return &Handler{publisher: publisher} +} + +type Server struct { + db *sqlc.Queries + publisher *rmq.Publisher // ← добавьте это поле +} + +func NewServer(db *sqlc.Queries, publisher *rmq.Publisher) *Server { + return &Server{ + db: db, + publisher: publisher, + } } func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.StorageType, error) { diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 77af7e4..9f11016 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -5,8 +5,10 @@ import ( "encoding/json" "fmt" oapi "nyanimedb/api" + "nyanimedb/modules/backend/rmq" sqlc "nyanimedb/sql" "strconv" + "time" "github.com/jackc/pgx/v5" log "github.com/sirupsen/logrus" @@ -154,6 +156,29 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject } func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { + + if request.Params.ExtSearch != nil && *request.Params.ExtSearch { + // Публикуем событие — как и просили + event := rmq.RabbitRequest{ + Name: "Attack on titans", + // Status oapi.TitleStatus `json:"titlestatus,omitempty"` + // Rating float64 `json:"titleraring,omitempty"` + // Year int32 `json:"year,omitempty"` + // Season oapi.ReleaseSeason `json:"season,omitempty"` + Timestamp: time.Now(), + } + + // Контекст с таймаутом (не блокируем ответ) + publishCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + if err := s.publisher.Publish(publishCtx, "events.user", event); err != nil { + log.Errorf("RMQ publish failed (non-critical): %v", err) + } else { + log.Infof("RMQ publish succeed %v", err) + } + } + opai_titles := make([]oapi.Title, 0) word := Word2Sqlc(request.Params.Word) diff --git a/modules/backend/main.go b/modules/backend/main.go index 3ac6603..25f175a 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "net/http" sqlc "nyanimedb/sql" "os" "reflect" @@ -10,11 +11,14 @@ import ( oapi "nyanimedb/api" handlers "nyanimedb/modules/backend/handlers" + "nyanimedb/modules/backend/rmq" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "github.com/pelletier/go-toml/v2" + "github.com/rabbitmq/amqp091-go" + log "github.com/sirupsen/logrus" ) var AppConfig Config @@ -43,7 +47,21 @@ func main() { queries := sqlc.New(pool) - server := handlers.NewServer(queries) + // === RabbitMQ setup === + rmqURL := os.Getenv("RABBITMQ_URL") + if rmqURL == "" { + rmqURL = "amqp://guest:guest@10.1.0.65:5672/" + } + + rmqConn, err := amqp091.Dial(rmqURL) + if err != nil { + log.Fatalf("Failed to connect to RabbitMQ: %v", err) + } + defer rmqConn.Close() + + publisher := rmq.NewPublisher(rmqConn) + + server := handlers.NewServer(queries, publisher) // r.LoadHTMLGlob("templates/*") r.Use(cors.New(cors.Config{ @@ -60,24 +78,15 @@ func main() { // сюда можно добавить middlewares, если нужно []oapi.StrictMiddlewareFunc{}, )) - // r.GET("/", func(c *gin.Context) { - // c.HTML(http.StatusOK, "index.html", gin.H{ - // "title": "Welcome Page", - // "message": "Hello, Gin with HTML templates!", - // }) - // }) - // r.GET("/api", func(c *gin.Context) { - // items := []Item{ - // {ID: 1, Title: "First Item", Description: "This is the description of the first item."}, - // {ID: 2, Title: "Second Item", Description: "This is the description of the second item."}, - // {ID: 3, Title: "Third Item", Description: "This is the description of the third item."}, - // } + // Внедряем publisher в сервер + server = handlers.NewServer(queries, publisher) - // c.JSON(http.StatusOK, items) - // }) - - r.Run(":8080") + // Запуск + log.Infof("Server starting on :8080") + if err := r.Run(":8080"); err != nil && err != http.ErrServerClosed { + log.Fatalf("server failed: %v", err) + } } func InitConfig() error { diff --git a/modules/backend/rabbit.go b/modules/backend/rabbit.go deleted file mode 100644 index f08bf39..0000000 --- a/modules/backend/rabbit.go +++ /dev/null @@ -1,103 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - oapi "nyanimedb/api" - "time" - - "github.com/google/uuid" - "github.com/sirupsen/logrus" - "github.com/streadway/amqp" -) - -type RabbitRequest struct { - Name string `json:"name"` - Status oapi.TitleStatus `json:"titlestatus,omitempty"` - Rating float64 `json:"titleraring,omitempty"` - Year int32 `json:"year,omitempty"` - Season oapi.ReleaseSeason `json:"season,omitempty"` - Timestamp time.Time `json:"timestamp"` -} - -// PublishAndAwaitReply отправляет запрос и ждёт ответа от worker’а. -// Возвращает раскодированный ответ или ошибку. -func PublishAndAwaitReply( - ctx context.Context, - ch *amqp.Channel, - requestQueue string, // например: "svc.media.process.requests" - request RabbitRequest, // ваша структура запроса - replyCh chan<- any, // куда положить ответ (вы читаете извне) -) error { - // 1. Создаём временную очередь для ответов - replyQueue, err := ch.QueueDeclare( - "", // auto-generated name - false, // not durable - true, // exclusive - true, // auto-delete - false, // no-wait - nil, - ) - if err != nil { - return fmt.Errorf("failed to declare reply queue: %w", err) - } - - // 2. Готовим корреляционный ID - corrID := uuid.New().String() // ← используйте github.com/google/uuid - logrus.Infof("New CorrID: %s", corrID) - - // 3. Сериализуем запрос - body, err := json.Marshal(request) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) - } - - // 4. Публикуем запрос - err = ch.Publish( - "", // default exchange (или свой, если используете) - requestQueue, - false, - false, - amqp.Publishing{ - ContentType: "application/json", - CorrelationId: corrID, - ReplyTo: replyQueue.Name, - DeliveryMode: amqp.Persistent, - Timestamp: time.Now(), - Body: body, - }, - ) - if err != nil { - return fmt.Errorf("failed to publish request, corrID: %s : %w", corrID, err) - } - - // 5. Подписываемся на ответы - msgs, err := ch.Consume( - replyQueue.Name, - "", // consumer tag - true, // auto-ack - true, // exclusive - false, // no-local - false, // no-wait - nil, // args - ) - if err != nil { - return fmt.Errorf("failed to consume from reply queue: %w", err) - } - - // 6. Ожидаем ответ с таймаутом - select { - case msg := <-msgs: - if msg.CorrelationId != corrID { - return fmt.Errorf("correlation ID mismatch: got %s, expected %s", msg.CorrelationId, corrID) - } - // Десериализуем — тут можно передать target-структуру или использовать interface{} - // В данном случае просто возвращаем байты или пусть вызывающая сторона парсит - replyCh <- msg.Body // или json.Unmarshal → и отправить структуру в канал - return nil - - case <-ctx.Done(): - return ctx.Err() - } -} diff --git a/modules/backend/rmq/rabbit.go b/modules/backend/rmq/rabbit.go new file mode 100644 index 0000000..85df89b --- /dev/null +++ b/modules/backend/rmq/rabbit.go @@ -0,0 +1,166 @@ +package rmq + +import ( + "context" + "encoding/json" + "fmt" + oapi "nyanimedb/api" + "sync" + "time" + + amqp "github.com/rabbitmq/amqp091-go" +) + +type RabbitRequest struct { + Name string `json:"name"` + Status oapi.TitleStatus `json:"titlestatus,omitempty"` + Rating float64 `json:"titleraring,omitempty"` + Year int32 `json:"year,omitempty"` + Season oapi.ReleaseSeason `json:"season,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// Publisher — потокобезопасный публикатор с пулом каналов. +type Publisher struct { + conn *amqp.Connection + pool *sync.Pool +} + +// NewPublisher создаёт новый Publisher. +// conn должен быть уже установленным и healthy. +// Рекомендуется передавать durable connection с reconnect-логикой. +func NewPublisher(conn *amqp.Connection) *Publisher { + return &Publisher{ + conn: conn, + pool: &sync.Pool{ + New: func() any { + ch, err := conn.Channel() + if err != nil { + // Паника уместна: невозможность открыть канал — критическая ошибка инициализации + panic(fmt.Errorf("rmqpool: failed to create channel: %w", err)) + } + return ch + }, + }, + } +} + +// Publish публикует сообщение в указанную очередь. +// Очередь объявляется как durable (если не существует). +// Поддерживает context для отмены/таймаута. +func (p *Publisher) Publish( + ctx context.Context, + queueName string, + payload RabbitRequest, + opts ...PublishOption, +) error { + // Применяем опции + options := &publishOptions{ + contentType: "application/json", + deliveryMode: amqp.Persistent, + timestamp: time.Now(), + } + for _, opt := range opts { + opt(options) + } + + // Сериализуем payload + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("rmqpool: failed to marshal payload: %w", err) + } + + // Берём канал из пула + ch := p.getChannel() + if ch == nil { + return fmt.Errorf("rmqpool: channel is nil (connection may be closed)") + } + defer p.returnChannel(ch) + + // Объявляем очередь (idempotent) + q, err := ch.QueueDeclare( + queueName, + true, // durable + false, // auto-delete + false, // exclusive + false, // no-wait + nil, // args + ) + if err != nil { + return fmt.Errorf("rmqpool: failed to declare queue %q: %w", queueName, err) + } + + // Подготавливаем сообщение + msg := amqp.Publishing{ + DeliveryMode: options.deliveryMode, + ContentType: options.contentType, + Timestamp: options.timestamp, + Body: body, + } + + // Публикуем с учётом контекста + done := make(chan error, 1) + go func() { + err := ch.Publish( + "", // exchange (default) + q.Name, // routing key + false, // mandatory + false, // immediate + msg, + ) + done <- err + }() + + select { + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (p *Publisher) getChannel() *amqp.Channel { + raw := p.pool.Get() + if raw == nil { + ch, _ := p.conn.Channel() + return ch + } + ch := raw.(*amqp.Channel) + if ch.IsClosed() { // ← теперь есть! + ch.Close() // освободить ресурсы + ch, _ = p.conn.Channel() + } + return ch +} + +// returnChannel возвращает канал в пул, если он жив. +func (p *Publisher) returnChannel(ch *amqp.Channel) { + if ch != nil && !ch.IsClosed() { + p.pool.Put(ch) + } +} + +// PublishOption позволяет кастомизировать публикацию. +type PublishOption func(*publishOptions) + +type publishOptions struct { + contentType string + deliveryMode uint8 + timestamp time.Time +} + +// WithContentType устанавливает Content-Type (по умолчанию "application/json"). +func WithContentType(ct string) PublishOption { + return func(o *publishOptions) { o.contentType = ct } +} + +// WithTransient делает сообщение transient (не сохраняется на диск). +// По умолчанию — Persistent. +func WithTransient() PublishOption { + return func(o *publishOptions) { o.deliveryMode = amqp.Transient } +} + +// WithTimestamp устанавливает кастомную метку времени. +func WithTimestamp(ts time.Time) PublishOption { + return func(o *publishOptions) { o.timestamp = ts } +} From a29aefbe977d826383b00e74088013b4299482bc Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 30 Nov 2025 03:20:52 +0300 Subject: [PATCH 151/224] fix --- modules/backend/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backend/main.go b/modules/backend/main.go index 7b995a5..13be887 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -50,7 +50,7 @@ func main() { // === RabbitMQ setup === rmqURL := os.Getenv("RABBITMQ_URL") if rmqURL == "" { - rmqURL = "amqp://guest:guest@10.1.0.65:5672/" + rmqURL = "amqp://guest:guest@rabbitmq:5672/" } rmqConn, err := amqp091.Dial(rmqURL) From ab29c33f5b522b68045b174cf06ddf33942468f1 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 30 Nov 2025 04:02:28 +0300 Subject: [PATCH 152/224] feat: now back wait for RMQ answer --- modules/backend/handlers/common.go | 6 +- modules/backend/handlers/titles.go | 62 ++++++++++++------- modules/backend/main.go | 6 +- modules/backend/rmq/rabbit.go | 99 +++++++++++++++++++++++++++++- 4 files changed, 143 insertions(+), 30 deletions(-) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index aece414..cad4f0f 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -19,13 +19,15 @@ func New(publisher *rmq.Publisher) *Handler { type Server struct { db *sqlc.Queries - publisher *rmq.Publisher // ← добавьте это поле + publisher *rmq.Publisher + RPCclient *rmq.RPCClient } -func NewServer(db *sqlc.Queries, publisher *rmq.Publisher) *Server { +func NewServer(db *sqlc.Queries, publisher *rmq.Publisher, rpcclient *rmq.RPCClient) *Server { return &Server{ db: db, publisher: publisher, + RPCclient: rpcclient, } } diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 9f11016..300cc87 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -157,43 +157,61 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { - if request.Params.ExtSearch != nil && *request.Params.ExtSearch { - // Публикуем событие — как и просили - event := rmq.RabbitRequest{ - Name: "Attack on titans", - // Status oapi.TitleStatus `json:"titlestatus,omitempty"` - // Rating float64 `json:"titleraring,omitempty"` - // Year int32 `json:"year,omitempty"` - // Season oapi.ReleaseSeason `json:"season,omitempty"` - Timestamp: time.Now(), - } - - // Контекст с таймаутом (не блокируем ответ) - publishCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - if err := s.publisher.Publish(publishCtx, "events.user", event); err != nil { - log.Errorf("RMQ publish failed (non-critical): %v", err) - } else { - log.Infof("RMQ publish succeed %v", err) - } + opai_titles := make([]oapi.Title, 0) + mqreq := rmq.RabbitRequest{ + Timestamp: time.Now(), } - opai_titles := make([]oapi.Title, 0) - word := Word2Sqlc(request.Params.Word) + if word != nil { + mqreq.Name = *word + } season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) if err != nil { log.Errorf("%v", err) return oapi.GetTitles400Response{}, err } + if season != nil { + mqreq.Season = *request.Params.ReleaseSeason + } title_statuses, err := TitleStatus2Sqlc(request.Params.Status) if err != nil { log.Errorf("%v", err) return oapi.GetTitles400Response{}, err } + if title_statuses != nil { + mqreq.Statuses = *request.Params.Status + } + + if request.Params.ExtSearch != nil && *request.Params.ExtSearch { + + // Структура для ответа (должна совпадать с тем, что шлёт микросервис) + var reply struct { + Status string `json:"status"` + Result string `json:"result"` + Preview string `json:"preview_url"` + } + + // Делаем RPC-вызов — и ЖДЁМ ответа + err := s.RPCclient.Call( + ctx, + "svc.media.process.requests", // ← очередь микросервиса + mqreq, + &reply, + ) + if err != nil { + log.Errorf("RabitMQ: %v", err) + // return oapi.GetTitles500Response{}, err + } + // // Возвращаем результат + // return oapi.ProcessMedia200JSONResponse{ + // Status: reply.Status, + // Result: reply.Result, + // Preview: reply.Preview, + // }, nil + } params := sqlc.SearchTitlesParams{ Word: word, diff --git a/modules/backend/main.go b/modules/backend/main.go index 13be887..9f992a5 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -60,8 +60,9 @@ func main() { defer rmqConn.Close() publisher := rmq.NewPublisher(rmqConn) + rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second) - server := handlers.NewServer(queries, publisher) + server := handlers.NewServer(queries, publisher, rpcClient) // r.LoadHTMLGlob("templates/*") r.Use(cors.New(cors.Config{ @@ -79,9 +80,6 @@ func main() { []oapi.StrictMiddlewareFunc{}, )) - // Внедряем publisher в сервер - server = handlers.NewServer(queries, publisher) - // Запуск log.Infof("Server starting on :8080") if err := r.Run(":8080"); err != nil && err != http.ErrServerClosed { diff --git a/modules/backend/rmq/rabbit.go b/modules/backend/rmq/rabbit.go index 85df89b..52c1979 100644 --- a/modules/backend/rmq/rabbit.go +++ b/modules/backend/rmq/rabbit.go @@ -13,8 +13,8 @@ import ( type RabbitRequest struct { Name string `json:"name"` - Status oapi.TitleStatus `json:"titlestatus,omitempty"` - Rating float64 `json:"titleraring,omitempty"` + Statuses []oapi.TitleStatus `json:"statuses,omitempty"` + Rating float64 `json:"rating,omitempty"` Year int32 `json:"year,omitempty"` Season oapi.ReleaseSeason `json:"season,omitempty"` Timestamp time.Time `json:"timestamp"` @@ -164,3 +164,98 @@ func WithTransient() PublishOption { func WithTimestamp(ts time.Time) PublishOption { return func(o *publishOptions) { o.timestamp = ts } } + +type RPCClient struct { + conn *amqp.Connection + timeout time.Duration +} + +func NewRPCClient(conn *amqp.Connection, timeout time.Duration) *RPCClient { + return &RPCClient{conn: conn, timeout: timeout} +} + +// Call отправляет запрос в очередь и ждёт ответа. +// replyPayload — указатель на структуру, в которую раскодировать ответ (например, &MediaResponse{}). +func (c *RPCClient) Call( + ctx context.Context, + requestQueue string, + request RabbitRequest, + replyPayload any, +) error { + // 1. Создаём временный канал (не из пула!) + ch, err := c.conn.Channel() + if err != nil { + return fmt.Errorf("channel: %w", err) + } + defer ch.Close() + + // 2. Создаём временную очередь для ответов + q, err := ch.QueueDeclare( + "", // auto name + false, // not durable + true, // exclusive + true, // auto-delete + false, + nil, + ) + if err != nil { + return fmt.Errorf("reply queue: %w", err) + } + + // 3. Подписываемся на ответы + msgs, err := ch.Consume( + q.Name, + "", + true, // auto-ack + true, // exclusive + false, + false, + nil, + ) + if err != nil { + return fmt.Errorf("consume: %w", err) + } + + // 4. Готовим correlation ID + corrID := time.Now().UnixNano() + + // 5. Сериализуем запрос + body, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + // 6. Публикуем запрос + err = ch.Publish( + "", + requestQueue, + false, + false, + amqp.Publishing{ + ContentType: "application/json", + CorrelationId: fmt.Sprintf("%d", corrID), + ReplyTo: q.Name, + Timestamp: time.Now(), + Body: body, + }, + ) + if err != nil { + return fmt.Errorf("publish: %w", err) + } + + // 7. Ждём ответ с таймаутом + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + for { + select { + case msg := <-msgs: + if msg.CorrelationId == fmt.Sprintf("%d", corrID) { + return json.Unmarshal(msg.Body, replyPayload) + } + // игнорируем другие сообщения (маловероятно, но возможно) + case <-ctx.Done(): + return ctx.Err() // timeout or cancelled + } + } +} From e11c5aec0091c9e28752783baf642ef73016820f Mon Sep 17 00:00:00 2001 From: garaev kamil <garaev.kr@phystech.edu> Date: Tue, 2 Dec 2025 01:15:58 +0300 Subject: [PATCH 153/224] Anilist extractor added --- .../anime_etl/sources/anilist_async_client.py | 46 +++++++++++++++++++ modules/anime_etl/sources/anilist_source.py | 35 ++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 modules/anime_etl/sources/anilist_async_client.py create mode 100644 modules/anime_etl/sources/anilist_source.py diff --git a/modules/anime_etl/sources/anilist_async_client.py b/modules/anime_etl/sources/anilist_async_client.py new file mode 100644 index 0000000..92e8dd2 --- /dev/null +++ b/modules/anime_etl/sources/anilist_async_client.py @@ -0,0 +1,46 @@ +import httpx +import asyncio +from rate_limiter import ANILIST_RATE_LIMITER + +URL = "https://graphql.anilist.co" + +QUERY = """ +query ($search: String, $season: MediaSeason, $seasonYear: Int, $format: MediaFormat, $page: Int, $perPage: Int) { + Page(page: $page, perPage: $perPage) { + media(search: $search, type: ANIME, season: $season, seasonYear: $seasonYear, format: $format) { + id + title { romaji english native } + status + season + seasonYear + # seasonNumber + episodes + duration + popularity + averageScore + coverImage { extraLarge large } + genres + studios(isMain: true) { nodes { id name } } + nextAiringEpisode { episode } + } + } +} +""" + +CLIENT = httpx.AsyncClient(timeout=15.0) + +async def _post(payload: dict) -> dict: + for i in range(5): + await ANILIST_RATE_LIMITER.acquire() + try: + r = await CLIENT.post(URL, json=payload) + r.raise_for_status() + return r.json() + except Exception: + await asyncio.sleep(1 * 2**i) + raise RuntimeError("AniList unreachable") + +async def search_raw(filters: dict) -> list[dict]: + payload = {"query": QUERY, "variables": filters} + data = await _post(payload) + return data.get("data", {}).get("Page", {}).get("media") or [] diff --git a/modules/anime_etl/sources/anilist_source.py b/modules/anime_etl/sources/anilist_source.py new file mode 100644 index 0000000..9307587 --- /dev/null +++ b/modules/anime_etl/sources/anilist_source.py @@ -0,0 +1,35 @@ +from mappers.anilist_filters import to_anilist_filters +from sources.anilist_async_client import search_raw +from normalizers.anilist_normalizer import normalize_media +import asyncio +import pprint + + +class AniListSource: + async def search(self, local_filters: dict) -> list: + ani_filters = to_anilist_filters(local_filters) + raw_list = await search_raw(ani_filters) + return [normalize_media(r) for r in raw_list] + + +async def _demo() -> None: + src = AniListSource() + filters = { + "query": "monogatari", + # "year": 2017, + # "season": "winter", + # "type": "tv", + # "limit": 5, + } + print("Запускаю поиск с фильтрами:", filters) + titles = await src.search(filters) + print("Найдено тайтлов:", len(titles)) + + for t in titles: + # t.title_names — dict[str, list[str]] + # en = (t.title_names.get("en") or [""])[0] + # print("-", en, "|", t.release_year, t.release_season) + pprint.pprint(t) + +if __name__ == "__main__": + asyncio.run(_demo()) From 9c59fb1328bc597511d7fa68565d97c31b3aa1a6 Mon Sep 17 00:00:00 2001 From: garaev kamil <garaev.kr@phystech.edu> Date: Wed, 3 Dec 2025 19:59:58 +0300 Subject: [PATCH 154/224] client modifyed --- modules/anime_etl/sources/anilist_async_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/anime_etl/sources/anilist_async_client.py b/modules/anime_etl/sources/anilist_async_client.py index 92e8dd2..523487b 100644 --- a/modules/anime_etl/sources/anilist_async_client.py +++ b/modules/anime_etl/sources/anilist_async_client.py @@ -13,7 +13,7 @@ query ($search: String, $season: MediaSeason, $seasonYear: Int, $format: MediaFo status season seasonYear - # seasonNumber + format episodes duration popularity @@ -27,6 +27,7 @@ query ($search: String, $season: MediaSeason, $seasonYear: Int, $format: MediaFo } """ + CLIENT = httpx.AsyncClient(timeout=15.0) async def _post(payload: dict) -> dict: From b6578cfb0cae6a8d10f63954610b54206994e805 Mon Sep 17 00:00:00 2001 From: garaev kamil <garaev.kr@phystech.edu> Date: Wed, 3 Dec 2025 20:00:39 +0300 Subject: [PATCH 155/224] data models --- modules/anime_etl/models.py | 82 +++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 modules/anime_etl/models.py diff --git a/modules/anime_etl/models.py b/modules/anime_etl/models.py new file mode 100644 index 0000000..a65516f --- /dev/null +++ b/modules/anime_etl/models.py @@ -0,0 +1,82 @@ +# anime_etl/models.py +from __future__ import annotations +from enum import Enum +from typing import Dict, List, Optional +from pydantic import BaseModel + + +class Source(str, Enum): + ANILIST = "anilist" + JIKAN = "jikan" + SHIKIMORI = "shikimori" + + +class Studio(BaseModel): + id: Optional[int] = None + name: str + poster: Optional["Image"] = None + description: Optional[str] = None + + +class Image(BaseModel): + id: Optional[int] = None + storage_type: Optional[str] = None # для БД: storage_type_t + image_path: str # для внешнего постера – URL + + +class Tag(BaseModel): + # локализованный тег, как в Tag.yaml: { "en": "Shojo", "ru": "Сёдзё", ... } + names: Dict[str, str] + + +class SourceTitle(BaseModel): + """ + Нормализованная инфа из одного источника (AniList/Jikan/...). + """ + source: Source + external_id: str + + title_names: Dict[str, List[str]] + studio: Optional[Studio] = None + tags: List[Tag] = [] + + poster: Optional[Image] = None + + title_status: Optional[str] = None + rating: Optional[float] = None + rating_count: Optional[int] = None + + release_year: Optional[int] = None + release_season: Optional[str] = None + + season: Optional[int] = None + + episodes_aired: Optional[int] = None + episodes_all: Optional[int] = None + episodes_len: Optional[Dict[str, float]] = None + + +class CanonicalTitle(BaseModel): + """ + То, что пойдёт в нашу БД + будет соответствовать Title.yaml. + """ + id: Optional[int] = None + + title_names: Dict[str, List[str]] + + studio: Optional[Studio] = None + tags: List[Tag] = [] + + poster: Optional[Image] = None + + title_status: Optional[str] = None + rating: Optional[float] = None + rating_count: Optional[int] = None + + release_year: Optional[int] = None + release_season: Optional[str] = None + + season: Optional[int] = None + episodes_aired: Optional[int] = None + episodes_all: Optional[int] = None + episodes_len: Optional[Dict[str, float]] = None From 4dd60f3b190ffa8b2ca91ade49d83c1b36ea295d Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 05:52:31 +0300 Subject: [PATCH 156/224] feat: TitlesFilterPanel component --- .../src/api/services/DefaultService.ts | 3 + .../TitlesFilterPanel/TitlesFilterPanel.tsx | 122 ++++++++++++++++++ .../src/pages/TitlesPage/TitlesPage.tsx | 23 +++- 3 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 modules/frontend/src/components/TitlesFilterPanel/TitlesFilterPanel.tsx diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 218b461..6898c46 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -20,6 +20,7 @@ export class DefaultService { * @param cursor * @param sort * @param sortForward + * @param extSearch * @param word * @param status List of title statuses to filter * @param rating @@ -35,6 +36,7 @@ export class DefaultService { cursor?: string, sort?: TitleSort, sortForward: boolean = true, + extSearch: boolean = false, word?: string, status?: Array<TitleStatus>, rating?: number, @@ -57,6 +59,7 @@ export class DefaultService { 'cursor': cursor, 'sort': sort, 'sort_forward': sortForward, + 'ext_search': extSearch, 'word': word, 'status': status, 'rating': rating, diff --git a/modules/frontend/src/components/TitlesFilterPanel/TitlesFilterPanel.tsx b/modules/frontend/src/components/TitlesFilterPanel/TitlesFilterPanel.tsx new file mode 100644 index 0000000..3cfef69 --- /dev/null +++ b/modules/frontend/src/components/TitlesFilterPanel/TitlesFilterPanel.tsx @@ -0,0 +1,122 @@ +import { useState } from "react"; +import type { TitleStatus, ReleaseSeason } from "../../api"; +import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/solid"; + +export type TitlesFilter = { + extSearch: boolean; + status: TitleStatus | ""; + rating: number | ""; + releaseYear: number | ""; + releaseSeason: ReleaseSeason | ""; +}; + +type TitlesFilterPanelProps = { + filters: TitlesFilter; + setFilters: (filters: TitlesFilter) => void; +}; + +const STATUS_OPTIONS: (TitleStatus | "")[] = ["", "planned", "finished", "ongoing"]; +const SEASON_OPTIONS: (ReleaseSeason | "")[] = ["", "winter", "spring", "summer", "fall"]; +const RATING_OPTIONS = ["", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + +export function TitlesFilterPanel({ filters, setFilters }: TitlesFilterPanelProps) { + const [open, setOpen] = useState(false); + + const handleChange = (field: keyof TitlesFilter, value: any) => { + setFilters({ ...filters, [field]: value }); + }; + + return ( + <div className="w-full flex justify-center my-4"> + <div className="bg-white shadow rounded-lg w-full max-w-3xl p-4"> + {/* Заголовок панели */} + <div + className="flex justify-between items-center cursor-pointer" + onClick={() => setOpen((prev) => !prev)} + > + <h3 className="text-lg font-medium">Filters</h3> + {open ? <ChevronUpIcon className="w-5 h-5" /> : <ChevronDownIcon className="w-5 h-5" />} + </div> + + {/* Контент панели */} + {open && ( + <div className="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-4"> + {/* Extended Search */} + <div className="flex items-center gap-2"> + <input + type="checkbox" + id="extSearch" + checked={filters.extSearch} + onChange={(e) => handleChange("extSearch", e.target.checked)} + className="w-4 h-4" + /> + <label htmlFor="extSearch" className="text-sm"> + Extended Search + </label> + </div> + + {/* Status */} + <div className="flex flex-col"> + <label htmlFor="status" className="text-sm mb-1">Status</label> + <select + id="status" + value={filters.status} + onChange={(e) => handleChange("status", e.target.value || "")} + className="border rounded px-2 py-1" + > + {STATUS_OPTIONS.map((s) => ( + <option key={s || "all"} value={s}>{s || "All"}</option> + ))} + </select> + </div> + + {/* Rating */} + <div className="flex flex-col"> + <label htmlFor="rating" className="text-sm mb-1">Rating</label> + <select + id="rating" + value={filters.rating} + onChange={(e) => handleChange("rating", e.target.value ? Number(e.target.value) : "")} + className="border rounded px-2 py-1" + > + {RATING_OPTIONS.map((r) => ( + <option key={r} value={r}>{r || "All"}</option> + ))} + </select> + </div> + + {/* Release Year */} + <div className="flex flex-col"> + <label htmlFor="releaseYear" className="text-sm mb-1">Release Year</label> + <input + type="number" + id="releaseYear" + value={filters.releaseYear || ""} + onChange={(e) => + handleChange("releaseYear", e.target.value ? Number(e.target.value) : "") + } + className="border rounded px-2 py-1" + placeholder="Any" + /> + </div> + + {/* Release Season */} + <div className="flex flex-col"> + <label htmlFor="releaseSeason" className="text-sm mb-1">Release Season</label> + <select + id="releaseSeason" + value={filters.releaseSeason} + onChange={(e) => handleChange("releaseSeason", e.target.value || "")} + className="border rounded px-2 py-1" + > + {SEASON_OPTIONS.map((s) => ( + <option key={s || "all"} value={s}>{s || "All"}</option> + ))} + </select> + </div> + </div> + )} + </div> + </div> + ); +} diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index c9911b9..ed55d8d 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -8,6 +8,7 @@ import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal" import type { CursorObj, Title, TitleSort } from "../../api"; import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; import { Link } from "react-router-dom"; +import { type TitlesFilter, TitlesFilterPanel } from "../../components/TitlesFilterPanel/TitlesFilterPanel"; const PAGE_SIZE = 10; @@ -22,6 +23,14 @@ export default function TitlesPage() { const [sortForward, setSortForward] = useState(true); const [layout, setLayout] = useState<"square" | "horizontal">("square"); + const [filters, setFilters] = useState<TitlesFilter>({ + extSearch: false, + status: "", + rating: "", + releaseYear: "", + releaseSeason: "", + }); + const fetchPage = async (cursorObj: CursorObj | null) => { const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') : ""; @@ -30,13 +39,14 @@ export default function TitlesPage() { cursorStr, sort, sortForward, + filters.extSearch, search.trim() || undefined, - undefined, - undefined, - undefined, - undefined, + filters.status ? [filters.status] : undefined, + filters.rating || undefined, + filters.releaseYear || undefined, + filters.releaseSeason || undefined, + PAGE_SIZE, PAGE_SIZE, - undefined, "all" ); @@ -73,7 +83,7 @@ export default function TitlesPage() { }; initLoad(); - }, [search, sort, sortForward]); + }, [search, sort, sortForward, filters]); const handleLoadMore = async () => { @@ -121,6 +131,7 @@ const handleLoadMore = async () => { setSortForward={setSortForward} /> </div> + <TitlesFilterPanel filters={filters} setFilters={setFilters} /> {loading && <div className="mt-20 font-medium text-black">Loading...</div>} From 6995ce58f6d8f588f235cbaf985b7b82e76ecda1 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 4 Dec 2025 06:13:03 +0300 Subject: [PATCH 157/224] feat: csrf tokens handling --- api/_build/openapi.yaml | 39 +++++++++++++++ api/api.gen.go | 72 +++++++++++++++++++++++++-- api/parameters/_index.yaml | 8 ++- api/parameters/access_token.yaml | 9 ++++ api/parameters/xsrf_token_cookie.yaml | 11 ++++ api/parameters/xsrf_token_header.yaml | 10 ++++ api/paths/titles-id.yaml | 2 + api/paths/users-id.yaml | 4 ++ api/schemas/JWTAuth.yaml | 7 +++ api/schemas/_index.yaml | 2 + modules/backend/main.go | 4 +- modules/backend/middlewares/csrf.go | 70 ++++++++++++++++++++++++++ 12 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 api/parameters/access_token.yaml create mode 100644 api/parameters/xsrf_token_cookie.yaml create mode 100644 api/parameters/xsrf_token_header.yaml create mode 100644 api/schemas/JWTAuth.yaml create mode 100644 modules/backend/middlewares/csrf.go diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index e85ddf9..58dd890 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -120,6 +120,8 @@ paths: description: Title not found '500': description: Unknown server error + security: + - JwtAuthCookies: [] '/users/{user_id}': get: operationId: getUsersId @@ -156,6 +158,8 @@ paths: Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. parameters: + - $ref: '#/components/parameters/accessToken' + - $ref: '#/components/parameters/csrfToken' - name: user_id in: path description: User ID (primary key) @@ -223,6 +227,8 @@ paths: description: 'Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`)' '500': description: Unknown server error + security: + - JwtAuthCookies: [] '/users/{user_id}/titles': get: operationId: getUserTitles @@ -474,6 +480,39 @@ paths: description: Internal server error components: parameters: + accessToken: + name: access_token + in: cookie + required: true + schema: + type: string + format: jwt + example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y + description: | + JWT access token. + csrfToken: + name: XSRF-TOKEN + in: cookie + required: true + schema: + type: string + pattern: '^[a-zA-Z0-9_-]{32,64}$' + example: abc123def456ghi789jkl012mno345pqr + description: | + Anti-CSRF token (Double Submit Cookie pattern). + Stored in non-HttpOnly cookie, readable by JavaScript. + Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). + csrfTokenHeader: + name: X-XSRF-TOKEN + in: header + required: true + schema: + type: string + pattern: '^[a-zA-Z0-9_-]{32,64}$' + description: | + Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + Required for all state-changing requests (POST/PUT/PATCH/DELETE). + example: abc123def456ghi789jkl012mno345pqr cursor: in: query name: cursor diff --git a/api/api.gen.go b/api/api.gen.go index c8fd9aa..62450e0 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -16,6 +16,10 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +const ( + JwtAuthCookiesScopes = "JwtAuthCookies.Scopes" +) + // Defines values for ReleaseSeason. const ( Fall ReleaseSeason = "fall" @@ -170,6 +174,12 @@ type UserTitleMini struct { // UserTitleStatus User's title status type UserTitleStatus string +// AccessToken defines model for accessToken. +type AccessToken = string + +// CsrfToken defines model for csrfToken. +type CsrfToken = string + // Cursor defines model for cursor. type Cursor = string @@ -219,6 +229,17 @@ type UpdateUserJSONBody struct { UserDesc *string `json:"user_desc,omitempty"` } +// UpdateUserParams defines parameters for UpdateUser. +type UpdateUserParams struct { + // AccessToken JWT access token. + AccessToken AccessToken `form:"access_token" json:"access_token"` + + // XSRFTOKEN Anti-CSRF token (Double Submit Cookie pattern). + // Stored in non-HttpOnly cookie, readable by JavaScript. + // Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). + XSRFTOKEN CsrfToken `form:"XSRF-TOKEN" json:"XSRF-TOKEN"` +} + // GetUserTitlesParams defines parameters for GetUserTitles. type GetUserTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -276,7 +297,7 @@ type ServerInterface interface { GetUsersId(c *gin.Context, userId string, params GetUsersIdParams) // Partially update a user account // (PATCH /users/{user_id}) - UpdateUser(c *gin.Context, userId int64) + UpdateUser(c *gin.Context, userId int64, params UpdateUserParams) // Get user titles // (GET /users/{user_id}/titles) GetUserTitles(c *gin.Context, userId string, params GetUserTitlesParams) @@ -431,6 +452,8 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { return } + c.Set(JwtAuthCookiesScopes, []string{}) + // Parameter object where we will unmarshal all parameters from the context var params GetTitleParams @@ -501,6 +524,47 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { return } + c.Set(JwtAuthCookiesScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params UpdateUserParams + + { + var cookie string + + if cookie, err = c.Cookie("access_token"); err == nil { + var value AccessToken + err = runtime.BindStyledParameterWithOptions("simple", "access_token", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter access_token: %w", err), http.StatusBadRequest) + return + } + params.AccessToken = value + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument access_token is required, but not found"), http.StatusBadRequest) + return + } + } + + { + var cookie string + + if cookie, err = c.Cookie("XSRF-TOKEN"); err == nil { + var value CsrfToken + err = runtime.BindStyledParameterWithOptions("simple", "XSRF-TOKEN", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter XSRF-TOKEN: %w", err), http.StatusBadRequest) + return + } + params.XSRFTOKEN = value + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument XSRF-TOKEN is required, but not found"), http.StatusBadRequest) + return + } + } + for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -508,7 +572,7 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { } } - siw.Handler.UpdateUser(c, userId) + siw.Handler.UpdateUser(c, userId, params) } // GetUserTitles operation middleware @@ -935,6 +999,7 @@ func (response GetUsersId500Response) VisitGetUsersIdResponse(w http.ResponseWri type UpdateUserRequestObject struct { UserId int64 `json:"user_id"` + Params UpdateUserParams Body *UpdateUserJSONRequestBody } @@ -1411,10 +1476,11 @@ func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetU } // UpdateUser operation middleware -func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { +func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64, params UpdateUserParams) { var request UpdateUserRequestObject request.UserId = userId + request.Params = params var body UpdateUserJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { diff --git a/api/parameters/_index.yaml b/api/parameters/_index.yaml index 6249e7d..d2e12a8 100644 --- a/api/parameters/_index.yaml +++ b/api/parameters/_index.yaml @@ -1,4 +1,10 @@ cursor: $ref: "./cursor.yaml" title_sort: - $ref: "./title_sort.yaml" \ No newline at end of file + $ref: "./title_sort.yaml" +accessToken: + $ref: "./access_token.yaml" +csrfToken: + $ref: "./xsrf_token_cookie.yaml" +csrfTokenHeader: + $ref: "./xsrf_token_header.yaml" \ No newline at end of file diff --git a/api/parameters/access_token.yaml b/api/parameters/access_token.yaml new file mode 100644 index 0000000..a7e727e --- /dev/null +++ b/api/parameters/access_token.yaml @@ -0,0 +1,9 @@ +name: access_token +in: cookie +required: true +schema: + type: string + format: jwt +example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y" +description: | + JWT access token. diff --git a/api/parameters/xsrf_token_cookie.yaml b/api/parameters/xsrf_token_cookie.yaml new file mode 100644 index 0000000..cf85999 --- /dev/null +++ b/api/parameters/xsrf_token_cookie.yaml @@ -0,0 +1,11 @@ +name: XSRF-TOKEN +in: cookie +required: true +schema: + type: string + pattern: "^[a-zA-Z0-9_-]{32,64}$" +example: "abc123def456ghi789jkl012mno345pqr" +description: | + Anti-CSRF token (Double Submit Cookie pattern). + Stored in non-HttpOnly cookie, readable by JavaScript. + Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). \ No newline at end of file diff --git a/api/parameters/xsrf_token_header.yaml b/api/parameters/xsrf_token_header.yaml new file mode 100644 index 0000000..ac14dc1 --- /dev/null +++ b/api/parameters/xsrf_token_header.yaml @@ -0,0 +1,10 @@ +name: X-XSRF-TOKEN +in: header +required: true +schema: + type: string + pattern: "^[a-zA-Z0-9_-]{32,64}$" +description: | + Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + Required for all state-changing requests (POST/PUT/PATCH/DELETE). +example: "abc123def456ghi789jkl012mno345pqr" \ No newline at end of file diff --git a/api/paths/titles-id.yaml b/api/paths/titles-id.yaml index 235743f..f1b9c55 100644 --- a/api/paths/titles-id.yaml +++ b/api/paths/titles-id.yaml @@ -1,5 +1,7 @@ get: summary: Get title description + security: + - JwtAuthCookies: [] operationId: getTitle parameters: - in: path diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index fe62e46..0f2f367 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -28,12 +28,16 @@ get: patch: summary: Partially update a user account + security: + - JwtAuthCookies: [] description: | Update selected user profile fields (excluding password). Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. operationId: updateUser parameters: + - $ref: '../parameters/access_token.yaml' # ← для поля в UI и GoDoc + - $ref: '../parameters/xsrf_token_cookie.yaml' # ← для CSRF - name: user_id in: path required: true diff --git a/api/schemas/JWTAuth.yaml b/api/schemas/JWTAuth.yaml new file mode 100644 index 0000000..63c3baa --- /dev/null +++ b/api/schemas/JWTAuth.yaml @@ -0,0 +1,7 @@ +# type: apiKey +# in: cookie +# name: access_token +# scheme: bearer +# bearerFormat: JWT +# description: | +# JWT access token sent in `Cookie: access_token=...`. \ No newline at end of file diff --git a/api/schemas/_index.yaml b/api/schemas/_index.yaml index d893ced..0cc0f9d 100644 --- a/api/schemas/_index.yaml +++ b/api/schemas/_index.yaml @@ -24,3 +24,5 @@ User: $ref: "./User.yaml" UserTitle: $ref: "./UserTitle.yaml" +# JwtAuth: +# $ref: "./JWTAuth.yaml" diff --git a/modules/backend/main.go b/modules/backend/main.go index 9f992a5..aab1287 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -11,6 +11,7 @@ import ( oapi "nyanimedb/api" handlers "nyanimedb/modules/backend/handlers" + middleware "nyanimedb/modules/backend/middlewares" "nyanimedb/modules/backend/rmq" "github.com/gin-contrib/cors" @@ -45,6 +46,8 @@ func main() { r := gin.Default() + r.Use(middleware.CSRFMiddleware()) + // jwt middle will be here queries := sqlc.New(pool) // === RabbitMQ setup === @@ -63,7 +66,6 @@ func main() { rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second) server := handlers.NewServer(queries, publisher, rpcClient) - // r.LoadHTMLGlob("templates/*") r.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production diff --git a/modules/backend/middlewares/csrf.go b/modules/backend/middlewares/csrf.go new file mode 100644 index 0000000..41fad7b --- /dev/null +++ b/modules/backend/middlewares/csrf.go @@ -0,0 +1,70 @@ +package middleware + +import ( + "crypto/subtle" + "net/http" + + "github.com/gin-gonic/gin" +) + +// CSRFMiddleware для Gin +func CSRFMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Пропускаем безопасные методы + if !isStateChangingMethod(c.Request.Method) { + c.Next() + return + } + + // 1. Получаем токен из заголовка + headerToken := c.GetHeader("X-XSRF-TOKEN") + if headerToken == "" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "missing X-XSRF-TOKEN header", + }) + return + } + + // 2. Получаем токен из cookie + cookie, err := c.Cookie("xsrf_token") + if err != nil { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "missing xsrf_token cookie", + }) + return + } + + // 3. Безопасное сравнение + if subtle.ConstantTimeCompare([]byte(headerToken), []byte(cookie)) != 1 { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "CSRF token mismatch", + }) + return + } + + // 4. Опционально: сохраняем токен в контексте + c.Set("csrf_token", headerToken) + c.Next() + } +} + +func isStateChangingMethod(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + default: + return false + } +} + +// CSRFTokenFromGin извлекает токен из Gin context +func CSRFTokenFromGin(c *gin.Context) (string, bool) { + token, exists := c.Get("xsrf_token") + if !exists { + return "", false + } + if s, ok := token.(string); ok { + return s, true + } + return "", false +} From ef871833c585e15fcba5e69a5e97bccb53e42eeb Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 06:29:20 +0300 Subject: [PATCH 158/224] feat: xsrf_token set --- deploy/docker-compose.yml | 2 ++ modules/auth/handlers/handlers.go | 38 +++++++++++++-------- modules/auth/helpers.go | 33 ++++++++++++++++++ modules/auth/main.go | 57 +++++++++++++++++++++++++++++-- modules/auth/types.go | 7 ++-- 5 files changed, 117 insertions(+), 20 deletions(-) create mode 100644 modules/auth/helpers.go diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 79ad2f5..0ae97c6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -62,6 +62,8 @@ services: environment: LOG_LEVEL: ${LOG_LEVEL} DATABASE_URL: ${DATABASE_URL} + SERVICE_ADDRESS: ${SERVICE_ADDRESS} + JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} ports: - "8082:8082" depends_on: diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 261826c..6fee512 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -2,6 +2,8 @@ package handlers import ( "context" + "crypto/rand" + "encoding/base64" "fmt" "net/http" auth "nyanimedb/auth" @@ -15,15 +17,13 @@ import ( log "github.com/sirupsen/logrus" ) -var accessSecret = []byte("my_access_secret_key") -var refreshSecret = []byte("my_refresh_secret_key") - type Server struct { - db *sqlc.Queries + db *sqlc.Queries + JwtPrivateKey string } -func NewServer(db *sqlc.Queries) Server { - return Server{db: db} +func NewServer(db *sqlc.Queries, JwtPrivatekey string) Server { + return Server{db: db, JwtPrivateKey: JwtPrivatekey} } func parseInt64(s string) (int32, error) { @@ -47,15 +47,15 @@ func CheckPassword(password, hash string) (bool, error) { return argon2id.ComparePasswordAndHash(password, hash) } -func generateTokens(userID string) (accessToken string, refreshToken string, err error) { +func (s Server) generateTokens(userID string) (accessToken string, refreshToken string, csrfToken string, err error) { accessClaims := jwt.MapClaims{ "user_id": userID, "exp": time.Now().Add(15 * time.Minute).Unix(), } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - accessToken, err = at.SignedString(accessSecret) + accessToken, err = at.SignedString(s.JwtPrivateKey) if err != nil { - return "", "", err + return "", "", "", err } refreshClaims := jwt.MapClaims{ @@ -63,12 +63,19 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), } rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) - refreshToken, err = rt.SignedString(refreshSecret) + refreshToken, err = rt.SignedString(s.JwtPrivateKey) if err != nil { - return "", "", err + return "", "", "", err } - return accessToken, refreshToken, nil + csrfBytes := make([]byte, 32) + _, err = rand.Read(csrfBytes) + if err != nil { + return "", "", "", err + } + csrfToken = base64.RawURLEncoding.EncodeToString(csrfBytes) + + return accessToken, refreshToken, csrfToken, nil } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { @@ -118,7 +125,7 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque }, nil } - accessToken, refreshToken, err := generateTokens(req.Body.Nickname) + accessToken, refreshToken, csrfToken, err := s.generateTokens(req.Body.Nickname) if err != nil { log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err) // TODO: return 500 @@ -126,8 +133,9 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque // TODO: check cookie settings carefully ginCtx.SetSameSite(http.SameSiteStrictMode) - ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true) - ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true) + ginCtx.SetCookie("access_token", accessToken, 900, "/api", "", false, true) + ginCtx.SetCookie("refresh_token", refreshToken, 1209600, "/auth", "", false, true) + ginCtx.SetCookie("xsrf_token", csrfToken, 1209600, "/api", "", false, false) result := auth.PostAuthSignIn200JSONResponse{ UserId: user.ID, diff --git a/modules/auth/helpers.go b/modules/auth/helpers.go new file mode 100644 index 0000000..9c3ab36 --- /dev/null +++ b/modules/auth/helpers.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "reflect" +) + +func setField(obj interface{}, name string, value interface{}) error { + v := reflect.ValueOf(obj) + + if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct { + return fmt.Errorf("expected pointer to a struct") + } + + v = v.Elem() + field := v.FieldByName(name) + + if !field.IsValid() { + return fmt.Errorf("no such field: %s", name) + } + if !field.CanSet() { + return fmt.Errorf("cannot set field: %s", name) + } + + val := reflect.ValueOf(value) + + if field.Type() != val.Type() { + return fmt.Errorf("provided value type (%s) doesn't match field type (%s)", val.Type(), field.Type()) + } + + field.Set(val) + return nil +} diff --git a/modules/auth/main.go b/modules/auth/main.go index 7554f42..ef9b977 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "reflect" "time" auth "nyanimedb/auth" @@ -13,12 +14,24 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" + "github.com/pelletier/go-toml/v2" + log "github.com/sirupsen/logrus" ) var AppConfig Config func main() { - // TODO: env args + if len(os.Args) != 2 { + AppConfig.Mode = "env" + } else { + AppConfig.Mode = "argv" + } + + err := InitConfig() + if err != nil { + log.Fatalf("Failed to init config: %v\n", err) + } + r := gin.Default() pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) @@ -29,10 +42,10 @@ func main() { var queries *sqlc.Queries = sqlc.New(pool) - server := handlers.NewServer(queries) + server := handlers.NewServer(queries, AppConfig.JwtPrivateKey) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowOrigins: []string{AppConfig.ServiceAddress}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, @@ -47,3 +60,41 @@ func main() { r.Run(":8082") } + +func InitConfig() error { + if AppConfig.Mode == "argv" { + content, err := os.ReadFile(os.Args[1]) + if err != nil { + return err + } + + toml.Unmarshal(content, &AppConfig) + + fmt.Printf("%+v\n", AppConfig) + + return nil + } else if AppConfig.Mode == "env" { + f := reflect.ValueOf(AppConfig) + + for i := 0; i < f.NumField(); i++ { + field := f.Type().Field(i) + tag := field.Tag + env_var := tag.Get("env") + fmt.Printf("Field: %v.\nEnvironment variable: %v.\n", field.Name, env_var) + if env_var != "" { + env_value, exists := os.LookupEnv(env_var) + if !exists { + return fmt.Errorf("there is no env variable %s", env_var) + } + err := setField(&AppConfig, field.Name, env_value) + if err != nil { + return fmt.Errorf("failed to set config field %s: %v", field.Name, err) + } + } + } + + return nil + } else { + return fmt.Errorf("incorrect config mode") + } +} diff --git a/modules/auth/types.go b/modules/auth/types.go index 038b179..694843e 100644 --- a/modules/auth/types.go +++ b/modules/auth/types.go @@ -1,6 +1,9 @@ package main type Config struct { - JwtPrivateKey string - LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` + Mode string + ServiceAddress string `toml:"ServiceAddress" env:"SERVICE_ADDRESS"` + DdUrl string `toml:"DbUrl" env:"DATABASE_URL"` + JwtPrivateKey string `toml:"JwtPrivateKey" env:"JWT_PRIVATE_KEY"` + LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` } From b79a6b9117e4a7384398541105c801e81e0351d2 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 06:29:20 +0300 Subject: [PATCH 159/224] feat: xsrf_token set --- deploy/docker-compose.yml | 2 ++ modules/auth/handlers/handlers.go | 38 +++++++++++++-------- modules/auth/helpers.go | 33 ++++++++++++++++++ modules/auth/main.go | 57 +++++++++++++++++++++++++++++-- modules/auth/types.go | 7 ++-- 5 files changed, 117 insertions(+), 20 deletions(-) create mode 100644 modules/auth/helpers.go diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 79ad2f5..0ae97c6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -62,6 +62,8 @@ services: environment: LOG_LEVEL: ${LOG_LEVEL} DATABASE_URL: ${DATABASE_URL} + SERVICE_ADDRESS: ${SERVICE_ADDRESS} + JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} ports: - "8082:8082" depends_on: diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 261826c..6fee512 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -2,6 +2,8 @@ package handlers import ( "context" + "crypto/rand" + "encoding/base64" "fmt" "net/http" auth "nyanimedb/auth" @@ -15,15 +17,13 @@ import ( log "github.com/sirupsen/logrus" ) -var accessSecret = []byte("my_access_secret_key") -var refreshSecret = []byte("my_refresh_secret_key") - type Server struct { - db *sqlc.Queries + db *sqlc.Queries + JwtPrivateKey string } -func NewServer(db *sqlc.Queries) Server { - return Server{db: db} +func NewServer(db *sqlc.Queries, JwtPrivatekey string) Server { + return Server{db: db, JwtPrivateKey: JwtPrivatekey} } func parseInt64(s string) (int32, error) { @@ -47,15 +47,15 @@ func CheckPassword(password, hash string) (bool, error) { return argon2id.ComparePasswordAndHash(password, hash) } -func generateTokens(userID string) (accessToken string, refreshToken string, err error) { +func (s Server) generateTokens(userID string) (accessToken string, refreshToken string, csrfToken string, err error) { accessClaims := jwt.MapClaims{ "user_id": userID, "exp": time.Now().Add(15 * time.Minute).Unix(), } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - accessToken, err = at.SignedString(accessSecret) + accessToken, err = at.SignedString(s.JwtPrivateKey) if err != nil { - return "", "", err + return "", "", "", err } refreshClaims := jwt.MapClaims{ @@ -63,12 +63,19 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), } rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) - refreshToken, err = rt.SignedString(refreshSecret) + refreshToken, err = rt.SignedString(s.JwtPrivateKey) if err != nil { - return "", "", err + return "", "", "", err } - return accessToken, refreshToken, nil + csrfBytes := make([]byte, 32) + _, err = rand.Read(csrfBytes) + if err != nil { + return "", "", "", err + } + csrfToken = base64.RawURLEncoding.EncodeToString(csrfBytes) + + return accessToken, refreshToken, csrfToken, nil } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { @@ -118,7 +125,7 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque }, nil } - accessToken, refreshToken, err := generateTokens(req.Body.Nickname) + accessToken, refreshToken, csrfToken, err := s.generateTokens(req.Body.Nickname) if err != nil { log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err) // TODO: return 500 @@ -126,8 +133,9 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque // TODO: check cookie settings carefully ginCtx.SetSameSite(http.SameSiteStrictMode) - ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true) - ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true) + ginCtx.SetCookie("access_token", accessToken, 900, "/api", "", false, true) + ginCtx.SetCookie("refresh_token", refreshToken, 1209600, "/auth", "", false, true) + ginCtx.SetCookie("xsrf_token", csrfToken, 1209600, "/api", "", false, false) result := auth.PostAuthSignIn200JSONResponse{ UserId: user.ID, diff --git a/modules/auth/helpers.go b/modules/auth/helpers.go new file mode 100644 index 0000000..9c3ab36 --- /dev/null +++ b/modules/auth/helpers.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "reflect" +) + +func setField(obj interface{}, name string, value interface{}) error { + v := reflect.ValueOf(obj) + + if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct { + return fmt.Errorf("expected pointer to a struct") + } + + v = v.Elem() + field := v.FieldByName(name) + + if !field.IsValid() { + return fmt.Errorf("no such field: %s", name) + } + if !field.CanSet() { + return fmt.Errorf("cannot set field: %s", name) + } + + val := reflect.ValueOf(value) + + if field.Type() != val.Type() { + return fmt.Errorf("provided value type (%s) doesn't match field type (%s)", val.Type(), field.Type()) + } + + field.Set(val) + return nil +} diff --git a/modules/auth/main.go b/modules/auth/main.go index 7554f42..ef9b977 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "reflect" "time" auth "nyanimedb/auth" @@ -13,12 +14,24 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" + "github.com/pelletier/go-toml/v2" + log "github.com/sirupsen/logrus" ) var AppConfig Config func main() { - // TODO: env args + if len(os.Args) != 2 { + AppConfig.Mode = "env" + } else { + AppConfig.Mode = "argv" + } + + err := InitConfig() + if err != nil { + log.Fatalf("Failed to init config: %v\n", err) + } + r := gin.Default() pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) @@ -29,10 +42,10 @@ func main() { var queries *sqlc.Queries = sqlc.New(pool) - server := handlers.NewServer(queries) + server := handlers.NewServer(queries, AppConfig.JwtPrivateKey) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowOrigins: []string{AppConfig.ServiceAddress}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, @@ -47,3 +60,41 @@ func main() { r.Run(":8082") } + +func InitConfig() error { + if AppConfig.Mode == "argv" { + content, err := os.ReadFile(os.Args[1]) + if err != nil { + return err + } + + toml.Unmarshal(content, &AppConfig) + + fmt.Printf("%+v\n", AppConfig) + + return nil + } else if AppConfig.Mode == "env" { + f := reflect.ValueOf(AppConfig) + + for i := 0; i < f.NumField(); i++ { + field := f.Type().Field(i) + tag := field.Tag + env_var := tag.Get("env") + fmt.Printf("Field: %v.\nEnvironment variable: %v.\n", field.Name, env_var) + if env_var != "" { + env_value, exists := os.LookupEnv(env_var) + if !exists { + return fmt.Errorf("there is no env variable %s", env_var) + } + err := setField(&AppConfig, field.Name, env_value) + if err != nil { + return fmt.Errorf("failed to set config field %s: %v", field.Name, err) + } + } + } + + return nil + } else { + return fmt.Errorf("incorrect config mode") + } +} diff --git a/modules/auth/types.go b/modules/auth/types.go index 038b179..694843e 100644 --- a/modules/auth/types.go +++ b/modules/auth/types.go @@ -1,6 +1,9 @@ package main type Config struct { - JwtPrivateKey string - LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` + Mode string + ServiceAddress string `toml:"ServiceAddress" env:"SERVICE_ADDRESS"` + DdUrl string `toml:"DbUrl" env:"DATABASE_URL"` + JwtPrivateKey string `toml:"JwtPrivateKey" env:"JWT_PRIVATE_KEY"` + LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` } From 7629f391ad0889af10eb44a359c8bd0a1aa6a6e3 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 4 Dec 2025 06:42:08 +0300 Subject: [PATCH 160/224] fix --- api/parameters/xsrf_token_cookie.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/parameters/xsrf_token_cookie.yaml b/api/parameters/xsrf_token_cookie.yaml index cf85999..37041e0 100644 --- a/api/parameters/xsrf_token_cookie.yaml +++ b/api/parameters/xsrf_token_cookie.yaml @@ -1,4 +1,4 @@ -name: XSRF-TOKEN +name: xsrf_token in: cookie required: true schema: From 1bbfa338d92b4122a658bb3487c98666aae4652a Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 07:17:31 +0300 Subject: [PATCH 161/224] feat: send xsrf_token header --- api/_build/openapi.yaml | 15 ++++-- api/parameters/xsrf_token_cookie.yaml | 2 +- api/paths/users-id-titles-id.yaml | 8 +++ api/paths/users-id.yaml | 5 +- auth/openapi-auth.yaml | 4 +- modules/frontend/package-lock.json | 53 ++++++++++++++++++- modules/frontend/package.json | 1 + modules/frontend/src/api/index.ts | 3 ++ .../frontend/src/api/models/accessToken.ts | 9 ++++ modules/frontend/src/api/models/csrfToken.ts | 11 ++++ .../src/api/models/csrfTokenHeader.ts | 10 ++++ .../src/api/services/DefaultService.ts | 21 ++++++++ .../frontend/src/auth/services/AuthService.ts | 17 +++--- .../TitleStatusControls.tsx | 9 +++- .../src/pages/LoginPage/LoginPage.tsx | 10 ++-- 15 files changed, 151 insertions(+), 27 deletions(-) create mode 100644 modules/frontend/src/api/models/accessToken.ts create mode 100644 modules/frontend/src/api/models/csrfToken.ts create mode 100644 modules/frontend/src/api/models/csrfTokenHeader.ts diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 58dd890..225e7cd 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -150,6 +150,8 @@ paths: description: User not found '500': description: Unknown server error + security: + - JwtAuthCookies: [] patch: operationId: updateUser summary: Partially update a user account @@ -158,8 +160,7 @@ paths: Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. parameters: - - $ref: '#/components/parameters/accessToken' - - $ref: '#/components/parameters/csrfToken' + - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path description: User ID (primary key) @@ -404,11 +405,14 @@ paths: description: User or title not found '500': description: Unknown server error + security: + - JwtAuthCookies: [] patch: operationId: updateUserTitle summary: Update a usertitle description: User updating title list of watched parameters: + - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path required: true @@ -450,11 +454,14 @@ paths: description: User or Title not found '500': description: Internal server error + security: + - JwtAuthCookies: [] delete: operationId: deleteUserTitle summary: Delete a usertitle description: User deleting title from list of watched parameters: + - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path required: true @@ -478,6 +485,8 @@ paths: description: User or Title not found '500': description: Internal server error + security: + - JwtAuthCookies: [] components: parameters: accessToken: @@ -491,7 +500,7 @@ components: description: | JWT access token. csrfToken: - name: XSRF-TOKEN + name: xsrf_token in: cookie required: true schema: diff --git a/api/parameters/xsrf_token_cookie.yaml b/api/parameters/xsrf_token_cookie.yaml index cf85999..37041e0 100644 --- a/api/parameters/xsrf_token_cookie.yaml +++ b/api/parameters/xsrf_token_cookie.yaml @@ -1,4 +1,4 @@ -name: XSRF-TOKEN +name: xsrf_token in: cookie required: true schema: diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml index b4ad884..b56d07a 100644 --- a/api/paths/users-id-titles-id.yaml +++ b/api/paths/users-id-titles-id.yaml @@ -1,6 +1,8 @@ get: summary: Get user title operationId: getUserTitle + security: + - JwtAuthCookies: [] parameters: - in: path name: user_id @@ -34,7 +36,10 @@ patch: summary: Update a usertitle description: User updating title list of watched operationId: updateUserTitle + security: + - JwtAuthCookies: [] parameters: + - $ref: '../parameters/xsrf_token_header.yaml' - in: path name: user_id required: true @@ -81,7 +86,10 @@ delete: summary: Delete a usertitle description: User deleting title from list of watched operationId: deleteUserTitle + security: + - JwtAuthCookies: [] parameters: + - $ref: '../parameters/xsrf_token_header.yaml' - in: path name: user_id required: true diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index 0f2f367..abb170e 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -1,6 +1,8 @@ get: summary: Get user info operationId: getUsersId + security: + - JwtAuthCookies: [] parameters: - in: path name: user_id @@ -36,8 +38,7 @@ patch: Fields not provided in the request body remain unchanged. operationId: updateUser parameters: - - $ref: '../parameters/access_token.yaml' # ← для поля в UI и GoDoc - - $ref: '../parameters/xsrf_token_cookie.yaml' # ← для CSRF + - $ref: '../parameters/xsrf_token_header.yaml' - name: user_id in: path required: true diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 239b03b..5f3ebd6 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -7,7 +7,7 @@ servers: - url: /auth paths: - /auth/sign-up: + /sign-up: post: summary: Sign up a new user tags: [Auth] @@ -38,7 +38,7 @@ paths: type: integer format: int64 - /auth/sign-in: + /sign-in: post: summary: Sign in a user and return JWT tags: [Auth] diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index 40bb520..d2b5573 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -13,6 +13,7 @@ "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", + "react-cookie": "^8.0.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.4", "tailwindcss": "^4.1.17" @@ -1868,6 +1869,18 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0" + }, + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1890,7 +1903,6 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -2524,7 +2536,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -3260,6 +3271,15 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4068,6 +4088,20 @@ "node": ">=0.10.0" } }, + "node_modules/react-cookie": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-8.0.1.tgz", + "integrity": "sha512-QNdAd0MLuAiDiLcDU/2s/eyKmmfMHtjPUKJ2dZ/5CcQ9QKUium4B3o61/haq6PQl/YWFqC5PO8GvxeHKhy3GFA==", + "license": "MIT", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.6", + "hoist-non-react-statics": "^3.3.2", + "universal-cookie": "^8.0.0" + }, + "peerDependencies": { + "react": ">= 16.3.0" + } + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", @@ -4081,6 +4115,12 @@ "react": "^19.2.0" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4481,6 +4521,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/universal-cookie": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-8.0.1.tgz", + "integrity": "sha512-B6ks9FLLnP1UbPPcveOidfvB9pHjP+wekP2uRYB9YDfKVpvcjKgy1W5Zj+cEXJ9KTPnqOKGfVDQBmn8/YCQfRg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/modules/frontend/package.json b/modules/frontend/package.json index e0b65ba..af07b41 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -15,6 +15,7 @@ "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", + "react-cookie": "^8.0.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.4", "tailwindcss": "^4.1.17" diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index 9013fc7..c1e9cdc 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -7,6 +7,9 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; +export type { accessToken } from './models/accessToken'; +export type { csrfToken } from './models/csrfToken'; +export type { csrfTokenHeader } from './models/csrfTokenHeader'; export type { cursor } from './models/cursor'; export type { CursorObj } from './models/CursorObj'; export type { Image } from './models/Image'; diff --git a/modules/frontend/src/api/models/accessToken.ts b/modules/frontend/src/api/models/accessToken.ts new file mode 100644 index 0000000..adc8fb7 --- /dev/null +++ b/modules/frontend/src/api/models/accessToken.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * JWT access token. + * + */ +export type accessToken = string; diff --git a/modules/frontend/src/api/models/csrfToken.ts b/modules/frontend/src/api/models/csrfToken.ts new file mode 100644 index 0000000..4af805b --- /dev/null +++ b/modules/frontend/src/api/models/csrfToken.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Anti-CSRF token (Double Submit Cookie pattern). + * Stored in non-HttpOnly cookie, readable by JavaScript. + * Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). + * + */ +export type csrfToken = string; diff --git a/modules/frontend/src/api/models/csrfTokenHeader.ts b/modules/frontend/src/api/models/csrfTokenHeader.ts new file mode 100644 index 0000000..354c8a3 --- /dev/null +++ b/modules/frontend/src/api/models/csrfTokenHeader.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + * Required for all state-changing requests (POST/PUT/PATCH/DELETE). + * + */ +export type csrfTokenHeader = string; diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 6898c46..f3d803d 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -135,12 +135,16 @@ export class DefaultService { * Password updates must be done via the dedicated auth-service (`/auth/`). * Fields not provided in the request body remain unchanged. * + * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + * Required for all state-changing requests (POST/PUT/PATCH/DELETE). + * * @param userId User ID (primary key) * @param requestBody * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). * @throws ApiError */ public static updateUser( + xXsrfToken: string, userId: number, requestBody: { /** @@ -171,6 +175,9 @@ export class DefaultService { path: { 'user_id': userId, }, + headers: { + 'X-XSRF-TOKEN': xXsrfToken, + }, body: requestBody, mediaType: 'application/json', errors: { @@ -309,6 +316,9 @@ export class DefaultService { /** * Update a usertitle * User updating title list of watched + * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + * Required for all state-changing requests (POST/PUT/PATCH/DELETE). + * * @param userId * @param titleId * @param requestBody @@ -316,6 +326,7 @@ export class DefaultService { * @throws ApiError */ public static updateUserTitle( + xXsrfToken: string, userId: number, titleId: number, requestBody: { @@ -330,6 +341,9 @@ export class DefaultService { 'user_id': userId, 'title_id': titleId, }, + headers: { + 'X-XSRF-TOKEN': xXsrfToken, + }, body: requestBody, mediaType: 'application/json', errors: { @@ -344,12 +358,16 @@ export class DefaultService { /** * Delete a usertitle * User deleting title from list of watched + * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + * Required for all state-changing requests (POST/PUT/PATCH/DELETE). + * * @param userId * @param titleId * @returns any Title successfully deleted * @throws ApiError */ public static deleteUserTitle( + xXsrfToken: string, userId: number, titleId: number, ): CancelablePromise<any> { @@ -360,6 +378,9 @@ export class DefaultService { 'user_id': userId, 'title_id': titleId, }, + headers: { + 'X-XSRF-TOKEN': xXsrfToken, + }, errors: { 401: `Unauthorized — missing or invalid auth token`, 403: `Forbidden — user not allowed to delete title`, diff --git a/modules/frontend/src/auth/services/AuthService.ts b/modules/frontend/src/auth/services/AuthService.ts index 94578d8..74a8fa7 100644 --- a/modules/frontend/src/auth/services/AuthService.ts +++ b/modules/frontend/src/auth/services/AuthService.ts @@ -12,19 +12,17 @@ export class AuthService { * @returns any Sign-up result * @throws ApiError */ - public static postAuthSignUp( + public static postSignUp( requestBody: { nickname: string; pass: string; }, ): CancelablePromise<{ - success?: boolean; - error?: string | null; - user_id?: string | null; + user_id: number; }> { return __request(OpenAPI, { method: 'POST', - url: '/auth/sign-up', + url: '/sign-up', body: requestBody, mediaType: 'application/json', }); @@ -35,19 +33,18 @@ export class AuthService { * @returns any Sign-in result with JWT * @throws ApiError */ - public static postAuthSignIn( + public static postSignIn( requestBody: { nickname: string; pass: string; }, ): CancelablePromise<{ - error?: string | null; - user_id?: string | null; - user_name?: string | null; + user_id: number; + user_name: string; }> { return __request(OpenAPI, { method: 'POST', - url: '/auth/sign-in', + url: '/sign-in', body: requestBody, mediaType: 'application/json', errors: { diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index 0c9c741..4fb535a 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from "react"; import { DefaultService } from "../../api"; import type { UserTitleStatus } from "../../api"; +import { useCookies } from 'react-cookie'; + import { ClockIcon, CheckCircleIcon, @@ -17,6 +19,9 @@ const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: s ]; export function TitleStatusControls({ titleId }: { titleId: number }) { + const [cookies] = useCookies(['xsrf_token']); + const xsrfToken = cookies['xsrf_token'] || null; + const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); const [loading, setLoading] = useState(false); @@ -41,7 +46,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { try { // 1) Если кликнули на текущий статус — DELETE if (currentStatus === status) { - await DefaultService.deleteUserTitle(userId, titleId); + await DefaultService.deleteUserTitle(xsrfToken, userId, titleId); setCurrentStatus(null); return; } @@ -56,7 +61,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { setCurrentStatus(added.status); } else { // уже есть запись — PATCH - const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); + const updated = await DefaultService.updateUserTitle(xsrfToken, userId, titleId, { status }); setCurrentStatus(updated.status); } } finally { diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx index 89ee88c..928766e 100644 --- a/modules/frontend/src/pages/LoginPage/LoginPage.tsx +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -17,23 +17,23 @@ export const LoginPage: React.FC = () => { try { if (isLogin) { - const res = await AuthService.postAuthSignIn({ nickname, pass: password }); + const res = await AuthService.postSignIn({ nickname, pass: password }); if (res.user_id && res.user_name) { // Сохраняем user_id и username в localStorage - localStorage.setItem("userId", res.user_id); + localStorage.setItem("userId", res.user_id.toString()); localStorage.setItem("username", res.user_name); navigate("/profile"); // редирект на профиль } else { - setError(res.error || "Login failed"); + setError("Login failed"); } } else { // SignUp оставляем без сохранения данных - const res = await AuthService.postAuthSignUp({ nickname, pass: password }); + const res = await AuthService.postSignUp({ nickname, pass: password }); if (res.user_id) { setIsLogin(true); // переключаемся на login после регистрации } else { - setError(res.error || "Sign up failed"); + setError("Sign up failed"); } } } catch (err: any) { From b03f9c9704d93e596b55a474ba3656f9ba8e61b9 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 07:20:10 +0300 Subject: [PATCH 162/224] fix: regen oapi for auth --- auth/auth.gen.go | 108 +++++++++++++++--------------- modules/auth/handlers/handlers.go | 12 ++-- 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index 7276545..b7cd839 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -13,32 +13,32 @@ import ( strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" ) -// PostAuthSignInJSONBody defines parameters for PostAuthSignIn. -type PostAuthSignInJSONBody struct { +// PostSignInJSONBody defines parameters for PostSignIn. +type PostSignInJSONBody struct { Nickname string `json:"nickname"` Pass string `json:"pass"` } -// PostAuthSignUpJSONBody defines parameters for PostAuthSignUp. -type PostAuthSignUpJSONBody struct { +// PostSignUpJSONBody defines parameters for PostSignUp. +type PostSignUpJSONBody struct { Nickname string `json:"nickname"` Pass string `json:"pass"` } -// PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. -type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody +// PostSignInJSONRequestBody defines body for PostSignIn for application/json ContentType. +type PostSignInJSONRequestBody PostSignInJSONBody -// PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. -type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody +// PostSignUpJSONRequestBody defines body for PostSignUp for application/json ContentType. +type PostSignUpJSONRequestBody PostSignUpJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { // Sign in a user and return JWT - // (POST /auth/sign-in) - PostAuthSignIn(c *gin.Context) + // (POST /sign-in) + PostSignIn(c *gin.Context) // Sign up a new user - // (POST /auth/sign-up) - PostAuthSignUp(c *gin.Context) + // (POST /sign-up) + PostSignUp(c *gin.Context) } // ServerInterfaceWrapper converts contexts to parameters. @@ -50,8 +50,8 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) -// PostAuthSignIn operation middleware -func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { +// PostSignIn operation middleware +func (siw *ServerInterfaceWrapper) PostSignIn(c *gin.Context) { for _, middleware := range siw.HandlerMiddlewares { middleware(c) @@ -60,11 +60,11 @@ func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { } } - siw.Handler.PostAuthSignIn(c) + siw.Handler.PostSignIn(c) } -// PostAuthSignUp operation middleware -func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { +// PostSignUp operation middleware +func (siw *ServerInterfaceWrapper) PostSignUp(c *gin.Context) { for _, middleware := range siw.HandlerMiddlewares { middleware(c) @@ -73,7 +73,7 @@ func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { } } - siw.Handler.PostAuthSignUp(c) + siw.Handler.PostSignUp(c) } // GinServerOptions provides options for the Gin server. @@ -103,54 +103,54 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } - router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) - router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) + router.POST(options.BaseURL+"/sign-in", wrapper.PostSignIn) + router.POST(options.BaseURL+"/sign-up", wrapper.PostSignUp) } -type PostAuthSignInRequestObject struct { - Body *PostAuthSignInJSONRequestBody +type PostSignInRequestObject struct { + Body *PostSignInJSONRequestBody } -type PostAuthSignInResponseObject interface { - VisitPostAuthSignInResponse(w http.ResponseWriter) error +type PostSignInResponseObject interface { + VisitPostSignInResponse(w http.ResponseWriter) error } -type PostAuthSignIn200JSONResponse struct { +type PostSignIn200JSONResponse struct { UserId int64 `json:"user_id"` UserName string `json:"user_name"` } -func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { +func (response PostSignIn200JSONResponse) VisitPostSignInResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type PostAuthSignIn401JSONResponse struct { +type PostSignIn401JSONResponse struct { Error *string `json:"error,omitempty"` } -func (response PostAuthSignIn401JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { +func (response PostSignIn401JSONResponse) VisitPostSignInResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(401) return json.NewEncoder(w).Encode(response) } -type PostAuthSignUpRequestObject struct { - Body *PostAuthSignUpJSONRequestBody +type PostSignUpRequestObject struct { + Body *PostSignUpJSONRequestBody } -type PostAuthSignUpResponseObject interface { - VisitPostAuthSignUpResponse(w http.ResponseWriter) error +type PostSignUpResponseObject interface { + VisitPostSignUpResponse(w http.ResponseWriter) error } -type PostAuthSignUp200JSONResponse struct { +type PostSignUp200JSONResponse struct { UserId int64 `json:"user_id"` } -func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { +func (response PostSignUp200JSONResponse) VisitPostSignUpResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) @@ -160,11 +160,11 @@ func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Sign in a user and return JWT - // (POST /auth/sign-in) - PostAuthSignIn(ctx context.Context, request PostAuthSignInRequestObject) (PostAuthSignInResponseObject, error) + // (POST /sign-in) + PostSignIn(ctx context.Context, request PostSignInRequestObject) (PostSignInResponseObject, error) // Sign up a new user - // (POST /auth/sign-up) - PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) + // (POST /sign-up) + PostSignUp(ctx context.Context, request PostSignUpRequestObject) (PostSignUpResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -179,11 +179,11 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } -// PostAuthSignIn operation middleware -func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { - var request PostAuthSignInRequestObject +// PostSignIn operation middleware +func (sh *strictHandler) PostSignIn(ctx *gin.Context) { + var request PostSignInRequestObject - var body PostAuthSignInJSONRequestBody + var body PostSignInJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { ctx.Status(http.StatusBadRequest) ctx.Error(err) @@ -192,10 +192,10 @@ func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { request.Body = &body handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostAuthSignIn(ctx, request.(PostAuthSignInRequestObject)) + return sh.ssi.PostSignIn(ctx, request.(PostSignInRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostAuthSignIn") + handler = middleware(handler, "PostSignIn") } response, err := handler(ctx, request) @@ -203,8 +203,8 @@ func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostAuthSignInResponseObject); ok { - if err := validResponse.VisitPostAuthSignInResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(PostSignInResponseObject); ok { + if err := validResponse.VisitPostSignInResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -212,11 +212,11 @@ func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { } } -// PostAuthSignUp operation middleware -func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { - var request PostAuthSignUpRequestObject +// PostSignUp operation middleware +func (sh *strictHandler) PostSignUp(ctx *gin.Context) { + var request PostSignUpRequestObject - var body PostAuthSignUpJSONRequestBody + var body PostSignUpJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { ctx.Status(http.StatusBadRequest) ctx.Error(err) @@ -225,10 +225,10 @@ func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { request.Body = &body handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostAuthSignUp(ctx, request.(PostAuthSignUpRequestObject)) + return sh.ssi.PostSignUp(ctx, request.(PostSignUpRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostAuthSignUp") + handler = middleware(handler, "PostSignUp") } response, err := handler(ctx, request) @@ -236,8 +236,8 @@ func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostAuthSignUpResponseObject); ok { - if err := validResponse.VisitPostAuthSignUpResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(PostSignUpResponseObject); ok { + if err := validResponse.VisitPostSignUpResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 6fee512..09907bc 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -78,7 +78,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken return accessToken, refreshToken, csrfToken, nil } -func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { +func (s Server) PostSignUp(ctx context.Context, req auth.PostSignUpRequestObject) (auth.PostSignUpResponseObject, error) { passhash, err := HashPassword(req.Body.Pass) if err != nil { log.Errorf("failed to hash password: %v", err) @@ -94,17 +94,17 @@ func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpReque // TODO: check err and retyrn 400/500 } - return auth.PostAuthSignUp200JSONResponse{ + return auth.PostSignUp200JSONResponse{ UserId: user_id, }, nil } -func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { +func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject) (auth.PostSignInResponseObject, error) { ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) if !ok { log.Print("failed to get gin context") // TODO: change to 500 - return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") + return auth.PostSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") } user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname) @@ -120,7 +120,7 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque } if !ok { err_msg := "invalid credentials" - return auth.PostAuthSignIn401JSONResponse{ + return auth.PostSignIn401JSONResponse{ Error: &err_msg, }, nil } @@ -137,7 +137,7 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque ginCtx.SetCookie("refresh_token", refreshToken, 1209600, "/auth", "", false, true) ginCtx.SetCookie("xsrf_token", csrfToken, 1209600, "/api", "", false, false) - result := auth.PostAuthSignIn200JSONResponse{ + result := auth.PostSignIn200JSONResponse{ UserId: user.ID, UserName: user.Nickname, } From 6786f7ac00741960ef886b6f352ea36811fd9084 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 4 Dec 2025 07:32:45 +0300 Subject: [PATCH 163/224] feat: access token check --- modules/backend/main.go | 35 ++++----- modules/backend/middlewares/access.go | 109 ++++++++++++++++++++++++++ modules/backend/types.go | 14 ++-- 3 files changed, 130 insertions(+), 28 deletions(-) create mode 100644 modules/backend/middlewares/access.go diff --git a/modules/backend/main.go b/modules/backend/main.go index aab1287..0cffdcf 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -25,18 +25,18 @@ import ( var AppConfig Config func main() { - // if len(os.Args) != 2 { - // AppConfig.Mode = "env" - // } else { - // AppConfig.Mode = "argv" - // } + if len(os.Args) != 2 { + AppConfig.Mode = "env" + } else { + AppConfig.Mode = "argv" + } - // err := InitConfig() - // if err != nil { - // log.Fatalf("Failed to init config: %v\n", err) - // } + err := InitConfig() + if err != nil { + log.Fatalf("Failed to init config: %v\n", err) + } - pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) + pool, err := pgxpool.New(context.Background(), AppConfig.DdUrl) if err != nil { fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) os.Exit(1) @@ -47,16 +47,11 @@ func main() { r := gin.Default() r.Use(middleware.CSRFMiddleware()) - // jwt middle will be here + r.Use(middleware.JWTAuthMiddleware(AppConfig.JwtPrivateKey)) + queries := sqlc.New(pool) - // === RabbitMQ setup === - rmqURL := os.Getenv("RABBITMQ_URL") - if rmqURL == "" { - rmqURL = "amqp://guest:guest@rabbitmq:5672/" - } - - rmqConn, err := amqp091.Dial(rmqURL) + rmqConn, err := amqp091.Dial(AppConfig.rmqURL) if err != nil { log.Fatalf("Failed to connect to RabbitMQ: %v", err) } @@ -68,7 +63,7 @@ func main() { server := handlers.NewServer(queries, publisher, rpcClient) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowOrigins: []string{AppConfig.ServiceAddress}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, @@ -78,7 +73,7 @@ func main() { oapi.RegisterHandlers(r, oapi.NewStrictHandler( server, - // сюда можно добавить middlewares, если нужно + []oapi.StrictMiddlewareFunc{}, )) diff --git a/modules/backend/middlewares/access.go b/modules/backend/middlewares/access.go new file mode 100644 index 0000000..73200e8 --- /dev/null +++ b/modules/backend/middlewares/access.go @@ -0,0 +1,109 @@ +package middleware + +import ( + "context" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// ctxKey — приватный тип для ключа контекста +type ctxKey struct{} + +// ginContextKey — уникальный ключ для хранения *gin.Context +var ginContextKey = &ctxKey{} + +// GinContextToContext сохраняет *gin.Context в context.Context запроса +func GinContextToContext(c *gin.Context) { + ctx := context.WithValue(c.Request.Context(), ginContextKey, c) + c.Request = c.Request.WithContext(ctx) +} + +// GinContextFromContext извлекает *gin.Context из context.Context +func GinContextFromContext(ctx context.Context) (*gin.Context, bool) { + ginCtx, ok := ctx.Value(ginContextKey).(*gin.Context) + return ginCtx, ok +} + +func JWTAuthMiddleware(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + // 1. Получаем access_token из cookie + tokenStr, err := c.Cookie("access_token") + if err != nil { + abortWithJSON(c, http.StatusUnauthorized, "missing access_token cookie") + return + } + + // 2. Парсим токен с MapClaims + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + if t.Method != jwt.SigningMethodHS256 { + return nil, errors.New("unexpected signing method: " + t.Method.Alg()) + } + return []byte(secret), nil // ← конвертируем string → []byte + }) + if err != nil { + abortWithJSON(c, http.StatusUnauthorized, "invalid token: "+err.Error()) + return + } + + // 3. Проверяем валидность + if !token.Valid { + abortWithJSON(c, http.StatusUnauthorized, "token is invalid") + return + } + + // 4. Извлекаем user_id из claims + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + abortWithJSON(c, http.StatusUnauthorized, "invalid claims format") + return + } + + userID, ok := claims["user_id"].(string) + if !ok || userID == "" { + abortWithJSON(c, http.StatusUnauthorized, "user_id claim missing or invalid") + return + } + + // 5. Сохраняем в контексте + c.Set("user_id", userID) + + // 6. Для oapi-codegen — кладём gin.Context в request context + GinContextToContext(c) + + c.Next() + } +} + +// Вспомогательные функции (без изменений) +func UserIDFromGin(c *gin.Context) (string, bool) { + id, exists := c.Get("user_id") + if !exists { + return "", false + } + if s, ok := id.(string); ok { + return s, true + } + return "", false +} + +func UserIDFromContext(ctx context.Context) (string, error) { + ginCtx, ok := GinContextFromContext(ctx) + if !ok { + return "", errors.New("gin context not found") + } + userID, ok := UserIDFromGin(ginCtx) + if !ok { + return "", errors.New("user_id not found in context") + } + return userID, nil +} + +func abortWithJSON(c *gin.Context, code int, message string) { + c.AbortWithStatusJSON(code, gin.H{ + "error": "unauthorized", + "message": message, + }) +} diff --git a/modules/backend/types.go b/modules/backend/types.go index 20d3158..c4f70ed 100644 --- a/modules/backend/types.go +++ b/modules/backend/types.go @@ -1,12 +1,10 @@ package main type Config struct { - Mode string - LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` -} - -type Item struct { - ID int `json:"id"` - Title string `json:"title"` - Description string `json:"description"` + Mode string + ServiceAddress string `toml:"ServiceAddress" env:"SERVICE_ADDRESS"` + DdUrl string `toml:"DbUrl" env:"DATABASE_URL"` + JwtPrivateKey string `toml:"JwtPrivateKey" env:"JWT_PRIVATE_KEY"` + LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` + rmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"` } From 066c44d08a13a5127340e9b116615e6786d3495d Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 07:35:49 +0300 Subject: [PATCH 164/224] fix: AllowOrigins --- modules/auth/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/auth/main.go b/modules/auth/main.go index ef9b977..7305b7d 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -44,8 +44,9 @@ func main() { server := handlers.NewServer(queries, AppConfig.JwtPrivateKey) + log.Info("allow origins:", AppConfig.ServiceAddress) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{AppConfig.ServiceAddress}, + AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, From 570be2a68b0fb246e5f7ce86745223b1a0da7924 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 4 Dec 2025 07:40:21 +0300 Subject: [PATCH 165/224] fix --- deploy/docker-compose.yml | 1 + modules/backend/main.go | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 0ae97c6..1bd7f71 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -64,6 +64,7 @@ services: DATABASE_URL: ${DATABASE_URL} SERVICE_ADDRESS: ${SERVICE_ADDRESS} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} + RABBITMQ_URL: ${RABBITMQ_URL} ports: - "8082:8082" depends_on: diff --git a/modules/backend/main.go b/modules/backend/main.go index 0cffdcf..9dac2a6 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -63,7 +63,8 @@ func main() { server := handlers.NewServer(queries, publisher, rpcClient) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{AppConfig.ServiceAddress}, + // AllowOrigins: []string{AppConfig.ServiceAddress}, + AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, From b6cf5231369035e40f6e32023f2eede6fd6f886b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 4 Dec 2025 07:43:37 +0300 Subject: [PATCH 166/224] fix --- deploy/docker-compose.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1bd7f71..82116eb 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -47,6 +47,9 @@ services: environment: LOG_LEVEL: ${LOG_LEVEL} DATABASE_URL: ${DATABASE_URL} + SERVICE_ADDRESS: ${SERVICE_ADDRESS} + RABBITMQ_URL: ${RABBITMQ_URL} + JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} ports: - "8080:8080" depends_on: @@ -64,7 +67,6 @@ services: DATABASE_URL: ${DATABASE_URL} SERVICE_ADDRESS: ${SERVICE_ADDRESS} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} - RABBITMQ_URL: ${RABBITMQ_URL} ports: - "8082:8082" depends_on: From e12dff3455c25c067df42af384ea9a6e82e393df Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 07:59:32 +0300 Subject: [PATCH 167/224] fix: cicd env fix --- .forgejo/workflows/build-and-deploy.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 3c473d2..dde9392 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -111,6 +111,11 @@ jobs: POSTGRES_VERSION: 18 LOG_LEVEL: ${{ vars.LOG_LEVEL }} DATABASE_URL: ${{ secrets.DATABASE_URL }} + SERVICE_ADDRESS: ${{ vars.SERVICE_ADDRESS }} + RABBITMQ_URL: ${{ secrets.RABBITMQ_URL }} + JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }} + RABBITMQ_DEFAULT_USER: ${{ secrets.RABBITMQ_USER }} + RABBITMQ_DEFAULT_PASS: ${{ secrets.RABBITMQ_PASSWORD }} steps: - name: Checkout code From 85a3c3ef107f9cbc4a80bef13861df559f8f2695 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 08:11:51 +0300 Subject: [PATCH 168/224] fix: backend config --- modules/backend/main.go | 2 +- modules/backend/types.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/backend/main.go b/modules/backend/main.go index 9dac2a6..37dcc7b 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -51,7 +51,7 @@ func main() { queries := sqlc.New(pool) - rmqConn, err := amqp091.Dial(AppConfig.rmqURL) + rmqConn, err := amqp091.Dial(AppConfig.RmqURL) if err != nil { log.Fatalf("Failed to connect to RabbitMQ: %v", err) } diff --git a/modules/backend/types.go b/modules/backend/types.go index c4f70ed..a069307 100644 --- a/modules/backend/types.go +++ b/modules/backend/types.go @@ -6,5 +6,5 @@ type Config struct { DdUrl string `toml:"DbUrl" env:"DATABASE_URL"` JwtPrivateKey string `toml:"JwtPrivateKey" env:"JWT_PRIVATE_KEY"` LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` - rmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"` + RmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"` } From 79a716cf550a96d3a9851932116c9d8358972fef Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 08:27:22 +0300 Subject: [PATCH 169/224] fix: use []byte for jwt key --- modules/auth/handlers/handlers.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 09907bc..03df151 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -53,7 +53,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken "exp": time.Now().Add(15 * time.Minute).Unix(), } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - accessToken, err = at.SignedString(s.JwtPrivateKey) + accessToken, err = at.SignedString([]byte(s.JwtPrivateKey)) if err != nil { return "", "", "", err } @@ -63,7 +63,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), } rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) - refreshToken, err = rt.SignedString(s.JwtPrivateKey) + refreshToken, err = rt.SignedString([]byte(s.JwtPrivateKey)) if err != nil { return "", "", "", err } From 3be58457aa82ab7c2017ed42dd526636f8a870b3 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 08:44:26 +0300 Subject: [PATCH 170/224] fix(front): CookiesProvider --- modules/frontend/src/main.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/frontend/src/main.tsx b/modules/frontend/src/main.tsx index bef5202..c225a33 100644 --- a/modules/frontend/src/main.tsx +++ b/modules/frontend/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { CookiesProvider } from 'react-cookie' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( <StrictMode> - <App /> + <CookiesProvider> + <App /> + </CookiesProvider> </StrictMode>, ) From 2f4f8164df2ed625c3e13f7a35ea3d17e47b2956 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 09:03:51 +0300 Subject: [PATCH 171/224] feat: CORS X-XSRF-TOKEN --- modules/backend/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backend/main.go b/modules/backend/main.go index 37dcc7b..24325eb 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -66,7 +66,7 @@ func main() { // AllowOrigins: []string{AppConfig.ServiceAddress}, AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, - AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "X-XSRF-TOKEN"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, MaxAge: 12 * time.Hour, From 475266eef6fd08b6448475ae77e3631aab836efc Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 09:04:37 +0300 Subject: [PATCH 172/224] fix: revert AllowOrigins --- modules/backend/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/backend/main.go b/modules/backend/main.go index 24325eb..b833cf9 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -63,8 +63,8 @@ func main() { server := handlers.NewServer(queries, publisher, rpcClient) r.Use(cors.New(cors.Config{ - // AllowOrigins: []string{AppConfig.ServiceAddress}, - AllowOrigins: []string{"*"}, + AllowOrigins: []string{AppConfig.ServiceAddress}, + // AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept", "X-XSRF-TOKEN"}, ExposeHeaders: []string{"Content-Length"}, From bd868bb724a7374f649779e5d48650155755f8c2 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 10:12:05 +0300 Subject: [PATCH 173/224] fix: reworked csrf --- api/_build/openapi.yaml | 54 ++++--------------- api/openapi.yaml | 2 + api/parameters/_index.yaml | 8 +-- api/parameters/access_token.yaml | 9 ---- api/parameters/xsrf_token_cookie.yaml | 11 ---- api/parameters/xsrf_token_header.yaml | 10 ---- api/paths/users-id-titles-id.yaml | 8 +-- api/paths/users-id.yaml | 8 ++- api/securitySchemes/_index.yaml | 11 ++++ modules/frontend/src/App.tsx | 4 ++ modules/frontend/src/api/index.ts | 3 -- .../frontend/src/api/models/accessToken.ts | 9 ---- modules/frontend/src/api/models/csrfToken.ts | 11 ---- .../src/api/models/csrfTokenHeader.ts | 10 ---- .../src/api/services/DefaultService.ts | 21 -------- .../TitleStatusControls.tsx | 10 ++-- 16 files changed, 39 insertions(+), 150 deletions(-) delete mode 100644 api/parameters/access_token.yaml delete mode 100644 api/parameters/xsrf_token_cookie.yaml delete mode 100644 api/parameters/xsrf_token_header.yaml create mode 100644 api/securitySchemes/_index.yaml delete mode 100644 modules/frontend/src/api/models/accessToken.ts delete mode 100644 modules/frontend/src/api/models/csrfToken.ts delete mode 100644 modules/frontend/src/api/models/csrfTokenHeader.ts diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 225e7cd..3cbb361 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -150,8 +150,6 @@ paths: description: User not found '500': description: Unknown server error - security: - - JwtAuthCookies: [] patch: operationId: updateUser summary: Partially update a user account @@ -160,7 +158,6 @@ paths: Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. parameters: - - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path description: User ID (primary key) @@ -229,7 +226,7 @@ paths: '500': description: Unknown server error security: - - JwtAuthCookies: [] + XsrfAuthHeader: [] '/users/{user_id}/titles': get: operationId: getUserTitles @@ -405,14 +402,11 @@ paths: description: User or title not found '500': description: Unknown server error - security: - - JwtAuthCookies: [] patch: operationId: updateUserTitle summary: Update a usertitle description: User updating title list of watched parameters: - - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path required: true @@ -455,13 +449,12 @@ paths: '500': description: Internal server error security: - - JwtAuthCookies: [] + - XsrfAuthHeader: [] delete: operationId: deleteUserTitle summary: Delete a usertitle description: User deleting title from list of watched parameters: - - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path required: true @@ -486,42 +479,9 @@ paths: '500': description: Internal server error security: - - JwtAuthCookies: [] + - XsrfAuthHeader: [] components: parameters: - accessToken: - name: access_token - in: cookie - required: true - schema: - type: string - format: jwt - example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y - description: | - JWT access token. - csrfToken: - name: xsrf_token - in: cookie - required: true - schema: - type: string - pattern: '^[a-zA-Z0-9_-]{32,64}$' - example: abc123def456ghi789jkl012mno345pqr - description: | - Anti-CSRF token (Double Submit Cookie pattern). - Stored in non-HttpOnly cookie, readable by JavaScript. - Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). - csrfTokenHeader: - name: X-XSRF-TOKEN - in: header - required: true - schema: - type: string - pattern: '^[a-zA-Z0-9_-]{32,64}$' - description: | - Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - Required for all state-changing requests (POST/PUT/PATCH/DELETE). - example: abc123def456ghi789jkl012mno345pqr cursor: in: query name: cursor @@ -780,3 +740,11 @@ components: Review: type: object additionalProperties: true + securitySchemes: + XsrfAuthHeader: + type: apiKey + in: header + name: X-XSRF-TOKEN + description: | + Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + Required for all state-changing requests (POST/PUT/PATCH/DELETE). diff --git a/api/openapi.yaml b/api/openapi.yaml index 08a4d54..d84797f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -23,3 +23,5 @@ components: $ref: "./parameters/_index.yaml" schemas: $ref: "./schemas/_index.yaml" + securitySchemes: + $ref: "./securitySchemes/_index.yaml" \ No newline at end of file diff --git a/api/parameters/_index.yaml b/api/parameters/_index.yaml index d2e12a8..6249e7d 100644 --- a/api/parameters/_index.yaml +++ b/api/parameters/_index.yaml @@ -1,10 +1,4 @@ cursor: $ref: "./cursor.yaml" title_sort: - $ref: "./title_sort.yaml" -accessToken: - $ref: "./access_token.yaml" -csrfToken: - $ref: "./xsrf_token_cookie.yaml" -csrfTokenHeader: - $ref: "./xsrf_token_header.yaml" \ No newline at end of file + $ref: "./title_sort.yaml" \ No newline at end of file diff --git a/api/parameters/access_token.yaml b/api/parameters/access_token.yaml deleted file mode 100644 index a7e727e..0000000 --- a/api/parameters/access_token.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: access_token -in: cookie -required: true -schema: - type: string - format: jwt -example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y" -description: | - JWT access token. diff --git a/api/parameters/xsrf_token_cookie.yaml b/api/parameters/xsrf_token_cookie.yaml deleted file mode 100644 index 37041e0..0000000 --- a/api/parameters/xsrf_token_cookie.yaml +++ /dev/null @@ -1,11 +0,0 @@ -name: xsrf_token -in: cookie -required: true -schema: - type: string - pattern: "^[a-zA-Z0-9_-]{32,64}$" -example: "abc123def456ghi789jkl012mno345pqr" -description: | - Anti-CSRF token (Double Submit Cookie pattern). - Stored in non-HttpOnly cookie, readable by JavaScript. - Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). \ No newline at end of file diff --git a/api/parameters/xsrf_token_header.yaml b/api/parameters/xsrf_token_header.yaml deleted file mode 100644 index ac14dc1..0000000 --- a/api/parameters/xsrf_token_header.yaml +++ /dev/null @@ -1,10 +0,0 @@ -name: X-XSRF-TOKEN -in: header -required: true -schema: - type: string - pattern: "^[a-zA-Z0-9_-]{32,64}$" -description: | - Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - Required for all state-changing requests (POST/PUT/PATCH/DELETE). -example: "abc123def456ghi789jkl012mno345pqr" \ No newline at end of file diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml index b56d07a..1da2b81 100644 --- a/api/paths/users-id-titles-id.yaml +++ b/api/paths/users-id-titles-id.yaml @@ -1,8 +1,6 @@ get: summary: Get user title operationId: getUserTitle - security: - - JwtAuthCookies: [] parameters: - in: path name: user_id @@ -37,9 +35,8 @@ patch: description: User updating title list of watched operationId: updateUserTitle security: - - JwtAuthCookies: [] + - XsrfAuthHeader: [] parameters: - - $ref: '../parameters/xsrf_token_header.yaml' - in: path name: user_id required: true @@ -87,9 +84,8 @@ delete: description: User deleting title from list of watched operationId: deleteUserTitle security: - - JwtAuthCookies: [] + - XsrfAuthHeader: [] parameters: - - $ref: '../parameters/xsrf_token_header.yaml' - in: path name: user_id required: true diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index abb170e..5e9e69d 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -1,8 +1,6 @@ get: summary: Get user info operationId: getUsersId - security: - - JwtAuthCookies: [] parameters: - in: path name: user_id @@ -30,15 +28,15 @@ get: patch: summary: Partially update a user account - security: - - JwtAuthCookies: [] description: | Update selected user profile fields (excluding password). Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. operationId: updateUser + security: + XsrfAuthHeader: [] parameters: - - $ref: '../parameters/xsrf_token_header.yaml' + # - $ref: '../parameters/xsrf_token_header.yaml' - name: user_id in: path required: true diff --git a/api/securitySchemes/_index.yaml b/api/securitySchemes/_index.yaml new file mode 100644 index 0000000..ecc0ff6 --- /dev/null +++ b/api/securitySchemes/_index.yaml @@ -0,0 +1,11 @@ +# accessToken: +# $ref: "./access_token.yaml" +# csrfToken: +# $ref: "./xsrf_token_cookie.yaml" +XsrfAuthHeader: + type: apiKey + in: header + name: X-XSRF-TOKEN + description: | + Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + Required for all state-changing requests (POST/PUT/PATCH/DELETE). \ No newline at end of file diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 95b59e3..5ff2b32 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -6,6 +6,10 @@ import TitlePage from "./pages/TitlePage/TitlePage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; +import { OpenAPI } from "./api"; + +OpenAPI.WITH_CREDENTIALS = true + const App: React.FC = () => { const username = localStorage.getItem("username") || undefined; const userId = localStorage.getItem("userId"); diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index c1e9cdc..9013fc7 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -7,9 +7,6 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; -export type { accessToken } from './models/accessToken'; -export type { csrfToken } from './models/csrfToken'; -export type { csrfTokenHeader } from './models/csrfTokenHeader'; export type { cursor } from './models/cursor'; export type { CursorObj } from './models/CursorObj'; export type { Image } from './models/Image'; diff --git a/modules/frontend/src/api/models/accessToken.ts b/modules/frontend/src/api/models/accessToken.ts deleted file mode 100644 index adc8fb7..0000000 --- a/modules/frontend/src/api/models/accessToken.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * JWT access token. - * - */ -export type accessToken = string; diff --git a/modules/frontend/src/api/models/csrfToken.ts b/modules/frontend/src/api/models/csrfToken.ts deleted file mode 100644 index 4af805b..0000000 --- a/modules/frontend/src/api/models/csrfToken.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Anti-CSRF token (Double Submit Cookie pattern). - * Stored in non-HttpOnly cookie, readable by JavaScript. - * Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). - * - */ -export type csrfToken = string; diff --git a/modules/frontend/src/api/models/csrfTokenHeader.ts b/modules/frontend/src/api/models/csrfTokenHeader.ts deleted file mode 100644 index 354c8a3..0000000 --- a/modules/frontend/src/api/models/csrfTokenHeader.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - * Required for all state-changing requests (POST/PUT/PATCH/DELETE). - * - */ -export type csrfTokenHeader = string; diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index f3d803d..6898c46 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -135,16 +135,12 @@ export class DefaultService { * Password updates must be done via the dedicated auth-service (`/auth/`). * Fields not provided in the request body remain unchanged. * - * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - * Required for all state-changing requests (POST/PUT/PATCH/DELETE). - * * @param userId User ID (primary key) * @param requestBody * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). * @throws ApiError */ public static updateUser( - xXsrfToken: string, userId: number, requestBody: { /** @@ -175,9 +171,6 @@ export class DefaultService { path: { 'user_id': userId, }, - headers: { - 'X-XSRF-TOKEN': xXsrfToken, - }, body: requestBody, mediaType: 'application/json', errors: { @@ -316,9 +309,6 @@ export class DefaultService { /** * Update a usertitle * User updating title list of watched - * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - * Required for all state-changing requests (POST/PUT/PATCH/DELETE). - * * @param userId * @param titleId * @param requestBody @@ -326,7 +316,6 @@ export class DefaultService { * @throws ApiError */ public static updateUserTitle( - xXsrfToken: string, userId: number, titleId: number, requestBody: { @@ -341,9 +330,6 @@ export class DefaultService { 'user_id': userId, 'title_id': titleId, }, - headers: { - 'X-XSRF-TOKEN': xXsrfToken, - }, body: requestBody, mediaType: 'application/json', errors: { @@ -358,16 +344,12 @@ export class DefaultService { /** * Delete a usertitle * User deleting title from list of watched - * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - * Required for all state-changing requests (POST/PUT/PATCH/DELETE). - * * @param userId * @param titleId * @returns any Title successfully deleted * @throws ApiError */ public static deleteUserTitle( - xXsrfToken: string, userId: number, titleId: number, ): CancelablePromise<any> { @@ -378,9 +360,6 @@ export class DefaultService { 'user_id': userId, 'title_id': titleId, }, - headers: { - 'X-XSRF-TOKEN': xXsrfToken, - }, errors: { 401: `Unauthorized — missing or invalid auth token`, 403: `Forbidden — user not allowed to delete title`, diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index 4fb535a..cc9f80d 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { DefaultService } from "../../api"; import type { UserTitleStatus } from "../../api"; -import { useCookies } from 'react-cookie'; +// import { useCookies } from 'react-cookie'; import { ClockIcon, @@ -19,8 +19,8 @@ const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: s ]; export function TitleStatusControls({ titleId }: { titleId: number }) { - const [cookies] = useCookies(['xsrf_token']); - const xsrfToken = cookies['xsrf_token'] || null; + // const [cookies] = useCookies(['xsrf_token']); + // const xsrfToken = cookies['xsrf_token'] || null; const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); const [loading, setLoading] = useState(false); @@ -46,7 +46,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { try { // 1) Если кликнули на текущий статус — DELETE if (currentStatus === status) { - await DefaultService.deleteUserTitle(xsrfToken, userId, titleId); + await DefaultService.deleteUserTitle(userId, titleId); setCurrentStatus(null); return; } @@ -61,7 +61,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { setCurrentStatus(added.status); } else { // уже есть запись — PATCH - const updated = await DefaultService.updateUserTitle(xsrfToken, userId, titleId, { status }); + const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); setCurrentStatus(updated.status); } } finally { From 128a33824a2bb6d4b6a9a9e3168f8770e8e420c6 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 10:18:37 +0300 Subject: [PATCH 174/224] feat: regenerated go oapi --- api/_build/openapi.yaml | 2 +- api/api.gen.go | 71 ++++++----------------------------------- api/paths/users-id.yaml | 2 +- 3 files changed, 11 insertions(+), 64 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 3cbb361..e096beb 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -226,7 +226,7 @@ paths: '500': description: Unknown server error security: - XsrfAuthHeader: [] + - XsrfAuthHeader: [] '/users/{user_id}/titles': get: operationId: getUserTitles diff --git a/api/api.gen.go b/api/api.gen.go index 62450e0..459a3e4 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -18,6 +18,7 @@ import ( const ( JwtAuthCookiesScopes = "JwtAuthCookies.Scopes" + XsrfAuthHeaderScopes = "XsrfAuthHeader.Scopes" ) // Defines values for ReleaseSeason. @@ -174,12 +175,6 @@ type UserTitleMini struct { // UserTitleStatus User's title status type UserTitleStatus string -// AccessToken defines model for accessToken. -type AccessToken = string - -// CsrfToken defines model for csrfToken. -type CsrfToken = string - // Cursor defines model for cursor. type Cursor = string @@ -229,17 +224,6 @@ type UpdateUserJSONBody struct { UserDesc *string `json:"user_desc,omitempty"` } -// UpdateUserParams defines parameters for UpdateUser. -type UpdateUserParams struct { - // AccessToken JWT access token. - AccessToken AccessToken `form:"access_token" json:"access_token"` - - // XSRFTOKEN Anti-CSRF token (Double Submit Cookie pattern). - // Stored in non-HttpOnly cookie, readable by JavaScript. - // Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). - XSRFTOKEN CsrfToken `form:"XSRF-TOKEN" json:"XSRF-TOKEN"` -} - // GetUserTitlesParams defines parameters for GetUserTitles. type GetUserTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -297,7 +281,7 @@ type ServerInterface interface { GetUsersId(c *gin.Context, userId string, params GetUsersIdParams) // Partially update a user account // (PATCH /users/{user_id}) - UpdateUser(c *gin.Context, userId int64, params UpdateUserParams) + UpdateUser(c *gin.Context, userId int64) // Get user titles // (GET /users/{user_id}/titles) GetUserTitles(c *gin.Context, userId string, params GetUserTitlesParams) @@ -524,46 +508,7 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { return } - c.Set(JwtAuthCookiesScopes, []string{}) - - // Parameter object where we will unmarshal all parameters from the context - var params UpdateUserParams - - { - var cookie string - - if cookie, err = c.Cookie("access_token"); err == nil { - var value AccessToken - err = runtime.BindStyledParameterWithOptions("simple", "access_token", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter access_token: %w", err), http.StatusBadRequest) - return - } - params.AccessToken = value - - } else { - siw.ErrorHandler(c, fmt.Errorf("Query argument access_token is required, but not found"), http.StatusBadRequest) - return - } - } - - { - var cookie string - - if cookie, err = c.Cookie("XSRF-TOKEN"); err == nil { - var value CsrfToken - err = runtime.BindStyledParameterWithOptions("simple", "XSRF-TOKEN", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter XSRF-TOKEN: %w", err), http.StatusBadRequest) - return - } - params.XSRFTOKEN = value - - } else { - siw.ErrorHandler(c, fmt.Errorf("Query argument XSRF-TOKEN is required, but not found"), http.StatusBadRequest) - return - } - } + c.Set(XsrfAuthHeaderScopes, []string{}) for _, middleware := range siw.HandlerMiddlewares { middleware(c) @@ -572,7 +517,7 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { } } - siw.Handler.UpdateUser(c, userId, params) + siw.Handler.UpdateUser(c, userId) } // GetUserTitles operation middleware @@ -745,6 +690,8 @@ func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { return } + c.Set(XsrfAuthHeaderScopes, []string{}) + for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -811,6 +758,8 @@ func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) { return } + c.Set(XsrfAuthHeaderScopes, []string{}) + for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -999,7 +948,6 @@ func (response GetUsersId500Response) VisitGetUsersIdResponse(w http.ResponseWri type UpdateUserRequestObject struct { UserId int64 `json:"user_id"` - Params UpdateUserParams Body *UpdateUserJSONRequestBody } @@ -1476,11 +1424,10 @@ func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetU } // UpdateUser operation middleware -func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64, params UpdateUserParams) { +func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { var request UpdateUserRequestObject request.UserId = userId - request.Params = params var body UpdateUserJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index 5e9e69d..701df6b 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -34,7 +34,7 @@ patch: Fields not provided in the request body remain unchanged. operationId: updateUser security: - XsrfAuthHeader: [] + - XsrfAuthHeader: [] parameters: # - $ref: '../parameters/xsrf_token_header.yaml' - name: user_id From 6e802d2402756998fcc09eab1eb4882bafb4f372 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 11:30:35 +0300 Subject: [PATCH 175/224] feat!(front): migrate to Hey API --- modules/frontend/src/App.tsx | 4 +- modules/frontend/src/api/client.gen.ts | 16 + modules/frontend/src/api/client/client.gen.ts | 301 +++++++++ modules/frontend/src/api/client/index.ts | 25 + modules/frontend/src/api/client/types.gen.ts | 241 ++++++++ modules/frontend/src/api/client/utils.gen.ts | 332 ++++++++++ modules/frontend/src/api/core/ApiError.ts | 25 - .../src/api/core/ApiRequestOptions.ts | 17 - modules/frontend/src/api/core/ApiResult.ts | 11 - .../src/api/core/CancelablePromise.ts | 131 ---- modules/frontend/src/api/core/OpenAPI.ts | 32 - modules/frontend/src/api/core/auth.gen.ts | 42 ++ .../src/api/core/bodySerializer.gen.ts | 100 +++ modules/frontend/src/api/core/params.gen.ts | 176 ++++++ .../src/api/core/pathSerializer.gen.ts | 181 ++++++ .../src/api/core/queryKeySerializer.gen.ts | 136 +++++ modules/frontend/src/api/core/request.ts | 323 ---------- .../src/api/core/serverSentEvents.gen.ts | 264 ++++++++ modules/frontend/src/api/core/types.gen.ts | 118 ++++ modules/frontend/src/api/core/utils.gen.ts | 143 +++++ modules/frontend/src/api/index.ts | 30 +- modules/frontend/src/api/models/CursorObj.ts | 9 - modules/frontend/src/api/models/Image.ts | 11 - .../frontend/src/api/models/ReleaseSeason.ts | 8 - modules/frontend/src/api/models/Review.ts | 5 - .../frontend/src/api/models/StorageType.ts | 8 - modules/frontend/src/api/models/Studio.ts | 12 - modules/frontend/src/api/models/Tag.ts | 8 - modules/frontend/src/api/models/Tags.ts | 9 - modules/frontend/src/api/models/Title.ts | 31 - modules/frontend/src/api/models/TitleSort.ts | 8 - .../frontend/src/api/models/TitleStatus.ts | 8 - modules/frontend/src/api/models/User.ts | 33 - modules/frontend/src/api/models/UserTitle.ts | 15 - .../frontend/src/api/models/UserTitleMini.ts | 14 - .../src/api/models/UserTitleStatus.ts | 8 - modules/frontend/src/api/models/cursor.ts | 5 - modules/frontend/src/api/models/title_sort.ts | 6 - modules/frontend/src/api/sdk.gen.ts | 110 ++++ .../src/api/services/DefaultService.ts | 371 ------------ modules/frontend/src/api/types.gen.ts | 570 ++++++++++++++++++ .../TitleStatusControls.tsx | 48 +- .../components/cards/TitleCardHorizontal.tsx | 2 +- .../src/components/cards/TitleCardSquare.tsx | 3 +- .../src/pages/TitlePage/TitlePage.tsx | 9 +- .../src/pages/TitlesPage/TitlesPage.tsx | 56 +- .../frontend/src/pages/UserPage/UserPage.tsx | 59 +- 47 files changed, 2865 insertions(+), 1209 deletions(-) create mode 100644 modules/frontend/src/api/client.gen.ts create mode 100644 modules/frontend/src/api/client/client.gen.ts create mode 100644 modules/frontend/src/api/client/index.ts create mode 100644 modules/frontend/src/api/client/types.gen.ts create mode 100644 modules/frontend/src/api/client/utils.gen.ts delete mode 100644 modules/frontend/src/api/core/ApiError.ts delete mode 100644 modules/frontend/src/api/core/ApiRequestOptions.ts delete mode 100644 modules/frontend/src/api/core/ApiResult.ts delete mode 100644 modules/frontend/src/api/core/CancelablePromise.ts delete mode 100644 modules/frontend/src/api/core/OpenAPI.ts create mode 100644 modules/frontend/src/api/core/auth.gen.ts create mode 100644 modules/frontend/src/api/core/bodySerializer.gen.ts create mode 100644 modules/frontend/src/api/core/params.gen.ts create mode 100644 modules/frontend/src/api/core/pathSerializer.gen.ts create mode 100644 modules/frontend/src/api/core/queryKeySerializer.gen.ts delete mode 100644 modules/frontend/src/api/core/request.ts create mode 100644 modules/frontend/src/api/core/serverSentEvents.gen.ts create mode 100644 modules/frontend/src/api/core/types.gen.ts create mode 100644 modules/frontend/src/api/core/utils.gen.ts delete mode 100644 modules/frontend/src/api/models/CursorObj.ts delete mode 100644 modules/frontend/src/api/models/Image.ts delete mode 100644 modules/frontend/src/api/models/ReleaseSeason.ts delete mode 100644 modules/frontend/src/api/models/Review.ts delete mode 100644 modules/frontend/src/api/models/StorageType.ts delete mode 100644 modules/frontend/src/api/models/Studio.ts delete mode 100644 modules/frontend/src/api/models/Tag.ts delete mode 100644 modules/frontend/src/api/models/Tags.ts delete mode 100644 modules/frontend/src/api/models/Title.ts delete mode 100644 modules/frontend/src/api/models/TitleSort.ts delete mode 100644 modules/frontend/src/api/models/TitleStatus.ts delete mode 100644 modules/frontend/src/api/models/User.ts delete mode 100644 modules/frontend/src/api/models/UserTitle.ts delete mode 100644 modules/frontend/src/api/models/UserTitleMini.ts delete mode 100644 modules/frontend/src/api/models/UserTitleStatus.ts delete mode 100644 modules/frontend/src/api/models/cursor.ts delete mode 100644 modules/frontend/src/api/models/title_sort.ts create mode 100644 modules/frontend/src/api/sdk.gen.ts delete mode 100644 modules/frontend/src/api/services/DefaultService.ts create mode 100644 modules/frontend/src/api/types.gen.ts diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 5ff2b32..84c9086 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -6,9 +6,9 @@ import TitlePage from "./pages/TitlePage/TitlePage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; -import { OpenAPI } from "./api"; +// import { OpenAPI } from "./api"; -OpenAPI.WITH_CREDENTIALS = true +// OpenAPI.WITH_CREDENTIALS = true const App: React.FC = () => { const username = localStorage.getItem("username") || undefined; diff --git a/modules/frontend/src/api/client.gen.ts b/modules/frontend/src/api/client.gen.ts new file mode 100644 index 0000000..952c663 --- /dev/null +++ b/modules/frontend/src/api/client.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type ClientOptions, type Config, createClient, createConfig } from './client'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>; + +export const client = createClient(createConfig<ClientOptions2>({ baseUrl: '/api/v1' })); diff --git a/modules/frontend/src/api/client/client.gen.ts b/modules/frontend/src/api/client/client.gen.ts new file mode 100644 index 0000000..c2a5190 --- /dev/null +++ b/modules/frontend/src/api/client/client.gen.ts @@ -0,0 +1,301 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { + Client, + Config, + RequestOptions, + ResolvedRequestOptions, +} from './types.gen'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils.gen'; + +type ReqInit = Omit<RequestInit, 'body' | 'headers'> & { + body?: any; + headers: ReturnType<typeof mergeHeaders>; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors< + Request, + Response, + unknown, + ResolvedRequestOptions + >(); + + const beforeRequest = async (options: RequestOptions) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined, + }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body); + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const url = buildUrl(opts); + + return { opts, url }; + }; + + const request: Client['request'] = async (options) => { + // @ts-expect-error + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; + + let request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + let response: Response; + + try { + response = await _fetch(request); + } catch (error) { + // Handle fetch exceptions (AbortError, network errors, etc.) + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn( + error, + undefined as any, + request, + opts, + )) as unknown; + } + } + + finalError = finalError || ({} as unknown); + + if (opts.throwOnError) { + throw finalError; + } + + // Return error response + return opts.responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response: undefined as any, + }; + } + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if ( + response.status === 204 || + response.headers.get('Content-Length') === '0' + ) { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'json': + case 'text': + data = await response[parseAs](); + break; + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + const error = jsonError ?? textError; + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn(error, response, request, opts)) as string; + } + } + + finalError = finalError || ({} as string); + + if (opts.throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return opts.responseStyle === 'data' + ? undefined + : { + error: finalError, + ...result, + }; + }; + + const makeMethodFn = + (method: Uppercase<HttpMethod>) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = + (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + headers: opts.headers as unknown as Record<string, string>, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + url, + }); + }; + + return { + buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/modules/frontend/src/api/client/index.ts b/modules/frontend/src/api/client/index.ts new file mode 100644 index 0000000..b295ede --- /dev/null +++ b/modules/frontend/src/api/client/index.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export { createClient } from './client.gen'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types.gen'; +export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/modules/frontend/src/api/client/types.gen.ts b/modules/frontend/src/api/client/types.gen.ts new file mode 100644 index 0000000..b4a499c --- /dev/null +++ b/modules/frontend/src/api/client/types.gen.ts @@ -0,0 +1,241 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen'; +import type { + Client as CoreClient, + Config as CoreConfig, +} from '../core/types.gen'; +import type { Middleware } from './utils.gen'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config<T extends ClientOptions = ClientOptions> + extends Omit<RequestInit, 'body' | 'headers' | 'method'>, + CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: + | 'arrayBuffer' + | 'auto' + | 'blob' + | 'formData' + | 'json' + | 'stream' + | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions<TData>, + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record<string, unknown>; + query?: Record<string, unknown>; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray<Auth>; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> { + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record<string, unknown> + ? TData[keyof TData] + : TData + : { + data: TData extends Record<string, unknown> + ? TData[keyof TData] + : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? + | (TData extends Record<string, unknown> + ? TData[keyof TData] + : TData) + | undefined + : ( + | { + data: TData extends Record<string, unknown> + ? TData[keyof TData] + : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record<string, unknown> + ? TError[keyof TError] + : TError; + } + ) & { + request: Request; + response: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>, +) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>; + +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>, +) => Promise<ServerSentEventsResult<TData, TError>>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> & + Pick< + Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, + 'method' + >, +) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record<string, unknown>; + query?: Record<string, unknown>; + url: string; + }, +>( + options: TData & Options<TData>, +) => string; + +export type Client = CoreClient< + RequestFn, + Config, + MethodFn, + BuildUrlFn, + SseFn +> & { + interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig<T extends ClientOptions = ClientOptions> = ( + override?: Config<ClientOptions & T>, +) => Config<Required<ClientOptions> & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions<TResponse, TResponseStyle, ThrowOnError>, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit<TData, 'url'>); diff --git a/modules/frontend/src/api/client/utils.gen.ts b/modules/frontend/src/api/client/utils.gen.ts new file mode 100644 index 0000000..4c48a9e --- /dev/null +++ b/modules/frontend/src/api/client/utils.gen.ts @@ -0,0 +1,332 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; + +export const createQuerySerializer = <T = unknown>({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record<string, unknown>, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = ( + contentType: string | null, +): Exclude<Config['parseAs'], 'auto'> => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if ( + cleanContent.startsWith('application/json') || + cleanContent.endsWith('+json') + ) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => + cleanContent.startsWith(type), + ) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +const checkForExistence = ( + options: Pick<RequestOptions, 'auth' | 'query'> & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick<Required<RequestOptions>, 'security'> & + Pick<RequestOptions, 'auth' | 'query'> & { + headers: Headers; + }) => { + for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +}; + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array<Required<Config>['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = + header instanceof Headers + ? headersEntries(header) + : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor<Err, Res, Req, Options> = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise<Err>; + +type ReqInterceptor<Req, Options> = ( + request: Req, + options: Options, +) => Req | Promise<Req>; + +type ResInterceptor<Res, Req, Options> = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise<Res>; + +class Interceptors<Interceptor> { + fns: Array<Interceptor | null> = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update( + id: number | Interceptor, + fn: Interceptor, + ): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware<Req, Res, Err, Options> { + error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>; + request: Interceptors<ReqInterceptor<Req, Options>>; + response: Interceptors<ResInterceptor<Res, Req, Options>>; +} + +export const createInterceptors = <Req, Res, Err, Options>(): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(), + request: new Interceptors<ReqInterceptor<Req, Options>>(), + response: new Interceptors<ResInterceptor<Res, Req, Options>>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = <T extends ClientOptions = ClientOptions>( + override: Config<Omit<ClientOptions, keyof T> & T> = {}, +): Config<Omit<ClientOptions, keyof T> & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/modules/frontend/src/api/core/ApiError.ts b/modules/frontend/src/api/core/ApiError.ts deleted file mode 100644 index ec7b16a..0000000 --- a/modules/frontend/src/api/core/ApiError.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ApiRequestOptions } from './ApiRequestOptions'; -import type { ApiResult } from './ApiResult'; - -export class ApiError extends Error { - public readonly url: string; - public readonly status: number; - public readonly statusText: string; - public readonly body: any; - public readonly request: ApiRequestOptions; - - constructor(request: ApiRequestOptions, response: ApiResult, message: string) { - super(message); - - this.name = 'ApiError'; - this.url = response.url; - this.status = response.status; - this.statusText = response.statusText; - this.body = response.body; - this.request = request; - } -} diff --git a/modules/frontend/src/api/core/ApiRequestOptions.ts b/modules/frontend/src/api/core/ApiRequestOptions.ts deleted file mode 100644 index 93143c3..0000000 --- a/modules/frontend/src/api/core/ApiRequestOptions.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type ApiRequestOptions = { - readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; - readonly url: string; - readonly path?: Record<string, any>; - readonly cookies?: Record<string, any>; - readonly headers?: Record<string, any>; - readonly query?: Record<string, any>; - readonly formData?: Record<string, any>; - readonly body?: any; - readonly mediaType?: string; - readonly responseHeader?: string; - readonly errors?: Record<number, string>; -}; diff --git a/modules/frontend/src/api/core/ApiResult.ts b/modules/frontend/src/api/core/ApiResult.ts deleted file mode 100644 index ee1126e..0000000 --- a/modules/frontend/src/api/core/ApiResult.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type ApiResult = { - readonly url: string; - readonly ok: boolean; - readonly status: number; - readonly statusText: string; - readonly body: any; -}; diff --git a/modules/frontend/src/api/core/CancelablePromise.ts b/modules/frontend/src/api/core/CancelablePromise.ts deleted file mode 100644 index d70de92..0000000 --- a/modules/frontend/src/api/core/CancelablePromise.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export class CancelError extends Error { - - constructor(message: string) { - super(message); - this.name = 'CancelError'; - } - - public get isCancelled(): boolean { - return true; - } -} - -export interface OnCancel { - readonly isResolved: boolean; - readonly isRejected: boolean; - readonly isCancelled: boolean; - - (cancelHandler: () => void): void; -} - -export class CancelablePromise<T> implements Promise<T> { - #isResolved: boolean; - #isRejected: boolean; - #isCancelled: boolean; - readonly #cancelHandlers: (() => void)[]; - readonly #promise: Promise<T>; - #resolve?: (value: T | PromiseLike<T>) => void; - #reject?: (reason?: any) => void; - - constructor( - executor: ( - resolve: (value: T | PromiseLike<T>) => void, - reject: (reason?: any) => void, - onCancel: OnCancel - ) => void - ) { - this.#isResolved = false; - this.#isRejected = false; - this.#isCancelled = false; - this.#cancelHandlers = []; - this.#promise = new Promise<T>((resolve, reject) => { - this.#resolve = resolve; - this.#reject = reject; - - const onResolve = (value: T | PromiseLike<T>): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isResolved = true; - if (this.#resolve) this.#resolve(value); - }; - - const onReject = (reason?: any): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isRejected = true; - if (this.#reject) this.#reject(reason); - }; - - const onCancel = (cancelHandler: () => void): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#cancelHandlers.push(cancelHandler); - }; - - Object.defineProperty(onCancel, 'isResolved', { - get: (): boolean => this.#isResolved, - }); - - Object.defineProperty(onCancel, 'isRejected', { - get: (): boolean => this.#isRejected, - }); - - Object.defineProperty(onCancel, 'isCancelled', { - get: (): boolean => this.#isCancelled, - }); - - return executor(onResolve, onReject, onCancel as OnCancel); - }); - } - - get [Symbol.toStringTag]() { - return "Cancellable Promise"; - } - - public then<TResult1 = T, TResult2 = never>( - onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, - onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null - ): Promise<TResult1 | TResult2> { - return this.#promise.then(onFulfilled, onRejected); - } - - public catch<TResult = never>( - onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null - ): Promise<T | TResult> { - return this.#promise.catch(onRejected); - } - - public finally(onFinally?: (() => void) | null): Promise<T> { - return this.#promise.finally(onFinally); - } - - public cancel(): void { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isCancelled = true; - if (this.#cancelHandlers.length) { - try { - for (const cancelHandler of this.#cancelHandlers) { - cancelHandler(); - } - } catch (error) { - console.warn('Cancellation threw an error', error); - return; - } - } - this.#cancelHandlers.length = 0; - if (this.#reject) this.#reject(new CancelError('Request aborted')); - } - - public get isCancelled(): boolean { - return this.#isCancelled; - } -} diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts deleted file mode 100644 index 185e5c3..0000000 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ApiRequestOptions } from './ApiRequestOptions'; - -type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; -type Headers = Record<string, string>; - -export type OpenAPIConfig = { - BASE: string; - VERSION: string; - WITH_CREDENTIALS: boolean; - CREDENTIALS: 'include' | 'omit' | 'same-origin'; - TOKEN?: string | Resolver<string> | undefined; - USERNAME?: string | Resolver<string> | undefined; - PASSWORD?: string | Resolver<string> | undefined; - HEADERS?: Headers | Resolver<Headers> | undefined; - ENCODE_PATH?: ((path: string) => string) | undefined; -}; - -export const OpenAPI: OpenAPIConfig = { - BASE: '/api/v1', - VERSION: '1.0.0', - WITH_CREDENTIALS: false, - CREDENTIALS: 'include', - TOKEN: undefined, - USERNAME: undefined, - PASSWORD: undefined, - HEADERS: undefined, - ENCODE_PATH: undefined, -}; diff --git a/modules/frontend/src/api/core/auth.gen.ts b/modules/frontend/src/api/core/auth.gen.ts new file mode 100644 index 0000000..f8a7326 --- /dev/null +++ b/modules/frontend/src/api/core/auth.gen.ts @@ -0,0 +1,42 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken, +): Promise<string | undefined> => { + const token = + typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/modules/frontend/src/api/core/bodySerializer.gen.ts b/modules/frontend/src/api/core/bodySerializer.gen.ts new file mode 100644 index 0000000..552b50f --- /dev/null +++ b/modules/frontend/src/api/core/bodySerializer.gen.ts @@ -0,0 +1,100 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { + ArrayStyle, + ObjectStyle, + SerializerOptions, +} from './pathSerializer.gen'; + +export type QuerySerializer = (query: Record<string, unknown>) => string; + +export type BodySerializer = (body: any) => any; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial<SerializerOptions<ArrayStyle>>; + object?: Partial<SerializerOptions<ObjectStyle>>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record<string, QuerySerializerOptionsObject>; +}; + +const serializeFormDataPair = ( + data: FormData, + key: string, + value: unknown, +): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = ( + data: URLSearchParams, + key: string, + value: unknown, +): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>( + body: T, + ): FormData => { + const data = new FormData(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: <T>(body: T): string => + JSON.stringify(body, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>( + body: T, + ): string => { + const data = new URLSearchParams(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/modules/frontend/src/api/core/params.gen.ts b/modules/frontend/src/api/core/params.gen.ts new file mode 100644 index 0000000..602715c --- /dev/null +++ b/modules/frontend/src/api/core/params.gen.ts @@ -0,0 +1,176 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude<Slot, 'body'>; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract<Slot, 'body'>; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; + +export interface Fields { + allowExtra?: Partial<Record<Slot, boolean>>; + args?: ReadonlyArray<Field>; +} + +export type FieldsConfig = ReadonlyArray<Field | Fields>; + +const extraPrefixesMap: Record<string, Slot> = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } +>; + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if ('key' in config) { + map.set(config.key, { + map: config.map, + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +}; + +interface Params { + body: unknown; + headers: Record<string, unknown>; + path: Record<string, unknown>; + query: Record<string, unknown>; +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === 'object' && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +}; + +export const buildClientParams = ( + args: ReadonlyArray<unknown>, + fields: FieldsConfig, +) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + }; + + const map = buildKeyMap(fields); + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + (params[field.in] as Record<string, unknown>)[name] = arg; + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + (params[field.in] as Record<string, unknown>)[name] = value; + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => + key.startsWith(prefix), + ); + + if (extra) { + const [prefix, slot] = extra; + (params[slot] as Record<string, unknown>)[ + key.slice(prefix.length) + ] = value; + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + (params[slot as Slot] as Record<string, unknown>)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +}; diff --git a/modules/frontend/src/api/core/pathSerializer.gen.ts b/modules/frontend/src/api/core/pathSerializer.gen.ts new file mode 100644 index 0000000..8d99931 --- /dev/null +++ b/modules/frontend/src/api/core/pathSerializer.gen.ts @@ -0,0 +1,181 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions<T> + extends SerializePrimitiveOptions, + SerializerOptions<T> {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions<T> { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions<ArraySeparatorStyle> & { + value: unknown[]; +}) => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' + ? separator + joinedValues + : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions<ObjectSeparatorStyle> & { + value: Record<string, unknown> | Date; + valueOnly?: boolean; +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [ + ...values, + key, + allowReserved ? (v as string) : encodeURIComponent(v as string), + ]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' + ? separator + joinedValues + : joinedValues; +}; diff --git a/modules/frontend/src/api/core/queryKeySerializer.gen.ts b/modules/frontend/src/api/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000..d3bb683 --- /dev/null +++ b/modules/frontend/src/api/core/queryKeySerializer.gen.ts @@ -0,0 +1,136 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown) => { + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record<string, unknown> => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => + a.localeCompare(b), + ); + const result: Record<string, JsonValue> = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = ( + value: unknown, +): JsonValue | undefined => { + if (value === null) { + return null; + } + + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; + } + + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if ( + typeof URLSearchParams !== 'undefined' && + value instanceof URLSearchParams + ) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/modules/frontend/src/api/core/request.ts b/modules/frontend/src/api/core/request.ts deleted file mode 100644 index 1dc6fef..0000000 --- a/modules/frontend/src/api/core/request.ts +++ /dev/null @@ -1,323 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import axios from 'axios'; -import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; -import FormData from 'form-data'; - -import { ApiError } from './ApiError'; -import type { ApiRequestOptions } from './ApiRequestOptions'; -import type { ApiResult } from './ApiResult'; -import { CancelablePromise } from './CancelablePromise'; -import type { OnCancel } from './CancelablePromise'; -import type { OpenAPIConfig } from './OpenAPI'; - -export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => { - return value !== undefined && value !== null; -}; - -export const isString = (value: any): value is string => { - return typeof value === 'string'; -}; - -export const isStringWithValue = (value: any): value is string => { - return isString(value) && value !== ''; -}; - -export const isBlob = (value: any): value is Blob => { - return ( - typeof value === 'object' && - typeof value.type === 'string' && - typeof value.stream === 'function' && - typeof value.arrayBuffer === 'function' && - typeof value.constructor === 'function' && - typeof value.constructor.name === 'string' && - /^(Blob|File)$/.test(value.constructor.name) && - /^(Blob|File)$/.test(value[Symbol.toStringTag]) - ); -}; - -export const isFormData = (value: any): value is FormData => { - return value instanceof FormData; -}; - -export const isSuccess = (status: number): boolean => { - return status >= 200 && status < 300; -}; - -export const base64 = (str: string): string => { - try { - return btoa(str); - } catch (err) { - // @ts-ignore - return Buffer.from(str).toString('base64'); - } -}; - -export const getQueryString = (params: Record<string, any>): string => { - const qs: string[] = []; - - const append = (key: string, value: any) => { - qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); - }; - - const process = (key: string, value: any) => { - if (isDefined(value)) { - if (Array.isArray(value)) { - value.forEach(v => { - process(key, v); - }); - } else if (typeof value === 'object') { - Object.entries(value).forEach(([k, v]) => { - process(`${key}[${k}]`, v); - }); - } else { - append(key, value); - } - } - }; - - Object.entries(params).forEach(([key, value]) => { - process(key, value); - }); - - if (qs.length > 0) { - return `?${qs.join('&')}`; - } - - return ''; -}; - -const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { - const encoder = config.ENCODE_PATH || encodeURI; - - const path = options.url - .replace('{api-version}', config.VERSION) - .replace(/{(.*?)}/g, (substring: string, group: string) => { - if (options.path?.hasOwnProperty(group)) { - return encoder(String(options.path[group])); - } - return substring; - }); - - const url = `${config.BASE}${path}`; - if (options.query) { - return `${url}${getQueryString(options.query)}`; - } - return url; -}; - -export const getFormData = (options: ApiRequestOptions): FormData | undefined => { - if (options.formData) { - const formData = new FormData(); - - const process = (key: string, value: any) => { - if (isString(value) || isBlob(value)) { - formData.append(key, value); - } else { - formData.append(key, JSON.stringify(value)); - } - }; - - Object.entries(options.formData) - .filter(([_, value]) => isDefined(value)) - .forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach(v => process(key, v)); - } else { - process(key, value); - } - }); - - return formData; - } - return undefined; -}; - -type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; - -export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => { - if (typeof resolver === 'function') { - return (resolver as Resolver<T>)(options); - } - return resolver; -}; - -export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => { - const [token, username, password, additionalHeaders] = await Promise.all([ - resolve(options, config.TOKEN), - resolve(options, config.USERNAME), - resolve(options, config.PASSWORD), - resolve(options, config.HEADERS), - ]); - - const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} - - const headers = Object.entries({ - Accept: 'application/json', - ...additionalHeaders, - ...options.headers, - ...formHeaders, - }) - .filter(([_, value]) => isDefined(value)) - .reduce((headers, [key, value]) => ({ - ...headers, - [key]: String(value), - }), {} as Record<string, string>); - - if (isStringWithValue(token)) { - headers['Authorization'] = `Bearer ${token}`; - } - - if (isStringWithValue(username) && isStringWithValue(password)) { - const credentials = base64(`${username}:${password}`); - headers['Authorization'] = `Basic ${credentials}`; - } - - if (options.body !== undefined) { - if (options.mediaType) { - headers['Content-Type'] = options.mediaType; - } else if (isBlob(options.body)) { - headers['Content-Type'] = options.body.type || 'application/octet-stream'; - } else if (isString(options.body)) { - headers['Content-Type'] = 'text/plain'; - } else if (!isFormData(options.body)) { - headers['Content-Type'] = 'application/json'; - } - } - - return headers; -}; - -export const getRequestBody = (options: ApiRequestOptions): any => { - if (options.body) { - return options.body; - } - return undefined; -}; - -export const sendRequest = async <T>( - config: OpenAPIConfig, - options: ApiRequestOptions, - url: string, - body: any, - formData: FormData | undefined, - headers: Record<string, string>, - onCancel: OnCancel, - axiosClient: AxiosInstance -): Promise<AxiosResponse<T>> => { - const source = axios.CancelToken.source(); - - const requestConfig: AxiosRequestConfig = { - url, - headers, - data: body ?? formData, - method: options.method, - withCredentials: config.WITH_CREDENTIALS, - withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, - cancelToken: source.token, - }; - - onCancel(() => source.cancel('The user aborted a request.')); - - try { - return await axiosClient.request(requestConfig); - } catch (error) { - const axiosError = error as AxiosError<T>; - if (axiosError.response) { - return axiosError.response; - } - throw error; - } -}; - -export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => { - if (responseHeader) { - const content = response.headers[responseHeader]; - if (isString(content)) { - return content; - } - } - return undefined; -}; - -export const getResponseBody = (response: AxiosResponse<any>): any => { - if (response.status !== 204) { - return response.data; - } - return undefined; -}; - -export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { - const errors: Record<number, string> = { - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 500: 'Internal Server Error', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - ...options.errors, - } - - const error = errors[result.status]; - if (error) { - throw new ApiError(options, result, error); - } - - if (!result.ok) { - const errorStatus = result.status ?? 'unknown'; - const errorStatusText = result.statusText ?? 'unknown'; - const errorBody = (() => { - try { - return JSON.stringify(result.body, null, 2); - } catch (e) { - return undefined; - } - })(); - - throw new ApiError(options, result, - `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` - ); - } -}; - -/** - * Request method - * @param config The OpenAPI configuration object - * @param options The request options from the service - * @param axiosClient The axios client instance to use - * @returns CancelablePromise<T> - * @throws ApiError - */ -export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => { - return new CancelablePromise(async (resolve, reject, onCancel) => { - try { - const url = getUrl(config, options); - const formData = getFormData(options); - const body = getRequestBody(options); - const headers = await getHeaders(config, options, formData); - - if (!onCancel.isCancelled) { - const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient); - const responseBody = getResponseBody(response); - const responseHeader = getResponseHeader(response, options.responseHeader); - - const result: ApiResult = { - url, - ok: isSuccess(response.status), - status: response.status, - statusText: response.statusText, - body: responseHeader ?? responseBody, - }; - - catchErrorCodes(options, result); - - resolve(result.body); - } - } catch (error) { - reject(error); - } - }); -}; diff --git a/modules/frontend/src/api/core/serverSentEvents.gen.ts b/modules/frontend/src/api/core/serverSentEvents.gen.ts new file mode 100644 index 0000000..f8fd78e --- /dev/null +++ b/modules/frontend/src/api/core/serverSentEvents.gen.ts @@ -0,0 +1,264 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +export type ServerSentEventsOptions<TData = unknown> = Omit< + RequestInit, + 'method' +> & + Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise<Request>; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent<TData>) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise<void>; + url: string; + }; + +export interface StreamEvent<TData = unknown> { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult< + TData = unknown, + TReturn = void, + TNext = unknown, +> = { + stream: AsyncGenerator< + TData extends Record<string, unknown> ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export const createSseClient = <TData = unknown>({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult<TData> => { + let lastEventId: string | undefined; + + const sleep = + sseSleepFn ?? + ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record<string, string> | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) + throw new Error( + `SSE failed: ${response.status} ${response.statusText}`, + ); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array<string> = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt( + line.replace(/^retry:\s*/, ''), + 10, + ); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if ( + sseMaxRetryAttempts !== undefined && + attempt >= sseMaxRetryAttempts + ) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min( + retryDelay * 2 ** (attempt - 1), + sseMaxRetryDelay ?? 30000, + ); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +}; diff --git a/modules/frontend/src/api/core/types.gen.ts b/modules/frontend/src/api/core/types.gen.ts new file mode 100644 index 0000000..643c070 --- /dev/null +++ b/modules/frontend/src/api/core/types.gen.ts @@ -0,0 +1,118 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from './bodySerializer.gen'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] + ? { sse?: never } + : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + | string + | number + | boolean + | (string | number | boolean)[] + | null + | undefined + | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase<HttpMethod>; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise<unknown>; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g. converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise<unknown>; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise<unknown>; +} + +type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever<T extends Record<string, unknown>> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true + ? never + : K]: T[K]; +}; diff --git a/modules/frontend/src/api/core/utils.gen.ts b/modules/frontend/src/api/core/utils.gen.ts new file mode 100644 index 0000000..0b5389d --- /dev/null +++ b/modules/frontend/src/api/core/utils.gen.ts @@ -0,0 +1,143 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen'; + +export interface PathSerializer { + path: Record<string, unknown>; + url: string; +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace( + match, + serializeArrayParam({ explode, name, style, value }), + ); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record<string, unknown>, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record<string, unknown>; + query?: Record<string, unknown>; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}) { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e. client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index 9013fc7..c352c10 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -1,28 +1,4 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export { ApiError } from './core/ApiError'; -export { CancelablePromise, CancelError } from './core/CancelablePromise'; -export { OpenAPI } from './core/OpenAPI'; -export type { OpenAPIConfig } from './core/OpenAPI'; +// This file is auto-generated by @hey-api/openapi-ts -export type { cursor } from './models/cursor'; -export type { CursorObj } from './models/CursorObj'; -export type { Image } from './models/Image'; -export type { ReleaseSeason } from './models/ReleaseSeason'; -export type { Review } from './models/Review'; -export type { StorageType } from './models/StorageType'; -export type { Studio } from './models/Studio'; -export type { Tag } from './models/Tag'; -export type { Tags } from './models/Tags'; -export type { Title } from './models/Title'; -export type { title_sort } from './models/title_sort'; -export type { TitleSort } from './models/TitleSort'; -export type { TitleStatus } from './models/TitleStatus'; -export type { User } from './models/User'; -export type { UserTitle } from './models/UserTitle'; -export type { UserTitleMini } from './models/UserTitleMini'; -export type { UserTitleStatus } from './models/UserTitleStatus'; - -export { DefaultService } from './services/DefaultService'; +export type * from './types.gen'; +export * from './sdk.gen'; diff --git a/modules/frontend/src/api/models/CursorObj.ts b/modules/frontend/src/api/models/CursorObj.ts deleted file mode 100644 index f54abb1..0000000 --- a/modules/frontend/src/api/models/CursorObj.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type CursorObj = { - id: number; - param?: string; -}; - diff --git a/modules/frontend/src/api/models/Image.ts b/modules/frontend/src/api/models/Image.ts deleted file mode 100644 index 887bf2f..0000000 --- a/modules/frontend/src/api/models/Image.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { StorageType } from './StorageType'; -export type Image = { - id?: number; - storage_type?: StorageType; - image_path?: string; -}; - diff --git a/modules/frontend/src/api/models/ReleaseSeason.ts b/modules/frontend/src/api/models/ReleaseSeason.ts deleted file mode 100644 index ad9f930..0000000 --- a/modules/frontend/src/api/models/ReleaseSeason.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Title release season - */ -export type ReleaseSeason = 'winter' | 'spring' | 'summer' | 'fall'; diff --git a/modules/frontend/src/api/models/Review.ts b/modules/frontend/src/api/models/Review.ts deleted file mode 100644 index 9b453b7..0000000 --- a/modules/frontend/src/api/models/Review.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type Review = Record<string, any>; diff --git a/modules/frontend/src/api/models/StorageType.ts b/modules/frontend/src/api/models/StorageType.ts deleted file mode 100644 index f6d086b..0000000 --- a/modules/frontend/src/api/models/StorageType.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Image storage type - */ -export type StorageType = 's3' | 'local'; diff --git a/modules/frontend/src/api/models/Studio.ts b/modules/frontend/src/api/models/Studio.ts deleted file mode 100644 index 062695a..0000000 --- a/modules/frontend/src/api/models/Studio.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { Image } from './Image'; -export type Studio = { - id: number; - name: string; - poster?: Image; - description?: string; -}; - diff --git a/modules/frontend/src/api/models/Tag.ts b/modules/frontend/src/api/models/Tag.ts deleted file mode 100644 index 665c724..0000000 --- a/modules/frontend/src/api/models/Tag.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * A localized tag: keys are language codes (ISO 639-1), values are tag names - */ -export type Tag = Record<string, string>; diff --git a/modules/frontend/src/api/models/Tags.ts b/modules/frontend/src/api/models/Tags.ts deleted file mode 100644 index 748f066..0000000 --- a/modules/frontend/src/api/models/Tags.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { Tag } from './Tag'; -/** - * Array of localized tags - */ -export type Tags = Array<Tag>; diff --git a/modules/frontend/src/api/models/Title.ts b/modules/frontend/src/api/models/Title.ts deleted file mode 100644 index 9ffdeb6..0000000 --- a/modules/frontend/src/api/models/Title.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { Image } from './Image'; -import type { ReleaseSeason } from './ReleaseSeason'; -import type { Studio } from './Studio'; -import type { Tags } from './Tags'; -import type { TitleStatus } from './TitleStatus'; -export type Title = { - /** - * Unique title ID (primary key) - */ - id: number; - /** - * Localized titles. Key = language (ISO 639-1), value = list of names - */ - title_names: Record<string, Array<string>>; - studio?: Studio; - tags: Tags; - poster?: Image; - title_status?: TitleStatus; - rating?: number; - rating_count?: number; - release_year?: number; - release_season?: ReleaseSeason; - episodes_aired?: number; - episodes_all?: number; - episodes_len?: Record<string, number>; -}; - diff --git a/modules/frontend/src/api/models/TitleSort.ts b/modules/frontend/src/api/models/TitleSort.ts deleted file mode 100644 index 1c9385e..0000000 --- a/modules/frontend/src/api/models/TitleSort.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Title sort order - */ -export type TitleSort = 'id' | 'year' | 'rating' | 'views'; diff --git a/modules/frontend/src/api/models/TitleStatus.ts b/modules/frontend/src/api/models/TitleStatus.ts deleted file mode 100644 index 72e0261..0000000 --- a/modules/frontend/src/api/models/TitleStatus.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Title status - */ -export type TitleStatus = 'finished' | 'ongoing' | 'planned'; diff --git a/modules/frontend/src/api/models/User.ts b/modules/frontend/src/api/models/User.ts deleted file mode 100644 index 969023f..0000000 --- a/modules/frontend/src/api/models/User.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { Image } from './Image'; -export type User = { - /** - * Unique user ID (primary key) - */ - id?: number; - image?: Image; - /** - * User email - */ - mail?: string; - /** - * Username (alphanumeric + _ or -) - */ - nickname: string; - /** - * Display name - */ - disp_name?: string; - /** - * User description - */ - user_desc?: string; - /** - * Timestamp when the user was created - */ - creation_date?: string; -}; - diff --git a/modules/frontend/src/api/models/UserTitle.ts b/modules/frontend/src/api/models/UserTitle.ts deleted file mode 100644 index 42b7919..0000000 --- a/modules/frontend/src/api/models/UserTitle.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { Title } from './Title'; -import type { UserTitleStatus } from './UserTitleStatus'; -export type UserTitle = { - user_id: number; - title?: Title; - status: UserTitleStatus; - rate?: number; - review_id?: number; - ctime?: string; -}; - diff --git a/modules/frontend/src/api/models/UserTitleMini.ts b/modules/frontend/src/api/models/UserTitleMini.ts deleted file mode 100644 index 2b223ce..0000000 --- a/modules/frontend/src/api/models/UserTitleMini.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { UserTitleStatus } from './UserTitleStatus'; -export type UserTitleMini = { - user_id: number; - title_id: number; - status: UserTitleStatus; - rate?: number; - review_id?: number; - ctime?: string; -}; - diff --git a/modules/frontend/src/api/models/UserTitleStatus.ts b/modules/frontend/src/api/models/UserTitleStatus.ts deleted file mode 100644 index 0a29626..0000000 --- a/modules/frontend/src/api/models/UserTitleStatus.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * User's title status - */ -export type UserTitleStatus = 'finished' | 'planned' | 'dropped' | 'in-progress'; diff --git a/modules/frontend/src/api/models/cursor.ts b/modules/frontend/src/api/models/cursor.ts deleted file mode 100644 index 5788e14..0000000 --- a/modules/frontend/src/api/models/cursor.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type cursor = string; diff --git a/modules/frontend/src/api/models/title_sort.ts b/modules/frontend/src/api/models/title_sort.ts deleted file mode 100644 index 69b01a7..0000000 --- a/modules/frontend/src/api/models/title_sort.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { TitleSort } from './TitleSort'; -export type title_sort = TitleSort; diff --git a/modules/frontend/src/api/sdk.gen.ts b/modules/frontend/src/api/sdk.gen.ts new file mode 100644 index 0000000..5359156 --- /dev/null +++ b/modules/frontend/src/api/sdk.gen.ts @@ -0,0 +1,110 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Client, Options as Options2, TDataShape } from './client'; +import { client } from './client.gen'; +import type { AddUserTitleData, AddUserTitleErrors, AddUserTitleResponses, DeleteUserTitleData, DeleteUserTitleErrors, DeleteUserTitleResponses, GetTitleData, GetTitleErrors, GetTitleResponses, GetTitlesData, GetTitlesErrors, GetTitlesResponses, GetUsersIdData, GetUsersIdErrors, GetUsersIdResponses, GetUserTitleData, GetUserTitleErrors, GetUserTitleResponses, GetUserTitlesData, GetUserTitlesErrors, GetUserTitlesResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses, UpdateUserTitleData, UpdateUserTitleErrors, UpdateUserTitleResponses } from './types.gen'; + +export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record<string, unknown>; +}; + +/** + * Get titles + */ +export const getTitles = <ThrowOnError extends boolean = false>(options?: Options<GetTitlesData, ThrowOnError>) => (options?.client ?? client).get<GetTitlesResponses, GetTitlesErrors, ThrowOnError>({ + querySerializer: { parameters: { status: { array: { explode: false } } } }, + url: '/titles', + ...options +}); + +/** + * Get title description + */ +export const getTitle = <ThrowOnError extends boolean = false>(options: Options<GetTitleData, ThrowOnError>) => (options.client ?? client).get<GetTitleResponses, GetTitleErrors, ThrowOnError>({ url: '/titles/{title_id}', ...options }); + +/** + * Get user info + */ +export const getUsersId = <ThrowOnError extends boolean = false>(options: Options<GetUsersIdData, ThrowOnError>) => (options.client ?? client).get<GetUsersIdResponses, GetUsersIdErrors, ThrowOnError>({ url: '/users/{user_id}', ...options }); + +/** + * Partially update a user account + * + * Update selected user profile fields (excluding password). + * Password updates must be done via the dedicated auth-service (`/auth/`). + * Fields not provided in the request body remain unchanged. + * + */ +export const updateUser = <ThrowOnError extends boolean = false>(options: Options<UpdateUserData, ThrowOnError>) => (options.client ?? client).patch<UpdateUserResponses, UpdateUserErrors, ThrowOnError>({ + security: [{ name: 'X-XSRF-TOKEN', type: 'apiKey' }], + url: '/users/{user_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get user titles + */ +export const getUserTitles = <ThrowOnError extends boolean = false>(options: Options<GetUserTitlesData, ThrowOnError>) => (options.client ?? client).get<GetUserTitlesResponses, GetUserTitlesErrors, ThrowOnError>({ + querySerializer: { parameters: { status: { array: { explode: false } }, watch_status: { array: { explode: false } } } }, + url: '/users/{user_id}/titles', + ...options +}); + +/** + * Add a title to a user + * + * User adding title to list af watched, status required + */ +export const addUserTitle = <ThrowOnError extends boolean = false>(options: Options<AddUserTitleData, ThrowOnError>) => (options.client ?? client).post<AddUserTitleResponses, AddUserTitleErrors, ThrowOnError>({ + url: '/users/{user_id}/titles', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Delete a usertitle + * + * User deleting title from list of watched + */ +export const deleteUserTitle = <ThrowOnError extends boolean = false>(options: Options<DeleteUserTitleData, ThrowOnError>) => (options.client ?? client).delete<DeleteUserTitleResponses, DeleteUserTitleErrors, ThrowOnError>({ + security: [{ name: 'X-XSRF-TOKEN', type: 'apiKey' }], + url: '/users/{user_id}/titles/{title_id}', + ...options +}); + +/** + * Get user title + */ +export const getUserTitle = <ThrowOnError extends boolean = false>(options: Options<GetUserTitleData, ThrowOnError>) => (options.client ?? client).get<GetUserTitleResponses, GetUserTitleErrors, ThrowOnError>({ url: '/users/{user_id}/titles/{title_id}', ...options }); + +/** + * Update a usertitle + * + * User updating title list of watched + */ +export const updateUserTitle = <ThrowOnError extends boolean = false>(options: Options<UpdateUserTitleData, ThrowOnError>) => (options.client ?? client).patch<UpdateUserTitleResponses, UpdateUserTitleErrors, ThrowOnError>({ + security: [{ name: 'X-XSRF-TOKEN', type: 'apiKey' }], + url: '/users/{user_id}/titles/{title_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts deleted file mode 100644 index 6898c46..0000000 --- a/modules/frontend/src/api/services/DefaultService.ts +++ /dev/null @@ -1,371 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { CursorObj } from '../models/CursorObj'; -import type { ReleaseSeason } from '../models/ReleaseSeason'; -import type { Title } from '../models/Title'; -import type { TitleSort } from '../models/TitleSort'; -import type { TitleStatus } from '../models/TitleStatus'; -import type { User } from '../models/User'; -import type { UserTitle } from '../models/UserTitle'; -import type { UserTitleMini } from '../models/UserTitleMini'; -import type { UserTitleStatus } from '../models/UserTitleStatus'; -import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; -import { request as __request } from '../core/request'; -export class DefaultService { - /** - * Get titles - * @param cursor - * @param sort - * @param sortForward - * @param extSearch - * @param word - * @param status List of title statuses to filter - * @param rating - * @param releaseYear - * @param releaseSeason - * @param limit - * @param offset - * @param fields - * @returns any List of titles with cursor - * @throws ApiError - */ - public static getTitles( - cursor?: string, - sort?: TitleSort, - sortForward: boolean = true, - extSearch: boolean = false, - word?: string, - status?: Array<TitleStatus>, - rating?: number, - releaseYear?: number, - releaseSeason?: ReleaseSeason, - limit: number = 10, - offset?: number, - fields: string = 'all', - ): CancelablePromise<{ - /** - * List of titles - */ - data: Array<Title>; - cursor: CursorObj; - }> { - return __request(OpenAPI, { - method: 'GET', - url: '/titles', - query: { - 'cursor': cursor, - 'sort': sort, - 'sort_forward': sortForward, - 'ext_search': extSearch, - 'word': word, - 'status': status, - 'rating': rating, - 'release_year': releaseYear, - 'release_season': releaseSeason, - 'limit': limit, - 'offset': offset, - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 500: `Unknown server error`, - }, - }); - } - /** - * Get title description - * @param titleId - * @param fields - * @returns Title Title description - * @throws ApiError - */ - public static getTitle( - titleId: number, - fields: string = 'all', - ): CancelablePromise<Title> { - return __request(OpenAPI, { - method: 'GET', - url: '/titles/{title_id}', - path: { - 'title_id': titleId, - }, - query: { - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 404: `Title not found`, - 500: `Unknown server error`, - }, - }); - } - /** - * Get user info - * @param userId - * @param fields - * @returns User User info - * @throws ApiError - */ - public static getUsersId( - userId: string, - fields: string = 'all', - ): CancelablePromise<User> { - return __request(OpenAPI, { - method: 'GET', - url: '/users/{user_id}', - path: { - 'user_id': userId, - }, - query: { - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 404: `User not found`, - 500: `Unknown server error`, - }, - }); - } - /** - * Partially update a user account - * Update selected user profile fields (excluding password). - * Password updates must be done via the dedicated auth-service (`/auth/`). - * Fields not provided in the request body remain unchanged. - * - * @param userId User ID (primary key) - * @param requestBody - * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). - * @throws ApiError - */ - public static updateUser( - userId: number, - requestBody: { - /** - * ID of the user avatar (references `images.id`); set to `null` to remove avatar - */ - avatar_id?: number | null; - /** - * User email (must be unique and valid) - */ - mail?: string; - /** - * Username (alphanumeric + `_` or `-`, 3–16 chars) - */ - nickname?: string; - /** - * Display name - */ - disp_name?: string; - /** - * User description / bio - */ - user_desc?: string; - }, - ): CancelablePromise<User> { - return __request(OpenAPI, { - method: 'PATCH', - url: '/users/{user_id}', - path: { - 'user_id': userId, - }, - body: requestBody, - mediaType: 'application/json', - errors: { - 400: `Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)`, - 401: `Unauthorized — missing or invalid authentication token`, - 403: `Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)`, - 404: `User not found`, - 409: `Conflict — e.g., requested \`nickname\` or \`mail\` already taken by another user`, - 422: `Unprocessable Entity — semantic errors not caught by schema (e.g., invalid \`avatar_id\`)`, - 500: `Unknown server error`, - }, - }); - } - /** - * Get user titles - * @param userId - * @param cursor - * @param sort - * @param sortForward - * @param word - * @param status List of title statuses to filter - * @param watchStatus - * @param rating - * @param myRate - * @param releaseYear - * @param releaseSeason - * @param limit - * @param fields - * @returns any List of user titles - * @throws ApiError - */ - public static getUserTitles( - userId: string, - cursor?: string, - sort?: TitleSort, - sortForward: boolean = true, - word?: string, - status?: Array<TitleStatus>, - watchStatus?: Array<UserTitleStatus>, - rating?: number, - myRate?: number, - releaseYear?: number, - releaseSeason?: ReleaseSeason, - limit: number = 10, - fields: string = 'all', - ): CancelablePromise<{ - data: Array<UserTitle>; - cursor: CursorObj; - }> { - return __request(OpenAPI, { - method: 'GET', - url: '/users/{user_id}/titles', - path: { - 'user_id': userId, - }, - query: { - 'cursor': cursor, - 'sort': sort, - 'sort_forward': sortForward, - 'word': word, - 'status': status, - 'watch_status': watchStatus, - 'rating': rating, - 'my_rate': myRate, - 'release_year': releaseYear, - 'release_season': releaseSeason, - 'limit': limit, - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 404: `User not found`, - 500: `Unknown server error`, - }, - }); - } - /** - * Add a title to a user - * User adding title to list af watched, status required - * @param userId ID of the user to assign the title to - * @param requestBody - * @returns UserTitleMini Title successfully added to user - * @throws ApiError - */ - public static addUserTitle( - userId: number, - requestBody: { - title_id: number; - status: UserTitleStatus; - rate?: number; - }, - ): CancelablePromise<UserTitleMini> { - return __request(OpenAPI, { - method: 'POST', - url: '/users/{user_id}/titles', - path: { - 'user_id': userId, - }, - body: requestBody, - mediaType: 'application/json', - errors: { - 400: `Invalid request body (missing fields, invalid types, etc.)`, - 401: `Unauthorized — missing or invalid auth token`, - 403: `Forbidden — user not allowed to assign titles to this user`, - 404: `User or Title not found`, - 409: `Conflict — title already assigned to user (if applicable)`, - 500: `Internal server error`, - }, - }); - } - /** - * Get user title - * @param userId - * @param titleId - * @returns UserTitleMini User titles - * @throws ApiError - */ - public static getUserTitle( - userId: number, - titleId: number, - ): CancelablePromise<UserTitleMini> { - return __request(OpenAPI, { - method: 'GET', - url: '/users/{user_id}/titles/{title_id}', - path: { - 'user_id': userId, - 'title_id': titleId, - }, - errors: { - 400: `Request params are not correct`, - 404: `User or title not found`, - 500: `Unknown server error`, - }, - }); - } - /** - * Update a usertitle - * User updating title list of watched - * @param userId - * @param titleId - * @param requestBody - * @returns UserTitleMini Title successfully updated - * @throws ApiError - */ - public static updateUserTitle( - userId: number, - titleId: number, - requestBody: { - status?: UserTitleStatus; - rate?: number; - }, - ): CancelablePromise<UserTitleMini> { - return __request(OpenAPI, { - method: 'PATCH', - url: '/users/{user_id}/titles/{title_id}', - path: { - 'user_id': userId, - 'title_id': titleId, - }, - body: requestBody, - mediaType: 'application/json', - errors: { - 400: `Invalid request body (missing fields, invalid types, etc.)`, - 401: `Unauthorized — missing or invalid auth token`, - 403: `Forbidden — user not allowed to update title`, - 404: `User or Title not found`, - 500: `Internal server error`, - }, - }); - } - /** - * Delete a usertitle - * User deleting title from list of watched - * @param userId - * @param titleId - * @returns any Title successfully deleted - * @throws ApiError - */ - public static deleteUserTitle( - userId: number, - titleId: number, - ): CancelablePromise<any> { - return __request(OpenAPI, { - method: 'DELETE', - url: '/users/{user_id}/titles/{title_id}', - path: { - 'user_id': userId, - 'title_id': titleId, - }, - errors: { - 401: `Unauthorized — missing or invalid auth token`, - 403: `Forbidden — user not allowed to delete title`, - 404: `User or Title not found`, - 500: `Internal server error`, - }, - }); - } -} diff --git a/modules/frontend/src/api/types.gen.ts b/modules/frontend/src/api/types.gen.ts new file mode 100644 index 0000000..ce4db4b --- /dev/null +++ b/modules/frontend/src/api/types.gen.ts @@ -0,0 +1,570 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}/api/v1` | (string & {}); +}; + +/** + * Title sort order + */ +export type TitleSort = 'id' | 'year' | 'rating' | 'views'; + +/** + * Title status + */ +export type TitleStatus = 'finished' | 'ongoing' | 'planned'; + +/** + * Title release season + */ +export type ReleaseSeason = 'winter' | 'spring' | 'summer' | 'fall'; + +/** + * Image storage type + */ +export type StorageType = 's3' | 'local'; + +export type Image = { + id?: number; + storage_type?: StorageType; + image_path?: string; +}; + +export type Studio = { + id: number; + name: string; + poster?: Image; + description?: string; +}; + +/** + * A localized tag: keys are language codes (ISO 639-1), values are tag names + */ +export type Tag = { + [key: string]: string; +}; + +/** + * Array of localized tags + */ +export type Tags = Array<Tag>; + +export type Title = { + /** + * Unique title ID (primary key) + */ + id: number; + /** + * Localized titles. Key = language (ISO 639-1), value = list of names + */ + title_names: { + [key: string]: Array<string>; + }; + studio?: Studio; + tags: Tags; + poster?: Image; + title_status?: TitleStatus; + rating?: number; + rating_count?: number; + release_year?: number; + release_season?: ReleaseSeason; + episodes_aired?: number; + episodes_all?: number; + episodes_len?: { + [key: string]: number; + }; +}; + +export type CursorObj = { + id: number; + param?: string; +}; + +export type User = { + /** + * Unique user ID (primary key) + */ + id?: number; + image?: Image; + /** + * User email + */ + mail?: string; + /** + * Username (alphanumeric + _ or -) + */ + nickname: string; + /** + * Display name + */ + disp_name?: string; + /** + * User description + */ + user_desc?: string; + /** + * Timestamp when the user was created + */ + creation_date?: string; +}; + +/** + * User's title status + */ +export type UserTitleStatus = 'finished' | 'planned' | 'dropped' | 'in-progress'; + +export type UserTitle = { + user_id: number; + title?: Title; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; +}; + +export type UserTitleMini = { + user_id: number; + title_id: number; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; +}; + +export type Review = { + [key: string]: unknown; +}; + +export type Cursor = string; + +export type TitleSort2 = TitleSort; + +export type GetTitlesData = { + body?: never; + path?: never; + query?: { + cursor?: string; + sort?: TitleSort; + sort_forward?: boolean; + ext_search?: boolean; + word?: string; + /** + * List of title statuses to filter + */ + status?: Array<TitleStatus>; + rating?: number; + release_year?: number; + release_season?: ReleaseSeason; + limit?: number; + offset?: number; + fields?: string; + }; + url: '/titles'; +}; + +export type GetTitlesErrors = { + /** + * Request params are not correct + */ + 400: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type GetTitlesResponses = { + /** + * List of titles with cursor + */ + 200: { + /** + * List of titles + */ + data: Array<Title>; + cursor: CursorObj; + }; + /** + * No titles found + */ + 204: void; +}; + +export type GetTitlesResponse = GetTitlesResponses[keyof GetTitlesResponses]; + +export type GetTitleData = { + body?: never; + path: { + title_id: number; + }; + query?: { + fields?: string; + }; + url: '/titles/{title_id}'; +}; + +export type GetTitleErrors = { + /** + * Request params are not correct + */ + 400: unknown; + /** + * Title not found + */ + 404: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type GetTitleResponses = { + /** + * Title description + */ + 200: Title; + /** + * No title found + */ + 204: void; +}; + +export type GetTitleResponse = GetTitleResponses[keyof GetTitleResponses]; + +export type GetUsersIdData = { + body?: never; + path: { + user_id: string; + }; + query?: { + fields?: string; + }; + url: '/users/{user_id}'; +}; + +export type GetUsersIdErrors = { + /** + * Request params are not correct + */ + 400: unknown; + /** + * User not found + */ + 404: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type GetUsersIdResponses = { + /** + * User info + */ + 200: User; +}; + +export type GetUsersIdResponse = GetUsersIdResponses[keyof GetUsersIdResponses]; + +export type UpdateUserData = { + /** + * Only provided fields are updated. Omitted fields remain unchanged. + */ + body: { + /** + * ID of the user avatar (references `images.id`); set to `null` to remove avatar + */ + avatar_id?: number | null; + /** + * User email (must be unique and valid) + */ + mail?: string; + /** + * Username (alphanumeric + `_` or `-`, 3–16 chars) + */ + nickname?: string; + /** + * Display name + */ + disp_name?: string; + /** + * User description / bio + */ + user_desc?: string; + }; + path: { + /** + * User ID (primary key) + */ + user_id: number; + }; + query?: never; + url: '/users/{user_id}'; +}; + +export type UpdateUserErrors = { + /** + * Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON) + */ + 400: unknown; + /** + * Unauthorized — missing or invalid authentication token + */ + 401: unknown; + /** + * Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights) + */ + 403: unknown; + /** + * User not found + */ + 404: unknown; + /** + * Conflict — e.g., requested `nickname` or `mail` already taken by another user + */ + 409: unknown; + /** + * Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`) + */ + 422: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type UpdateUserResponses = { + /** + * User updated successfully. Returns updated user representation (excluding sensitive fields). + */ + 200: User; +}; + +export type UpdateUserResponse = UpdateUserResponses[keyof UpdateUserResponses]; + +export type GetUserTitlesData = { + body?: never; + path: { + user_id: string; + }; + query?: { + cursor?: string; + sort?: TitleSort; + sort_forward?: boolean; + word?: string; + /** + * List of title statuses to filter + */ + status?: Array<TitleStatus>; + watch_status?: Array<UserTitleStatus>; + rating?: number; + my_rate?: number; + release_year?: number; + release_season?: ReleaseSeason; + limit?: number; + fields?: string; + }; + url: '/users/{user_id}/titles'; +}; + +export type GetUserTitlesErrors = { + /** + * Request params are not correct + */ + 400: unknown; + /** + * User not found + */ + 404: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type GetUserTitlesResponses = { + /** + * List of user titles + */ + 200: { + data: Array<UserTitle>; + cursor: CursorObj; + }; + /** + * No titles found + */ + 204: void; +}; + +export type GetUserTitlesResponse = GetUserTitlesResponses[keyof GetUserTitlesResponses]; + +export type AddUserTitleData = { + body: { + title_id: number; + status: UserTitleStatus; + rate?: number; + }; + path: { + /** + * ID of the user to assign the title to + */ + user_id: number; + }; + query?: never; + url: '/users/{user_id}/titles'; +}; + +export type AddUserTitleErrors = { + /** + * Invalid request body (missing fields, invalid types, etc.) + */ + 400: unknown; + /** + * Unauthorized — missing or invalid auth token + */ + 401: unknown; + /** + * Forbidden — user not allowed to assign titles to this user + */ + 403: unknown; + /** + * User or Title not found + */ + 404: unknown; + /** + * Conflict — title already assigned to user (if applicable) + */ + 409: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type AddUserTitleResponses = { + /** + * Title successfully added to user + */ + 200: UserTitleMini; +}; + +export type AddUserTitleResponse = AddUserTitleResponses[keyof AddUserTitleResponses]; + +export type DeleteUserTitleData = { + body?: never; + path: { + user_id: number; + title_id: number; + }; + query?: never; + url: '/users/{user_id}/titles/{title_id}'; +}; + +export type DeleteUserTitleErrors = { + /** + * Unauthorized — missing or invalid auth token + */ + 401: unknown; + /** + * Forbidden — user not allowed to delete title + */ + 403: unknown; + /** + * User or Title not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type DeleteUserTitleResponses = { + /** + * Title successfully deleted + */ + 200: unknown; +}; + +export type GetUserTitleData = { + body?: never; + path: { + user_id: number; + title_id: number; + }; + query?: never; + url: '/users/{user_id}/titles/{title_id}'; +}; + +export type GetUserTitleErrors = { + /** + * Request params are not correct + */ + 400: unknown; + /** + * User or title not found + */ + 404: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type GetUserTitleResponses = { + /** + * User titles + */ + 200: UserTitleMini; + /** + * No user title found + */ + 204: void; +}; + +export type GetUserTitleResponse = GetUserTitleResponses[keyof GetUserTitleResponses]; + +export type UpdateUserTitleData = { + body: { + status?: UserTitleStatus; + rate?: number; + }; + path: { + user_id: number; + title_id: number; + }; + query?: never; + url: '/users/{user_id}/titles/{title_id}'; +}; + +export type UpdateUserTitleErrors = { + /** + * Invalid request body (missing fields, invalid types, etc.) + */ + 400: unknown; + /** + * Unauthorized — missing or invalid auth token + */ + 401: unknown; + /** + * Forbidden — user not allowed to update title + */ + 403: unknown; + /** + * User or Title not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type UpdateUserTitleResponses = { + /** + * Title successfully updated + */ + 200: UserTitleMini; +}; + +export type UpdateUserTitleResponse = UpdateUserTitleResponses[keyof UpdateUserTitleResponses]; diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index cc9f80d..3cc16cf 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; -import { DefaultService } from "../../api"; +// import { DefaultService } from "../../api"; +import { addUserTitle, deleteUserTitle, getUserTitle, updateUserTitle } from "../../api"; import type { UserTitleStatus } from "../../api"; -// import { useCookies } from 'react-cookie'; +import { useCookies } from 'react-cookie'; import { ClockIcon, @@ -9,6 +10,7 @@ import { PlayCircleIcon, XCircleIcon, } from "@heroicons/react/24/solid"; +// import { stat } from "fs"; // Статусы с иконками и подписью const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [ @@ -19,8 +21,8 @@ const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: s ]; export function TitleStatusControls({ titleId }: { titleId: number }) { - // const [cookies] = useCookies(['xsrf_token']); - // const xsrfToken = cookies['xsrf_token'] || null; + const [cookies] = useCookies(['xsrf_token']); + const xsrfToken = cookies['xsrf_token'] || null; const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); const [loading, setLoading] = useState(false); @@ -31,10 +33,13 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { // --- Load initial status --- useEffect(() => { if (!userId) return; + getUserTitle({ path: { user_id: userId, title_id: titleId } }) + .then(res => setCurrentStatus(res.data?.status ?? null)) + .catch(() => setCurrentStatus(null)); // 404 = not assigned - DefaultService.getUserTitle(userId, titleId) - .then((res) => setCurrentStatus(res.status)) - .catch(() => setCurrentStatus(null)); // 404 = user title does not exist + // DefaultService.getUserTitle(userId, titleId) + // .then((res) => setCurrentStatus(res.status)) + // .catch(() => setCurrentStatus(null)); // 404 = user title does not exist }, [titleId, userId]); // --- Handle click --- @@ -46,7 +51,11 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { try { // 1) Если кликнули на текущий статус — DELETE if (currentStatus === status) { - await DefaultService.deleteUserTitle(userId, titleId); + // await DefaultService.deleteUserTitle(userId, titleId); + await deleteUserTitle({path: { + user_id: userId, + title_id: titleId, + }}) setCurrentStatus(null); return; } @@ -54,15 +63,28 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { // 2) Если другой статус — POST или PATCH if (!currentStatus) { // ещё нет записи — POST - const added = await DefaultService.addUserTitle(userId, { + // const added = await DefaultService.addUserTitle(userId, { + // title_id: titleId, + // status, + // }); + const added = await addUserTitle({ + body: { title_id: titleId, - status, + status: status, + }, + path: {user_id: userId} }); - setCurrentStatus(added.status); + + setCurrentStatus(added.data?.status ?? null); } else { // уже есть запись — PATCH - const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); - setCurrentStatus(updated.status); + //const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); + const updated = await updateUserTitle({ + path: { user_id: userId, title_id: titleId }, + body: { status }, + headers: { "X-XSRF-TOKEN": xsrfToken }, + }); + setCurrentStatus(updated.data?.status ?? null); } } finally { setLoading(false); diff --git a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx index cde6037..b848702 100644 --- a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx +++ b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx @@ -1,4 +1,4 @@ -import type { Title } from "../../api/models/Title"; +import type { Title } from "../../api"; export function TitleCardHorizontal({ title }: { title: Title }) { return ( diff --git a/modules/frontend/src/components/cards/TitleCardSquare.tsx b/modules/frontend/src/components/cards/TitleCardSquare.tsx index e21c258..0bcb49d 100644 --- a/modules/frontend/src/components/cards/TitleCardSquare.tsx +++ b/modules/frontend/src/components/cards/TitleCardSquare.tsx @@ -1,5 +1,4 @@ -// TitleCardSquare.tsx -import type { Title } from "../../api/models/Title"; +import type { Title } from "../../api"; export function TitleCardSquare({ title }: { title: Title }) { return ( diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index 01f9c49..0d9e297 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useParams, Link } from "react-router-dom"; -import { DefaultService } from "../../api/services/DefaultService"; -import type { Title } from "../../api"; +// import { DefaultService } from "../../api/services/DefaultService"; +import { getTitle, type Title } from "../../api"; import { TitleStatusControls } from "../../components/TitleStatusControls/TitleStatusControls"; export default function TitlePage() { @@ -19,8 +19,9 @@ export default function TitlePage() { const fetchTitle = async () => { setLoading(true); try { - const data = await DefaultService.getTitle(titleId, "all"); - setTitle(data); + // const data = await DefaultService.getTitle(titleId, "all"); + const data = await getTitle({path: {title_id: titleId}}) + setTitle(data?.data ?? null); setError(null); } catch (err: any) { console.error(err); diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index ed55d8d..481d116 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -2,10 +2,10 @@ import { useEffect, useState } from "react"; import { ListView } from "../../components/ListView/ListView"; import { SearchBar } from "../../components/SearchBar/SearchBar"; import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; -import { DefaultService } from "../../api/services/DefaultService"; +// import { DefaultService } from "../../api/services/DefaultService"; import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; -import type { CursorObj, Title, TitleSort } from "../../api"; +import { getTitles, type CursorObj, type Title, type TitleSort } from "../../api"; import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; import { Link } from "react-router-dom"; import { type TitlesFilter, TitlesFilterPanel } from "../../components/TitlesFilterPanel/TitlesFilterPanel"; @@ -32,37 +32,31 @@ export default function TitlesPage() { }); const fetchPage = async (cursorObj: CursorObj | null) => { - const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') : ""; + const cursorStr = cursorObj + ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") + : undefined; - try { - const result = await DefaultService.getTitles( - cursorStr, - sort, - sortForward, - filters.extSearch, - search.trim() || undefined, - filters.status ? [filters.status] : undefined, - filters.rating || undefined, - filters.releaseYear || undefined, - filters.releaseSeason || undefined, - PAGE_SIZE, - PAGE_SIZE, - "all" - ); + const response = await getTitles({ + query: { + cursor: cursorStr, + sort: sort, + sort_forward: sortForward, + ext_search: filters.extSearch, + word: search.trim() || undefined, + status: filters.status ? [filters.status] : undefined, + rating: filters.rating || undefined, + release_year: filters.releaseYear || undefined, + release_season: filters.releaseSeason || undefined, + limit: PAGE_SIZE, + offset: PAGE_SIZE, + fields: "all", + }, + }); - if ((result === undefined) || !result.data?.length) { - return { items: [], nextCursor: null }; - } - return { - items: result.data ?? [], - nextCursor: result.cursor ?? null - }; - } catch (err: any) { - if (err.status === 204) { - return { items: [], nextCursor: null }; - } - throw err; - } + return { + items: response.data?.data ?? [], + nextCursor: response.data?.cursor ?? null, + }; }; // Инициализация: загружаем сразу две страницы diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 7cc0db5..d9ff5f2 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -1,14 +1,14 @@ // pages/UserPage/UserPage.tsx import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; -import { DefaultService } from "../../api/services/DefaultService"; +// import { DefaultService } from "../../api/services/DefaultService"; import { SearchBar } from "../../components/SearchBar/SearchBar"; import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; import { ListView } from "../../components/ListView/ListView"; import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; -import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; +import { type User, type UserTitle, type CursorObj, type TitleSort, getUsersId, getUserTitles } from "../../api"; import { Link } from "react-router-dom"; const PAGE_SIZE = 10; @@ -42,8 +42,9 @@ export default function UserPage({ userId }: UserPageProps) { if (!id) return; setLoadingUser(true); try { - const result = await DefaultService.getUsersId(id, "all"); - setUser(result); + // const result = await DefaultService.getUsersId(id, "all"); + const result = await getUsersId({path: {user_id: id}}) + setUser(result?.data ?? null); setErrorUser(null); } catch (err: any) { console.error(err); @@ -63,25 +64,41 @@ export default function UserPage({ userId }: UserPageProps) { : ""; try { - const result = await DefaultService.getUserTitles( - id, - cursorStr, - sort, - sortForward, - search.trim() || undefined, - undefined, // status фильтр, можно добавить - undefined, // watchStatus - undefined, // rating - undefined, // myRate - undefined, // releaseYear - undefined, // releaseSeason - PAGE_SIZE, - "all" - ); + const result = await getUserTitles({ + path: { + user_id: id, + }, + query: { + cursor: cursorStr, + sort: sort, + sort_forward: sortForward, + word: search.trim() || undefined, + status: undefined, + watch_status: undefined, + rating: undefined, + my_rate: undefined, + release_year: undefined, + release_season: undefined, + limit: PAGE_SIZE}}) + // const result = await DefaultService.getUserTitles( + // id, + // cursorStr, + // sort, + // sortForward, + // search.trim() || undefined, + // undefined, // status фильтр, можно добавить + // undefined, // watchStatus + // undefined, // rating + // undefined, // myRate + // undefined, // releaseYear + // undefined, // releaseSeason + // PAGE_SIZE, + // "all" + // ); - if (!result?.data?.length) return { items: [], nextCursor: null }; + if (!result?.data?.data.length) return { items: [], nextCursor: null }; - return { items: result.data, nextCursor: result.cursor ?? null }; + return { items: result.data?.data, nextCursor: result.data?.cursor ?? null }; } catch (err: any) { if (err.status === 204) return { items: [], nextCursor: null }; throw err; From fc2fa6b9786808f0164c0d6f7c8c7bb92b545675 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 11:52:18 +0300 Subject: [PATCH 176/224] feat: oapi credenials include --- modules/frontend/src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 5ff2b32..67336c1 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { Header } from "./components/Header/Header"; import { OpenAPI } from "./api"; OpenAPI.WITH_CREDENTIALS = true +OpenAPI.CREDENTIALS = 'include' const App: React.FC = () => { const username = localStorage.getItem("username") || undefined; From 1ec5b2f09cc21383aaa5f190802464047763843b Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 12:34:56 +0300 Subject: [PATCH 177/224] debug: csrf cookie --- modules/frontend/src/api/client.gen.ts | 2 +- modules/frontend/src/auth/core/OpenAPI.ts | 2 +- .../src/components/TitleStatusControls/TitleStatusControls.tsx | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/frontend/src/api/client.gen.ts b/modules/frontend/src/api/client.gen.ts index 952c663..2de06ac 100644 --- a/modules/frontend/src/api/client.gen.ts +++ b/modules/frontend/src/api/client.gen.ts @@ -13,4 +13,4 @@ import type { ClientOptions as ClientOptions2 } from './types.gen'; */ export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>; -export const client = createClient(createConfig<ClientOptions2>({ baseUrl: '/api/v1' })); +export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://10.1.0.65:8081/api/v1' })); diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts index 2d0edf8..79aa305 100644 --- a/modules/frontend/src/auth/core/OpenAPI.ts +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: '/auth', + BASE: 'http://10.1.0.65:8081/auth', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index 3cc16cf..0566fbf 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -23,6 +23,7 @@ const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: s export function TitleStatusControls({ titleId }: { titleId: number }) { const [cookies] = useCookies(['xsrf_token']); const xsrfToken = cookies['xsrf_token'] || null; + console.log("xsrf_token: " + xsrfToken) const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); const [loading, setLoading] = useState(false); From 5d1d138aca61784f9d2411ae4db4687f78d7ced9 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 13:01:10 +0300 Subject: [PATCH 178/224] fix: minor fixes for the frontend --- modules/auth/handlers/handlers.go | 2 +- .../TitleStatusControls/TitleStatusControls.tsx | 8 +++++--- modules/frontend/src/pages/UserPage/UserPage.tsx | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 03df151..ac55abe 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -135,7 +135,7 @@ func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject ginCtx.SetSameSite(http.SameSiteStrictMode) ginCtx.SetCookie("access_token", accessToken, 900, "/api", "", false, true) ginCtx.SetCookie("refresh_token", refreshToken, 1209600, "/auth", "", false, true) - ginCtx.SetCookie("xsrf_token", csrfToken, 1209600, "/api", "", false, false) + ginCtx.SetCookie("xsrf_token", csrfToken, 1209600, "/", "", false, false) result := auth.PostSignIn200JSONResponse{ UserId: user.ID, diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index 0566fbf..98fa868 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -23,7 +23,6 @@ const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: s export function TitleStatusControls({ titleId }: { titleId: number }) { const [cookies] = useCookies(['xsrf_token']); const xsrfToken = cookies['xsrf_token'] || null; - console.log("xsrf_token: " + xsrfToken) const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); const [loading, setLoading] = useState(false); @@ -56,7 +55,9 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { await deleteUserTitle({path: { user_id: userId, title_id: titleId, - }}) + }, + headers: { "X-XSRF-TOKEN": xsrfToken }, + }) setCurrentStatus(null); return; } @@ -73,7 +74,8 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { title_id: titleId, status: status, }, - path: {user_id: userId} + path: {user_id: userId}, + headers: { "X-XSRF-TOKEN": xsrfToken }, }); setCurrentStatus(added.data?.status ?? null); diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index d9ff5f2..1a8ba1e 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -96,7 +96,7 @@ export default function UserPage({ userId }: UserPageProps) { // "all" // ); - if (!result?.data?.data.length) return { items: [], nextCursor: null }; + if (!result?.data?.data?.length) return { items: [], nextCursor: null }; return { items: result.data?.data, nextCursor: result.data?.cursor ?? null }; } catch (err: any) { From 604ac0ebbccd35b0081337f985e21abefa700307 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 4 Dec 2025 20:12:54 +0300 Subject: [PATCH 179/224] feat: now auth could be disabled with pipeline param --- .forgejo/workflows/build-and-deploy.yml | 1 + deploy/docker-compose.yml | 1 + modules/backend/main.go | 6 ++++-- modules/backend/types.go | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index dde9392..b82fb3d 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -116,6 +116,7 @@ jobs: JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }} RABBITMQ_DEFAULT_USER: ${{ secrets.RABBITMQ_USER }} RABBITMQ_DEFAULT_PASS: ${{ secrets.RABBITMQ_PASSWORD }} + AUTH_ENABLED: ${{ vars.AUTH_ENABLED }} steps: - name: Checkout code diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 82116eb..1119335 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -50,6 +50,7 @@ services: SERVICE_ADDRESS: ${SERVICE_ADDRESS} RABBITMQ_URL: ${RABBITMQ_URL} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} + AUTH_ENABLED: ${AUTH_ENABLED} ports: - "8080:8080" depends_on: diff --git a/modules/backend/main.go b/modules/backend/main.go index b833cf9..755e3ef 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -46,8 +46,10 @@ func main() { r := gin.Default() - r.Use(middleware.CSRFMiddleware()) - r.Use(middleware.JWTAuthMiddleware(AppConfig.JwtPrivateKey)) + if len(AppConfig.AuthEnabled) > 0 && AppConfig.AuthEnabled != "false" { + r.Use(middleware.CSRFMiddleware()) + r.Use(middleware.JWTAuthMiddleware(AppConfig.JwtPrivateKey)) + } queries := sqlc.New(pool) diff --git a/modules/backend/types.go b/modules/backend/types.go index a069307..ceaec4e 100644 --- a/modules/backend/types.go +++ b/modules/backend/types.go @@ -7,4 +7,5 @@ type Config struct { JwtPrivateKey string `toml:"JwtPrivateKey" env:"JWT_PRIVATE_KEY"` LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` RmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"` + AuthEnabled string `toml:"AuthEnabled" env:"AUTH_ENABLED"` } From 0f619dd954258dfc4b7bfab0a345b5a11467363f Mon Sep 17 00:00:00 2001 From: garaev kamil <garaev.kr@phystech.edu> Date: Fri, 5 Dec 2025 14:04:07 +0300 Subject: [PATCH 180/224] rate limit jikan added --- modules/anime_etl/rate_limiter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/anime_etl/rate_limiter.py b/modules/anime_etl/rate_limiter.py index c2b06ff..00383c6 100644 --- a/modules/anime_etl/rate_limiter.py +++ b/modules/anime_etl/rate_limiter.py @@ -119,3 +119,4 @@ def wrap_with_rate_limiter( # Глобальные инстансы per-process для источников ANILIST_RATE_LIMITER = TokenBucketRateLimiter(rate=30, per=60.0) SHIKIMORI_RATE_LIMITER = TokenBucketRateLimiter(rate=90, per=60.0) +JIKAN_RATE_LIMITER = TokenBucketRateLimiter(rate=30, per=60.0) \ No newline at end of file From 169bb482ce522ddac00ae5c841f6f6c618d38f51 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 5 Dec 2025 19:42:25 +0300 Subject: [PATCH 181/224] feat: desc field for title was added --- sql/migrations/000001_init.up.sql | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 3499fe2..d6353d6 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -47,6 +47,8 @@ CREATE TABLE titles ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, + -- example {"ru": "Кулинарное аниме как правильно приготовить людей.","en": "A culinary anime about how to cook people properly."} + title_desc jsonb, studio_id bigint NOT NULL REFERENCES studios (id), poster_id bigint REFERENCES images (id) ON DELETE SET NULL, title_status title_status_t NOT NULL, From 40e341c05ad2f5fea246c82afa40717450d2ebf6 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 5 Dec 2025 20:13:16 +0300 Subject: [PATCH 182/224] feat: query SearchUsers was written --- sql/models.go | 1 + sql/queries.sql.go | 85 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/sql/models.go b/sql/models.go index 842d58c..b1ea282 100644 --- a/sql/models.go +++ b/sql/models.go @@ -246,6 +246,7 @@ type Tag struct { type Title struct { ID int64 `json:"id"` TitleNames json.RawMessage `json:"title_names"` + TitleDesc []byte `json:"title_desc"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 1cca986..0c17599 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -129,7 +129,7 @@ func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, er const getTitleByID = `-- name: GetTitleByID :one SELECT - t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + t.id, t.title_names, t.title_desc, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( @@ -157,6 +157,7 @@ GROUP BY type GetTitleByIDRow struct { ID int64 `json:"id"` TitleNames json.RawMessage `json:"title_names"` + TitleDesc []byte `json:"title_desc"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` @@ -185,6 +186,7 @@ func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (GetTitleByID err := row.Scan( &i.ID, &i.TitleNames, + &i.TitleDesc, &i.StudioID, &i.PosterID, &i.TitleStatus, @@ -638,6 +640,87 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S return items, nil } +const searchUser = `-- name: SearchUser :many +SELECT + u.id AS id, + u.avatar_id AS avatar_id, + u.mail AS mail, + u.nickname AS nickname, + u.disp_name AS disp_name, + u.user_desc AS user_desc, + u.creation_date AS creation_date, + i.storage_type AS storage_type, + i.image_path AS image_path +FROM users AS u +LEFT JOIN images AS i ON u.avatar_id = i.id +WHERE + ( + $1::text IS NULL + OR ( + SELECT bool_and( + u.nickname ILIKE ('%' || term || '%') + OR u.disp_name ILIKE ('%' || term || '%') + ) + FROM unnest(string_to_array(trim($1::text), ' ')) AS term + WHERE term <> '' + ) + ) + AND ( + $2::int IS NULL + OR u.id > $2::int + ) +ORDER BY u.id ASC +LIMIT COALESCE($3::int, 20) +` + +type SearchUserParams struct { + Word *string `json:"word"` + Cursor *int32 `json:"cursor"` + Limit *int32 `json:"limit"` +} + +type SearchUserRow struct { + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Mail *string `json:"mail"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + StorageType *StorageTypeT `json:"storage_type"` + ImagePath *string `json:"image_path"` +} + +func (q *Queries) SearchUser(ctx context.Context, arg SearchUserParams) ([]SearchUserRow, error) { + rows, err := q.db.Query(ctx, searchUser, arg.Word, arg.Cursor, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SearchUserRow{} + for rows.Next() { + var i SearchUserRow + if err := rows.Scan( + &i.ID, + &i.AvatarID, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.StorageType, + &i.ImagePath, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const searchUserTitles = `-- name: SearchUserTitles :many SELECT From fe18c0d865c4d27e27ad7be4d79ce328693c6850 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 5 Dec 2025 20:14:08 +0300 Subject: [PATCH 183/224] feat /users path is specified --- api/_build/openapi.yaml | 47 ++++++++++++++ api/api.gen.go | 131 ++++++++++++++++++++++++++++++++++++++++ api/openapi.yaml | 2 + api/paths/users.yaml | 46 ++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 api/paths/users.yaml diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index e096beb..7f483fa 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -122,6 +122,53 @@ paths: description: Unknown server error security: - JwtAuthCookies: [] + /users/: + get: + summary: 'Search user by nickname or dispname (both in one param), response is always sorted by id' + parameters: + - name: word + in: query + schema: + type: string + - name: limit + in: query + schema: + type: integer + format: int32 + default: 10 + - name: cursor_id + in: query + description: pass cursor naked + schema: + type: integer + format: int32 + default: 1 + responses: + '200': + description: List of users with cursor + content: + application/json: + schema: + type: object + properties: + data: + description: List of users + type: array + items: + $ref: '#/components/schemas/User' + cursor: + type: integer + format: int64 + default: 1 + required: + - data + - cursor + '204': + description: No users found + '400': + description: Request params are not correct + '500': + description: Unknown server error '/users/{user_id}': get: operationId: getUsersId diff --git a/api/api.gen.go b/api/api.gen.go index 459a3e4..4fa16f4 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -201,6 +201,15 @@ type GetTitleParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// GetUsersParams defines parameters for GetUsers. +type GetUsersParams struct { + Word *string `form:"word,omitempty" json:"word,omitempty"` + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + + // CursorId pass cursor naked + CursorId *int32 `form:"cursor_id,omitempty" json:"cursor_id,omitempty"` +} + // GetUsersIdParams defines parameters for GetUsersId. type GetUsersIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` @@ -276,6 +285,9 @@ type ServerInterface interface { // Get title description // (GET /titles/{title_id}) GetTitle(c *gin.Context, titleId int64, params GetTitleParams) + // Search user by nickname or dispname (both in one param), response is always sorted by id + // (GET /users/) + GetUsers(c *gin.Context, params GetUsersParams) // Get user info // (GET /users/{user_id}) GetUsersId(c *gin.Context, userId string, params GetUsersIdParams) @@ -459,6 +471,48 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { siw.Handler.GetTitle(c, titleId, params) } +// GetUsers operation middleware +func (siw *ServerInterfaceWrapper) GetUsers(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersParams + + // ------------- Optional query parameter "word" ------------- + + err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter word: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "cursor_id" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor_id", c.Request.URL.Query(), ¶ms.CursorId) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter cursor_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsers(c, params) +} + // GetUsersId operation middleware func (siw *ServerInterfaceWrapper) GetUsersId(c *gin.Context) { @@ -799,6 +853,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/titles", wrapper.GetTitles) router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitle) + router.GET(options.BaseURL+"/users/", wrapper.GetUsers) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersId) router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUserTitles) @@ -904,6 +959,52 @@ func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) return nil } +type GetUsersRequestObject struct { + Params GetUsersParams +} + +type GetUsersResponseObject interface { + VisitGetUsersResponse(w http.ResponseWriter) error +} + +type GetUsers200JSONResponse struct { + Cursor int64 `json:"cursor"` + + // Data List of users + Data []User `json:"data"` +} + +func (response GetUsers200JSONResponse) VisitGetUsersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsers204Response struct { +} + +func (response GetUsers204Response) VisitGetUsersResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetUsers400Response struct { +} + +func (response GetUsers400Response) VisitGetUsersResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetUsers500Response struct { +} + +func (response GetUsers500Response) VisitGetUsersResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type GetUsersIdRequestObject struct { UserId string `json:"user_id"` Params GetUsersIdParams @@ -1305,6 +1406,9 @@ type StrictServerInterface interface { // Get title description // (GET /titles/{title_id}) GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) + // Search user by nickname or dispname (both in one param), response is always sorted by id + // (GET /users/) + GetUsers(ctx context.Context, request GetUsersRequestObject) (GetUsersResponseObject, error) // Get user info // (GET /users/{user_id}) GetUsersId(ctx context.Context, request GetUsersIdRequestObject) (GetUsersIdResponseObject, error) @@ -1395,6 +1499,33 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, titleId int64, params GetTit } } +// GetUsers operation middleware +func (sh *strictHandler) GetUsers(ctx *gin.Context, params GetUsersParams) { + var request GetUsersRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsers(ctx, request.(GetUsersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsers") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersResponseObject); ok { + if err := validResponse.VisitGetUsersResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetUsersId operation middleware func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetUsersIdParams) { var request GetUsersIdRequestObject diff --git a/api/openapi.yaml b/api/openapi.yaml index d84797f..0759a54 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -11,6 +11,8 @@ paths: $ref: "./paths/titles.yaml" /titles/{title_id}: $ref: "./paths/titles-id.yaml" + /users/: + $ref: "./paths/users.yaml" /users/{user_id}: $ref: "./paths/users-id.yaml" /users/{user_id}/titles: diff --git a/api/paths/users.yaml b/api/paths/users.yaml new file mode 100644 index 0000000..14fb0c0 --- /dev/null +++ b/api/paths/users.yaml @@ -0,0 +1,46 @@ +get: + summary: Search user by nickname or dispname (both in one param), response is always sorted by id + parameters: + - in: query + name: word + schema: + type: string + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: cursor_id + description: pass cursor naked + schema: + type: integer + format: int32 + default: 1 + responses: + '200': + description: List of users with cursor + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '../schemas/User.yaml' + description: List of users + cursor: + type: integer + format: int64 + default: 1 + required: + - data + - cursor + '204': + description: No users found + '400': + description: Request params are not correct + '500': + description: Unknown server error From 6a5994e33e5f840d7e3b646d7ed7266f1a4d9cc9 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 5 Dec 2025 20:15:05 +0300 Subject: [PATCH 184/224] feat: handler for get /users is implemented --- modules/backend/handlers/users.go | 36 +++++++++++++++++++++++++++++++ modules/backend/queries.sql | 31 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index d6faade..995d5af 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -485,3 +485,39 @@ func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleReque return oapi.GetUserTitle200JSONResponse(oapi_usertitle), nil } + +// GetUsers implements oapi.StrictServerInterface. +func (s *Server) GetUsers(ctx context.Context, request oapi.GetUsersRequestObject) (oapi.GetUsersResponseObject, error) { + params := sqlc.SearchUserParams{ + Word: request.Params.Word, + Cursor: request.Params.CursorId, + Limit: request.Params.Limit, + } + _users, err := s.db.SearchUser(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsers500Response{}, nil + } + if len(_users) == 0 { + return oapi.GetUsers204Response{}, nil + } + + var users []oapi.User + var cursor int64 + for _, user := range _users { + oapi_user := oapi.User{ // maybe its possible to make one sqlc type and use one map func iinstead of this shit + // add image + CreationDate: &user.CreationDate, + DispName: user.DispName, + Id: &user.ID, + Mail: StringToEmail(user.Mail), + Nickname: user.Nickname, + UserDesc: user.UserDesc, + } + users = append(users, oapi_user) + + cursor = user.ID + } + + return oapi.GetUsers200JSONResponse{Data: users, Cursor: cursor}, nil +} diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index ff41cb1..03502c4 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -23,6 +23,37 @@ FROM users as t LEFT JOIN images as i ON (t.avatar_id = i.id) WHERE t.id = sqlc.arg('id')::bigint; +-- name: SearchUser :many +SELECT + u.id AS id, + u.avatar_id AS avatar_id, + u.mail AS mail, + u.nickname AS nickname, + u.disp_name AS disp_name, + u.user_desc AS user_desc, + u.creation_date AS creation_date, + i.storage_type AS storage_type, + i.image_path AS image_path +FROM users AS u +LEFT JOIN images AS i ON u.avatar_id = i.id +WHERE + ( + sqlc.narg('word')::text IS NULL + OR ( + SELECT bool_and( + u.nickname ILIKE ('%' || term || '%') + OR u.disp_name ILIKE ('%' || term || '%') + ) + FROM unnest(string_to_array(trim(sqlc.narg('word')::text), ' ')) AS term + WHERE term <> '' + ) + ) + AND ( + sqlc.narg('cursor')::int IS NULL + OR u.id > sqlc.narg('cursor')::int + ) +ORDER BY u.id ASC +LIMIT COALESCE(sqlc.narg('limit')::int, 20); -- name: GetStudioByID :one SELECT * From 62e0633e69a5bd4b658847155bb808beb34b821b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 5 Dec 2025 21:20:51 +0300 Subject: [PATCH 185/224] fix: rmq --- modules/backend/handlers/common.go | 22 +-- modules/backend/handlers/titles.go | 1 - modules/backend/main.go | 3 +- modules/backend/rmq/rabbit.go | 214 ++++++----------------------- 4 files changed, 53 insertions(+), 187 deletions(-) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index cad4f0f..58862e1 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -9,24 +9,24 @@ import ( "strconv" ) -type Handler struct { - publisher *rmq.Publisher -} +// type Handler struct { +// publisher *rmq.Publisher +// } -func New(publisher *rmq.Publisher) *Handler { - return &Handler{publisher: publisher} -} +// func New(publisher *rmq.Publisher) *Handler { +// return &Handler{publisher: publisher} +// } type Server struct { - db *sqlc.Queries - publisher *rmq.Publisher + db *sqlc.Queries + // publisher *rmq.Publisher RPCclient *rmq.RPCClient } -func NewServer(db *sqlc.Queries, publisher *rmq.Publisher, rpcclient *rmq.RPCClient) *Server { +func NewServer(db *sqlc.Queries, rpcclient *rmq.RPCClient) *Server { return &Server{ - db: db, - publisher: publisher, + db: db, + // publisher: publisher, RPCclient: rpcclient, } } diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 300cc87..7aeeb11 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -197,7 +197,6 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje // Делаем RPC-вызов — и ЖДЁМ ответа err := s.RPCclient.Call( ctx, - "svc.media.process.requests", // ← очередь микросервиса mqreq, &reply, ) diff --git a/modules/backend/main.go b/modules/backend/main.go index 755e3ef..e7e6ec8 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -59,10 +59,9 @@ func main() { } defer rmqConn.Close() - publisher := rmq.NewPublisher(rmqConn) rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second) - server := handlers.NewServer(queries, publisher, rpcClient) + server := handlers.NewServer(queries, rpcClient) r.Use(cors.New(cors.Config{ AllowOrigins: []string{AppConfig.ServiceAddress}, diff --git a/modules/backend/rmq/rabbit.go b/modules/backend/rmq/rabbit.go index 52c1979..25abbdb 100644 --- a/modules/backend/rmq/rabbit.go +++ b/modules/backend/rmq/rabbit.go @@ -4,13 +4,16 @@ import ( "context" "encoding/json" "fmt" - oapi "nyanimedb/api" - "sync" "time" + oapi "nyanimedb/api" + amqp "github.com/rabbitmq/amqp091-go" ) +const RPCQueueName = "anime_import_rpc" + +// RabbitRequest не меняем type RabbitRequest struct { Name string `json:"name"` Statuses []oapi.TitleStatus `json:"statuses,omitempty"` @@ -20,151 +23,6 @@ type RabbitRequest struct { Timestamp time.Time `json:"timestamp"` } -// Publisher — потокобезопасный публикатор с пулом каналов. -type Publisher struct { - conn *amqp.Connection - pool *sync.Pool -} - -// NewPublisher создаёт новый Publisher. -// conn должен быть уже установленным и healthy. -// Рекомендуется передавать durable connection с reconnect-логикой. -func NewPublisher(conn *amqp.Connection) *Publisher { - return &Publisher{ - conn: conn, - pool: &sync.Pool{ - New: func() any { - ch, err := conn.Channel() - if err != nil { - // Паника уместна: невозможность открыть канал — критическая ошибка инициализации - panic(fmt.Errorf("rmqpool: failed to create channel: %w", err)) - } - return ch - }, - }, - } -} - -// Publish публикует сообщение в указанную очередь. -// Очередь объявляется как durable (если не существует). -// Поддерживает context для отмены/таймаута. -func (p *Publisher) Publish( - ctx context.Context, - queueName string, - payload RabbitRequest, - opts ...PublishOption, -) error { - // Применяем опции - options := &publishOptions{ - contentType: "application/json", - deliveryMode: amqp.Persistent, - timestamp: time.Now(), - } - for _, opt := range opts { - opt(options) - } - - // Сериализуем payload - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("rmqpool: failed to marshal payload: %w", err) - } - - // Берём канал из пула - ch := p.getChannel() - if ch == nil { - return fmt.Errorf("rmqpool: channel is nil (connection may be closed)") - } - defer p.returnChannel(ch) - - // Объявляем очередь (idempotent) - q, err := ch.QueueDeclare( - queueName, - true, // durable - false, // auto-delete - false, // exclusive - false, // no-wait - nil, // args - ) - if err != nil { - return fmt.Errorf("rmqpool: failed to declare queue %q: %w", queueName, err) - } - - // Подготавливаем сообщение - msg := amqp.Publishing{ - DeliveryMode: options.deliveryMode, - ContentType: options.contentType, - Timestamp: options.timestamp, - Body: body, - } - - // Публикуем с учётом контекста - done := make(chan error, 1) - go func() { - err := ch.Publish( - "", // exchange (default) - q.Name, // routing key - false, // mandatory - false, // immediate - msg, - ) - done <- err - }() - - select { - case err := <-done: - return err - case <-ctx.Done(): - return ctx.Err() - } -} - -func (p *Publisher) getChannel() *amqp.Channel { - raw := p.pool.Get() - if raw == nil { - ch, _ := p.conn.Channel() - return ch - } - ch := raw.(*amqp.Channel) - if ch.IsClosed() { // ← теперь есть! - ch.Close() // освободить ресурсы - ch, _ = p.conn.Channel() - } - return ch -} - -// returnChannel возвращает канал в пул, если он жив. -func (p *Publisher) returnChannel(ch *amqp.Channel) { - if ch != nil && !ch.IsClosed() { - p.pool.Put(ch) - } -} - -// PublishOption позволяет кастомизировать публикацию. -type PublishOption func(*publishOptions) - -type publishOptions struct { - contentType string - deliveryMode uint8 - timestamp time.Time -} - -// WithContentType устанавливает Content-Type (по умолчанию "application/json"). -func WithContentType(ct string) PublishOption { - return func(o *publishOptions) { o.contentType = ct } -} - -// WithTransient делает сообщение transient (не сохраняется на диск). -// По умолчанию — Persistent. -func WithTransient() PublishOption { - return func(o *publishOptions) { o.deliveryMode = amqp.Transient } -} - -// WithTimestamp устанавливает кастомную метку времени. -func WithTimestamp(ts time.Time) PublishOption { - return func(o *publishOptions) { o.timestamp = ts } -} - type RPCClient struct { conn *amqp.Connection timeout time.Duration @@ -174,37 +32,48 @@ func NewRPCClient(conn *amqp.Connection, timeout time.Duration) *RPCClient { return &RPCClient{conn: conn, timeout: timeout} } -// Call отправляет запрос в очередь и ждёт ответа. -// replyPayload — указатель на структуру, в которую раскодировать ответ (например, &MediaResponse{}). func (c *RPCClient) Call( ctx context.Context, - requestQueue string, request RabbitRequest, replyPayload any, ) error { - // 1. Создаём временный канал (не из пула!) + + // 1. Канал для запроса и ответа ch, err := c.conn.Channel() if err != nil { return fmt.Errorf("channel: %w", err) } defer ch.Close() - // 2. Создаём временную очередь для ответов - q, err := ch.QueueDeclare( - "", // auto name - false, // not durable - true, // exclusive - true, // auto-delete + // 2. Декларируем фиксированную очередь RPC (идемпотентно) + _, err = ch.QueueDeclare( + RPCQueueName, + true, // durable + false, // auto-delete + false, // exclusive + false, // no-wait + nil, + ) + if err != nil { + return fmt.Errorf("declare rpc queue: %w", err) + } + + // 3. Создаём временную очередь ДЛЯ ОТВЕТА + replyQueue, err := ch.QueueDeclare( + "", + false, + true, + true, false, nil, ) if err != nil { - return fmt.Errorf("reply queue: %w", err) + return fmt.Errorf("declare reply queue: %w", err) } - // 3. Подписываемся на ответы + // 4. Подписываемся на очередь ответов msgs, err := ch.Consume( - q.Name, + replyQueue.Name, "", true, // auto-ack true, // exclusive @@ -213,28 +82,28 @@ func (c *RPCClient) Call( nil, ) if err != nil { - return fmt.Errorf("consume: %w", err) + return fmt.Errorf("consume reply: %w", err) } - // 4. Готовим correlation ID - corrID := time.Now().UnixNano() + // correlation ID + corrID := fmt.Sprintf("%d", time.Now().UnixNano()) - // 5. Сериализуем запрос + // 5. сериализация запроса body, err := json.Marshal(request) if err != nil { return fmt.Errorf("marshal request: %w", err) } - // 6. Публикуем запрос + // 6. Публикация RPC-запроса err = ch.Publish( "", - requestQueue, + RPCQueueName, // ← фиксированная очередь! false, false, amqp.Publishing{ ContentType: "application/json", - CorrelationId: fmt.Sprintf("%d", corrID), - ReplyTo: q.Name, + CorrelationId: corrID, + ReplyTo: replyQueue.Name, Timestamp: time.Now(), Body: body, }, @@ -244,18 +113,17 @@ func (c *RPCClient) Call( } // 7. Ждём ответ с таймаутом - ctx, cancel := context.WithTimeout(ctx, c.timeout) + timeoutCtx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() for { select { case msg := <-msgs: - if msg.CorrelationId == fmt.Sprintf("%d", corrID) { + if msg.CorrelationId == corrID { return json.Unmarshal(msg.Body, replyPayload) } - // игнорируем другие сообщения (маловероятно, но возможно) - case <-ctx.Done(): - return ctx.Err() // timeout or cancelled + case <-timeoutCtx.Done(): + return fmt.Errorf("rpc timeout: %w", timeoutCtx.Err()) } } } From ff361737205f5ae7774284f1bb4b4beb459eaccb Mon Sep 17 00:00:00 2001 From: garaev kamil <garaev.kr@phystech.edu> Date: Fri, 5 Dec 2025 22:59:33 +0300 Subject: [PATCH 186/224] etl module added --- modules/anime_etl/canonicalizer.py | 26 + modules/anime_etl/db/repository.py | 235 +++++++ modules/anime_etl/images/downloader.py | 77 +++ modules/anime_etl/jikan_studio_enricher.py | 38 ++ modules/anime_etl/mappers/__init__.py | 0 modules/anime_etl/mappers/anilist_filters.py | 35 + modules/anime_etl/normalizers/__init__.py | 0 .../normalizers/anilist_normalizer.py | 184 ++++++ modules/anime_etl/pyproject.toml | 13 + modules/anime_etl/rabbit_worker.py | 123 ++++ .../anime_etl/services/anilist_importer.py | 93 +++ modules/anime_etl/sources/__init__.py | 0 .../anime_etl/sources/jikan_async_client.py | 54 ++ modules/anime_etl/utils/__init__.py | 0 modules/anime_etl/utils/season_resolver.py | 89 +++ modules/anime_etl/uv.lock | 606 ++++++++++++++++++ 16 files changed, 1573 insertions(+) create mode 100644 modules/anime_etl/canonicalizer.py create mode 100644 modules/anime_etl/db/repository.py create mode 100644 modules/anime_etl/images/downloader.py create mode 100644 modules/anime_etl/jikan_studio_enricher.py create mode 100644 modules/anime_etl/mappers/__init__.py create mode 100644 modules/anime_etl/mappers/anilist_filters.py create mode 100644 modules/anime_etl/normalizers/__init__.py create mode 100644 modules/anime_etl/normalizers/anilist_normalizer.py create mode 100644 modules/anime_etl/pyproject.toml create mode 100644 modules/anime_etl/rabbit_worker.py create mode 100644 modules/anime_etl/services/anilist_importer.py create mode 100644 modules/anime_etl/sources/__init__.py create mode 100644 modules/anime_etl/sources/jikan_async_client.py create mode 100644 modules/anime_etl/utils/__init__.py create mode 100644 modules/anime_etl/utils/season_resolver.py create mode 100644 modules/anime_etl/uv.lock diff --git a/modules/anime_etl/canonicalizer.py b/modules/anime_etl/canonicalizer.py new file mode 100644 index 0000000..3aa5c2b --- /dev/null +++ b/modules/anime_etl/canonicalizer.py @@ -0,0 +1,26 @@ +# anime_etl/canonicalizer.py +from __future__ import annotations + +from models import SourceTitle, CanonicalTitle + + +def source_title_to_canonical(src: SourceTitle) -> CanonicalTitle: + return CanonicalTitle( + id=None, + title_names=src.title_names, + studio=src.studio, + tags=list(src.tags), + poster=src.poster, + + title_status=src.title_status, + rating=src.rating, + rating_count=src.rating_count, + + release_year=src.release_year, + release_season=src.release_season, + + season=src.season, + episodes_aired=src.episodes_aired, + episodes_all=src.episodes_all, + episodes_len=src.episodes_len, + ) diff --git a/modules/anime_etl/db/repository.py b/modules/anime_etl/db/repository.py new file mode 100644 index 0000000..45fba0f --- /dev/null +++ b/modules/anime_etl/db/repository.py @@ -0,0 +1,235 @@ +# anime_etl/db/repository.py +from __future__ import annotations + +import json +from typing import Optional, Dict, List + +import psycopg +from psycopg.rows import dict_row + +from models import CanonicalTitle, Studio, Image +from images.downloader import ensure_image_downloaded + + +Conn = psycopg.AsyncConnection + + +def _choose_primary_name( + title_names: Dict[str, List[str]], +) -> Optional[tuple[str, str]]: + # (lang, name) + for lang in ("en", "romaji", "ja"): + variants = title_names.get(lang) or [] + if variants: + return lang, variants[0] + + for lang, variants in title_names.items(): + if variants: + return lang, variants[0] + + return None + + +async def get_or_create_image( + conn: Conn, + img: Optional[Image], + *, + subdir: str = "posters", +) -> Optional[int]: + if img is None or not img.image_path: + return None + + # img.image_path сейчас — URL из AniList + url = img.image_path + + # 1) решаем, куда кладём картинку, и если надо — скачиваем + rel_path = await ensure_image_downloaded(url, subdir=subdir) + + async with conn.cursor(row_factory=dict_row) as cur: + # 2) пробуем найти уже существующую запись по относительному пути + await cur.execute( + "SELECT id FROM images WHERE image_path = %s", + (rel_path,), + ) + row = await cur.fetchone() + if row: + return row["id"] + + # 3) создаём новую запись + await cur.execute( + """ + INSERT INTO images (storage_type, image_path) + VALUES (%s, %s) + RETURNING id + """, + ("local", rel_path), + ) + row = await cur.fetchone() + return row["id"] + + +async def get_or_create_studio( + conn: Conn, + studio: Optional[Studio], +) -> Optional[int]: + if studio is None or not studio.name: + return None + + async with conn.cursor(row_factory=dict_row) as cur: + # 1. Сначала ищем студию + await cur.execute( + "SELECT id, illust_id, studio_desc FROM studios WHERE studio_name = %s", + (studio.name,), + ) + row = await cur.fetchone() + + if row: + studio_id = row["id"] + illust_id = row["illust_id"] + studio_desc = row["studio_desc"] + + # 1a. Если нет illust_id, а нам пришёл постер — докачаем и обновим + if illust_id is None and studio.poster is not None: + illust_id = await get_or_create_image(conn, studio.poster, subdir="studios") + await cur.execute( + "UPDATE studios SET illust_id = %s WHERE id = %s", + (illust_id, studio_id), + ) + + # 1b. Если нет описания, а enrich уже поднял description — обновим описание + if studio_desc is None and studio.description: + await cur.execute( + "UPDATE studios SET studio_desc = %s WHERE id = %s", + (studio.description, studio_id), + ) + + return studio_id + + # 2. Студии нет — создаём + illust_id: Optional[int] = None + if studio.poster is not None: + illust_id = await get_or_create_image(conn, studio.poster, subdir="studios") + + await cur.execute( + """ + INSERT INTO studios (studio_name, illust_id, studio_desc) + VALUES (%s, %s, %s) + RETURNING id + """, + (studio.name, illust_id, studio.description), + ) + row = await cur.fetchone() + return row["id"] + +async def find_title_id_by_name_and_year( + conn: Conn, + title_names: Dict[str, List[str]], + release_year: Optional[int], +) -> Optional[int]: + if release_year is None: + return None + + pair = _choose_primary_name(title_names) + if not pair: + return None + + lang, primary_name = pair + probe = json.dumps({lang: [primary_name]}) + + async with conn.cursor(row_factory=dict_row) as cur: + await cur.execute( + """ + SELECT id + FROM titles + WHERE release_year = %s + AND title_names @> %s::jsonb + LIMIT 1 + """, + (release_year, probe), + ) + row = await cur.fetchone() + + if not row: + return None + + return row["id"] + + +async def insert_title( + conn: Conn, + title: CanonicalTitle, + studio_id: Optional[int], + poster_id: Optional[int], +) -> int: + episodes_len_json = ( + json.dumps(title.episodes_len) if title.episodes_len is not None else None + ) + + async with conn.cursor(row_factory=dict_row) as cur: + await cur.execute( + """ + INSERT INTO titles ( + title_names, + studio_id, + poster_id, + title_status, + rating, + rating_count, + release_year, + release_season, + season, + episodes_aired, + episodes_all, + episodes_len + ) + VALUES ( + %s::jsonb, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s::jsonb + ) + RETURNING id + """, + ( + json.dumps(title.title_names), + studio_id, + poster_id, + title.title_status, + title.rating, + title.rating_count, + title.release_year, + title.release_season, + title.season, + title.episodes_aired, + title.episodes_all, + episodes_len_json, + ), + ) + row = await cur.fetchone() + return row["id"] + + + +async def insert_title_if_not_exists( + conn: Conn, + title: CanonicalTitle, + studio_id: Optional[int], + poster_id: Optional[int], +) -> int: + existing_id = await find_title_id_by_name_and_year( + conn, + title.title_names, + title.release_year, + ) + if existing_id is not None: + return existing_id + + return await insert_title(conn, title, studio_id, poster_id) \ No newline at end of file diff --git a/modules/anime_etl/images/downloader.py b/modules/anime_etl/images/downloader.py new file mode 100644 index 0000000..1c5134c --- /dev/null +++ b/modules/anime_etl/images/downloader.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import asyncio +import hashlib +import os +from pathlib import Path +from typing import Tuple +from urllib.parse import urlparse + +import httpx + +# Корень хранилища картинок внутри контейнера/процесса +MEDIA_ROOT = Path(os.getenv("NYANIMEDB_MEDIA_ROOT", "media")).resolve() + + +def _guess_ext_from_url(url: str) -> str: + path = urlparse(url).path + _, ext = os.path.splitext(path) + if ext and len(ext) <= 5: + return ext + return ".jpg" + + +def _build_rel_path_from_hash(h: str, ext: str, subdir: str = "posters") -> Tuple[str, Path]: + """ + Строим путь вида subdir/ab/cd/<hash>.ext по sha1-хешу содержимого. + """ + level1 = h[:2] + level2 = h[2:4] + rel = f"{subdir}/{level1}/{level2}/{h}{ext}" + abs_path = MEDIA_ROOT / rel + return rel, abs_path + + +async def _fetch_bytes(url: str) -> bytes: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(url) + r.raise_for_status() + return r.content + + +async def ensure_image_downloaded(url: str, subdir: str = "posters") -> str: + """ + Гарантирует, что картинка по URL лежит в MEDIA_ROOT/subdir в структуре: + subdir/ab/cd/<sha1(content)>.ext + + Возвращает относительный путь (для записи в БД). + Один и тот же файл (по содержимому) всегда даёт один и тот же путь, + даже если URL меняется. + """ + # Скачиваем данные + data = await _fetch_bytes(url) + + # Хешируем именно содержимое, а не URL + h = hashlib.sha1(data).hexdigest() + ext = _guess_ext_from_url(url) + + rel, abs_path = _build_rel_path_from_hash(h, ext, subdir=subdir) + + # Если файл уже есть (другой процесс/воркер успел сохранить) — просто возвращаем путь + if abs_path.exists(): + return rel + + abs_path.parent.mkdir(parents=True, exist_ok=True) + + # Пишем во временный файл и затем делаем atomic rename + tmp_path = abs_path.with_suffix(abs_path.suffix + ".tmp") + + def _write() -> None: + with open(tmp_path, "wb") as f: + f.write(data) + # os.replace атомарно заменит файл, даже если он уже появился + os.replace(tmp_path, abs_path) + + await asyncio.to_thread(_write) + + return rel diff --git a/modules/anime_etl/jikan_studio_enricher.py b/modules/anime_etl/jikan_studio_enricher.py new file mode 100644 index 0000000..527020d --- /dev/null +++ b/modules/anime_etl/jikan_studio_enricher.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Optional + +from models import Studio +from sources.jikan_async_client import search_producer, fetch_producer_full + + +async def enrich_studio_with_jikan_desc(studio: Studio) -> Studio: + """ + Если у студии нет description — ищем её в Jikan и подтягиваем about. + + Ничего не ломает: + - если не нашли / нет about — возвращаем студию как есть + - poster/id не трогаем + """ + if not studio or studio.description: + return studio + + matches = await search_producer(studio.name, limit=1) + if not matches: + return studio + + mal_id = matches[0].get("mal_id") + if not isinstance(mal_id, int): + return studio + + full = await fetch_producer_full(mal_id) + if not full: + return studio + + about = full.get("about") + if not isinstance(about, str) or not about.strip(): + return studio + + # лёгкая нормализация: убираем лишние переносы/пробелы + studio.description = " ".join(about.split()) + return studio diff --git a/modules/anime_etl/mappers/__init__.py b/modules/anime_etl/mappers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/anime_etl/mappers/anilist_filters.py b/modules/anime_etl/mappers/anilist_filters.py new file mode 100644 index 0000000..38bc31a --- /dev/null +++ b/modules/anime_etl/mappers/anilist_filters.py @@ -0,0 +1,35 @@ +SEASON = { + "winter": "WINTER", + "spring": "SPRING", + "summer": "SUMMER", + "fall": "FALL", +} + +FORMAT = { + "tv": "TV", + "movie": "MOVIE", + "ova": "OVA", + "ona": "ONA", + "special": "SPECIAL", + "music": "MUSIC", +} + +def to_anilist_filters(local: dict) -> dict: + """Наши фильтры → AniList GraphQL variables.""" + q = local.get("query") + year = local.get("year") + season = SEASON.get(local.get("season")) + fmt = FORMAT.get(local.get("type")) + limit = local.get("limit", 10) + + variables = { + "page": 1, + "perPage": limit, + } + + if q: variables["search"] = q + if year: variables["seasonYear"] = year + if season: variables["season"] = season + if fmt: variables["format"] = fmt + + return variables diff --git a/modules/anime_etl/normalizers/__init__.py b/modules/anime_etl/normalizers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/anime_etl/normalizers/anilist_normalizer.py b/modules/anime_etl/normalizers/anilist_normalizer.py new file mode 100644 index 0000000..7243091 --- /dev/null +++ b/modules/anime_etl/normalizers/anilist_normalizer.py @@ -0,0 +1,184 @@ +# anime_etl/anilist_normalizer.py +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from models import Source, SourceTitle, Studio, Image, Tag +from utils.season_resolver import resolve_season_from_media + +STATUS_MAP: Dict[str, str] = { + "FINISHED": "finished", + "RELEASING": "ongoing", + "NOT_YET_RELEASED": "planned", + "CANCELLED": "planned", + "HIATUS": "ongoing", +} + +SEASON_MAP: Dict[str, str] = { + "WINTER": "winter", + "SPRING": "spring", + "SUMMER": "summer", + "FALL": "fall", +} + + +def _title_names(media: Dict[str, Any]) -> Dict[str, List[str]]: + t = media.get("title") or {} + native = t.get("native") + english = t.get("english") + romaji = t.get("romaji") + + res: Dict[str, List[str]] = {} + if native: + res.setdefault("ja", []).append(native) + if english: + res.setdefault("en", []).append(english) + if romaji: + res.setdefault("romaji", []).append(romaji) + return res + + +def _studio(media: Dict[str, Any]) -> Optional[Studio]: + studios_nodes = (media.get("studios") or {}).get("nodes") or [] + if not studios_nodes: + return None + name = studios_nodes[0].get("name") + if not name: + return None + return Studio(id=None, name=name, poster=None, description=None) + + +def _tags(media: Dict[str, Any]) -> List[Tag]: + genres = media.get("genres") or [] + res: List[Tag] = [] + for g in genres: + if g: + res.append(Tag(names={"en": g})) + return res + + +def _poster(media: Dict[str, Any]) -> Optional[Image]: + cover = media.get("coverImage") or {} + url = cover.get("extraLarge") or cover.get("large") + if not url: + return None + return Image(id=None, storage_type=None, image_path=url) + + +def _status(media: Dict[str, Any]) -> Optional[str]: + raw = media.get("status") + if not raw: + return None + return STATUS_MAP.get(raw) + + +def _rating(media: Dict[str, Any]) -> Optional[float]: + avg = media.get("averageScore") + if avg is None: + return None + try: + return float(avg) / 10.0 + except (TypeError, ValueError): + return None + + +def _rating_count(media: Dict[str, Any]) -> Optional[int]: + pop = media.get("popularity") + if pop is None: + return None + try: + return int(pop) + except (TypeError, ValueError): + return None + + +def _year_and_season(media: Dict[str, Any]) -> tuple[Optional[int], Optional[str]]: + year = media.get("seasonYear") + raw_season = media.get("season") + release_year = year if isinstance(year, int) else None + release_season = None + if isinstance(raw_season, str): + release_season = SEASON_MAP.get(raw_season.upper()) + return release_year, release_season + + +def _episodes(media: Dict[str, Any]) -> tuple[Optional[int], Optional[int]]: + episodes_all = media.get("episodes") + if not isinstance(episodes_all, int): + episodes_all = None + + next_ep = media.get("nextAiringEpisode") or {} + ep_num = next_ep.get("episode") if isinstance(next_ep, dict) else None + if not isinstance(ep_num, int): + ep_num = None + + # базовая логика + if ep_num is not None: + episodes_aired = ep_num - 1 + else: + episodes_aired = episodes_all + + # приведение к инварианту БД: + # либо обе NULL, либо обе заданы и episodes_aired <= episodes_all + if episodes_aired is None and episodes_all is None: + return None, None + + if episodes_all is None and episodes_aired is not None: + episodes_all = episodes_aired + + if episodes_aired is None and episodes_all is not None: + episodes_aired = episodes_all + + if ( + episodes_aired is not None + and episodes_all is not None + and episodes_aired > episodes_all + ): + episodes_aired = episodes_all + + return episodes_aired, episodes_all + + + +def _episodes_len(media: Dict[str, Any]) -> Optional[Dict[str, float]]: + duration = media.get("duration") + if duration is None: + return None + try: + return {"default": float(duration)} + except (TypeError, ValueError): + return None + + +def normalize_media(media: Dict[str, Any]) -> SourceTitle: + """AniList Media JSON -> наш SourceTitle.""" + title_names = _title_names(media) + studio = _studio(media) + tags = _tags(media) + poster = _poster(media) + title_status = _status(media) + rating = _rating(media) + rating_count = _rating_count(media) + release_year, release_season = _year_and_season(media) + episodes_aired, episodes_all = _episodes(media) + episodes_len = _episodes_len(media) + + season = resolve_season_from_media(media) + + return SourceTitle( + source=Source.ANILIST, + external_id=str(media["id"]), + title_names=title_names, + studio=studio, + tags=tags, + poster=poster, + title_status=title_status, + rating=rating, + rating_count=rating_count, + release_year=release_year, + release_season=release_season, + season=season, + episodes_aired=episodes_aired, + episodes_all=episodes_all, + episodes_len=episodes_len, + ) diff --git a/modules/anime_etl/pyproject.toml b/modules/anime_etl/pyproject.toml new file mode 100644 index 0000000..342c74a --- /dev/null +++ b/modules/anime_etl/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "anime-etl" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "aio-pika>=9.5.8", + "httpx>=0.28.1", + "psycopg[binary]>=3.3.1", + "pydantic>=2.12.5", + "python-dotenv>=1.2.1", +] diff --git a/modules/anime_etl/rabbit_worker.py b/modules/anime_etl/rabbit_worker.py new file mode 100644 index 0000000..fed33dc --- /dev/null +++ b/modules/anime_etl/rabbit_worker.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import asyncio +import json +import os +import sys +from typing import Any, Dict + +from dotenv import load_dotenv + +import aio_pika +import psycopg +from psycopg.rows import dict_row + +from services.anilist_importer import AniListImporter + +load_dotenv() + +PG_DSN = os.getenv("NYANIMEDB_PG_DSN") +RMQ_URL = os.getenv("NYANIMEDB_RABBITMQ_URL", "amqp://guest:guest@10.1.0.65:5672/") +RPC_QUEUE_NAME = os.getenv("NYANIMEDB_IMPORT_RPC_QUEUE", "anime_import_rpc") + + +def rmq_request_to_filters(payload: Dict[str, Any]) -> Dict[str, Any]: + filters: Dict[str, Any] = {} + + name = payload.get("name") + if isinstance(name, str) and name.strip(): + filters["query"] = name.strip() + + year = payload.get("year") + if isinstance(year, int) and year > 0: + filters["year"] = year + + season = payload.get("season") + if isinstance(season, str) and season: + filters["season"] = season.lower() + + filters.setdefault("limit", 10) + return filters + + +def create_handler(channel: aio_pika.Channel): + async def handle_message(message: aio_pika.IncomingMessage) -> None: + async with message.process(): + try: + payload = json.loads(message.body.decode("utf-8")) + except json.JSONDecodeError: + return + + if not isinstance(payload, dict): + return + + filters = rmq_request_to_filters(payload) + timestamp = payload.get("timestamp") + + try: + async with await psycopg.AsyncConnection.connect( + PG_DSN, + row_factory=dict_row, + ) as conn: + importer = AniListImporter() + titles = await importer.import_by_filters_in_tx(conn, filters) + + response: dict[str, Any] = { + "timestamp": timestamp, + "ok": True, + "titles": titles, + "error": None, + } + + except Exception as e: + response = { + "timestamp": timestamp, + "ok": False, + "titles": [], + "error": { + "code": "import_failed", + "message": str(e), + }, + } + + body = json.dumps(response).encode("utf-8") + + if message.reply_to: + await channel.default_exchange.publish( + aio_pika.Message( + body=body, + content_type="application/json", + correlation_id=message.correlation_id, + ), + routing_key=message.reply_to, + ) + + return handle_message + + +async def main() -> None: + if not PG_DSN: + raise RuntimeError("NYANIMEDB_PG_DSN is not set") + + connection = await aio_pika.connect_robust(RMQ_URL) + channel = await connection.channel() + + queue = await channel.declare_queue( + RPC_QUEUE_NAME, + durable=True, + ) + + handler = create_handler(channel) + await queue.consume(handler) + + try: + await asyncio.Future() # run forever + finally: + await connection.close() + + +if __name__ == "__main__": + if sys.platform.startswith("win"): + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + asyncio.run(main()) diff --git a/modules/anime_etl/services/anilist_importer.py b/modules/anime_etl/services/anilist_importer.py new file mode 100644 index 0000000..728a301 --- /dev/null +++ b/modules/anime_etl/services/anilist_importer.py @@ -0,0 +1,93 @@ +# anime_etl/services/anilist_importer.py +from __future__ import annotations + +from typing import Any, Dict, List + +import psycopg +from psycopg.rows import dict_row + +from sources.anilist_source import AniListSource +from canonicalizer import source_title_to_canonical +from db.repository import ( + get_or_create_studio, + get_or_create_image, + insert_title_if_not_exists, +) +from models import CanonicalTitle +from jikan_studio_enricher import enrich_studio_with_jikan_desc + + +Conn = psycopg.AsyncConnection + + +class AniListImporter: + def __init__(self, source: AniListSource | None = None) -> None: + self._source = source or AniListSource() + + async def import_by_filters_in_tx( + self, + conn: Conn, + filters: Dict[str, Any], + ) -> List[Dict[str, Any]]: + """ + Выполнить импорт в рамках одной транзакции: + - поиск в AniList + - канонизация + - обогащение студии (Jikan) + - get_or_create_studio (+ illust_id) + - скачивание постера тайтла -> images + - insert_title_if_not_exists + """ + async with conn.transaction(): + return await self._import_by_filters(conn, filters) + + async def _import_by_filters( + self, + conn: Conn, + filters: Dict[str, Any], + ) -> List[Dict[str, Any]]: + source_titles = await self._source.search(filters) + + results: List[Dict[str, Any]] = [] + + for st in source_titles: + canonical: CanonicalTitle = source_title_to_canonical(st) + + # 1) обогатить студию описанием из Jikan (если есть студия и ещё нет description) + if canonical.studio is None: + continue + canonical.studio = await enrich_studio_with_jikan_desc(canonical.studio) + + # 2) создать/обновить студию (studio_name, illust_id, studio_desc) + studio_id = await get_or_create_studio(conn, canonical.studio) + + # 3) скачать постер тайтла и создать запись в images + poster_id = await get_or_create_image(conn, canonical.poster, subdir="posters") + + # 4) создать тайтл, если его ещё нет (с учётом studio_id и poster_id) + title_id = await insert_title_if_not_exists(conn, canonical, studio_id, poster_id) + + results.append( + { + "id": title_id, + "title_names": canonical.title_names, + "release_year": canonical.release_year, + "release_season": canonical.release_season, + "season": canonical.season, + } + ) + + return results + + +async def import_from_anilist( + dsn: str, + filters: Dict[str, Any], +) -> List[Dict[str, Any]]: + """ + Открывает подключение к БД, делает транзакцию и импорт. + """ + importer = AniListImporter() + + async with await psycopg.AsyncConnection.connect(dsn, row_factory=dict_row) as conn: + return await importer.import_by_filters_in_tx(conn, filters) diff --git a/modules/anime_etl/sources/__init__.py b/modules/anime_etl/sources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/anime_etl/sources/jikan_async_client.py b/modules/anime_etl/sources/jikan_async_client.py new file mode 100644 index 0000000..e2c1a40 --- /dev/null +++ b/modules/anime_etl/sources/jikan_async_client.py @@ -0,0 +1,54 @@ +import asyncio +from typing import Any, Dict, List, Optional + +import httpx + +from rate_limiter import JIKAN_RATE_LIMITER + +BASE_URL = "https://api.jikan.moe/v4" +CLIENT = httpx.AsyncClient(timeout=15.0) + + +async def _get(path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Обёртка над GET с ретраями и rate-limit'ом. + """ + url = f"{BASE_URL}{path}" + last_exc: Exception | None = None + + for i in range(5): + await JIKAN_RATE_LIMITER.acquire() + try: + r = await CLIENT.get(url, params=params) + r.raise_for_status() + return r.json() + except Exception as exc: + last_exc = exc + await asyncio.sleep(1 * 2**i) + + raise RuntimeError(f"Jikan unreachable: {last_exc!r}") + + +async def search_producer(name: str, limit: int = 1) -> List[Dict[str, Any]]: + """ + Поиск студии/продюсера по имени. + + Возвращает список элементов из data[]: + [{ "mal_id": int, "name": str, ... }, ...] + """ + if not name: + return [] + + data = await _get("/producers", {"q": name, "limit": limit}) + return data.get("data") or [] + + +async def fetch_producer_full(mal_id: int) -> Optional[Dict[str, Any]]: + """ + Полная инфа по producer'у (есть поле about). + """ + if not isinstance(mal_id, int): + return None + + data = await _get(f"/producers/{mal_id}/full") + return data.get("data") or None diff --git a/modules/anime_etl/utils/__init__.py b/modules/anime_etl/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/anime_etl/utils/season_resolver.py b/modules/anime_etl/utils/season_resolver.py new file mode 100644 index 0000000..d147b49 --- /dev/null +++ b/modules/anime_etl/utils/season_resolver.py @@ -0,0 +1,89 @@ +# anime_etl/utils/season_resolver.py +from __future__ import annotations + +import re +from typing import Optional + + +_ROMAN = { + "I": 1, "II": 2, "III": 3, "IV": 4, + "V": 5, "VI": 6, "VII": 7, "VIII": 8, + "IX": 9, "X": 10, +} + + +def _roman_to_int(token: str) -> Optional[int]: + return _ROMAN.get(token.upper()) + + +# Паттерны типа: +# - "Season 2" +# - "2nd Season" +# - "S3" +# - "III" +_SEASON_PATTERNS = [ + # "Season 2" + re.compile(r"\bseason\s*(\d{1,2})\b", re.IGNORECASE), + # "2nd Season" + re.compile(r"\b(\d{1,2})(?:st|nd|rd|th)\s+season\b", re.IGNORECASE), + # "S3" + re.compile(r"\bs(\d{1,2})\b", re.IGNORECASE), + # одиночное число (осторожно, поэтому используем как самый последний fallback) + re.compile(r"\b(\d{1,2})\b"), + # римские цифры: "III" + re.compile(r"\b([IVX]{1,5})\b", re.IGNORECASE), +] + + +def extract_season_number_from_title(name: str) -> Optional[int]: + name = name.strip() + if not name: + return None + + for pat in _SEASON_PATTERNS: + m = pat.search(name) + if not m: + continue + + token = m.group(1) + + # пробуем римские + roman = _roman_to_int(token) + if roman is not None: + return roman + + # иначе просто число + try: + return int(token) + except ValueError: + continue + + return None + + +def resolve_season_from_media(media: dict) -> Optional[int]: + """ + Определяем номер сезона по данным AniList Media. + + Логика: + - Если формат не TV/ONA → считаем, что это не нумерованный сезон (OVA/MOVIE/...) + - Берём title.english / romaji / native, пытаемся вытащить номер через regex. + """ + fmt = media.get("format") + if fmt not in ("TV", "ONA"): + return None + + title = media.get("title") or {} + candidates: list[str] = [] + + for key in ("english", "romaji", "native"): + v = title.get(key) + if isinstance(v, str): + candidates.append(v) + + for name in candidates: + n = extract_season_number_from_title(name) + if n is not None: + return n + + return None diff --git a/modules/anime_etl/uv.lock b/modules/anime_etl/uv.lock new file mode 100644 index 0000000..45d059e --- /dev/null +++ b/modules/anime_etl/uv.lock @@ -0,0 +1,606 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "aio-pika" +version = "9.5.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiormq" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/73/8d1020683970de5532b3b01732d75c8bf922a6505fcdad1a9c7c6405242a/aio_pika-9.5.8.tar.gz", hash = "sha256:7c36874115f522bbe7486c46d8dd711a4dbedd67c4e8a8c47efe593d01862c62", size = 47408, upload-time = "2025-11-12T10:37:10.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/91/513971861d845d28160ecb205ae2cfaf618b16918a9cd4e0b832b5360ce7/aio_pika-9.5.8-py3-none-any.whl", hash = "sha256:f4c6cb8a6c5176d00f39fd7431e9702e638449bc6e86d1769ad7548b2a506a8d", size = 54397, upload-time = "2025-11-12T10:37:08.374Z" }, +] + +[[package]] +name = "aiormq" +version = "6.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pamqp" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/f6/01bc850db6d9b46ae825e3c373f610b0544e725a1159745a6de99ad0d9f1/aiormq-6.9.2.tar.gz", hash = "sha256:d051d46086079934d3a7157f4d8dcb856b77683c2a94aee9faa165efa6a785d3", size = 30554, upload-time = "2025-10-20T10:49:59.763Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/ec/763b13f148f3760c1562cedb593feaffbae177eeece61af5d0ace7b72a3e/aiormq-6.9.2-py3-none-any.whl", hash = "sha256:ab0f4e88e70f874b0ea344b3c41634d2484b5dc8b17cb6ae0ae7892a172ad003", size = 31829, upload-time = "2025-10-20T10:49:58.547Z" }, +] + +[[package]] +name = "anime-etl" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "aio-pika" }, + { name = "httpx" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, + { name = "python-dotenv" }, +] + +[package.metadata] +requires-dist = [ + { name = "aio-pika", specifier = ">=9.5.8" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.1" }, + { name = "pydantic", specifier = ">=2.12.5" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, + { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, + { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, + { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, + { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, + { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, + { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, + { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, + { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, + { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, + { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, + { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, + { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, + { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, + { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, + { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, + { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, + { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, + { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, + { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +] + +[[package]] +name = "pamqp" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/62/35bbd3d3021e008606cd0a9532db7850c65741bbf69ac8a3a0d8cfeb7934/pamqp-3.3.0.tar.gz", hash = "sha256:40b8795bd4efcf2b0f8821c1de83d12ca16d5760f4507836267fd7a02b06763b", size = 30993, upload-time = "2024-01-12T20:37:25.085Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/8d/c1e93296e109a320e508e38118cf7d1fc2a4d1c2ec64de78565b3c445eb5/pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0", size = 33848, upload-time = "2024-01-12T20:37:21.359Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/ed/3a30e8ef82d4128c76aa9bd6b2a7fe6c16c283811e6655997f5047801b47/psycopg-3.3.1.tar.gz", hash = "sha256:ccfa30b75874eef809c0fbbb176554a2640cc1735a612accc2e2396a92442fc6", size = 165596, upload-time = "2025-12-02T21:09:55.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/f3/0b4a4c25a47c2d907afa97674287dab61bc9941c9ac3972a67100e33894d/psycopg-3.3.1-py3-none-any.whl", hash = "sha256:e44d8eae209752efe46318f36dd0fdf5863e928009338d736843bb1084f6435c", size = 212760, upload-time = "2025-12-02T21:02:36.029Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/29/12cfc28594aa940f5894da1b2f5368f9163260e3d6b53cf3eb9413d07489/psycopg_binary-3.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f0afb5653614ad4a9a2fa0fa8c593508a18bd319afc26b20a33b883f263bf90", size = 4579837, upload-time = "2025-12-02T21:07:09.264Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f0/2b4cfca5161af8bb573963d9540f97b191a5dfe1afd02c3183feeade47a2/psycopg_binary-3.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b84ed483a4d0271be201005c7567161fc6bc884f7ebc08ed9f82083b3a0d1f9e", size = 4658787, upload-time = "2025-12-02T21:07:17.39Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/ade0f141178633b098cc80af7922d13bbfc1014401232785af6e485563a2/psycopg_binary-3.3.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3323652b73305e23cc9b5f4e332b25f00c8cb16f47ef84ee4430b7df38273707", size = 5454896, upload-time = "2025-12-02T21:07:23.918Z" }, + { url = "https://files.pythonhosted.org/packages/65/14/90aac9ec57580da90bd6a0986288f0422b0a650f1686e10444b8b579c0f2/psycopg_binary-3.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c64ed9a49e606c764510a1d98270cc42a38527776aa98baf6e8c4e20c5341b96", size = 5132733, upload-time = "2025-12-02T21:07:28.789Z" }, + { url = "https://files.pythonhosted.org/packages/90/62/bb2da10712e409ec1579be67a879824ab484989de8ed773309c880b57213/psycopg_binary-3.3.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b81eee7d7f5fa9a85778fb854d979ba16f48cec584c17c51117ba94ad9d6a667", size = 6724495, upload-time = "2025-12-02T21:07:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/02/34/baf21418e62002c3cc0d35f4431b0b2953c44272e572ccd3b4161ffaa886/psycopg_binary-3.3.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d0b967eada1831f6e8b652f6868c9fbdf80e397c1f096226fe0d545112f907d", size = 4964978, upload-time = "2025-12-02T21:07:39.34Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a375f37a852722878e2292f64dba8632d89c9afe0a3e0b9920a6bbcee847/psycopg_binary-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b2c86492e9b41d942bc263e5b961498bd404444b0547e1e2456e8f919599ad14", size = 4493649, upload-time = "2025-12-02T21:07:43.991Z" }, + { url = "https://files.pythonhosted.org/packages/bc/40/bb4bf3a141a1cbc36abd86867ca352c0807f062d5cb01d3e9141c685975b/psycopg_binary-3.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5468dcdb2717dc764d1e1d9a391b714d28717bc8613e2e5481f261718e4e72c5", size = 4173392, upload-time = "2025-12-02T21:07:48.509Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/178274150e5f0398697e74c0027651c668ca2e2ec57db98c811ba97bf69b/psycopg_binary-3.3.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e3aa33e5553d12b91e23b928e869587289c6c26de58b3b14f70bed06eb767c58", size = 3909241, upload-time = "2025-12-02T21:07:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/42/01/220119f18bf8447756b92f5db87a6e723ae1dd1db81ad591393714b71f5e/psycopg_binary-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bbd26acb1ba8416a16256bfd87de9a1427fb2e04f8d79eae3fb64a112ede06f1", size = 4219745, upload-time = "2025-12-02T21:07:57.374Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/ece0da8b6befb17bb5ffd64eb28fb5ddd539d2569700f2e3e78e91385434/psycopg_binary-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ce74da70444348135f9b5b9b67121eb9816ef483159bf54083765792c948f249", size = 3537480, upload-time = "2025-12-02T21:08:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/79/44/f907c508267bc203082217faf5750274f4c240471f99990db70ed9f4dada/psycopg_binary-3.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fda22ce8530236381ff79a674ebc319f1a224f2e39a44158774e55e1488f89b9", size = 4579070, upload-time = "2025-12-02T21:08:04.885Z" }, + { url = "https://files.pythonhosted.org/packages/08/61/554bd7b9b93aef79f0bd3c4d381d9809ecf38e55ab4eb5984f58a74695f7/psycopg_binary-3.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d2fc5fa6c45c406e43c8cc2787055d487b3ae597d2139125191b37fa04835f01", size = 4657517, upload-time = "2025-12-02T21:08:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/c3bc20b72b9b056d1f7a3a131d676c66fe2bc7585f9d9f23d659d8725407/psycopg_binary-3.3.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4d97e4d27cc7ee6938faf7dc9919c452581b4795bd97f3f48582846f24ab81ed", size = 5452087, upload-time = "2025-12-02T21:08:15.144Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e6/58ae55874b963893f46d68748b28e05c432b0c109f53b40970ba6bf9fe95/psycopg_binary-3.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f4fb5a3cf4373a8b06c2feb8f29ff4c69968ba443687dedec9f79ce22ef339ae", size = 5131126, upload-time = "2025-12-02T21:08:23.463Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/bfcfb1b7c2907585292aaf61e902cbd00ecff50120178bc3e8268a74c1d6/psycopg_binary-3.3.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4de5298b81423648ae751c789a6adb7f9396bd4de7f402db8f469901d676ebe4", size = 6722913, upload-time = "2025-12-02T21:08:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ba/7f6d3117059d4824854fb4eeafe7cb9fe069a43724f6a36b21aac0cd911d/psycopg_binary-3.3.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a41139976b8546b78ccd776b9f63665c247e209ae384fa6908ea401e9df2c385", size = 4966088, upload-time = "2025-12-02T21:08:34.666Z" }, + { url = "https://files.pythonhosted.org/packages/a5/18/d1baed589d7254be32eda44262787e397d1845fc7f08a30c38bf9345d361/psycopg_binary-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8ea017e58fa7fd8df1d9058ff0248e28f29312bf150a00114fc0ace8a800bfa", size = 4493332, upload-time = "2025-12-02T21:08:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/57c6ac97cd4d1297115c3840fe09a19ae50b96294e3b8e988385497dc074/psycopg_binary-3.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:447e31350ea6816af03f39e6fc5ccccf18e34dc223a7d82734621048bb3fb9af", size = 4170782, upload-time = "2025-12-02T21:08:43.949Z" }, + { url = "https://files.pythonhosted.org/packages/12/27/b2577aad1baaa476cf482fb207850fb3f03467b3e6f3485e8cf360588ea8/psycopg_binary-3.3.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1f97f1814b046c8103b0a46a8c36c28399489716eba70ed38fbae30a27866e2c", size = 3910543, upload-time = "2025-12-02T21:08:48.202Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/7ad39e369706beda88827031504d9bb5fed58bf6fe50ec7045bd6750736b/psycopg_binary-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3986f783ff656a0392b81b7ac1e0f88a276a38276a5c93c0b89d4862e309d618", size = 4220070, upload-time = "2025-12-02T21:08:52.752Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3a/703c155ab9fb64c874921116fd740c93494e6bd599b1bcd9e4987f5dcfb4/psycopg_binary-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:63e7c689a4249a1303da35df0b813e7cb3b9e2e5eae492a47482ee1a41dc66b2", size = 3540907, upload-time = "2025-12-02T21:08:56.898Z" }, + { url = "https://files.pythonhosted.org/packages/89/a5/056d227c85e4b769f2f9a2f2be71d1754492277410e15b2035637bafb92e/psycopg_binary-3.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f7464571c2f4936810cdd7aed9108d3d80c6ea3d668a6e23fe8e9a4f4942d09", size = 4596368, upload-time = "2025-12-02T21:09:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/5c628a984f3a53ec49d4f56827b6593abc609dc580f879e471fce39835b9/psycopg_binary-3.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4889aa16b2bfd4796a8648087262680d332fe9c6926fd7fd3d85c4f5eb483f01", size = 4675138, upload-time = "2025-12-02T21:09:06.095Z" }, + { url = "https://files.pythonhosted.org/packages/60/a8/e3cfd12e1bff144ce4af6e2c1ff72f2562a2853c4020843cb790c014c564/psycopg_binary-3.3.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:87a01fc62483b4cb5c343194d78ef9d7588624ad260fa82b31bf3c08e285a95f", size = 5456120, upload-time = "2025-12-02T21:09:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/84/d7/70905001d6865c155b08badc5225d56daafbc064702cc42874f88d21bef8/psycopg_binary-3.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:64b93d8456e17545c53bd23f601488fc508b68c4128133bc97762a00e61e8ab2", size = 5133485, upload-time = "2025-12-02T21:09:15.775Z" }, + { url = "https://files.pythonhosted.org/packages/49/6e/0cf90710f154d52163db920d059766ad27b510d90961a7f8f068ae3830f1/psycopg_binary-3.3.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f6b7bc0d230932aed188b9cc44b6fc6d43e2ec1585903d09a2d15095731ee07", size = 6731818, upload-time = "2025-12-02T21:09:25.092Z" }, + { url = "https://files.pythonhosted.org/packages/41/0f/c6c81861a8b2be54ff2b57a9eff84e50b3ec97d246dcb23311a25c9f78f0/psycopg_binary-3.3.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d4e3d94c2475fefcb7b93b507fe8fa5c8c61f993c9bece0ffd05906f1dbb47d", size = 4983866, upload-time = "2025-12-02T21:09:31.937Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/9804e7749680f35d1eda8d9a979156f3f685a1af3a7c0f124b6a728e3836/psycopg_binary-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e0998da49b193d35641be04068f061965428a4b5e776065691b7f8c1bbc472e", size = 4516389, upload-time = "2025-12-02T21:09:36.058Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cf/f136ba0afab5fce9c621883055918cee730671d22f83caa843dae0d728a8/psycopg_binary-3.3.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:cbf1c2fb03b7114808aa251ff6401cad0fa85e78dbecf45d711510797708b256", size = 4192382, upload-time = "2025-12-02T21:09:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/ca/57/3c089efb7c52b455b3cfcc6d0a367e30f286777a3b291f93abe8eeeaa748/psycopg_binary-3.3.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:18c87c715dda836dcdf1f71c6b71a3d10194f07faf8358d2d42bc637d70137cf", size = 3928661, upload-time = "2025-12-02T21:09:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/1c/95/a8096c8f61622ae74d55bc4442c5e86b009fe8a07e7e4dd58783fe14fc81/psycopg_binary-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ec0a04b95faf5c6c0af24917883b282f23dc588f0ee565efdd1146ed8129d258", size = 4239169, upload-time = "2025-12-02T21:09:49.4Z" }, + { url = "https://files.pythonhosted.org/packages/21/f0/9603f03eb2f887d47b6554def8f01317069515f4294878011b341759e332/psycopg_binary-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:c0bcb5a5ec01ccc34f884470473b2b9d1730513b7fb7175f741224af6af14182", size = 3642104, upload-time = "2025-12-02T21:09:53.514Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] From 57956f1f6e0b8cd565584317fc14d76f6dc8c5de Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 5 Dec 2025 23:36:05 +0300 Subject: [PATCH 187/224] feat: field description is added to Title --- api/_build/openapi.yaml | 5 +++++ api/api.gen.go | 3 +++ api/schemas/Title.yaml | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 7f483fa..5b6f731 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -647,6 +647,11 @@ components: example: - Attack on Titan - AoT + title_desc: + description: 'Localized description. Key = language (ISO 639-1), value = description.' + type: object + additionalProperties: + type: string studio: $ref: '#/components/schemas/Studio' tags: diff --git a/api/api.gen.go b/api/api.gen.go index 4fa16f4..ff37ed9 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -113,6 +113,9 @@ type Title struct { // Tags Array of localized tags Tags Tags `json:"tags"` + // TitleDesc Localized description. Key = language (ISO 639-1), value = description. + TitleDesc *map[string]string `json:"title_desc,omitempty"` + // TitleNames Localized titles. Key = language (ISO 639-1), value = list of names TitleNames map[string][]string `json:"title_names"` diff --git a/api/schemas/Title.yaml b/api/schemas/Title.yaml index 877ee24..fac4a3f 100644 --- a/api/schemas/Title.yaml +++ b/api/schemas/Title.yaml @@ -30,6 +30,11 @@ properties: - Титаны ja: - 進撃の巨人 + title_desc: + type: object + description: Localized description. Key = language (ISO 639-1), value = description. + additionalProperties: + type: string studio: $ref: ./Studio.yaml tags: From 93f12666cd731b02e1f5549aebcae5a072bcdb9e Mon Sep 17 00:00:00 2001 From: garaev kamil <garaev.kr@phystech.edu> Date: Fri, 5 Dec 2025 23:36:46 +0300 Subject: [PATCH 188/224] service files added --- modules/anime_etl/.gitignore | 2 ++ modules/anime_etl/.python-version | 1 + modules/anime_etl/README.md | 0 modules/anime_etl/__init__.py | 0 4 files changed, 3 insertions(+) create mode 100644 modules/anime_etl/.gitignore create mode 100644 modules/anime_etl/.python-version create mode 100644 modules/anime_etl/README.md create mode 100644 modules/anime_etl/__init__.py diff --git a/modules/anime_etl/.gitignore b/modules/anime_etl/.gitignore new file mode 100644 index 0000000..0e5ac79 --- /dev/null +++ b/modules/anime_etl/.gitignore @@ -0,0 +1,2 @@ +.venv +__pycache__ \ No newline at end of file diff --git a/modules/anime_etl/.python-version b/modules/anime_etl/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/modules/anime_etl/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/modules/anime_etl/README.md b/modules/anime_etl/README.md new file mode 100644 index 0000000..e69de29 diff --git a/modules/anime_etl/__init__.py b/modules/anime_etl/__init__.py new file mode 100644 index 0000000..e69de29 From 6d14ac365bbbb9d795a81b3c2028ab4fbd2c0eb4 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 5 Dec 2025 23:37:13 +0300 Subject: [PATCH 189/224] feat: title desc handling --- modules/backend/handlers/common.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 58862e1..7f2807f 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -73,6 +73,14 @@ func (s Server) mapTitle(title sqlc.GetTitleByIDRow) (oapi.Title, error) { } oapi_title.TitleNames = title_names + if len(title.TitleDesc) > 0 { + title_descs := make(map[string]string, 0) + err = json.Unmarshal(title.TitleDesc, &title_descs) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshal TitleDesc: %v", err) + } + oapi_title.TitleDesc = &title_descs + } if len(title.EpisodesLen) > 0 { episodes_lens := make(map[string]float64, 0) err = json.Unmarshal(title.EpisodesLen, &episodes_lens) From 9cb3f94e2700cfeee798f74b3b8eda672cee1fea Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 5 Dec 2025 23:53:29 +0300 Subject: [PATCH 190/224] feat: gif fot waiting --- modules/frontend/src/pages/TitlesPage/TitlesPage.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 481d116..449cb70 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -127,7 +127,16 @@ const handleLoadMore = async () => { </div> <TitlesFilterPanel filters={filters} setFilters={setFilters} /> - {loading && <div className="mt-20 font-medium text-black">Loading...</div>} + {loading && ( + <div className="mt-20 flex flex-col items-center justify-center space-y-4 font-medium text-black"> + <span>Loading...</span> + <img + src="https://images.steamusercontent.com/ugc/920301026407341369/69CBEF69DED504CD8CC7838D370061089F4D81BD/?imw=5000&imh=5000&ima=fit&impolicy=Letterbox&imcolor=%23000000&letterbox=false" + alt="Loading animation" + className="w-16 h-16 object-contain" + /> + </div> + )} {!loading && titles.length === 0 && ( <div className="mt-20 font-medium text-black">No titles found.</div> From 90d7de51f3f81649fb5b713542d011db5f150968 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 6 Dec 2025 00:04:51 +0300 Subject: [PATCH 191/224] fix: gif scaled --- modules/frontend/src/pages/TitlesPage/TitlesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 449cb70..75d8fa5 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -133,7 +133,7 @@ const handleLoadMore = async () => { <img src="https://images.steamusercontent.com/ugc/920301026407341369/69CBEF69DED504CD8CC7838D370061089F4D81BD/?imw=5000&imh=5000&ima=fit&impolicy=Letterbox&imcolor=%23000000&letterbox=false" alt="Loading animation" - className="w-16 h-16 object-contain" + className="w-100 h-100 object-contain" /> </div> )} From 7623adf2a7a75d9100bc2d1d54f0f0c1ea5eedfd Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 6 Dec 2025 00:27:59 +0300 Subject: [PATCH 192/224] fix --- modules/frontend/src/pages/TitlesPage/TitlesPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 75d8fa5..727e072 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -131,9 +131,9 @@ const handleLoadMore = async () => { <div className="mt-20 flex flex-col items-center justify-center space-y-4 font-medium text-black"> <span>Loading...</span> <img - src="https://images.steamusercontent.com/ugc/920301026407341369/69CBEF69DED504CD8CC7838D370061089F4D81BD/?imw=5000&imh=5000&ima=fit&impolicy=Letterbox&imcolor=%23000000&letterbox=false" + src="https://images.steamusercontent.com/ugc/920301026407341369/69CBEF69DED504CD8CC7838D370061089F4D81BD/" alt="Loading animation" - className="w-100 h-100 object-contain" + className="size-100 object-contain" /> </div> )} From e67f0d7e5a89924d232098b75f51ba9c1c11477e Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 04:03:04 +0300 Subject: [PATCH 193/224] feat: get impersonation token implementation --- auth/auth.gen.go | 115 +++++++++++++++++++++++++-- auth/openapi-auth.yaml | 124 +++++++++++------------------- modules/auth/handlers/handlers.go | 38 ++++++++- modules/auth/queries.sql | 4 + sql/migrations/000001_init.up.sql | 5 +- sql/models.go | 5 +- sql/queries.sql.go | 13 ++++ 7 files changed, 209 insertions(+), 95 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index b7cd839..89a2168 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -13,6 +13,23 @@ import ( strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" ) +const ( + BearerAuthScopes = "bearerAuth.Scopes" +) + +// GetImpersonationTokenJSONBody defines parameters for GetImpersonationToken. +type GetImpersonationTokenJSONBody struct { + TgId *int64 `json:"tg_id,omitempty"` + UserId *int64 `json:"user_id,omitempty"` + union json.RawMessage +} + +// GetImpersonationTokenJSONBody0 defines parameters for GetImpersonationToken. +type GetImpersonationTokenJSONBody0 = interface{} + +// GetImpersonationTokenJSONBody1 defines parameters for GetImpersonationToken. +type GetImpersonationTokenJSONBody1 = interface{} + // PostSignInJSONBody defines parameters for PostSignIn. type PostSignInJSONBody struct { Nickname string `json:"nickname"` @@ -25,6 +42,9 @@ type PostSignUpJSONBody struct { Pass string `json:"pass"` } +// GetImpersonationTokenJSONRequestBody defines body for GetImpersonationToken for application/json ContentType. +type GetImpersonationTokenJSONRequestBody GetImpersonationTokenJSONBody + // PostSignInJSONRequestBody defines body for PostSignIn for application/json ContentType. type PostSignInJSONRequestBody PostSignInJSONBody @@ -33,6 +53,9 @@ type PostSignUpJSONRequestBody PostSignUpJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { + // Get service impersontaion token + // (POST /get-impersonation-token) + GetImpersonationToken(c *gin.Context) // Sign in a user and return JWT // (POST /sign-in) PostSignIn(c *gin.Context) @@ -50,6 +73,21 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) +// GetImpersonationToken operation middleware +func (siw *ServerInterfaceWrapper) GetImpersonationToken(c *gin.Context) { + + c.Set(BearerAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetImpersonationToken(c) +} + // PostSignIn operation middleware func (siw *ServerInterfaceWrapper) PostSignIn(c *gin.Context) { @@ -103,10 +141,41 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } + router.POST(options.BaseURL+"/get-impersonation-token", wrapper.GetImpersonationToken) router.POST(options.BaseURL+"/sign-in", wrapper.PostSignIn) router.POST(options.BaseURL+"/sign-up", wrapper.PostSignUp) } +type UnauthorizedErrorResponse struct { +} + +type GetImpersonationTokenRequestObject struct { + Body *GetImpersonationTokenJSONRequestBody +} + +type GetImpersonationTokenResponseObject interface { + VisitGetImpersonationTokenResponse(w http.ResponseWriter) error +} + +type GetImpersonationToken200JSONResponse struct { + // AccessToken JWT access token + AccessToken string `json:"access_token"` +} + +func (response GetImpersonationToken200JSONResponse) VisitGetImpersonationTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetImpersonationToken401Response = UnauthorizedErrorResponse + +func (response GetImpersonationToken401Response) VisitGetImpersonationTokenResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + type PostSignInRequestObject struct { Body *PostSignInJSONRequestBody } @@ -127,15 +196,11 @@ func (response PostSignIn200JSONResponse) VisitPostSignInResponse(w http.Respons return json.NewEncoder(w).Encode(response) } -type PostSignIn401JSONResponse struct { - Error *string `json:"error,omitempty"` -} +type PostSignIn401Response = UnauthorizedErrorResponse -func (response PostSignIn401JSONResponse) VisitPostSignInResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") +func (response PostSignIn401Response) VisitPostSignInResponse(w http.ResponseWriter) error { w.WriteHeader(401) - - return json.NewEncoder(w).Encode(response) + return nil } type PostSignUpRequestObject struct { @@ -159,6 +224,9 @@ func (response PostSignUp200JSONResponse) VisitPostSignUpResponse(w http.Respons // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // Get service impersontaion token + // (POST /get-impersonation-token) + GetImpersonationToken(ctx context.Context, request GetImpersonationTokenRequestObject) (GetImpersonationTokenResponseObject, error) // Sign in a user and return JWT // (POST /sign-in) PostSignIn(ctx context.Context, request PostSignInRequestObject) (PostSignInResponseObject, error) @@ -179,6 +247,39 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// GetImpersonationToken operation middleware +func (sh *strictHandler) GetImpersonationToken(ctx *gin.Context) { + var request GetImpersonationTokenRequestObject + + var body GetImpersonationTokenJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetImpersonationToken(ctx, request.(GetImpersonationTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetImpersonationToken") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetImpersonationTokenResponseObject); ok { + if err := validResponse.VisitGetImpersonationTokenResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // PostSignIn operation middleware func (sh *strictHandler) PostSignIn(ctx *gin.Context) { var request PostSignInRequestObject diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 5f3ebd6..93db937 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -10,6 +10,7 @@ paths: /sign-up: post: summary: Sign up a new user + operationId: postSignUp tags: [Auth] requestBody: required: true @@ -41,6 +42,7 @@ paths: /sign-in: post: summary: Sign in a user and return JWT + operationId: postSignIn tags: [Auth] requestBody: required: true @@ -73,88 +75,52 @@ paths: user_name: type: string "401": - description: Access denied due to invalid credentials + $ref: '#/components/responses/UnauthorizedError' + + /get-impersonation-token: + post: + summary: Get service impersontaion token + operationId: getImpersonationToken + tags: [Auth] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + user_id: + type: integer + format: int64 + tg_id: + type: integer + format: int64 + oneOf: + - required: ["user_id"] + - required: ["tg_id"] + responses: + "200": + description: Generated impersonation access token content: application/json: schema: type: object + required: + - access_token properties: - error: + access_token: type: string - example: "Access denied" - # /auth/verify-token: - # post: - # summary: Verify JWT validity - # tags: [Auth] - # requestBody: - # required: true - # content: - # application/json: - # schema: - # type: object - # required: [token] - # properties: - # token: - # type: string - # description: JWT token to validate - # responses: - # "200": - # description: Token validation result - # content: - # application/json: - # schema: - # type: object - # properties: - # valid: - # type: boolean - # description: True if token is valid - # user_id: - # type: string - # nullable: true - # description: User ID extracted from token if valid - # error: - # type: string - # nullable: true - # description: Error message if token is invalid - # /auth/refresh-token: - # post: - # summary: Refresh JWT using a refresh token - # tags: [Auth] - # requestBody: - # required: true - # content: - # application/json: - # schema: - # type: object - # required: [refresh_token] - # properties: - # refresh_token: - # type: string - # description: JWT refresh token obtained from sign-in - # responses: - # "200": - # description: New access (and optionally refresh) token - # content: - # application/json: - # schema: - # type: object - # properties: - # valid: - # type: boolean - # description: True if refresh token was valid - # user_id: - # type: string - # nullable: true - # description: User ID extracted from refresh token - # access_token: - # type: string - # description: New access token - # nullable: true - # refresh_token: - # type: string - # description: New refresh token (optional) - # nullable: true - # error: - # type: string - # nullable: true - # description: Error message if refresh token is invalid + description: JWT access token + "401": + $ref: '#/components/responses/UnauthorizedError' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + responses: + UnauthorizedError: + description: Access token is missing or invalid \ No newline at end of file diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 09907bc..39067a6 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -47,10 +47,28 @@ func CheckPassword(password, hash string) (bool, error) { return argon2id.ComparePasswordAndHash(password, hash) } +func (s Server) generateImpersonationToken(userID string, impersonated_by string) (accessToken string, err error) { + accessClaims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(15 * time.Minute).Unix(), + "imp_id": impersonated_by, + } + + at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + + accessToken, err = at.SignedString(s.JwtPrivateKey) + if err != nil { + return "", err + } + + return accessToken, nil +} + func (s Server) generateTokens(userID string) (accessToken string, refreshToken string, csrfToken string, err error) { accessClaims := jwt.MapClaims{ "user_id": userID, "exp": time.Now().Add(15 * time.Minute).Unix(), + //TODO: add created_at } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) accessToken, err = at.SignedString(s.JwtPrivateKey) @@ -119,10 +137,7 @@ func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject // TODO: return 500 } if !ok { - err_msg := "invalid credentials" - return auth.PostSignIn401JSONResponse{ - Error: &err_msg, - }, nil + return auth.PostSignIn401Response{}, nil } accessToken, refreshToken, csrfToken, err := s.generateTokens(req.Body.Nickname) @@ -144,6 +159,21 @@ func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject return result, nil } +func (s Server) GetImpersonationToken(ctx context.Context, request auth.GetImpersonationTokenRequestObject) (auth.GetImpersonationTokenResponseObject, error) { + ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) + if !ok { + log.Print("failed to get gin context") + // TODO: change to 500 + return auth.GetImpersonationToken200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") + } + + token := ginCtx.Request.Header.Get("Authorization") + log.Printf("got auth token: %s", token) + //s.db.GetExternalServiceByToken() + + return auth.PostSignIn401Response{}, nil +} + // func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { // valid := false // var userID *string diff --git a/modules/auth/queries.sql b/modules/auth/queries.sql index 828d2af..363f07a 100644 --- a/modules/auth/queries.sql +++ b/modules/auth/queries.sql @@ -9,3 +9,7 @@ INTO users (passhash, nickname) VALUES (sqlc.arg(passhash), sqlc.arg(nickname)) RETURNING id; +-- name: GetExternalServiceByToken :one +SELECT * +FROM external_services +WHERE auth_token = sqlc.arg('auth_token'); \ No newline at end of file diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 3499fe2..cda8d71 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -33,8 +33,6 @@ CREATE TABLE users ( last_login timestamptz ); - - CREATE TABLE studios ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, studio_name text NOT NULL UNIQUE, @@ -106,7 +104,8 @@ CREATE TABLE signals ( CREATE TABLE external_services ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name text UNIQUE NOT NULL + name text UNIQUE NOT NULL, + auth_token text ); CREATE TABLE external_ids ( diff --git a/sql/models.go b/sql/models.go index 842d58c..ee30f58 100644 --- a/sql/models.go +++ b/sql/models.go @@ -193,8 +193,9 @@ type ExternalID struct { } type ExternalService struct { - ID int64 `json:"id"` - Name string `json:"name"` + ID int64 `json:"id"` + Name string `json:"name"` + AuthToken *string `json:"auth_token"` } type Image struct { diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 1cca986..7fd8765 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -74,6 +74,19 @@ func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams return i, err } +const getExternalServiceByToken = `-- name: GetExternalServiceByToken :one +SELECT id, name, auth_token +FROM external_services +WHERE auth_token = $1 +` + +func (q *Queries) GetExternalServiceByToken(ctx context.Context, authToken *string) (ExternalService, error) { + row := q.db.QueryRow(ctx, getExternalServiceByToken, authToken) + var i ExternalService + err := row.Scan(&i.ID, &i.Name, &i.AuthToken) + return i, err +} + const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images From 184868b142376c800d82988ccf05950db810c9a8 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 6 Dec 2025 04:13:27 +0300 Subject: [PATCH 194/224] feat: file upload imlemented --- api/_build/openapi.yaml | 36 ++++++++ api/api.gen.go | 109 ++++++++++++++++++++++ api/openapi.yaml | 4 +- api/paths/media_upload.yaml | 37 ++++++++ go.mod | 16 ++-- go.sum | 20 ++++ modules/backend/handlers/images.go | 141 +++++++++++++++++++++++++++++ 7 files changed, 355 insertions(+), 8 deletions(-) create mode 100644 api/paths/media_upload.yaml create mode 100644 modules/backend/handlers/images.go diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 5b6f731..9ed5b5f 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -527,6 +527,42 @@ paths: description: Internal server error security: - XsrfAuthHeader: [] + /media/upload: + post: + summary: 'Upload an image (PNG, JPEG, or WebP)' + description: | + Uploads a single image file. Supported formats: **PNG**, **JPEG/JPG**, **WebP**. + requestBody: + required: true + content: + encoding: + image: + contentType: 'image/png, image/jpeg, image/webp' + multipart/form-data: + schema: + image: + type: string + format: binary + description: 'Image file (PNG, JPEG, or WebP)' + responses: + '200': + description: Image uploaded successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Image' + '400': + description: 'Bad request — e.g., invalid/malformed image, empty file' + content: + application/json: + schema: + type: string + '415': + description: | + Unsupported Media Type — e.g., request `Content-Type` is not `multipart/form-data`, + or the `image` part has an unsupported `Content-Type` (not image/png, image/jpeg, or image/webp) + '500': + description: Internal server error components: parameters: cursor: diff --git a/api/api.gen.go b/api/api.gen.go index ff37ed9..d93e925 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -7,7 +7,10 @@ import ( "context" "encoding/json" "fmt" + "io" + "mime/multipart" "net/http" + "strings" "time" "github.com/gin-gonic/gin" @@ -181,6 +184,9 @@ type UserTitleStatus string // Cursor defines model for cursor. type Cursor = string +// PostMediaUploadMultipartBody defines parameters for PostMediaUpload. +type PostMediaUploadMultipartBody = interface{} + // GetTitlesParams defines parameters for GetTitles. type GetTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -271,6 +277,9 @@ type UpdateUserTitleJSONBody struct { Status *UserTitleStatus `json:"status,omitempty"` } +// PostMediaUploadMultipartRequestBody defines body for PostMediaUpload for multipart/form-data ContentType. +type PostMediaUploadMultipartRequestBody = PostMediaUploadMultipartBody + // UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. type UpdateUserJSONRequestBody UpdateUserJSONBody @@ -282,6 +291,9 @@ type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { + // Upload an image (PNG, JPEG, or WebP) + // (POST /media/upload) + PostMediaUpload(c *gin.Context) // Get titles // (GET /titles) GetTitles(c *gin.Context, params GetTitlesParams) @@ -323,6 +335,19 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) +// PostMediaUpload operation middleware +func (siw *ServerInterfaceWrapper) PostMediaUpload(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostMediaUpload(c) +} + // GetTitles operation middleware func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { @@ -854,6 +879,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } + router.POST(options.BaseURL+"/media/upload", wrapper.PostMediaUpload) router.GET(options.BaseURL+"/titles", wrapper.GetTitles) router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitle) router.GET(options.BaseURL+"/users/", wrapper.GetUsers) @@ -866,6 +892,49 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.PATCH(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.UpdateUserTitle) } +type PostMediaUploadRequestObject struct { + Body io.Reader + MultipartBody *multipart.Reader +} + +type PostMediaUploadResponseObject interface { + VisitPostMediaUploadResponse(w http.ResponseWriter) error +} + +type PostMediaUpload200JSONResponse Image + +func (response PostMediaUpload200JSONResponse) VisitPostMediaUploadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostMediaUpload400JSONResponse string + +func (response PostMediaUpload400JSONResponse) VisitPostMediaUploadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type PostMediaUpload415Response struct { +} + +func (response PostMediaUpload415Response) VisitPostMediaUploadResponse(w http.ResponseWriter) error { + w.WriteHeader(415) + return nil +} + +type PostMediaUpload500Response struct { +} + +func (response PostMediaUpload500Response) VisitPostMediaUploadResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type GetTitlesRequestObject struct { Params GetTitlesParams } @@ -1403,6 +1472,9 @@ func (response UpdateUserTitle500Response) VisitUpdateUserTitleResponse(w http.R // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // Upload an image (PNG, JPEG, or WebP) + // (POST /media/upload) + PostMediaUpload(ctx context.Context, request PostMediaUploadRequestObject) (PostMediaUploadResponseObject, error) // Get titles // (GET /titles) GetTitles(ctx context.Context, request GetTitlesRequestObject) (GetTitlesResponseObject, error) @@ -1447,6 +1519,43 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// PostMediaUpload operation middleware +func (sh *strictHandler) PostMediaUpload(ctx *gin.Context) { + var request PostMediaUploadRequestObject + + if strings.HasPrefix(ctx.GetHeader("Content-Type"), "encoding") { + request.Body = ctx.Request.Body + } + if strings.HasPrefix(ctx.GetHeader("Content-Type"), "multipart/form-data") { + if reader, err := ctx.Request.MultipartReader(); err == nil { + request.MultipartBody = reader + } else { + ctx.Error(err) + return + } + } + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostMediaUpload(ctx, request.(PostMediaUploadRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostMediaUpload") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostMediaUploadResponseObject); ok { + if err := validResponse.VisitPostMediaUploadResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetTitles operation middleware func (sh *strictHandler) GetTitles(ctx *gin.Context, params GetTitlesParams) { var request GetTitlesRequestObject diff --git a/api/openapi.yaml b/api/openapi.yaml index 0759a54..26813fc 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -19,7 +19,9 @@ paths: $ref: "./paths/users-id-titles.yaml" /users/{user_id}/titles/{title_id}: $ref: "./paths/users-id-titles-id.yaml" - + /media/upload: + $ref: "./paths/media_upload.yaml" + components: parameters: $ref: "./parameters/_index.yaml" diff --git a/api/paths/media_upload.yaml b/api/paths/media_upload.yaml new file mode 100644 index 0000000..0453952 --- /dev/null +++ b/api/paths/media_upload.yaml @@ -0,0 +1,37 @@ +post: + summary: Upload an image (PNG, JPEG, or WebP) + description: | + Uploads a single image file. Supported formats: **PNG**, **JPEG/JPG**, **WebP**. + requestBody: + required: true + content: + multipart/form-data: + schema: + image: + type: string + format: binary + description: Image file (PNG, JPEG, or WebP) + encoding: + image: + contentType: image/png, image/jpeg, image/webp + + responses: + '200': + description: Image uploaded successfully + content: + application/json: + schema: + $ref: "../schemas/Image.yaml" + '400': + description: Bad request — e.g., invalid/malformed image, empty file + content: + application/json: + schema: + type: string + '415': + description: | + Unsupported Media Type — e.g., request `Content-Type` is not `multipart/form-data`, + or the `image` part has an unsupported `Content-Type` (not image/png, image/jpeg, or image/webp) + + '500': + description: Internal server error \ No newline at end of file diff --git a/go.mod b/go.mod index 6662bc1..08a3dc1 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/disintegration/imaging v1.6.2 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -42,12 +43,13 @@ require ( github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/image v0.33.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect google.golang.org/protobuf v1.36.9 // indirect ) diff --git a/go.sum b/go.sum index 520a22b..dc41797 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE 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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= @@ -103,10 +105,18 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= +golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -114,11 +124,15 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -131,6 +145,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -144,12 +160,16 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= diff --git a/modules/backend/handlers/images.go b/modules/backend/handlers/images.go new file mode 100644 index 0000000..5309480 --- /dev/null +++ b/modules/backend/handlers/images.go @@ -0,0 +1,141 @@ +package handlers + +import ( + "bytes" + "context" + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "net/http" + oapi "nyanimedb/api" + "os" + "path/filepath" + "strings" + + "github.com/disintegration/imaging" + log "github.com/sirupsen/logrus" + "golang.org/x/image/webp" +) + +// PostMediaUpload implements oapi.StrictServerInterface. +func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUploadRequestObject) (oapi.PostMediaUploadResponseObject, error) { + // Получаем multipart body + mp := request.MultipartBody + if mp == nil { + log.Errorf("PostMedia without body") + return oapi.PostMediaUpload400JSONResponse("Multipart body is required"), nil + } + + // Парсим первую часть (предполагаем, что файл в поле "file") + part, err := mp.NextPart() + if err != nil { + log.Errorf("PostMedia without file") + return oapi.PostMediaUpload400JSONResponse("File required"), nil + } + defer part.Close() + + // Читаем ВЕСЬ файл в память (для небольших изображений — нормально) + // Если файлы могут быть большими — используйте лимитированный буфер (см. ниже) + data, err := io.ReadAll(part) + if err != nil { + log.Errorf("PostMedia cannot read file") + return oapi.PostMediaUpload400JSONResponse("File required"), nil + } + + if len(data) == 0 { + log.Errorf("PostMedia empty file") + return oapi.PostMediaUpload400JSONResponse("Empty file"), nil + } + + // Проверка MIME по первым 512 байтам + mimeType := http.DetectContentType(data) + if mimeType != "image/jpeg" && mimeType != "image/png" && mimeType != "image/webp" { + log.Errorf("PostMedia bad type") + return oapi.PostMediaUpload400JSONResponse("Bad data type"), nil + } + + // Декодируем изображение из буфера + var img image.Image + switch mimeType { + case "image/jpeg": + { + img, err = jpeg.Decode(bytes.NewReader(data)) + if err != nil { + log.Errorf("PostMedia cannot decode file: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + } + case "image/png": + { + img, err = png.Decode(bytes.NewReader(data)) + if err != nil { + log.Errorf("PostMedia cannot decode file: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + } + case "image/webp": + { + img, err = webp.Decode(bytes.NewReader(data)) + if err != nil { + log.Errorf("PostMedia cannot decode file: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + } + } + + // Перекодируем в чистый JPEG (без EXIF, сжатие, RGB) + var buf bytes.Buffer + err = imaging.Encode(&buf, img, imaging.PNG) + if err != nil { + log.Errorf("PostMedia failed to re-encode JPEG: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + + // TODO: to delete + filename := part.FileName() + if filename == "" { + filename = "upload_" + generateRandomHex(8) + ".jpg" + } else { + filename = sanitizeFilename(filename) + if !strings.HasSuffix(strings.ToLower(filename), ".jpg") { + filename += ".jpg" + } + } + + // TODO: пойти на хуй ( вызвать файловую помойку) + err = os.WriteFile(filepath.Join("/uploads", filename), buf.Bytes(), 0644) + if err != nil { + log.Errorf("PostMedia failed to write: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + + return oapi.PostMediaUpload200JSONResponse{}, nil +} + +// Вспомогательные функции — как раньше +func generateRandomHex(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = byte('a' + (i % 16)) + } + return fmt.Sprintf("%x", b) +} + +func sanitizeFilename(name string) string { + var clean strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '.' || r == '_' || r == '-' { + clean.WriteRune(r) + } + } + s := clean.String() + if s == "" { + return "file" + } + return s +} From 5acc53ec9d8bd34fc395bc5cd527d7935c470cad Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 6 Dec 2025 04:34:18 +0300 Subject: [PATCH 195/224] fix --- modules/backend/handlers/images.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/backend/handlers/images.go b/modules/backend/handlers/images.go index 5309480..c1e3d4b 100644 --- a/modules/backend/handlers/images.go +++ b/modules/backend/handlers/images.go @@ -85,7 +85,6 @@ func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUplo } } - // Перекодируем в чистый JPEG (без EXIF, сжатие, RGB) var buf bytes.Buffer err = imaging.Encode(&buf, img, imaging.PNG) if err != nil { @@ -99,13 +98,14 @@ func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUplo filename = "upload_" + generateRandomHex(8) + ".jpg" } else { filename = sanitizeFilename(filename) - if !strings.HasSuffix(strings.ToLower(filename), ".jpg") { - filename += ".jpg" + if !strings.HasSuffix(strings.ToLower(filename), ".png") { + filename += ".png" } } // TODO: пойти на хуй ( вызвать файловую помойку) - err = os.WriteFile(filepath.Join("/uploads", filename), buf.Bytes(), 0644) + os.Mkdir("uploads", 0644) + err = os.WriteFile(filepath.Join("./uploads", filename), buf.Bytes(), 0644) if err != nil { log.Errorf("PostMedia failed to write: %v", err) return oapi.PostMediaUpload500Response{}, nil From afb1db17bdaca025da47a8eb78c758278f71b6a3 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 04:51:04 +0300 Subject: [PATCH 196/224] feat: generateImpersonationToken function --- modules/auth/handlers/handlers.go | 37 ++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 2c4ee6c..9138fa7 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -56,7 +56,7 @@ func (s Server) generateImpersonationToken(userID string, impersonated_by string at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - accessToken, err = at.SignedString(s.JwtPrivateKey) + accessToken, err = at.SignedString([]byte(s.JwtPrivateKey)) if err != nil { return "", err } @@ -159,7 +159,7 @@ func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject return result, nil } -func (s Server) GetImpersonationToken(ctx context.Context, request auth.GetImpersonationTokenRequestObject) (auth.GetImpersonationTokenResponseObject, error) { +func (s Server) GetImpersonationToken(ctx context.Context, req auth.GetImpersonationTokenRequestObject) (auth.GetImpersonationTokenResponseObject, error) { ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) if !ok { log.Print("failed to get gin context") @@ -167,11 +167,30 @@ func (s Server) GetImpersonationToken(ctx context.Context, request auth.GetImper return auth.GetImpersonationToken200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") } - token := ginCtx.Request.Header.Get("Authorization") + token, err := ExtractBearerToken(ginCtx.Request.Header.Get("Authorization")) + if err != nil { + // TODO: return 500 + log.Errorf("failed to extract bearer token: %v", err) + return auth.GetImpersonationToken401Response{}, err + } log.Printf("got auth token: %s", token) - //s.db.GetExternalServiceByToken() - return auth.PostSignIn401Response{}, nil + ext_service, err := s.db.GetExternalServiceByToken(context.Background(), &token) + if err != nil { + log.Errorf("failed to get external service by token: %v", err) + return auth.GetImpersonationToken401Response{}, err + // TODO: check err and retyrn 400/500 + } + + // TODO: handle tgid + accessToken, err := s.generateImpersonationToken(fmt.Sprintf("%d", *req.Body.UserId), fmt.Sprintf("%d", ext_service.ID)) + if err != nil { + log.Errorf("failed to generate impersonation token: %v", err) + return auth.GetImpersonationToken401Response{}, err + // TODO: check err and retyrn 400/500 + } + + return auth.GetImpersonationToken200JSONResponse{AccessToken: accessToken}, nil } // func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { @@ -266,3 +285,11 @@ func (s Server) GetImpersonationToken(ctx context.Context, request auth.GetImper // Error: errStr, // }, nil // } + +func ExtractBearerToken(header string) (string, error) { + const prefix = "Bearer " + if len(header) <= len(prefix) || header[:len(prefix)] != prefix { + return "", fmt.Errorf("invalid bearer token format") + } + return header[len(prefix):], nil +} From 8bd515c33f081bd062013ac29ae3e6313848de13 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 05:15:21 +0300 Subject: [PATCH 197/224] fix: GetImpersonationToken external_id handling --- auth/auth.gen.go | 6 +-- auth/openapi-auth.yaml | 4 +- modules/auth/handlers/handlers.go | 71 ++++++++++++------------------- modules/auth/queries.sql | 8 +++- sql/migrations/000001_init.up.sql | 2 +- sql/models.go | 2 +- sql/queries.sql.go | 29 +++++++++++++ 7 files changed, 70 insertions(+), 52 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index 89a2168..1e8803e 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -19,9 +19,9 @@ const ( // GetImpersonationTokenJSONBody defines parameters for GetImpersonationToken. type GetImpersonationTokenJSONBody struct { - TgId *int64 `json:"tg_id,omitempty"` - UserId *int64 `json:"user_id,omitempty"` - union json.RawMessage + ExternalId *int64 `json:"external_id,omitempty"` + UserId *int64 `json:"user_id,omitempty"` + union json.RawMessage } // GetImpersonationTokenJSONBody0 defines parameters for GetImpersonationToken. diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 93db937..803a4ae 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -94,12 +94,12 @@ paths: user_id: type: integer format: int64 - tg_id: + external_id: type: integer format: int64 oneOf: - required: ["user_id"] - - required: ["tg_id"] + - required: ["external_id"] responses: "200": description: Generated impersonation access token diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 9138fa7..2a6518e 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -182,8 +182,33 @@ func (s Server) GetImpersonationToken(ctx context.Context, req auth.GetImpersona // TODO: check err and retyrn 400/500 } - // TODO: handle tgid - accessToken, err := s.generateImpersonationToken(fmt.Sprintf("%d", *req.Body.UserId), fmt.Sprintf("%d", ext_service.ID)) + var user_id string = "" + + if req.Body.ExternalId != nil { + user, err := s.db.GetUserByExternalServiceId(context.Background(), sqlc.GetUserByExternalServiceIdParams{ + ExternalID: fmt.Sprintf("%d", *req.Body.ExternalId), + ServiceID: ext_service.ID, + }) + if err != nil { + log.Errorf("failed to get user by external user id: %v", err) + return auth.GetImpersonationToken401Response{}, err + // TODO: check err and retyrn 400/500 + } + + user_id = fmt.Sprintf("%d", user.ID) + } + + if req.Body.UserId != nil { + if user_id != "" && user_id != fmt.Sprintf("%d", *req.Body.UserId) { + log.Error("user_id and external_d are incorrect") + // TODO: 405 + return auth.GetImpersonationToken401Response{}, nil + } else { + user_id = fmt.Sprintf("%d", *req.Body.UserId) + } + } + + accessToken, err := s.generateImpersonationToken(user_id, fmt.Sprintf("%d", ext_service.ID)) if err != nil { log.Errorf("failed to generate impersonation token: %v", err) return auth.GetImpersonationToken401Response{}, err @@ -193,48 +218,6 @@ func (s Server) GetImpersonationToken(ctx context.Context, req auth.GetImpersona return auth.GetImpersonationToken200JSONResponse{AccessToken: accessToken}, nil } -// func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { -// valid := false -// var userID *string -// var errStr *string - -// token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { -// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { -// return nil, fmt.Errorf("unexpected signing method") -// } -// return accessSecret, nil -// }) - -// if err != nil { -// e := err.Error() -// errStr = &e -// return auth.PostAuthVerifyToken200JSONResponse{ -// Valid: &valid, -// UserId: userID, -// Error: errStr, -// }, nil -// } - -// if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { -// if uid, ok := claims["user_id"].(string); ok { -// valid = true -// userID = &uid -// } else { -// e := "user_id not found in token" -// errStr = &e -// } -// } else { -// e := "invalid token claims" -// errStr = &e -// } - -// return auth.PostAuthVerifyToken200JSONResponse{ -// Valid: &valid, -// UserId: userID, -// Error: errStr, -// }, nil -// } - // func (s Server) PostAuthRefreshToken(ctx context.Context, req auth.PostAuthRefreshTokenRequestObject) (auth.PostAuthRefreshTokenResponseObject, error) { // valid := false // var userID *string diff --git a/modules/auth/queries.sql b/modules/auth/queries.sql index 363f07a..0b9b941 100644 --- a/modules/auth/queries.sql +++ b/modules/auth/queries.sql @@ -12,4 +12,10 @@ RETURNING id; -- name: GetExternalServiceByToken :one SELECT * FROM external_services -WHERE auth_token = sqlc.arg('auth_token'); \ No newline at end of file +WHERE auth_token = sqlc.arg('auth_token'); + +-- name: GetUserByExternalServiceId :one +SELECT u.* +FROM users u +LEFT JOIN external_ids ei ON eu.user_id = u.id +WHERE ei.external_id = sqlc.arg('external_id') AND ei.service_id = sqlc.arg('service_id'); \ No newline at end of file diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 9bf99dc..946fe7e 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -112,7 +112,7 @@ CREATE TABLE external_services ( CREATE TABLE external_ids ( user_id bigint NOT NULL REFERENCES users (id), - service_id bigint REFERENCES external_services (id), + service_id bigint NOT NULL REFERENCES external_services (id), external_id text NOT NULL ); diff --git a/sql/models.go b/sql/models.go index 1395a19..c299609 100644 --- a/sql/models.go +++ b/sql/models.go @@ -188,7 +188,7 @@ func (ns NullUsertitleStatusT) Value() (driver.Value, error) { type ExternalID struct { UserID int64 `json:"user_id"` - ServiceID *int64 `json:"service_id"` + ServiceID int64 `json:"service_id"` ExternalID string `json:"external_id"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index e12619e..2d4067d 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -251,6 +251,35 @@ func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([]json.RawMe return items, nil } +const getUserByExternalServiceId = `-- name: GetUserByExternalServiceId :one +SELECT u.id, u.avatar_id, u.passhash, u.mail, u.nickname, u.disp_name, u.user_desc, u.creation_date, u.last_login +FROM users u +LEFT JOIN external_ids ei ON eu.user_id = u.id +WHERE ei.external_id = $1 AND ei.service_id = $2 +` + +type GetUserByExternalServiceIdParams struct { + ExternalID string `json:"external_id"` + ServiceID int64 `json:"service_id"` +} + +func (q *Queries) GetUserByExternalServiceId(ctx context.Context, arg GetUserByExternalServiceIdParams) (User, error) { + row := q.db.QueryRow(ctx, getUserByExternalServiceId, arg.ExternalID, arg.ServiceID) + var i User + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Passhash, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.LastLogin, + ) + return i, err +} + const getUserByID = `-- name: GetUserByID :one SELECT t.id as id, From 00894f45266d2c67f108674cce5130752ff3dfd9 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 6 Dec 2025 05:18:23 +0300 Subject: [PATCH 198/224] feat: ftime logic for usertitle is changed --- api/_build/openapi.yaml | 6 +++ api/api.gen.go | 6 ++- api/paths/users-id-titles-id.yaml | 3 ++ api/paths/users-id-titles.yaml | 3 ++ modules/backend/handlers/users.go | 12 +++++ modules/backend/queries.sql | 8 +-- modules/frontend/src/api/client.gen.ts | 2 +- modules/frontend/src/api/sdk.gen.ts | 7 ++- modules/frontend/src/api/types.gen.ts | 50 +++++++++++++++++++ .../src/pages/UsersPage/UsersPage.tsx | 0 sql/migrations/000001_init.up.sql | 15 +----- sql/queries.sql.go | 36 +++++++------ 12 files changed, 113 insertions(+), 35 deletions(-) create mode 100644 modules/frontend/src/pages/UsersPage/UsersPage.tsx diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 9ed5b5f..ad0c9be 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -395,6 +395,9 @@ paths: rate: type: integer format: int32 + ftime: + type: string + format: date-time required: - title_id - status @@ -478,6 +481,9 @@ paths: rate: type: integer format: int32 + ftime: + type: string + format: date-time responses: '200': description: Title successfully updated diff --git a/api/api.gen.go b/api/api.gen.go index d93e925..04d10c0 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -262,7 +262,8 @@ type GetUserTitlesParams struct { // AddUserTitleJSONBody defines parameters for AddUserTitle. type AddUserTitleJSONBody struct { - Rate *int32 `json:"rate,omitempty"` + Ftime *time.Time `json:"ftime,omitempty"` + Rate *int32 `json:"rate,omitempty"` // Status User's title status Status UserTitleStatus `json:"status"` @@ -271,7 +272,8 @@ type AddUserTitleJSONBody struct { // UpdateUserTitleJSONBody defines parameters for UpdateUserTitle. type UpdateUserTitleJSONBody struct { - Rate *int32 `json:"rate,omitempty"` + Ftime *time.Time `json:"ftime,omitempty"` + Rate *int32 `json:"rate,omitempty"` // Status User's title status Status *UserTitleStatus `json:"status,omitempty"` diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml index 1da2b81..20a174f 100644 --- a/api/paths/users-id-titles-id.yaml +++ b/api/paths/users-id-titles-id.yaml @@ -61,6 +61,9 @@ patch: rate: type: integer format: int32 + ftime: + type: string + format: date-time responses: '200': description: Title successfully updated diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 75f5461..f1e5e95 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -122,6 +122,9 @@ post: rate: type: integer format: int32 + ftime: + type: string + format: date-time responses: '200': description: Title successfully added to user diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 995d5af..eecd82f 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -69,6 +69,16 @@ func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { return nil } +func oapiDate2sql(t *time.Time) pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{Valid: false} + } + return pgtype.Timestamptz{ + Time: *t, + Valid: true, + } +} + // func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) { // var sqlc_status SqlcUserStatus // if s == nil { @@ -365,6 +375,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque TitleID: request.Body.TitleId, Status: *status, Rate: request.Body.Rate, + Ftime: oapiDate2sql(request.Body.Ftime), } user_title, err := s.db.InsertUserTitle(ctx, params) @@ -428,6 +439,7 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl Rate: request.Body.Rate, UserID: request.UserId, TitleID: request.TitleId, + Ftime: oapiDate2sql(request.Body.Ftime), } user_title, err := s.db.UpdateUserTitle(ctx, params) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 03502c4..19971e5 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -400,13 +400,14 @@ FROM reviews WHERE review_id = sqlc.arg('review_id')::bigint; -- name: InsertUserTitle :one -INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +INSERT INTO usertitles (user_id, title_id, status, rate, review_id, ctime) VALUES ( sqlc.arg('user_id')::bigint, sqlc.arg('title_id')::bigint, sqlc.arg('status')::usertitle_status_t, sqlc.narg('rate')::int, - sqlc.narg('review_id')::bigint + sqlc.narg('review_id')::bigint, + sqlc.narg('ftime')::timestamptz ) RETURNING user_id, title_id, status, rate, review_id, ctime; @@ -415,7 +416,8 @@ RETURNING user_id, title_id, status, rate, review_id, ctime; UPDATE usertitles SET status = COALESCE(sqlc.narg('status')::usertitle_status_t, status), - rate = COALESCE(sqlc.narg('rate')::int, rate) + rate = COALESCE(sqlc.narg('rate')::int, rate), + ctime = COALESCE(sqlc.narg('ftime')::timestamptz, ctime) WHERE user_id = sqlc.arg('user_id') AND title_id = sqlc.arg('title_id') diff --git a/modules/frontend/src/api/client.gen.ts b/modules/frontend/src/api/client.gen.ts index 2de06ac..952c663 100644 --- a/modules/frontend/src/api/client.gen.ts +++ b/modules/frontend/src/api/client.gen.ts @@ -13,4 +13,4 @@ import type { ClientOptions as ClientOptions2 } from './types.gen'; */ export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>; -export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://10.1.0.65:8081/api/v1' })); +export const client = createClient(createConfig<ClientOptions2>({ baseUrl: '/api/v1' })); diff --git a/modules/frontend/src/api/sdk.gen.ts b/modules/frontend/src/api/sdk.gen.ts index 5359156..7d46120 100644 --- a/modules/frontend/src/api/sdk.gen.ts +++ b/modules/frontend/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddUserTitleData, AddUserTitleErrors, AddUserTitleResponses, DeleteUserTitleData, DeleteUserTitleErrors, DeleteUserTitleResponses, GetTitleData, GetTitleErrors, GetTitleResponses, GetTitlesData, GetTitlesErrors, GetTitlesResponses, GetUsersIdData, GetUsersIdErrors, GetUsersIdResponses, GetUserTitleData, GetUserTitleErrors, GetUserTitleResponses, GetUserTitlesData, GetUserTitlesErrors, GetUserTitlesResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses, UpdateUserTitleData, UpdateUserTitleErrors, UpdateUserTitleResponses } from './types.gen'; +import type { AddUserTitleData, AddUserTitleErrors, AddUserTitleResponses, DeleteUserTitleData, DeleteUserTitleErrors, DeleteUserTitleResponses, GetTitleData, GetTitleErrors, GetTitleResponses, GetTitlesData, GetTitlesErrors, GetTitlesResponses, GetUsersData, GetUsersErrors, GetUsersIdData, GetUsersIdErrors, GetUsersIdResponses, GetUsersResponses, GetUserTitleData, GetUserTitleErrors, GetUserTitleResponses, GetUserTitlesData, GetUserTitlesErrors, GetUserTitlesResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses, UpdateUserTitleData, UpdateUserTitleErrors, UpdateUserTitleResponses } from './types.gen'; export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { /** @@ -32,6 +32,11 @@ export const getTitles = <ThrowOnError extends boolean = false>(options?: Option */ export const getTitle = <ThrowOnError extends boolean = false>(options: Options<GetTitleData, ThrowOnError>) => (options.client ?? client).get<GetTitleResponses, GetTitleErrors, ThrowOnError>({ url: '/titles/{title_id}', ...options }); +/** + * Search user by nickname or dispname (both in one param), response is always sorted by id + */ +export const getUsers = <ThrowOnError extends boolean = false>(options?: Options<GetUsersData, ThrowOnError>) => (options?.client ?? client).get<GetUsersResponses, GetUsersErrors, ThrowOnError>({ url: '/users/', ...options }); + /** * Get user info */ diff --git a/modules/frontend/src/api/types.gen.ts b/modules/frontend/src/api/types.gen.ts index ce4db4b..d4526a7 100644 --- a/modules/frontend/src/api/types.gen.ts +++ b/modules/frontend/src/api/types.gen.ts @@ -60,6 +60,12 @@ export type Title = { title_names: { [key: string]: Array<string>; }; + /** + * Localized description. Key = language (ISO 639-1), value = description. + */ + title_desc?: { + [key: string]: string; + }; studio?: Studio; tags: Tags; poster?: Image; @@ -231,6 +237,50 @@ export type GetTitleResponses = { export type GetTitleResponse = GetTitleResponses[keyof GetTitleResponses]; +export type GetUsersData = { + body?: never; + path?: never; + query?: { + word?: string; + limit?: number; + /** + * pass cursor naked + */ + cursor_id?: number; + }; + url: '/users/'; +}; + +export type GetUsersErrors = { + /** + * Request params are not correct + */ + 400: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type GetUsersResponses = { + /** + * List of users with cursor + */ + 200: { + /** + * List of users + */ + data: Array<User>; + cursor: number; + }; + /** + * No users found + */ + 204: void; +}; + +export type GetUsersResponse = GetUsersResponses[keyof GetUsersResponses]; + export type GetUsersIdData = { body?: never; path: { diff --git a/modules/frontend/src/pages/UsersPage/UsersPage.tsx b/modules/frontend/src/pages/UsersPage/UsersPage.tsx new file mode 100644 index 0000000..e69de29 diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index d6353d6..d57b807 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -170,17 +170,4 @@ EXECUTE FUNCTION update_title_rating(); CREATE TRIGGER trg_notify_new_signal AFTER INSERT ON signals FOR EACH ROW -EXECUTE FUNCTION notify_new_signal(); - -CREATE OR REPLACE FUNCTION set_ctime() -RETURNS TRIGGER AS $$ -BEGIN - NEW.ctime = now(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER set_ctime_on_update -BEFORE UPDATE ON usertitles -FOR EACH ROW -EXECUTE FUNCTION set_ctime(); \ No newline at end of file +EXECUTE FUNCTION notify_new_signal(); \ No newline at end of file diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 0c17599..d253cc9 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -9,6 +9,8 @@ import ( "context" "encoding/json" "time" + + "github.com/jackc/pgx/v5/pgtype" ) const createImage = `-- name: CreateImage :one @@ -394,23 +396,25 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams } const insertUserTitle = `-- name: InsertUserTitle :one -INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +INSERT INTO usertitles (user_id, title_id, status, rate, review_id, ctime) VALUES ( $1::bigint, $2::bigint, $3::usertitle_status_t, $4::int, - $5::bigint + $5::bigint, + $6::timestamptz ) RETURNING user_id, title_id, status, rate, review_id, ctime ` type InsertUserTitleParams struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ftime pgtype.Timestamptz `json:"ftime"` } func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams) (Usertitle, error) { @@ -420,6 +424,7 @@ func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams arg.Status, arg.Rate, arg.ReviewID, + arg.Ftime, ) var i Usertitle err := row.Scan( @@ -1017,18 +1022,20 @@ const updateUserTitle = `-- name: UpdateUserTitle :one UPDATE usertitles SET status = COALESCE($1::usertitle_status_t, status), - rate = COALESCE($2::int, rate) + rate = COALESCE($2::int, rate), + ctime = COALESCE($3::timestamptz, ctime) WHERE - user_id = $3 - AND title_id = $4 + user_id = $4 + AND title_id = $5 RETURNING user_id, title_id, status, rate, review_id, ctime ` type UpdateUserTitleParams struct { - Status *UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` + Status *UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + Ftime pgtype.Timestamptz `json:"ftime"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` } // Fails with sql.ErrNoRows if (user_id, title_id) not found @@ -1036,6 +1043,7 @@ func (q *Queries) UpdateUserTitle(ctx context.Context, arg UpdateUserTitleParams row := q.db.QueryRow(ctx, updateUserTitle, arg.Status, arg.Rate, + arg.Ftime, arg.UserID, arg.TitleID, ) From 6955216568c9db4cdb49d9771f333632010d5364 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 05:24:29 +0300 Subject: [PATCH 199/224] feat(cicd): added redis --- deploy/docker-compose.yml | 25 +++++++++++++++++++++---- modules/auth/handlers/handlers.go | 1 + 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1119335..3eff3d3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -40,6 +40,22 @@ services: retries: 5 start_period: 10s + redis: + image: redis:8.4.0-alpine + container_name: redis + ports: + - "6379:6379" + restart: always + command: ["redis-server", "--appendonly", "yes"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + volumes: + - redis_data:/data + nyanimedb-backend: image: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest container_name: nyanimedb-backend @@ -51,8 +67,8 @@ services: RABBITMQ_URL: ${RABBITMQ_URL} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} AUTH_ENABLED: ${AUTH_ENABLED} - ports: - - "8080:8080" + # ports: + # - "8080:8080" depends_on: - postgres - rabbitmq @@ -68,8 +84,8 @@ services: DATABASE_URL: ${DATABASE_URL} SERVICE_ADDRESS: ${SERVICE_ADDRESS} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} - ports: - - "8082:8082" + # ports: + # - "8082:8082" depends_on: - postgres networks: @@ -89,6 +105,7 @@ services: volumes: postgres_data: rabbitmq_data: + redis_data: networks: nyanimedb-network: diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 2a6518e..3af44f3 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -199,6 +199,7 @@ func (s Server) GetImpersonationToken(ctx context.Context, req auth.GetImpersona } if req.Body.UserId != nil { + // TODO: check user existence if user_id != "" && user_id != fmt.Sprintf("%d", *req.Body.UserId) { log.Error("user_id and external_d are incorrect") // TODO: 405 From 713c0adc14ad57633fb243a358301935d301b293 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 06:25:21 +0300 Subject: [PATCH 200/224] feat: fully featured token checks --- auth/auth.gen.go | 87 +++++++++++++++ auth/claims.go | 10 ++ auth/openapi-auth.yaml | 22 +++- modules/auth/handlers/handlers.go | 154 +++++++++++++++----------- modules/auth/main.go | 2 +- modules/backend/middlewares/access.go | 28 +++-- 6 files changed, 226 insertions(+), 77 deletions(-) create mode 100644 auth/claims.go diff --git a/auth/auth.gen.go b/auth/auth.gen.go index 1e8803e..fd7a224 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -56,6 +56,9 @@ type ServerInterface interface { // Get service impersontaion token // (POST /get-impersonation-token) GetImpersonationToken(c *gin.Context) + // Refreshes access_token and refresh_token + // (GET /refresh-tokens) + RefreshTokens(c *gin.Context) // Sign in a user and return JWT // (POST /sign-in) PostSignIn(c *gin.Context) @@ -88,6 +91,19 @@ func (siw *ServerInterfaceWrapper) GetImpersonationToken(c *gin.Context) { siw.Handler.GetImpersonationToken(c) } +// RefreshTokens operation middleware +func (siw *ServerInterfaceWrapper) RefreshTokens(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.RefreshTokens(c) +} + // PostSignIn operation middleware func (siw *ServerInterfaceWrapper) PostSignIn(c *gin.Context) { @@ -142,10 +158,17 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options } router.POST(options.BaseURL+"/get-impersonation-token", wrapper.GetImpersonationToken) + router.GET(options.BaseURL+"/refresh-tokens", wrapper.RefreshTokens) router.POST(options.BaseURL+"/sign-in", wrapper.PostSignIn) router.POST(options.BaseURL+"/sign-up", wrapper.PostSignUp) } +type ClientErrorResponse struct { +} + +type ServerErrorResponse struct { +} + type UnauthorizedErrorResponse struct { } @@ -176,6 +199,42 @@ func (response GetImpersonationToken401Response) VisitGetImpersonationTokenRespo return nil } +type RefreshTokensRequestObject struct { +} + +type RefreshTokensResponseObject interface { + VisitRefreshTokensResponse(w http.ResponseWriter) error +} + +type RefreshTokens200Response struct { +} + +func (response RefreshTokens200Response) VisitRefreshTokensResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type RefreshTokens400Response = ClientErrorResponse + +func (response RefreshTokens400Response) VisitRefreshTokensResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type RefreshTokens401Response = UnauthorizedErrorResponse + +func (response RefreshTokens401Response) VisitRefreshTokensResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type RefreshTokens500Response = ServerErrorResponse + +func (response RefreshTokens500Response) VisitRefreshTokensResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type PostSignInRequestObject struct { Body *PostSignInJSONRequestBody } @@ -227,6 +286,9 @@ type StrictServerInterface interface { // Get service impersontaion token // (POST /get-impersonation-token) GetImpersonationToken(ctx context.Context, request GetImpersonationTokenRequestObject) (GetImpersonationTokenResponseObject, error) + // Refreshes access_token and refresh_token + // (GET /refresh-tokens) + RefreshTokens(ctx context.Context, request RefreshTokensRequestObject) (RefreshTokensResponseObject, error) // Sign in a user and return JWT // (POST /sign-in) PostSignIn(ctx context.Context, request PostSignInRequestObject) (PostSignInResponseObject, error) @@ -280,6 +342,31 @@ func (sh *strictHandler) GetImpersonationToken(ctx *gin.Context) { } } +// RefreshTokens operation middleware +func (sh *strictHandler) RefreshTokens(ctx *gin.Context) { + var request RefreshTokensRequestObject + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.RefreshTokens(ctx, request.(RefreshTokensRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RefreshTokens") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(RefreshTokensResponseObject); ok { + if err := validResponse.VisitRefreshTokensResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // PostSignIn operation middleware func (sh *strictHandler) PostSignIn(ctx *gin.Context) { var request PostSignInRequestObject diff --git a/auth/claims.go b/auth/claims.go new file mode 100644 index 0000000..d888a1b --- /dev/null +++ b/auth/claims.go @@ -0,0 +1,10 @@ +package auth + +import "github.com/golang-jwt/jwt/v5" + +type TokenClaims struct { + UserID string `json:"user_id"` + Type string `json:"type"` + ImpID *string `json:"imp_id,omitempty"` + jwt.RegisteredClaims +} diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 803a4ae..e95e8c2 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -116,6 +116,22 @@ paths: "401": $ref: '#/components/responses/UnauthorizedError' + /refresh-tokens: + get: + summary: Refreshes access_token and refresh_token + operationId: refreshTokens + tags: [Auth] + responses: + # This one sets two cookies: access_token and refresh_token + "200": + description: Refresh success + "400": + $ref: '#/components/responses/ClientError' + "401": + $ref: '#/components/responses/UnauthorizedError' + "500": + $ref: '#/components/responses/ServerError' + components: securitySchemes: bearerAuth: @@ -123,4 +139,8 @@ components: scheme: bearer responses: UnauthorizedError: - description: Access token is missing or invalid \ No newline at end of file + description: Access token is missing or invalid + ServerError: + description: ServerError + ClientError: + description: ClientError \ No newline at end of file diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 3af44f3..4f67448 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -47,28 +47,35 @@ func CheckPassword(password, hash string) (bool, error) { return argon2id.ComparePasswordAndHash(password, hash) } -func (s Server) generateImpersonationToken(userID string, impersonated_by string) (accessToken string, err error) { - accessClaims := jwt.MapClaims{ - "user_id": userID, - "exp": time.Now().Add(15 * time.Minute).Unix(), - "imp_id": impersonated_by, +func (s *Server) generateImpersonationToken(userID string, impersonatedBy string) (string, error) { + now := time.Now() + claims := auth.TokenClaims{ + UserID: userID, + ImpID: &impersonatedBy, + Type: "access", + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), + ID: generateJTI(), + }, } - at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - - accessToken, err = at.SignedString([]byte(s.JwtPrivateKey)) - if err != nil { - return "", err - } - - return accessToken, nil + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.JwtPrivateKey)) } -func (s Server) generateTokens(userID string) (accessToken string, refreshToken string, csrfToken string, err error) { - accessClaims := jwt.MapClaims{ - "user_id": userID, - "exp": time.Now().Add(15 * time.Minute).Unix(), - //TODO: add created_at +func (s *Server) generateTokens(userID string) (accessToken string, refreshToken string, csrfToken string, err error) { + now := time.Now() + + // Access token (15 мин) + accessClaims := auth.TokenClaims{ + UserID: userID, + Type: "access", + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), + ID: generateJTI(), + }, } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) accessToken, err = at.SignedString([]byte(s.JwtPrivateKey)) @@ -76,9 +83,15 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken return "", "", "", err } - refreshClaims := jwt.MapClaims{ - "user_id": userID, - "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), + // Refresh token (7 дней) + refreshClaims := auth.TokenClaims{ + UserID: userID, + Type: "refresh", + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)), + ID: generateJTI(), + }, } rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) refreshToken, err = rt.SignedString([]byte(s.JwtPrivateKey)) @@ -86,6 +99,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken return "", "", "", err } + // CSRF token csrfBytes := make([]byte, 32) _, err = rand.Read(csrfBytes) if err != nil { @@ -219,56 +233,56 @@ func (s Server) GetImpersonationToken(ctx context.Context, req auth.GetImpersona return auth.GetImpersonationToken200JSONResponse{AccessToken: accessToken}, nil } -// func (s Server) PostAuthRefreshToken(ctx context.Context, req auth.PostAuthRefreshTokenRequestObject) (auth.PostAuthRefreshTokenResponseObject, error) { -// valid := false -// var userID *string -// var errStr *string +func (s Server) RefreshTokens(ctx context.Context, req auth.RefreshTokensRequestObject) (auth.RefreshTokensResponseObject, error) { + ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) + if !ok { + log.Print("failed to get gin context") + return auth.RefreshTokens500Response{}, fmt.Errorf("failed to get gin.Context from context.Context") + } -// token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { -// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { -// return nil, fmt.Errorf("unexpected signing method") -// } -// return refreshSecret, nil -// }) + rtCookie, err := ginCtx.Request.Cookie("refresh_token") + if err != nil { + log.Print("failed to get refresh_token cookie") + return auth.RefreshTokens400Response{}, fmt.Errorf("failed to get refresh_token cookie") + } -// if err != nil { -// e := err.Error() -// errStr = &e -// return auth.PostAuthVerifyToken200JSONResponse{ -// Valid: &valid, -// UserId: userID, -// Error: errStr, -// }, nil -// } + refreshToken := rtCookie.Value -// if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { -// if uid, ok := claims["user_id"].(string); ok { -// // Refresh token is valid, generate new tokens -// newAccessToken, newRefreshToken, _ := generateTokens(uid) -// valid = true -// userID = &uid -// return auth.PostAuthVerifyToken200JSONResponse{ -// Valid: &valid, -// UserId: userID, -// Error: nil, -// Token: &newAccessToken, // return new access token -// // optionally return newRefreshToken as well -// }, nil -// } else { -// e := "user_id not found in refresh token" -// errStr = &e -// } -// } else { -// e := "invalid refresh token claims" -// errStr = &e -// } + token, err := jwt.ParseWithClaims(refreshToken, &auth.TokenClaims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(s.JwtPrivateKey), nil + }) + if err != nil || !token.Valid { + log.Print("invalid refresh token") + return auth.RefreshTokens401Response{}, nil + } -// return auth.PostAuthVerifyToken200JSONResponse{ -// Valid: &valid, -// UserId: userID, -// Error: errStr, -// }, nil -// } + claims, ok := token.Claims.(*auth.TokenClaims) + if !ok || claims.UserID == "" { + log.Print("invalid refresh token claims") + return auth.RefreshTokens401Response{}, nil + } + if claims.Type != "refresh" { + log.Errorf("token is not a refresh token") + return auth.RefreshTokens401Response{}, nil + } + + accessToken, refreshToken, csrfToken, err := s.generateTokens(claims.UserID) + if err != nil { + log.Errorf("failed to generate tokens for user %s: %v", claims.UserID, err) + return auth.RefreshTokens500Response{}, nil + } + + // TODO: check cookie settings carefully + ginCtx.SetSameSite(http.SameSiteStrictMode) + ginCtx.SetCookie("access_token", accessToken, 900, "/api", "", false, true) + ginCtx.SetCookie("refresh_token", refreshToken, 1209600, "/auth", "", false, true) + ginCtx.SetCookie("xsrf_token", csrfToken, 1209600, "/", "", false, false) + + return auth.RefreshTokens200Response{}, nil +} func ExtractBearerToken(header string) (string, error) { const prefix = "Bearer " @@ -277,3 +291,9 @@ func ExtractBearerToken(header string) (string, error) { } return header[len(prefix):], nil } + +func generateJTI() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/modules/auth/main.go b/modules/auth/main.go index 7305b7d..bbeb014 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -46,7 +46,7 @@ func main() { log.Info("allow origins:", AppConfig.ServiceAddress) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, + AllowOrigins: []string{AppConfig.ServiceAddress}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, diff --git a/modules/backend/middlewares/access.go b/modules/backend/middlewares/access.go index 73200e8..8e787f8 100644 --- a/modules/backend/middlewares/access.go +++ b/modules/backend/middlewares/access.go @@ -3,8 +3,11 @@ package middleware import ( "context" "errors" + "fmt" "net/http" + "nyanimedb/auth" + "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" ) @@ -37,12 +40,18 @@ func JWTAuthMiddleware(secret string) gin.HandlerFunc { } // 2. Парсим токен с MapClaims - token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { - if t.Method != jwt.SigningMethodHS256 { - return nil, errors.New("unexpected signing method: " + t.Method.Alg()) + token, err := jwt.ParseWithClaims(tokenStr, &auth.TokenClaims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") } - return []byte(secret), nil // ← конвертируем string → []byte + return []byte(secret), nil }) + // token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + // if t.Method != jwt.SigningMethodHS256 { + // return nil, errors.New("unexpected signing method: " + t.Method.Alg()) + // } + // return []byte(secret), nil // ← конвертируем string → []byte + // }) if err != nil { abortWithJSON(c, http.StatusUnauthorized, "invalid token: "+err.Error()) return @@ -55,20 +64,23 @@ func JWTAuthMiddleware(secret string) gin.HandlerFunc { } // 4. Извлекаем user_id из claims - claims, ok := token.Claims.(jwt.MapClaims) + claims, ok := token.Claims.(*auth.TokenClaims) if !ok { abortWithJSON(c, http.StatusUnauthorized, "invalid claims format") return } - userID, ok := claims["user_id"].(string) - if !ok || userID == "" { + if claims.UserID == "" { abortWithJSON(c, http.StatusUnauthorized, "user_id claim missing or invalid") return } + if claims.Type != "access" { + abortWithJSON(c, http.StatusUnauthorized, "token type is not access") + return + } // 5. Сохраняем в контексте - c.Set("user_id", userID) + c.Set("user_id", claims.UserID) // 6. Для oapi-codegen — кладём gin.Context в request context GinContextToContext(c) From fc0ddf334d92f161673a91ed091b6b86d679b6f6 Mon Sep 17 00:00:00 2001 From: garaev kamil <garaev.kr@phystech.edu> Date: Sat, 6 Dec 2025 06:37:33 +0300 Subject: [PATCH 201/224] changed to interact with standalone image downloader service --- modules/anime_etl/db/repository.py | 31 ++++++++-- modules/anime_etl/images/downloader.py | 84 +++++++------------------- 2 files changed, 46 insertions(+), 69 deletions(-) diff --git a/modules/anime_etl/db/repository.py b/modules/anime_etl/db/repository.py index 45fba0f..4c5caee 100644 --- a/modules/anime_etl/db/repository.py +++ b/modules/anime_etl/db/repository.py @@ -30,6 +30,16 @@ def _choose_primary_name( return None +import re + +_SHA1_PATH_RE = re.compile( + r"^[a-zA-Z0-9_-]+/[0-9a-f]{2}/[0-9a-f]{2}/[0-9a-f]{40}\.[a-zA-Z0-9]{1,5}$" +) + +def is_normalized_image_path(path: str) -> bool: + return bool(_SHA1_PATH_RE.match(path)) + + async def get_or_create_image( conn: Conn, img: Optional[Image], @@ -39,14 +49,22 @@ async def get_or_create_image( if img is None or not img.image_path: return None - # img.image_path сейчас — URL из AniList url = img.image_path - # 1) решаем, куда кладём картинку, и если надо — скачиваем - rel_path = await ensure_image_downloaded(url, subdir=subdir) + # 1) Если это URL → скачиваем через image-service + if url.startswith("http://") or url.startswith("https://"): + try: + rel_path = await ensure_image_downloaded(url, subdir=subdir) + except Exception as e: + # не удалось скачать картинку — просто пропускаем + return None + else: + # неправильный формат — просто пропуск + return None + + # 3) Проверка в базе async with conn.cursor(row_factory=dict_row) as cur: - # 2) пробуем найти уже существующую запись по относительному пути await cur.execute( "SELECT id FROM images WHERE image_path = %s", (rel_path,), @@ -55,19 +73,20 @@ async def get_or_create_image( if row: return row["id"] - # 3) создаём новую запись + # 4) Вставляем запись await cur.execute( """ INSERT INTO images (storage_type, image_path) VALUES (%s, %s) RETURNING id """, - ("local", rel_path), + ("image-service", rel_path), ) row = await cur.fetchone() return row["id"] + async def get_or_create_studio( conn: Conn, studio: Optional[Studio], diff --git a/modules/anime_etl/images/downloader.py b/modules/anime_etl/images/downloader.py index 1c5134c..a896d5f 100644 --- a/modules/anime_etl/images/downloader.py +++ b/modules/anime_etl/images/downloader.py @@ -1,77 +1,35 @@ +# anime_etl/images/downloader.py from __future__ import annotations -import asyncio -import hashlib import os -from pathlib import Path -from typing import Tuple -from urllib.parse import urlparse +from typing import Final import httpx -# Корень хранилища картинок внутри контейнера/процесса -MEDIA_ROOT = Path(os.getenv("NYANIMEDB_MEDIA_ROOT", "media")).resolve() - - -def _guess_ext_from_url(url: str) -> str: - path = urlparse(url).path - _, ext = os.path.splitext(path) - if ext and len(ext) <= 5: - return ext - return ".jpg" - - -def _build_rel_path_from_hash(h: str, ext: str, subdir: str = "posters") -> Tuple[str, Path]: - """ - Строим путь вида subdir/ab/cd/<hash>.ext по sha1-хешу содержимого. - """ - level1 = h[:2] - level2 = h[2:4] - rel = f"{subdir}/{level1}/{level2}/{h}{ext}" - abs_path = MEDIA_ROOT / rel - return rel, abs_path - - -async def _fetch_bytes(url: str) -> bytes: - async with httpx.AsyncClient(timeout=20.0) as client: - r = await client.get(url) - r.raise_for_status() - return r.content +IMAGE_SERVICE_URL: Final[str] = os.getenv( + "NYANIMEDB_IMAGE_SERVICE_URL", + "http://127.0.0.1:8000" +) async def ensure_image_downloaded(url: str, subdir: str = "posters") -> str: """ - Гарантирует, что картинка по URL лежит в MEDIA_ROOT/subdir в структуре: - subdir/ab/cd/<sha1(content)>.ext + Просит image-service скачать картинку по URL и сохранить её у себя. - Возвращает относительный путь (для записи в БД). - Один и тот же файл (по содержимому) всегда даёт один и тот же путь, - даже если URL меняется. + Возвращает относительный путь (subdir/ab/cd/<sha1>.ext), + который можно писать в images.image_path. """ - # Скачиваем данные - data = await _fetch_bytes(url) + async with httpx.AsyncClient(timeout=20.0) as client: + resp = await client.post( + f"{IMAGE_SERVICE_URL}/download-by-url", + json={"url": url, "subdir": subdir}, + ) + resp.raise_for_status() + data = resp.json() - # Хешируем именно содержимое, а не URL - h = hashlib.sha1(data).hexdigest() - ext = _guess_ext_from_url(url) + # ожидаем {"path": "..."} + path = data["path"] + if not isinstance(path, str): + raise RuntimeError(f"Invalid response from image service: {data!r}") - rel, abs_path = _build_rel_path_from_hash(h, ext, subdir=subdir) - - # Если файл уже есть (другой процесс/воркер успел сохранить) — просто возвращаем путь - if abs_path.exists(): - return rel - - abs_path.parent.mkdir(parents=True, exist_ok=True) - - # Пишем во временный файл и затем делаем atomic rename - tmp_path = abs_path.with_suffix(abs_path.suffix + ".tmp") - - def _write() -> None: - with open(tmp_path, "wb") as f: - f.write(data) - # os.replace атомарно заменит файл, даже если он уже появился - os.replace(tmp_path, abs_path) - - await asyncio.to_thread(_write) - - return rel + return path From 1012ac22b6f01be7f5dc36ed896da2498a6613a4 Mon Sep 17 00:00:00 2001 From: garaev kamil <garaev.kr@phystech.edu> Date: Sat, 6 Dec 2025 06:38:11 +0300 Subject: [PATCH 202/224] connection reties added --- modules/anime_etl/rabbit_worker.py | 42 +++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/modules/anime_etl/rabbit_worker.py b/modules/anime_etl/rabbit_worker.py index fed33dc..2c4e595 100644 --- a/modules/anime_etl/rabbit_worker.py +++ b/modules/anime_etl/rabbit_worker.py @@ -6,18 +6,15 @@ import os import sys from typing import Any, Dict -from dotenv import load_dotenv - import aio_pika +from aio_pika.exceptions import AMQPConnectionError import psycopg from psycopg.rows import dict_row from services.anilist_importer import AniListImporter -load_dotenv() - -PG_DSN = os.getenv("NYANIMEDB_PG_DSN") -RMQ_URL = os.getenv("NYANIMEDB_RABBITMQ_URL", "amqp://guest:guest@10.1.0.65:5672/") +PG_DSN = os.getenv("NYANIMEDB_PG_DSN") or os.getenv("DATABASE_URL") +RMQ_URL = os.getenv("NYANIMEDB_RMQ_URL") or os.getenv("RABBITMQ_URL") or "amqp://guest:guest@rabbitmq:5672/" RPC_QUEUE_NAME = os.getenv("NYANIMEDB_IMPORT_RPC_QUEUE", "anime_import_rpc") @@ -95,11 +92,37 @@ def create_handler(channel: aio_pika.Channel): return handle_message +async def connect_rmq_with_retry( + url: str, + retries: int = 20, + delay: float = 3.0, +) -> aio_pika.RobustConnection: + last_exc: Exception | None = None + + for attempt in range(1, retries + 1): + try: + print(f"[worker] Connecting to RabbitMQ ({attempt}/{retries}) {url}", flush=True) + conn = await aio_pika.connect_robust(url) + print("[worker] Connected to RabbitMQ", flush=True) + return conn + except AMQPConnectionError as e: + last_exc = e + print(f"[worker] RabbitMQ connection failed: {e!r}, retry in {delay}s", flush=True) + await asyncio.sleep(delay) + + print("[worker] Failed to connect to RabbitMQ after retries", file=sys.stderr, flush=True) + if last_exc: + raise last_exc + raise RuntimeError("Failed to connect to RabbitMQ") + + async def main() -> None: if not PG_DSN: - raise RuntimeError("NYANIMEDB_PG_DSN is not set") + raise RuntimeError("PG_DSN is not set (NYANIMEDB_PG_DSN / DATABASE_URL)") - connection = await aio_pika.connect_robust(RMQ_URL) + print(f"[worker] Starting. PG_DSN={PG_DSN!r}, RMQ_URL={RMQ_URL!r}, queue={RPC_QUEUE_NAME!r}", flush=True) + + connection = await connect_rmq_with_retry(RMQ_URL) channel = await connection.channel() queue = await channel.declare_queue( @@ -110,6 +133,8 @@ async def main() -> None: handler = create_handler(channel) await queue.consume(handler) + print(f"[*] Waiting for messages in '{RPC_QUEUE_NAME}'. Ctrl+C to exit.", flush=True) + try: await asyncio.Future() # run forever finally: @@ -119,5 +144,4 @@ async def main() -> None: if __name__ == "__main__": if sys.platform.startswith("win"): asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - asyncio.run(main()) From 74d6adf23a03b156fe78e67e0b01a2b64d1c9d0a Mon Sep 17 00:00:00 2001 From: garaev kamil <garaev.kr@phystech.edu> Date: Sat, 6 Dec 2025 06:41:25 +0300 Subject: [PATCH 203/224] image downloader service added --- modules/image_storage/.gitignore | 3 + modules/image_storage/.python-version | 1 + modules/image_storage/README.md | 0 modules/image_storage/app/main.py | 110 ++++++++++ modules/image_storage/pyproject.toml | 12 ++ modules/image_storage/uv.lock | 286 ++++++++++++++++++++++++++ 6 files changed, 412 insertions(+) create mode 100644 modules/image_storage/.gitignore create mode 100644 modules/image_storage/.python-version create mode 100644 modules/image_storage/README.md create mode 100644 modules/image_storage/app/main.py create mode 100644 modules/image_storage/pyproject.toml create mode 100644 modules/image_storage/uv.lock diff --git a/modules/image_storage/.gitignore b/modules/image_storage/.gitignore new file mode 100644 index 0000000..bcbf763 --- /dev/null +++ b/modules/image_storage/.gitignore @@ -0,0 +1,3 @@ +.venv +__pycache__ +media/ \ No newline at end of file diff --git a/modules/image_storage/.python-version b/modules/image_storage/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/modules/image_storage/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/modules/image_storage/README.md b/modules/image_storage/README.md new file mode 100644 index 0000000..e69de29 diff --git a/modules/image_storage/app/main.py b/modules/image_storage/app/main.py new file mode 100644 index 0000000..ff59d36 --- /dev/null +++ b/modules/image_storage/app/main.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +from typing import Tuple +from urllib.parse import urlparse + +import httpx +from fastapi import FastAPI, UploadFile, File, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel + +# Хранилище картинок только для этого сервиса +MEDIA_ROOT = Path(os.getenv("NYANIMEDB_MEDIA_ROOT", "media")).resolve() +MEDIA_ROOT.mkdir(parents=True, exist_ok=True) + +app = FastAPI() + + +def _guess_ext_from_url(url: str) -> str: + path = urlparse(url).path + _, ext = os.path.splitext(path) + if ext and len(ext) <= 5: + return ext + return ".jpg" + + +def _guess_ext_from_name(name: str) -> str: + _, ext = os.path.splitext(name) + if ext and len(ext) <= 5: + return ext + return ".jpg" + + +def _build_rel_path_from_hash(h: str, ext: str, subdir: str = "posters") -> Tuple[str, Path]: + """ + Строим путь вида subdir/ab/cd/<hash>.ext по sha1-хешу содержимого. + """ + level1 = h[:2] + level2 = h[2:4] + rel = f"{subdir}/{level1}/{level2}/{h}{ext}" + abs_path = MEDIA_ROOT / rel + return rel, abs_path + + +async def _save_bytes(data: bytes, *, filename_hint: str, subdir: str = "posters") -> str: + # sha1 по содержимому + h = hashlib.sha1(data).hexdigest() + # расширение либо из имени, либо .jpg + ext = _guess_ext_from_name(filename_hint) + + rel, abs_path = _build_rel_path_from_hash(h, ext, subdir=subdir) + + if abs_path.exists(): + return rel + + abs_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = abs_path.with_suffix(abs_path.suffix + ".tmp") + + with open(tmp_path, "wb") as f: + f.write(data) + os.replace(tmp_path, abs_path) + + return rel + + +class DownloadByUrlRequest(BaseModel): + url: str + subdir: str = "posters" + + +@app.post("/upload") +async def upload_image( + file: UploadFile = File(...), + subdir: str = "posters", +): + """ + Загрузка файла от клиента (ETL/админка/бекенд). + Возвращает относительный путь (subdir/ab/cd/hash.ext). + """ + data = await file.read() + rel = await _save_bytes(data, filename_hint=file.filename or "", subdir=subdir) + return {"path": rel} + + +@app.post("/download-by-url") +async def download_by_url(payload: DownloadByUrlRequest): + """ + Скачивает картинку по URL и сохраняет по тем же правилам, что downloader.py. + """ + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(payload.url) + r.raise_for_status() + data = r.content + + # filename_hint берём из URL + rel = await _save_bytes(data, filename_hint=payload.url, subdir=payload.subdir) + return {"path": rel} + + +@app.get("/media/{path:path}") +async def get_image(path: str): + """ + Отдаёт файл по относительному пути (например, posters/ab/cd/hash.jpg). + """ + abs_path = MEDIA_ROOT / path + if not abs_path.is_file(): + raise HTTPException(status_code=404, detail="Image not found") + return FileResponse(abs_path) diff --git a/modules/image_storage/pyproject.toml b/modules/image_storage/pyproject.toml new file mode 100644 index 0000000..0d6f3ab --- /dev/null +++ b/modules/image_storage/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "image-storage" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.123.10", + "httpx>=0.28.1", + "python-multipart>=0.0.20", + "uvicorn>=0.38.0", +] diff --git a/modules/image_storage/uv.lock b/modules/image_storage/uv.lock new file mode 100644 index 0000000..3bd742d --- /dev/null +++ b/modules/image_storage/uv.lock @@ -0,0 +1,286 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.123.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/ff/e01087de891010089f1620c916c0c13130f3898177955c13e2b02d22ec4a/fastapi-0.123.10.tar.gz", hash = "sha256:624d384d7cda7c096449c889fc776a0571948ba14c3c929fa8e9a78cd0b0a6a8", size = 356360, upload-time = "2025-12-05T21:27:46.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/f0/7cb92c4a720def85240fd63fbbcf147ce19e7a731c8e1032376bb5a486ac/fastapi-0.123.10-py3-none-any.whl", hash = "sha256:0503b7b7bc71bc98f7c90c9117d21fdf6147c0d74703011b87936becc86985c1", size = 111774, upload-time = "2025-12-05T21:27:44.78Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "image-storage" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.123.10" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "uvicorn", specifier = ">=0.38.0" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, +] From 714ef5702722d388d60eb6513ee143109e07a90d Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 06:47:01 +0300 Subject: [PATCH 204/224] feat: use JWT Subject --- auth/claims.go | 5 ++--- modules/auth/handlers/handlers.go | 22 +++++++++++----------- modules/backend/middlewares/access.go | 4 ++-- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/auth/claims.go b/auth/claims.go index d888a1b..6a97483 100644 --- a/auth/claims.go +++ b/auth/claims.go @@ -3,8 +3,7 @@ package auth import "github.com/golang-jwt/jwt/v5" type TokenClaims struct { - UserID string `json:"user_id"` - Type string `json:"type"` - ImpID *string `json:"imp_id,omitempty"` + Type string `json:"type"` + ImpID *string `json:"imp_id,omitempty"` jwt.RegisteredClaims } diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 4f67448..1813035 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -50,10 +50,10 @@ func CheckPassword(password, hash string) (bool, error) { func (s *Server) generateImpersonationToken(userID string, impersonatedBy string) (string, error) { now := time.Now() claims := auth.TokenClaims{ - UserID: userID, - ImpID: &impersonatedBy, - Type: "access", + ImpID: &impersonatedBy, + Type: "access", RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), ID: generateJTI(), @@ -69,9 +69,9 @@ func (s *Server) generateTokens(userID string) (accessToken string, refreshToken // Access token (15 мин) accessClaims := auth.TokenClaims{ - UserID: userID, - Type: "access", + Type: "access", RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), ID: generateJTI(), @@ -85,9 +85,9 @@ func (s *Server) generateTokens(userID string) (accessToken string, refreshToken // Refresh token (7 дней) refreshClaims := auth.TokenClaims{ - UserID: userID, - Type: "refresh", + Type: "refresh", RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)), ID: generateJTI(), @@ -154,7 +154,7 @@ func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject return auth.PostSignIn401Response{}, nil } - accessToken, refreshToken, csrfToken, err := s.generateTokens(req.Body.Nickname) + accessToken, refreshToken, csrfToken, err := s.generateTokens(fmt.Sprintf("%d", user.ID)) if err != nil { log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err) // TODO: return 500 @@ -260,7 +260,7 @@ func (s Server) RefreshTokens(ctx context.Context, req auth.RefreshTokensRequest } claims, ok := token.Claims.(*auth.TokenClaims) - if !ok || claims.UserID == "" { + if !ok || claims.Subject == "" { log.Print("invalid refresh token claims") return auth.RefreshTokens401Response{}, nil } @@ -269,9 +269,9 @@ func (s Server) RefreshTokens(ctx context.Context, req auth.RefreshTokensRequest return auth.RefreshTokens401Response{}, nil } - accessToken, refreshToken, csrfToken, err := s.generateTokens(claims.UserID) + accessToken, refreshToken, csrfToken, err := s.generateTokens(claims.Subject) if err != nil { - log.Errorf("failed to generate tokens for user %s: %v", claims.UserID, err) + log.Errorf("failed to generate tokens for user %s: %v", claims.Subject, err) return auth.RefreshTokens500Response{}, nil } diff --git a/modules/backend/middlewares/access.go b/modules/backend/middlewares/access.go index 8e787f8..9b15f8f 100644 --- a/modules/backend/middlewares/access.go +++ b/modules/backend/middlewares/access.go @@ -70,7 +70,7 @@ func JWTAuthMiddleware(secret string) gin.HandlerFunc { return } - if claims.UserID == "" { + if claims.Subject == "" { abortWithJSON(c, http.StatusUnauthorized, "user_id claim missing or invalid") return } @@ -80,7 +80,7 @@ func JWTAuthMiddleware(secret string) gin.HandlerFunc { } // 5. Сохраняем в контексте - c.Set("user_id", claims.UserID) + c.Set("user_id", claims.Subject) // 6. Для oapi-codegen — кладём gin.Context в request context GinContextToContext(c) From 69eacd724015227509ac2aa4b16618ce630ce581 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 07:01:38 +0300 Subject: [PATCH 205/224] feat: logout --- auth/auth.gen.go | 67 +++++++++++++++++++++++++++++++ auth/openapi-auth.yaml | 11 +++++ modules/auth/handlers/handlers.go | 16 ++++++++ 3 files changed, 94 insertions(+) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index fd7a224..ebef832 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -56,6 +56,9 @@ type ServerInterface interface { // Get service impersontaion token // (POST /get-impersonation-token) GetImpersonationToken(c *gin.Context) + // Logs out the user + // (POST /logout) + Logout(c *gin.Context) // Refreshes access_token and refresh_token // (GET /refresh-tokens) RefreshTokens(c *gin.Context) @@ -91,6 +94,19 @@ func (siw *ServerInterfaceWrapper) GetImpersonationToken(c *gin.Context) { siw.Handler.GetImpersonationToken(c) } +// Logout operation middleware +func (siw *ServerInterfaceWrapper) Logout(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.Logout(c) +} + // RefreshTokens operation middleware func (siw *ServerInterfaceWrapper) RefreshTokens(c *gin.Context) { @@ -158,6 +174,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options } router.POST(options.BaseURL+"/get-impersonation-token", wrapper.GetImpersonationToken) + router.POST(options.BaseURL+"/logout", wrapper.Logout) router.GET(options.BaseURL+"/refresh-tokens", wrapper.RefreshTokens) router.POST(options.BaseURL+"/sign-in", wrapper.PostSignIn) router.POST(options.BaseURL+"/sign-up", wrapper.PostSignUp) @@ -199,6 +216,28 @@ func (response GetImpersonationToken401Response) VisitGetImpersonationTokenRespo return nil } +type LogoutRequestObject struct { +} + +type LogoutResponseObject interface { + VisitLogoutResponse(w http.ResponseWriter) error +} + +type Logout200Response struct { +} + +func (response Logout200Response) VisitLogoutResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type Logout500Response = ServerErrorResponse + +func (response Logout500Response) VisitLogoutResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type RefreshTokensRequestObject struct { } @@ -286,6 +325,9 @@ type StrictServerInterface interface { // Get service impersontaion token // (POST /get-impersonation-token) GetImpersonationToken(ctx context.Context, request GetImpersonationTokenRequestObject) (GetImpersonationTokenResponseObject, error) + // Logs out the user + // (POST /logout) + Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) // Refreshes access_token and refresh_token // (GET /refresh-tokens) RefreshTokens(ctx context.Context, request RefreshTokensRequestObject) (RefreshTokensResponseObject, error) @@ -342,6 +384,31 @@ func (sh *strictHandler) GetImpersonationToken(ctx *gin.Context) { } } +// Logout operation middleware +func (sh *strictHandler) Logout(ctx *gin.Context) { + var request LogoutRequestObject + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.Logout(ctx, request.(LogoutRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Logout") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(LogoutResponseObject); ok { + if err := validResponse.VisitLogoutResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // RefreshTokens operation middleware func (sh *strictHandler) RefreshTokens(ctx *gin.Context) { var request RefreshTokensRequestObject diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index e95e8c2..8603423 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -132,6 +132,17 @@ paths: "500": $ref: '#/components/responses/ServerError' + /logout: + post: + summary: Logs out the user + operationId: logout + tags: [Auth] + responses: + "200": + description: Logout success + "500": + $ref: '#/components/responses/ServerError' + components: securitySchemes: bearerAuth: diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 1813035..163efc2 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -284,6 +284,22 @@ func (s Server) RefreshTokens(ctx context.Context, req auth.RefreshTokensRequest return auth.RefreshTokens200Response{}, nil } +func (s Server) Logout(ctx context.Context, req auth.LogoutRequestObject) (auth.LogoutResponseObject, error) { + // TODO: get current tokens and add them to block list + ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) + if !ok { + log.Print("failed to get gin context") + return auth.Logout500Response{}, fmt.Errorf("failed to get gin.Context from context.Context") + } + + // Delete cookies by setting MaxAge negative + ginCtx.SetCookie("access_token", "", -1, "/api", "", true, true) + ginCtx.SetCookie("refresh_token", "", -1, "/auth", "", true, true) + ginCtx.SetCookie("xsrf_token", "", -1, "/", "", false, false) + + return auth.Logout200Response{}, nil +} + func ExtractBearerToken(header string) (string, error) { const prefix = "Bearer " if len(header) <= len(prefix) || header[:len(prefix)] != prefix { From da9d0f8dda1be3dc68bb144d5d367df1b96e041a Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 07:19:27 +0300 Subject: [PATCH 206/224] feat: frontend logout menu --- modules/frontend/src/App.tsx | 6 +- modules/frontend/src/auth/client.gen.ts | 16 + .../frontend/src/auth/client/client.gen.ts | 301 ++++++++++++++++ modules/frontend/src/auth/client/index.ts | 25 ++ modules/frontend/src/auth/client/types.gen.ts | 241 +++++++++++++ modules/frontend/src/auth/client/utils.gen.ts | 332 ++++++++++++++++++ modules/frontend/src/auth/core/ApiError.ts | 25 -- .../src/auth/core/ApiRequestOptions.ts | 17 - modules/frontend/src/auth/core/ApiResult.ts | 11 - .../src/auth/core/CancelablePromise.ts | 131 ------- modules/frontend/src/auth/core/OpenAPI.ts | 32 -- modules/frontend/src/auth/core/auth.gen.ts | 42 +++ .../src/auth/core/bodySerializer.gen.ts | 100 ++++++ modules/frontend/src/auth/core/params.gen.ts | 176 ++++++++++ .../src/auth/core/pathSerializer.gen.ts | 181 ++++++++++ .../src/auth/core/queryKeySerializer.gen.ts | 136 +++++++ modules/frontend/src/auth/core/request.ts | 323 ----------------- .../src/auth/core/serverSentEvents.gen.ts | 264 ++++++++++++++ modules/frontend/src/auth/core/types.gen.ts | 118 +++++++ modules/frontend/src/auth/core/utils.gen.ts | 143 ++++++++ modules/frontend/src/auth/index.ts | 12 +- modules/frontend/src/auth/sdk.gen.ts | 66 ++++ .../frontend/src/auth/services/AuthService.ts | 55 --- modules/frontend/src/auth/types.gen.ts | 136 +++++++ .../frontend/src/components/Header/Header.tsx | 113 +++--- .../src/pages/LoginPage/LoginPage.tsx | 98 ++---- 26 files changed, 2388 insertions(+), 712 deletions(-) create mode 100644 modules/frontend/src/auth/client.gen.ts create mode 100644 modules/frontend/src/auth/client/client.gen.ts create mode 100644 modules/frontend/src/auth/client/index.ts create mode 100644 modules/frontend/src/auth/client/types.gen.ts create mode 100644 modules/frontend/src/auth/client/utils.gen.ts delete mode 100644 modules/frontend/src/auth/core/ApiError.ts delete mode 100644 modules/frontend/src/auth/core/ApiRequestOptions.ts delete mode 100644 modules/frontend/src/auth/core/ApiResult.ts delete mode 100644 modules/frontend/src/auth/core/CancelablePromise.ts delete mode 100644 modules/frontend/src/auth/core/OpenAPI.ts create mode 100644 modules/frontend/src/auth/core/auth.gen.ts create mode 100644 modules/frontend/src/auth/core/bodySerializer.gen.ts create mode 100644 modules/frontend/src/auth/core/params.gen.ts create mode 100644 modules/frontend/src/auth/core/pathSerializer.gen.ts create mode 100644 modules/frontend/src/auth/core/queryKeySerializer.gen.ts delete mode 100644 modules/frontend/src/auth/core/request.ts create mode 100644 modules/frontend/src/auth/core/serverSentEvents.gen.ts create mode 100644 modules/frontend/src/auth/core/types.gen.ts create mode 100644 modules/frontend/src/auth/core/utils.gen.ts create mode 100644 modules/frontend/src/auth/sdk.gen.ts delete mode 100644 modules/frontend/src/auth/services/AuthService.ts create mode 100644 modules/frontend/src/auth/types.gen.ts diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 84c9086..a92cc17 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -11,12 +11,12 @@ import { Header } from "./components/Header/Header"; // OpenAPI.WITH_CREDENTIALS = true const App: React.FC = () => { - const username = localStorage.getItem("username") || undefined; - const userId = localStorage.getItem("userId"); + // const username = localStorage.getItem("username") || undefined; + const userId = localStorage.getItem("user_id"); return ( <Router> - <Header username={username} /> + <Header /> <Routes> {/* auth */} <Route path="/login" element={<LoginPage />} /> diff --git a/modules/frontend/src/auth/client.gen.ts b/modules/frontend/src/auth/client.gen.ts new file mode 100644 index 0000000..ba4855c --- /dev/null +++ b/modules/frontend/src/auth/client.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type ClientOptions, type Config, createClient, createConfig } from './client'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>; + +export const client = createClient(createConfig<ClientOptions2>({ baseUrl: '/auth' })); diff --git a/modules/frontend/src/auth/client/client.gen.ts b/modules/frontend/src/auth/client/client.gen.ts new file mode 100644 index 0000000..c2a5190 --- /dev/null +++ b/modules/frontend/src/auth/client/client.gen.ts @@ -0,0 +1,301 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { + Client, + Config, + RequestOptions, + ResolvedRequestOptions, +} from './types.gen'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils.gen'; + +type ReqInit = Omit<RequestInit, 'body' | 'headers'> & { + body?: any; + headers: ReturnType<typeof mergeHeaders>; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors< + Request, + Response, + unknown, + ResolvedRequestOptions + >(); + + const beforeRequest = async (options: RequestOptions) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined, + }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body); + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const url = buildUrl(opts); + + return { opts, url }; + }; + + const request: Client['request'] = async (options) => { + // @ts-expect-error + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; + + let request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + let response: Response; + + try { + response = await _fetch(request); + } catch (error) { + // Handle fetch exceptions (AbortError, network errors, etc.) + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn( + error, + undefined as any, + request, + opts, + )) as unknown; + } + } + + finalError = finalError || ({} as unknown); + + if (opts.throwOnError) { + throw finalError; + } + + // Return error response + return opts.responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response: undefined as any, + }; + } + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if ( + response.status === 204 || + response.headers.get('Content-Length') === '0' + ) { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'json': + case 'text': + data = await response[parseAs](); + break; + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + const error = jsonError ?? textError; + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn(error, response, request, opts)) as string; + } + } + + finalError = finalError || ({} as string); + + if (opts.throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return opts.responseStyle === 'data' + ? undefined + : { + error: finalError, + ...result, + }; + }; + + const makeMethodFn = + (method: Uppercase<HttpMethod>) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = + (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + headers: opts.headers as unknown as Record<string, string>, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + url, + }); + }; + + return { + buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/modules/frontend/src/auth/client/index.ts b/modules/frontend/src/auth/client/index.ts new file mode 100644 index 0000000..b295ede --- /dev/null +++ b/modules/frontend/src/auth/client/index.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export { createClient } from './client.gen'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types.gen'; +export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/modules/frontend/src/auth/client/types.gen.ts b/modules/frontend/src/auth/client/types.gen.ts new file mode 100644 index 0000000..b4a499c --- /dev/null +++ b/modules/frontend/src/auth/client/types.gen.ts @@ -0,0 +1,241 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen'; +import type { + Client as CoreClient, + Config as CoreConfig, +} from '../core/types.gen'; +import type { Middleware } from './utils.gen'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config<T extends ClientOptions = ClientOptions> + extends Omit<RequestInit, 'body' | 'headers' | 'method'>, + CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: + | 'arrayBuffer' + | 'auto' + | 'blob' + | 'formData' + | 'json' + | 'stream' + | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions<TData>, + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record<string, unknown>; + query?: Record<string, unknown>; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray<Auth>; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> { + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record<string, unknown> + ? TData[keyof TData] + : TData + : { + data: TData extends Record<string, unknown> + ? TData[keyof TData] + : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? + | (TData extends Record<string, unknown> + ? TData[keyof TData] + : TData) + | undefined + : ( + | { + data: TData extends Record<string, unknown> + ? TData[keyof TData] + : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record<string, unknown> + ? TError[keyof TError] + : TError; + } + ) & { + request: Request; + response: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>, +) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>; + +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>, +) => Promise<ServerSentEventsResult<TData, TError>>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> & + Pick< + Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, + 'method' + >, +) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record<string, unknown>; + query?: Record<string, unknown>; + url: string; + }, +>( + options: TData & Options<TData>, +) => string; + +export type Client = CoreClient< + RequestFn, + Config, + MethodFn, + BuildUrlFn, + SseFn +> & { + interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig<T extends ClientOptions = ClientOptions> = ( + override?: Config<ClientOptions & T>, +) => Config<Required<ClientOptions> & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions<TResponse, TResponseStyle, ThrowOnError>, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit<TData, 'url'>); diff --git a/modules/frontend/src/auth/client/utils.gen.ts b/modules/frontend/src/auth/client/utils.gen.ts new file mode 100644 index 0000000..4c48a9e --- /dev/null +++ b/modules/frontend/src/auth/client/utils.gen.ts @@ -0,0 +1,332 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; + +export const createQuerySerializer = <T = unknown>({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record<string, unknown>, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = ( + contentType: string | null, +): Exclude<Config['parseAs'], 'auto'> => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if ( + cleanContent.startsWith('application/json') || + cleanContent.endsWith('+json') + ) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => + cleanContent.startsWith(type), + ) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +const checkForExistence = ( + options: Pick<RequestOptions, 'auth' | 'query'> & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick<Required<RequestOptions>, 'security'> & + Pick<RequestOptions, 'auth' | 'query'> & { + headers: Headers; + }) => { + for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +}; + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array<Required<Config>['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = + header instanceof Headers + ? headersEntries(header) + : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor<Err, Res, Req, Options> = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise<Err>; + +type ReqInterceptor<Req, Options> = ( + request: Req, + options: Options, +) => Req | Promise<Req>; + +type ResInterceptor<Res, Req, Options> = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise<Res>; + +class Interceptors<Interceptor> { + fns: Array<Interceptor | null> = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update( + id: number | Interceptor, + fn: Interceptor, + ): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware<Req, Res, Err, Options> { + error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>; + request: Interceptors<ReqInterceptor<Req, Options>>; + response: Interceptors<ResInterceptor<Res, Req, Options>>; +} + +export const createInterceptors = <Req, Res, Err, Options>(): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(), + request: new Interceptors<ReqInterceptor<Req, Options>>(), + response: new Interceptors<ResInterceptor<Res, Req, Options>>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = <T extends ClientOptions = ClientOptions>( + override: Config<Omit<ClientOptions, keyof T> & T> = {}, +): Config<Omit<ClientOptions, keyof T> & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/modules/frontend/src/auth/core/ApiError.ts b/modules/frontend/src/auth/core/ApiError.ts deleted file mode 100644 index ec7b16a..0000000 --- a/modules/frontend/src/auth/core/ApiError.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ApiRequestOptions } from './ApiRequestOptions'; -import type { ApiResult } from './ApiResult'; - -export class ApiError extends Error { - public readonly url: string; - public readonly status: number; - public readonly statusText: string; - public readonly body: any; - public readonly request: ApiRequestOptions; - - constructor(request: ApiRequestOptions, response: ApiResult, message: string) { - super(message); - - this.name = 'ApiError'; - this.url = response.url; - this.status = response.status; - this.statusText = response.statusText; - this.body = response.body; - this.request = request; - } -} diff --git a/modules/frontend/src/auth/core/ApiRequestOptions.ts b/modules/frontend/src/auth/core/ApiRequestOptions.ts deleted file mode 100644 index 93143c3..0000000 --- a/modules/frontend/src/auth/core/ApiRequestOptions.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type ApiRequestOptions = { - readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; - readonly url: string; - readonly path?: Record<string, any>; - readonly cookies?: Record<string, any>; - readonly headers?: Record<string, any>; - readonly query?: Record<string, any>; - readonly formData?: Record<string, any>; - readonly body?: any; - readonly mediaType?: string; - readonly responseHeader?: string; - readonly errors?: Record<number, string>; -}; diff --git a/modules/frontend/src/auth/core/ApiResult.ts b/modules/frontend/src/auth/core/ApiResult.ts deleted file mode 100644 index ee1126e..0000000 --- a/modules/frontend/src/auth/core/ApiResult.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type ApiResult = { - readonly url: string; - readonly ok: boolean; - readonly status: number; - readonly statusText: string; - readonly body: any; -}; diff --git a/modules/frontend/src/auth/core/CancelablePromise.ts b/modules/frontend/src/auth/core/CancelablePromise.ts deleted file mode 100644 index d70de92..0000000 --- a/modules/frontend/src/auth/core/CancelablePromise.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export class CancelError extends Error { - - constructor(message: string) { - super(message); - this.name = 'CancelError'; - } - - public get isCancelled(): boolean { - return true; - } -} - -export interface OnCancel { - readonly isResolved: boolean; - readonly isRejected: boolean; - readonly isCancelled: boolean; - - (cancelHandler: () => void): void; -} - -export class CancelablePromise<T> implements Promise<T> { - #isResolved: boolean; - #isRejected: boolean; - #isCancelled: boolean; - readonly #cancelHandlers: (() => void)[]; - readonly #promise: Promise<T>; - #resolve?: (value: T | PromiseLike<T>) => void; - #reject?: (reason?: any) => void; - - constructor( - executor: ( - resolve: (value: T | PromiseLike<T>) => void, - reject: (reason?: any) => void, - onCancel: OnCancel - ) => void - ) { - this.#isResolved = false; - this.#isRejected = false; - this.#isCancelled = false; - this.#cancelHandlers = []; - this.#promise = new Promise<T>((resolve, reject) => { - this.#resolve = resolve; - this.#reject = reject; - - const onResolve = (value: T | PromiseLike<T>): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isResolved = true; - if (this.#resolve) this.#resolve(value); - }; - - const onReject = (reason?: any): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isRejected = true; - if (this.#reject) this.#reject(reason); - }; - - const onCancel = (cancelHandler: () => void): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#cancelHandlers.push(cancelHandler); - }; - - Object.defineProperty(onCancel, 'isResolved', { - get: (): boolean => this.#isResolved, - }); - - Object.defineProperty(onCancel, 'isRejected', { - get: (): boolean => this.#isRejected, - }); - - Object.defineProperty(onCancel, 'isCancelled', { - get: (): boolean => this.#isCancelled, - }); - - return executor(onResolve, onReject, onCancel as OnCancel); - }); - } - - get [Symbol.toStringTag]() { - return "Cancellable Promise"; - } - - public then<TResult1 = T, TResult2 = never>( - onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, - onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null - ): Promise<TResult1 | TResult2> { - return this.#promise.then(onFulfilled, onRejected); - } - - public catch<TResult = never>( - onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null - ): Promise<T | TResult> { - return this.#promise.catch(onRejected); - } - - public finally(onFinally?: (() => void) | null): Promise<T> { - return this.#promise.finally(onFinally); - } - - public cancel(): void { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isCancelled = true; - if (this.#cancelHandlers.length) { - try { - for (const cancelHandler of this.#cancelHandlers) { - cancelHandler(); - } - } catch (error) { - console.warn('Cancellation threw an error', error); - return; - } - } - this.#cancelHandlers.length = 0; - if (this.#reject) this.#reject(new CancelError('Request aborted')); - } - - public get isCancelled(): boolean { - return this.#isCancelled; - } -} diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts deleted file mode 100644 index 79aa305..0000000 --- a/modules/frontend/src/auth/core/OpenAPI.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ApiRequestOptions } from './ApiRequestOptions'; - -type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; -type Headers = Record<string, string>; - -export type OpenAPIConfig = { - BASE: string; - VERSION: string; - WITH_CREDENTIALS: boolean; - CREDENTIALS: 'include' | 'omit' | 'same-origin'; - TOKEN?: string | Resolver<string> | undefined; - USERNAME?: string | Resolver<string> | undefined; - PASSWORD?: string | Resolver<string> | undefined; - HEADERS?: Headers | Resolver<Headers> | undefined; - ENCODE_PATH?: ((path: string) => string) | undefined; -}; - -export const OpenAPI: OpenAPIConfig = { - BASE: 'http://10.1.0.65:8081/auth', - VERSION: '1.0.0', - WITH_CREDENTIALS: false, - CREDENTIALS: 'include', - TOKEN: undefined, - USERNAME: undefined, - PASSWORD: undefined, - HEADERS: undefined, - ENCODE_PATH: undefined, -}; diff --git a/modules/frontend/src/auth/core/auth.gen.ts b/modules/frontend/src/auth/core/auth.gen.ts new file mode 100644 index 0000000..f8a7326 --- /dev/null +++ b/modules/frontend/src/auth/core/auth.gen.ts @@ -0,0 +1,42 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken, +): Promise<string | undefined> => { + const token = + typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/modules/frontend/src/auth/core/bodySerializer.gen.ts b/modules/frontend/src/auth/core/bodySerializer.gen.ts new file mode 100644 index 0000000..552b50f --- /dev/null +++ b/modules/frontend/src/auth/core/bodySerializer.gen.ts @@ -0,0 +1,100 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { + ArrayStyle, + ObjectStyle, + SerializerOptions, +} from './pathSerializer.gen'; + +export type QuerySerializer = (query: Record<string, unknown>) => string; + +export type BodySerializer = (body: any) => any; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial<SerializerOptions<ArrayStyle>>; + object?: Partial<SerializerOptions<ObjectStyle>>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record<string, QuerySerializerOptionsObject>; +}; + +const serializeFormDataPair = ( + data: FormData, + key: string, + value: unknown, +): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = ( + data: URLSearchParams, + key: string, + value: unknown, +): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>( + body: T, + ): FormData => { + const data = new FormData(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: <T>(body: T): string => + JSON.stringify(body, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>( + body: T, + ): string => { + const data = new URLSearchParams(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/modules/frontend/src/auth/core/params.gen.ts b/modules/frontend/src/auth/core/params.gen.ts new file mode 100644 index 0000000..602715c --- /dev/null +++ b/modules/frontend/src/auth/core/params.gen.ts @@ -0,0 +1,176 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude<Slot, 'body'>; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract<Slot, 'body'>; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; + +export interface Fields { + allowExtra?: Partial<Record<Slot, boolean>>; + args?: ReadonlyArray<Field>; +} + +export type FieldsConfig = ReadonlyArray<Field | Fields>; + +const extraPrefixesMap: Record<string, Slot> = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } +>; + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if ('key' in config) { + map.set(config.key, { + map: config.map, + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +}; + +interface Params { + body: unknown; + headers: Record<string, unknown>; + path: Record<string, unknown>; + query: Record<string, unknown>; +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === 'object' && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +}; + +export const buildClientParams = ( + args: ReadonlyArray<unknown>, + fields: FieldsConfig, +) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + }; + + const map = buildKeyMap(fields); + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + (params[field.in] as Record<string, unknown>)[name] = arg; + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + (params[field.in] as Record<string, unknown>)[name] = value; + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => + key.startsWith(prefix), + ); + + if (extra) { + const [prefix, slot] = extra; + (params[slot] as Record<string, unknown>)[ + key.slice(prefix.length) + ] = value; + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + (params[slot as Slot] as Record<string, unknown>)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +}; diff --git a/modules/frontend/src/auth/core/pathSerializer.gen.ts b/modules/frontend/src/auth/core/pathSerializer.gen.ts new file mode 100644 index 0000000..8d99931 --- /dev/null +++ b/modules/frontend/src/auth/core/pathSerializer.gen.ts @@ -0,0 +1,181 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions<T> + extends SerializePrimitiveOptions, + SerializerOptions<T> {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions<T> { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions<ArraySeparatorStyle> & { + value: unknown[]; +}) => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' + ? separator + joinedValues + : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions<ObjectSeparatorStyle> & { + value: Record<string, unknown> | Date; + valueOnly?: boolean; +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [ + ...values, + key, + allowReserved ? (v as string) : encodeURIComponent(v as string), + ]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' + ? separator + joinedValues + : joinedValues; +}; diff --git a/modules/frontend/src/auth/core/queryKeySerializer.gen.ts b/modules/frontend/src/auth/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000..d3bb683 --- /dev/null +++ b/modules/frontend/src/auth/core/queryKeySerializer.gen.ts @@ -0,0 +1,136 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown) => { + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record<string, unknown> => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => + a.localeCompare(b), + ); + const result: Record<string, JsonValue> = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = ( + value: unknown, +): JsonValue | undefined => { + if (value === null) { + return null; + } + + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; + } + + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if ( + typeof URLSearchParams !== 'undefined' && + value instanceof URLSearchParams + ) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/modules/frontend/src/auth/core/request.ts b/modules/frontend/src/auth/core/request.ts deleted file mode 100644 index 1dc6fef..0000000 --- a/modules/frontend/src/auth/core/request.ts +++ /dev/null @@ -1,323 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import axios from 'axios'; -import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; -import FormData from 'form-data'; - -import { ApiError } from './ApiError'; -import type { ApiRequestOptions } from './ApiRequestOptions'; -import type { ApiResult } from './ApiResult'; -import { CancelablePromise } from './CancelablePromise'; -import type { OnCancel } from './CancelablePromise'; -import type { OpenAPIConfig } from './OpenAPI'; - -export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => { - return value !== undefined && value !== null; -}; - -export const isString = (value: any): value is string => { - return typeof value === 'string'; -}; - -export const isStringWithValue = (value: any): value is string => { - return isString(value) && value !== ''; -}; - -export const isBlob = (value: any): value is Blob => { - return ( - typeof value === 'object' && - typeof value.type === 'string' && - typeof value.stream === 'function' && - typeof value.arrayBuffer === 'function' && - typeof value.constructor === 'function' && - typeof value.constructor.name === 'string' && - /^(Blob|File)$/.test(value.constructor.name) && - /^(Blob|File)$/.test(value[Symbol.toStringTag]) - ); -}; - -export const isFormData = (value: any): value is FormData => { - return value instanceof FormData; -}; - -export const isSuccess = (status: number): boolean => { - return status >= 200 && status < 300; -}; - -export const base64 = (str: string): string => { - try { - return btoa(str); - } catch (err) { - // @ts-ignore - return Buffer.from(str).toString('base64'); - } -}; - -export const getQueryString = (params: Record<string, any>): string => { - const qs: string[] = []; - - const append = (key: string, value: any) => { - qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); - }; - - const process = (key: string, value: any) => { - if (isDefined(value)) { - if (Array.isArray(value)) { - value.forEach(v => { - process(key, v); - }); - } else if (typeof value === 'object') { - Object.entries(value).forEach(([k, v]) => { - process(`${key}[${k}]`, v); - }); - } else { - append(key, value); - } - } - }; - - Object.entries(params).forEach(([key, value]) => { - process(key, value); - }); - - if (qs.length > 0) { - return `?${qs.join('&')}`; - } - - return ''; -}; - -const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { - const encoder = config.ENCODE_PATH || encodeURI; - - const path = options.url - .replace('{api-version}', config.VERSION) - .replace(/{(.*?)}/g, (substring: string, group: string) => { - if (options.path?.hasOwnProperty(group)) { - return encoder(String(options.path[group])); - } - return substring; - }); - - const url = `${config.BASE}${path}`; - if (options.query) { - return `${url}${getQueryString(options.query)}`; - } - return url; -}; - -export const getFormData = (options: ApiRequestOptions): FormData | undefined => { - if (options.formData) { - const formData = new FormData(); - - const process = (key: string, value: any) => { - if (isString(value) || isBlob(value)) { - formData.append(key, value); - } else { - formData.append(key, JSON.stringify(value)); - } - }; - - Object.entries(options.formData) - .filter(([_, value]) => isDefined(value)) - .forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach(v => process(key, v)); - } else { - process(key, value); - } - }); - - return formData; - } - return undefined; -}; - -type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; - -export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => { - if (typeof resolver === 'function') { - return (resolver as Resolver<T>)(options); - } - return resolver; -}; - -export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => { - const [token, username, password, additionalHeaders] = await Promise.all([ - resolve(options, config.TOKEN), - resolve(options, config.USERNAME), - resolve(options, config.PASSWORD), - resolve(options, config.HEADERS), - ]); - - const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} - - const headers = Object.entries({ - Accept: 'application/json', - ...additionalHeaders, - ...options.headers, - ...formHeaders, - }) - .filter(([_, value]) => isDefined(value)) - .reduce((headers, [key, value]) => ({ - ...headers, - [key]: String(value), - }), {} as Record<string, string>); - - if (isStringWithValue(token)) { - headers['Authorization'] = `Bearer ${token}`; - } - - if (isStringWithValue(username) && isStringWithValue(password)) { - const credentials = base64(`${username}:${password}`); - headers['Authorization'] = `Basic ${credentials}`; - } - - if (options.body !== undefined) { - if (options.mediaType) { - headers['Content-Type'] = options.mediaType; - } else if (isBlob(options.body)) { - headers['Content-Type'] = options.body.type || 'application/octet-stream'; - } else if (isString(options.body)) { - headers['Content-Type'] = 'text/plain'; - } else if (!isFormData(options.body)) { - headers['Content-Type'] = 'application/json'; - } - } - - return headers; -}; - -export const getRequestBody = (options: ApiRequestOptions): any => { - if (options.body) { - return options.body; - } - return undefined; -}; - -export const sendRequest = async <T>( - config: OpenAPIConfig, - options: ApiRequestOptions, - url: string, - body: any, - formData: FormData | undefined, - headers: Record<string, string>, - onCancel: OnCancel, - axiosClient: AxiosInstance -): Promise<AxiosResponse<T>> => { - const source = axios.CancelToken.source(); - - const requestConfig: AxiosRequestConfig = { - url, - headers, - data: body ?? formData, - method: options.method, - withCredentials: config.WITH_CREDENTIALS, - withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, - cancelToken: source.token, - }; - - onCancel(() => source.cancel('The user aborted a request.')); - - try { - return await axiosClient.request(requestConfig); - } catch (error) { - const axiosError = error as AxiosError<T>; - if (axiosError.response) { - return axiosError.response; - } - throw error; - } -}; - -export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => { - if (responseHeader) { - const content = response.headers[responseHeader]; - if (isString(content)) { - return content; - } - } - return undefined; -}; - -export const getResponseBody = (response: AxiosResponse<any>): any => { - if (response.status !== 204) { - return response.data; - } - return undefined; -}; - -export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { - const errors: Record<number, string> = { - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 500: 'Internal Server Error', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - ...options.errors, - } - - const error = errors[result.status]; - if (error) { - throw new ApiError(options, result, error); - } - - if (!result.ok) { - const errorStatus = result.status ?? 'unknown'; - const errorStatusText = result.statusText ?? 'unknown'; - const errorBody = (() => { - try { - return JSON.stringify(result.body, null, 2); - } catch (e) { - return undefined; - } - })(); - - throw new ApiError(options, result, - `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` - ); - } -}; - -/** - * Request method - * @param config The OpenAPI configuration object - * @param options The request options from the service - * @param axiosClient The axios client instance to use - * @returns CancelablePromise<T> - * @throws ApiError - */ -export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => { - return new CancelablePromise(async (resolve, reject, onCancel) => { - try { - const url = getUrl(config, options); - const formData = getFormData(options); - const body = getRequestBody(options); - const headers = await getHeaders(config, options, formData); - - if (!onCancel.isCancelled) { - const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient); - const responseBody = getResponseBody(response); - const responseHeader = getResponseHeader(response, options.responseHeader); - - const result: ApiResult = { - url, - ok: isSuccess(response.status), - status: response.status, - statusText: response.statusText, - body: responseHeader ?? responseBody, - }; - - catchErrorCodes(options, result); - - resolve(result.body); - } - } catch (error) { - reject(error); - } - }); -}; diff --git a/modules/frontend/src/auth/core/serverSentEvents.gen.ts b/modules/frontend/src/auth/core/serverSentEvents.gen.ts new file mode 100644 index 0000000..f8fd78e --- /dev/null +++ b/modules/frontend/src/auth/core/serverSentEvents.gen.ts @@ -0,0 +1,264 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +export type ServerSentEventsOptions<TData = unknown> = Omit< + RequestInit, + 'method' +> & + Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise<Request>; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent<TData>) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise<void>; + url: string; + }; + +export interface StreamEvent<TData = unknown> { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult< + TData = unknown, + TReturn = void, + TNext = unknown, +> = { + stream: AsyncGenerator< + TData extends Record<string, unknown> ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export const createSseClient = <TData = unknown>({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult<TData> => { + let lastEventId: string | undefined; + + const sleep = + sseSleepFn ?? + ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record<string, string> | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) + throw new Error( + `SSE failed: ${response.status} ${response.statusText}`, + ); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array<string> = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt( + line.replace(/^retry:\s*/, ''), + 10, + ); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if ( + sseMaxRetryAttempts !== undefined && + attempt >= sseMaxRetryAttempts + ) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min( + retryDelay * 2 ** (attempt - 1), + sseMaxRetryDelay ?? 30000, + ); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +}; diff --git a/modules/frontend/src/auth/core/types.gen.ts b/modules/frontend/src/auth/core/types.gen.ts new file mode 100644 index 0000000..643c070 --- /dev/null +++ b/modules/frontend/src/auth/core/types.gen.ts @@ -0,0 +1,118 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from './bodySerializer.gen'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] + ? { sse?: never } + : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + | string + | number + | boolean + | (string | number | boolean)[] + | null + | undefined + | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase<HttpMethod>; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise<unknown>; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g. converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise<unknown>; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise<unknown>; +} + +type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever<T extends Record<string, unknown>> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true + ? never + : K]: T[K]; +}; diff --git a/modules/frontend/src/auth/core/utils.gen.ts b/modules/frontend/src/auth/core/utils.gen.ts new file mode 100644 index 0000000..0b5389d --- /dev/null +++ b/modules/frontend/src/auth/core/utils.gen.ts @@ -0,0 +1,143 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen'; + +export interface PathSerializer { + path: Record<string, unknown>; + url: string; +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace( + match, + serializeArrayParam({ explode, name, style, value }), + ); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record<string, unknown>, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record<string, unknown>; + query?: Record<string, unknown>; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}) { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e. client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/modules/frontend/src/auth/index.ts b/modules/frontend/src/auth/index.ts index b0989c4..c352c10 100644 --- a/modules/frontend/src/auth/index.ts +++ b/modules/frontend/src/auth/index.ts @@ -1,10 +1,4 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export { ApiError } from './core/ApiError'; -export { CancelablePromise, CancelError } from './core/CancelablePromise'; -export { OpenAPI } from './core/OpenAPI'; -export type { OpenAPIConfig } from './core/OpenAPI'; +// This file is auto-generated by @hey-api/openapi-ts -export { AuthService } from './services/AuthService'; +export type * from './types.gen'; +export * from './sdk.gen'; diff --git a/modules/frontend/src/auth/sdk.gen.ts b/modules/frontend/src/auth/sdk.gen.ts new file mode 100644 index 0000000..f69153e --- /dev/null +++ b/modules/frontend/src/auth/sdk.gen.ts @@ -0,0 +1,66 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Client, Options as Options2, TDataShape } from './client'; +import { client } from './client.gen'; +import type { GetImpersonationTokenData, GetImpersonationTokenErrors, GetImpersonationTokenResponses, LogoutData, LogoutErrors, LogoutResponses, PostSignInData, PostSignInErrors, PostSignInResponses, PostSignUpData, PostSignUpResponses, RefreshTokensData, RefreshTokensErrors, RefreshTokensResponses } from './types.gen'; + +export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record<string, unknown>; +}; + +/** + * Sign up a new user + */ +export const postSignUp = <ThrowOnError extends boolean = false>(options: Options<PostSignUpData, ThrowOnError>) => (options.client ?? client).post<PostSignUpResponses, unknown, ThrowOnError>({ + url: '/sign-up', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Sign in a user and return JWT + */ +export const postSignIn = <ThrowOnError extends boolean = false>(options: Options<PostSignInData, ThrowOnError>) => (options.client ?? client).post<PostSignInResponses, PostSignInErrors, ThrowOnError>({ + url: '/sign-in', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get service impersontaion token + */ +export const getImpersonationToken = <ThrowOnError extends boolean = false>(options: Options<GetImpersonationTokenData, ThrowOnError>) => (options.client ?? client).post<GetImpersonationTokenResponses, GetImpersonationTokenErrors, ThrowOnError>({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/get-impersonation-token', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Refreshes access_token and refresh_token + */ +export const refreshTokens = <ThrowOnError extends boolean = false>(options?: Options<RefreshTokensData, ThrowOnError>) => (options?.client ?? client).get<RefreshTokensResponses, RefreshTokensErrors, ThrowOnError>({ url: '/refresh-tokens', ...options }); + +/** + * Logs out the user + */ +export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => (options?.client ?? client).post<LogoutResponses, LogoutErrors, ThrowOnError>({ url: '/logout', ...options }); diff --git a/modules/frontend/src/auth/services/AuthService.ts b/modules/frontend/src/auth/services/AuthService.ts deleted file mode 100644 index 74a8fa7..0000000 --- a/modules/frontend/src/auth/services/AuthService.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; -import { request as __request } from '../core/request'; -export class AuthService { - /** - * Sign up a new user - * @param requestBody - * @returns any Sign-up result - * @throws ApiError - */ - public static postSignUp( - requestBody: { - nickname: string; - pass: string; - }, - ): CancelablePromise<{ - user_id: number; - }> { - return __request(OpenAPI, { - method: 'POST', - url: '/sign-up', - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Sign in a user and return JWT - * @param requestBody - * @returns any Sign-in result with JWT - * @throws ApiError - */ - public static postSignIn( - requestBody: { - nickname: string; - pass: string; - }, - ): CancelablePromise<{ - user_id: number; - user_name: string; - }> { - return __request(OpenAPI, { - method: 'POST', - url: '/sign-in', - body: requestBody, - mediaType: 'application/json', - errors: { - 401: `Access denied due to invalid credentials`, - }, - }); - } -} diff --git a/modules/frontend/src/auth/types.gen.ts b/modules/frontend/src/auth/types.gen.ts new file mode 100644 index 0000000..5c0fdc0 --- /dev/null +++ b/modules/frontend/src/auth/types.gen.ts @@ -0,0 +1,136 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}/auth` | (string & {}); +}; + +export type PostSignUpData = { + body: { + nickname: string; + pass: string; + }; + path?: never; + query?: never; + url: '/sign-up'; +}; + +export type PostSignUpResponses = { + /** + * Sign-up result + */ + 200: { + user_id: number; + }; +}; + +export type PostSignUpResponse = PostSignUpResponses[keyof PostSignUpResponses]; + +export type PostSignInData = { + body: { + nickname: string; + pass: string; + }; + path?: never; + query?: never; + url: '/sign-in'; +}; + +export type PostSignInErrors = { + /** + * Access token is missing or invalid + */ + 401: unknown; +}; + +export type PostSignInResponses = { + /** + * Sign-in result with JWT + */ + 200: { + user_id: number; + user_name: string; + }; +}; + +export type PostSignInResponse = PostSignInResponses[keyof PostSignInResponses]; + +export type GetImpersonationTokenData = { + body: unknown & { + user_id?: number; + external_id?: number; + }; + path?: never; + query?: never; + url: '/get-impersonation-token'; +}; + +export type GetImpersonationTokenErrors = { + /** + * Access token is missing or invalid + */ + 401: unknown; +}; + +export type GetImpersonationTokenResponses = { + /** + * Generated impersonation access token + */ + 200: { + /** + * JWT access token + */ + access_token: string; + }; +}; + +export type GetImpersonationTokenResponse = GetImpersonationTokenResponses[keyof GetImpersonationTokenResponses]; + +export type RefreshTokensData = { + body?: never; + path?: never; + query?: never; + url: '/refresh-tokens'; +}; + +export type RefreshTokensErrors = { + /** + * ClientError + */ + 400: unknown; + /** + * Access token is missing or invalid + */ + 401: unknown; + /** + * ServerError + */ + 500: unknown; +}; + +export type RefreshTokensResponses = { + /** + * Refresh success + */ + 200: unknown; +}; + +export type LogoutData = { + body?: never; + path?: never; + query?: never; + url: '/logout'; +}; + +export type LogoutErrors = { + /** + * ServerError + */ + 500: unknown; +}; + +export type LogoutResponses = { + /** + * Logout success + */ + 200: unknown; +}; diff --git a/modules/frontend/src/components/Header/Header.tsx b/modules/frontend/src/components/Header/Header.tsx index 26f1658..3c86802 100644 --- a/modules/frontend/src/components/Header/Header.tsx +++ b/modules/frontend/src/components/Header/Header.tsx @@ -1,72 +1,98 @@ -import React, { useState } from "react"; -import { Link } from "react-router-dom"; +import React, { useState, useEffect, useRef } from "react"; +import { Link, useNavigate } from "react-router-dom"; import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid"; +import { logout } from "../../auth"; -type HeaderProps = { - username?: string; -}; - -export const Header: React.FC<HeaderProps> = ({ username }) => { +export const Header: React.FC = () => { + const navigate = useNavigate(); + const [username, setUsername] = useState<string | null>(localStorage.getItem("user_name")); const [menuOpen, setMenuOpen] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); - const toggleMenu = () => setMenuOpen(!menuOpen); + const dropdownRef = useRef<HTMLDivElement>(null); + + // Listen for localStorage changes to update username dynamically + useEffect(() => { + const handleStorage = () => setUsername(localStorage.getItem("user_name")); + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, []); + + const handleLogout = async () => { + try { + await logout(); + localStorage.removeItem("user_id"); + localStorage.removeItem("user_name"); + setUsername(null); + navigate("/login"); + } catch (err) { + console.error(err); + } + }; + + // Close dropdown on click outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setDropdownOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); return ( <header className="w-full bg-white shadow-md sticky top-0 left-0 z-50"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="flex justify-between h-16 items-center"> - {/* Левый блок — логотип / название */} + {/* Logo */} <div className="flex-shrink-0"> <Link to="/" className="text-xl font-bold text-gray-800 hover:text-blue-600"> NyanimeDB </Link> </div> - {/* Центр — ссылки на разделы (desktop) */} + {/* Navigation (desktop) */} <nav className="hidden md:flex space-x-4"> - <Link to="/titles" className="text-gray-700 hover:text-blue-600"> - Titles - </Link> - <Link to="/users" className="text-gray-700 hover:text-blue-600"> - Users - </Link> - <Link to="/about" className="text-gray-700 hover:text-blue-600"> - About - </Link> + <Link to="/titles" className="text-gray-700 hover:text-blue-600">Titles</Link> + <Link to="/users" className="text-gray-700 hover:text-blue-600">Users</Link> + <Link to="/about" className="text-gray-700 hover:text-blue-600">About</Link> </nav> - {/* Правый блок — профиль */} - <div className="hidden md:flex items-center space-x-4"> + {/* Profile / login */} + <div className="hidden md:flex items-center space-x-4" ref={dropdownRef}> {username ? ( - <Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium"> - {username} - </Link> + <div className="relative"> + <button + onClick={() => setDropdownOpen(!dropdownOpen)} + className="text-gray-700 hover:text-blue-600 font-medium" + > + {username} + </button> + {dropdownOpen && ( + <div className="absolute right-0 mt-2 w-40 bg-white border rounded shadow-md z-50"> + <Link to="/profile" className="block px-4 py-2 hover:bg-gray-100" onClick={() => setDropdownOpen(false)}>Profile</Link> + <button onClick={handleLogout} className="w-full text-left px-4 py-2 hover:bg-gray-100">Logout</button> + </div> + )} + </div> ) : ( - <Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium"> - Login - </Link> + <Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium">Login</Link> )} </div> - {/* Бургер для мобильного */} + {/* Mobile burger */} <div className="md:hidden flex items-center"> - <button - onClick={toggleMenu} - className="p-2 rounded-md hover:bg-gray-200 transition" - > - {menuOpen ? ( - <XMarkIcon className="w-6 h-6 text-gray-800" /> - ) : ( - <Bars3Icon className="w-6 h-6 text-gray-800" /> - )} + <button onClick={() => setMenuOpen(!menuOpen)} className="p-2 rounded-md hover:bg-gray-200 transition"> + {menuOpen ? <XMarkIcon className="w-6 h-6 text-gray-800" /> : <Bars3Icon className="w-6 h-6 text-gray-800" />} </button> </div> </div> </div> - {/* Мобильное меню */} + {/* Mobile menu */} {menuOpen && ( <div className="md:hidden bg-white border-t border-gray-200 shadow-md"> <nav className="flex flex-col p-4 space-y-2"> @@ -74,13 +100,12 @@ export const Header: React.FC<HeaderProps> = ({ username }) => { <Link to="/users" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>Users</Link> <Link to="/about" className="text-gray-700 hover:text-blue-600" onClick={() => setMenuOpen(false)}>About</Link> {username ? ( - <Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}> - {username} - </Link> + <> + <Link to="/profile" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}>Profile</Link> + <button onClick={handleLogout} className="text-gray-700 hover:text-blue-600 font-medium text-left">Logout</button> + </> ) : ( - <Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}> - Login - </Link> + <Link to="/login" className="text-gray-700 hover:text-blue-600 font-medium" onClick={() => setMenuOpen(false)}>Login</Link> )} </nav> </div> diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx index 928766e..4932a3b 100644 --- a/modules/frontend/src/pages/LoginPage/LoginPage.tsx +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -1,10 +1,10 @@ import React, { useState } from "react"; -import { AuthService } from "../../auth/services/AuthService"; +import { postSignIn, postSignUp } from "../../auth"; import { useNavigate } from "react-router-dom"; export const LoginPage: React.FC = () => { const navigate = useNavigate(); - const [isLogin, setIsLogin] = useState(true); // true = login, false = signup + const [isLogin, setIsLogin] = useState(true); const [nickname, setNickname] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); @@ -17,27 +17,30 @@ export const LoginPage: React.FC = () => { try { if (isLogin) { - const res = await AuthService.postSignIn({ nickname, pass: password }); - if (res.user_id && res.user_name) { - // Сохраняем user_id и username в localStorage - localStorage.setItem("userId", res.user_id.toString()); - localStorage.setItem("username", res.user_name); - - navigate("/profile"); // редирект на профиль + const res = await postSignIn({ body: { nickname, pass: password } }); + if (res.data?.user_id && res.data?.user_name) { + localStorage.setItem("user_id", res.data.user_id.toString()); + localStorage.setItem("user_name", res.data.user_name); + navigate("/profile"); } else { setError("Login failed"); } } else { - // SignUp оставляем без сохранения данных - const res = await AuthService.postSignUp({ nickname, pass: password }); - if (res.user_id) { - setIsLogin(true); // переключаемся на login после регистрации + // Sign up + const res = await postSignUp({ body: { nickname, pass: password } }); + if (res.data?.user_id) { + // Auto-login after sign-up + const loginRes = await postSignIn({ body: { nickname, pass: password } }); + if (loginRes.data?.user_id && loginRes.data?.user_name) { + localStorage.setItem("user_id", loginRes.data.user_id.toString()); + localStorage.setItem("user_name", loginRes.data.user_name); + navigate("/profile"); + } } else { setError("Sign up failed"); } } } catch (err: any) { - console.error(err); setError(err?.message || "Something went wrong"); } finally { setLoading(false); @@ -47,39 +50,26 @@ export const LoginPage: React.FC = () => { return ( <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> <div className="max-w-md w-full bg-white shadow-md rounded-lg p-8"> - <h2 className="text-2xl font-bold mb-6 text-center"> - {isLogin ? "Login" : "Sign Up"} - </h2> - + <h2 className="text-2xl font-bold mb-6 text-center">{isLogin ? "Login" : "Sign Up"}</h2> {error && <div className="text-red-600 mb-4">{error}</div>} <form onSubmit={handleSubmit} className="space-y-4"> - <div> - <label className="block text-sm font-medium text-gray-700 mb-1"> - Nickname - </label> - <input - type="text" - value={nickname} - onChange={(e) => setNickname(e.target.value)} - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" - required - /> - </div> - - <div> - <label className="block text-sm font-medium text-gray-700 mb-1"> - Password - </label> - <input - type="password" - value={password} - onChange={(e) => setPassword(e.target.value)} - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" - required - /> - </div> - + <input + type="text" + value={nickname} + onChange={(e) => setNickname(e.target.value)} + placeholder="Nickname" + className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" + required + /> + <input + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder="Password" + className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" + required + /> <button type="submit" disabled={loading} @@ -91,25 +81,9 @@ export const LoginPage: React.FC = () => { <div className="mt-4 text-center text-sm text-gray-600"> {isLogin ? ( - <> - Don't have an account?{" "} - <button - onClick={() => setIsLogin(false)} - className="text-blue-600 hover:underline" - > - Sign Up - </button> - </> + <>Don't have an account? <button onClick={() => setIsLogin(false)} className="text-blue-600 hover:underline">Sign Up</button></> ) : ( - <> - Already have an account?{" "} - <button - onClick={() => setIsLogin(true)} - className="text-blue-600 hover:underline" - > - Login - </button> - </> + <>Already have an account? <button onClick={() => setIsLogin(true)} className="text-blue-600 hover:underline">Login</button></> )} </div> </div> From 5fb7b16c9611a8a5b377220e069f733a82b57b98 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 07:21:21 +0300 Subject: [PATCH 207/224] fix: temp change reset cookie to non secure --- modules/auth/handlers/handlers.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 163efc2..0569b59 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -293,8 +293,9 @@ func (s Server) Logout(ctx context.Context, req auth.LogoutRequestObject) (auth. } // Delete cookies by setting MaxAge negative - ginCtx.SetCookie("access_token", "", -1, "/api", "", true, true) - ginCtx.SetCookie("refresh_token", "", -1, "/auth", "", true, true) + // TODO: change secure to true + ginCtx.SetCookie("access_token", "", -1, "/api", "", false, true) + ginCtx.SetCookie("refresh_token", "", -1, "/auth", "", false, true) ginCtx.SetCookie("xsrf_token", "", -1, "/", "", false, false) return auth.Logout200Response{}, nil From 486c6d8407148ed88e6794e8071e2425bae31904 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 07:25:12 +0300 Subject: [PATCH 208/224] fix(front): local storage user id name --- .../src/components/TitleStatusControls/TitleStatusControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index 98fa868..fc652af 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -27,7 +27,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); const [loading, setLoading] = useState(false); - const userIdStr = localStorage.getItem("userId"); + const userIdStr = localStorage.getItem("user_id"); const userId = userIdStr ? Number(userIdStr) : null; // --- Load initial status --- From 3d0ffd0df39be0051c628da8a17eb1c596872d68 Mon Sep 17 00:00:00 2001 From: garaev kamil <garaev.kr@phystech.edu> Date: Sat, 6 Dec 2025 07:32:34 +0300 Subject: [PATCH 209/224] deploy settings changes --- .forgejo/workflows/build-and-deploy.yml | 17 ++++++++++++ deploy/docker-compose.yml | 35 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index b82fb3d..81682e1 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -100,6 +100,23 @@ jobs: push: true tags: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest + - name: Build and push etl image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfiles/Dockerfile_etl + push: true + tags: meowgit.nekoea.red/nihonium/nyanimedb-etl:latest + + - name: Build and push image-storage image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfiles/Dockerfile_image_storage + push: true + tags: meowgit.nekoea.red/nihonium/nyanimedb-image-storage:latest + + deploy: runs-on: debian-test needs: build diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1119335..aa4c065 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -51,6 +51,7 @@ services: RABBITMQ_URL: ${RABBITMQ_URL} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} AUTH_ENABLED: ${AUTH_ENABLED} + IMAGES_BASE_URL: http://nyanimedb-images:8000 ports: - "8080:8080" depends_on: @@ -86,9 +87,43 @@ services: networks: - nyanimedb-network + nyanimedb-etl: + image: meowgit.nekoea.red/nihonium/nyanimedb-etl:latest + container_name: nyanimedb-etl + restart: always + environment: + DATABASE_URL: ${DATABASE_URL} + RABBITMQ_URL: ${RABBITMQ_URL} + NYANIMEDB_IMPORT_RPC_QUEUE: anime_import_rpc + NYANIMEDB_IMAGE_SERVICE_URL: http://nyanimedb-images:8000 + depends_on: + - postgres + - rabbitmq + - nyanimedb-images + networks: + - nyanimedb-network + + nyanimedb-images: + image: meowgit.nekoea.red/nihonium/nyanimedb-image-storage:latest + container_name: nyanimedb-images + restart: always + environment: + NYANIMEDB_MEDIA_ROOT: /media + volumes: + - media_data:/media + networks: + - nyanimedb-network + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000/docs || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + volumes: postgres_data: rabbitmq_data: + media_data: networks: nyanimedb-network: From 1b6c536b773b202af6d11bd53284bf64ce2e387a Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 07:49:26 +0300 Subject: [PATCH 210/224] fix: usertitle ftime logic --- modules/frontend/src/api/sdk.gen.ts | 17 +++++++++++- modules/frontend/src/api/types.gen.ts | 37 +++++++++++++++++++++++++++ sql/migrations/000001_init.up.sql | 3 ++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/modules/frontend/src/api/sdk.gen.ts b/modules/frontend/src/api/sdk.gen.ts index 7d46120..24153db 100644 --- a/modules/frontend/src/api/sdk.gen.ts +++ b/modules/frontend/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddUserTitleData, AddUserTitleErrors, AddUserTitleResponses, DeleteUserTitleData, DeleteUserTitleErrors, DeleteUserTitleResponses, GetTitleData, GetTitleErrors, GetTitleResponses, GetTitlesData, GetTitlesErrors, GetTitlesResponses, GetUsersData, GetUsersErrors, GetUsersIdData, GetUsersIdErrors, GetUsersIdResponses, GetUsersResponses, GetUserTitleData, GetUserTitleErrors, GetUserTitleResponses, GetUserTitlesData, GetUserTitlesErrors, GetUserTitlesResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses, UpdateUserTitleData, UpdateUserTitleErrors, UpdateUserTitleResponses } from './types.gen'; +import type { AddUserTitleData, AddUserTitleErrors, AddUserTitleResponses, DeleteUserTitleData, DeleteUserTitleErrors, DeleteUserTitleResponses, GetTitleData, GetTitleErrors, GetTitleResponses, GetTitlesData, GetTitlesErrors, GetTitlesResponses, GetUsersData, GetUsersErrors, GetUsersIdData, GetUsersIdErrors, GetUsersIdResponses, GetUsersResponses, GetUserTitleData, GetUserTitleErrors, GetUserTitleResponses, GetUserTitlesData, GetUserTitlesErrors, GetUserTitlesResponses, PostMediaUploadData, PostMediaUploadErrors, PostMediaUploadResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses, UpdateUserTitleData, UpdateUserTitleErrors, UpdateUserTitleResponses } from './types.gen'; export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { /** @@ -113,3 +113,18 @@ export const updateUserTitle = <ThrowOnError extends boolean = false>(options: O ...options.headers } }); + +/** + * Upload an image (PNG, JPEG, or WebP) + * + * Uploads a single image file. Supported formats: **PNG**, **JPEG/JPG**, **WebP**. + * + */ +export const postMediaUpload = <ThrowOnError extends boolean = false>(options: Options<PostMediaUploadData, ThrowOnError>) => (options.client ?? client).post<PostMediaUploadResponses, PostMediaUploadErrors, ThrowOnError>({ + url: '/media/upload', + ...options, + headers: { + 'Content-Type': 'encoding', + ...options.headers + } +}); diff --git a/modules/frontend/src/api/types.gen.ts b/modules/frontend/src/api/types.gen.ts index d4526a7..d0ca425 100644 --- a/modules/frontend/src/api/types.gen.ts +++ b/modules/frontend/src/api/types.gen.ts @@ -453,6 +453,7 @@ export type AddUserTitleData = { title_id: number; status: UserTitleStatus; rate?: number; + ftime?: string; }; path: { /** @@ -578,6 +579,7 @@ export type UpdateUserTitleData = { body: { status?: UserTitleStatus; rate?: number; + ftime?: string; }; path: { user_id: number; @@ -618,3 +620,38 @@ export type UpdateUserTitleResponses = { }; export type UpdateUserTitleResponse = UpdateUserTitleResponses[keyof UpdateUserTitleResponses]; + +export type PostMediaUploadData = { + body: unknown; + path?: never; + query?: never; + url: '/media/upload'; +}; + +export type PostMediaUploadErrors = { + /** + * Bad request — e.g., invalid/malformed image, empty file + */ + 400: string; + /** + * Unsupported Media Type — e.g., request `Content-Type` is not `multipart/form-data`, + * or the `image` part has an unsupported `Content-Type` (not image/png, image/jpeg, or image/webp) + * + */ + 415: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type PostMediaUploadError = PostMediaUploadErrors[keyof PostMediaUploadErrors]; + +export type PostMediaUploadResponses = { + /** + * Image uploaded successfully + */ + 200: Image; +}; + +export type PostMediaUploadResponse = PostMediaUploadResponses[keyof PostMediaUploadResponses]; diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 369e455..415b9af 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -86,7 +86,8 @@ CREATE TABLE usertitles ( status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), review_id bigint REFERENCES reviews (id) ON DELETE SET NULL, - ctime timestamptz NOT NULL DEFAULT now() + ctime timestamptz NOT NULL DEFAULT now(), + ftime timestamptz NOT NULL DEFAULT now() -- // TODO: series status ); From f983ed10354b94b2a4930ba7e10ebca2e494f54a Mon Sep 17 00:00:00 2001 From: garaev kamil <garaev.kr@phystech.edu> Date: Sat, 6 Dec 2025 08:01:12 +0300 Subject: [PATCH 211/224] Dockerfiles added --- Dockerfiles/Dockerfile_etl | 21 +++++++++++++++++++++ Dockerfiles/Dockerfile_image_storage | 25 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 Dockerfiles/Dockerfile_etl create mode 100644 Dockerfiles/Dockerfile_image_storage diff --git a/Dockerfiles/Dockerfile_etl b/Dockerfiles/Dockerfile_etl new file mode 100644 index 0000000..ddb0bb3 --- /dev/null +++ b/Dockerfiles/Dockerfile_etl @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +WORKDIR /app/modules/anime_etl + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY modules/anime_etl/pyproject.toml modules/anime_etl/uv.lock ./ + +RUN pip install --no-cache-dir uv \ + && uv sync --frozen --no-dev + +COPY modules/anime_etl ./ + +ENV NYANIMEDB_MEDIA_ROOT=/media + +# было: CMD ["python", "-m", "rabbit_worker"] +CMD ["uv", "run", "python", "-m", "rabbit_worker"] diff --git a/Dockerfiles/Dockerfile_image_storage b/Dockerfiles/Dockerfile_image_storage new file mode 100644 index 0000000..34d7496 --- /dev/null +++ b/Dockerfiles/Dockerfile_image_storage @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +# каталог внутри контейнера +WORKDIR /app/modules/image_storage + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# 1. зависимости через uv +COPY modules/image_storage/pyproject.toml modules/image_storage/uv.lock ./ + +RUN pip install --no-cache-dir uv \ + && uv sync --frozen --no-dev + +# 2. сам код +COPY modules/image_storage ./ + +# 3. где будем хранить файлы внутри контейнера +ENV NYANIMEDB_MEDIA_ROOT=/media + +EXPOSE 8000 + +# 4. поднимаем FastAPI-приложение (app/main.py → app.main:app) +CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] From fe1bf7ec1070bcbedac6fe64971b51832341e5e5 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 08:38:34 +0300 Subject: [PATCH 212/224] fix(etl): sql enum for image storage --- modules/anime_etl/db/repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/anime_etl/db/repository.py b/modules/anime_etl/db/repository.py index 4c5caee..7c09329 100644 --- a/modules/anime_etl/db/repository.py +++ b/modules/anime_etl/db/repository.py @@ -80,7 +80,7 @@ async def get_or_create_image( VALUES (%s, %s) RETURNING id """, - ("image-service", rel_path), + ("local", rel_path), ) row = await cur.fetchone() return row["id"] From eef3696e5ee6bbd4e97dd50f7a0bbf1e5582a4f7 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 09:17:06 +0300 Subject: [PATCH 213/224] fix(cicd): refact etl dockerfile --- Dockerfiles/Dockerfile_etl | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfiles/Dockerfile_etl b/Dockerfiles/Dockerfile_etl index ddb0bb3..c721b51 100644 --- a/Dockerfiles/Dockerfile_etl +++ b/Dockerfiles/Dockerfile_etl @@ -2,11 +2,11 @@ FROM python:3.12-slim WORKDIR /app/modules/anime_etl -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - libpq-dev \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* +# RUN apt-get update && apt-get install -y --no-install-recommends \ +# build-essential \ +# libpq-dev \ +# ca-certificates \ +# && rm -rf /var/lib/apt/lists/* COPY modules/anime_etl/pyproject.toml modules/anime_etl/uv.lock ./ From 7787eb328f0590cdd102e6458e1b84c0ca06e2ab Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 09:17:45 +0300 Subject: [PATCH 214/224] feat: media routing --- modules/frontend/nginx-default.conf | 10 ++++++++++ modules/image_storage/app/main.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/modules/frontend/nginx-default.conf b/modules/frontend/nginx-default.conf index c3a851f..6075999 100644 --- a/modules/frontend/nginx-default.conf +++ b/modules/frontend/nginx-default.conf @@ -28,6 +28,16 @@ server { proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } + + location /media/ { + rewrite ^/media/(.*)$ /$1 break; + proxy_pass http://nyanimedb-images:8000/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } #error_page 404 /404.html; error_page 500 502 503 504 /50x.html; diff --git a/modules/image_storage/app/main.py b/modules/image_storage/app/main.py index ff59d36..2c915c3 100644 --- a/modules/image_storage/app/main.py +++ b/modules/image_storage/app/main.py @@ -99,7 +99,7 @@ async def download_by_url(payload: DownloadByUrlRequest): return {"path": rel} -@app.get("/media/{path:path}") +@app.get("/{path:path}") async def get_image(path: str): """ Отдаёт файл по относительному пути (например, posters/ab/cd/hash.jpg). From 6f808a715bdaf8f8eb10ab88ed5836a5d82dadeb Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 6 Dec 2025 09:30:18 +0300 Subject: [PATCH 215/224] fix --- modules/backend/queries.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 19971e5..5f0c74e 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -409,7 +409,7 @@ VALUES ( sqlc.narg('review_id')::bigint, sqlc.narg('ftime')::timestamptz ) -RETURNING user_id, title_id, status, rate, review_id, ctime; +RETURNING *; -- name: UpdateUserTitle :one -- Fails with sql.ErrNoRows if (user_id, title_id) not found From e62f0fa96c6637741ef5ea64711c7db098937d12 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 09:51:01 +0300 Subject: [PATCH 216/224] feat!: FUCK FTIME --- api/_build/openapi.yaml | 4 ++-- api/api.gen.go | 4 ++-- api/schemas/UserTitle.yaml | 2 +- api/schemas/UserTitleMini.yaml | 2 +- modules/backend/handlers/users.go | 8 ++++---- modules/backend/queries.sql | 10 +++++----- modules/frontend/src/api/types.gen.ts | 4 ++-- sql/migrations/000001_init.up.sql | 1 - sql/models.go | 2 +- sql/queries.sql.go | 28 +++++++++++++-------------- 10 files changed, 32 insertions(+), 33 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index ad0c9be..738fdde 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -800,7 +800,7 @@ components: review_id: type: integer format: int64 - ctime: + ftime: type: string format: date-time required: @@ -824,7 +824,7 @@ components: review_id: type: integer format: int64 - ctime: + ftime: type: string format: date-time required: diff --git a/api/api.gen.go b/api/api.gen.go index 04d10c0..39c7080 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -156,7 +156,7 @@ type User struct { // UserTitle defines model for UserTitle. type UserTitle struct { - Ctime *time.Time `json:"ctime,omitempty"` + Ftime *time.Time `json:"ftime,omitempty"` Rate *int32 `json:"rate,omitempty"` ReviewId *int64 `json:"review_id,omitempty"` @@ -168,7 +168,7 @@ type UserTitle struct { // UserTitleMini defines model for UserTitleMini. type UserTitleMini struct { - Ctime *time.Time `json:"ctime,omitempty"` + Ftime *time.Time `json:"ftime,omitempty"` Rate *int32 `json:"rate,omitempty"` ReviewId *int64 `json:"review_id,omitempty"` diff --git a/api/schemas/UserTitle.yaml b/api/schemas/UserTitle.yaml index ef619cb..0b114a3 100644 --- a/api/schemas/UserTitle.yaml +++ b/api/schemas/UserTitle.yaml @@ -17,6 +17,6 @@ properties: review_id: type: integer format: int64 - ctime: + ftime: type: string format: date-time diff --git a/api/schemas/UserTitleMini.yaml b/api/schemas/UserTitleMini.yaml index e1a5a74..0467252 100644 --- a/api/schemas/UserTitleMini.yaml +++ b/api/schemas/UserTitleMini.yaml @@ -18,6 +18,6 @@ properties: review_id: type: integer format: int64 - ctime: + ftime: type: string format: date-time diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index eecd82f..e215e64 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -167,7 +167,7 @@ func UserTitleStatus2Sqlc1(s *oapi.UserTitleStatus) (*sqlc.UsertitleStatusT, err func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (oapi.UserTitle, error) { oapi_usertitle := oapi.UserTitle{ - Ctime: &t.UserCtime, + Ftime: &t.UserFtime, Rate: t.UserRate, ReviewId: t.ReviewID, // Status: , @@ -398,7 +398,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque return oapi.AddUserTitle500Response{}, nil } oapi_usertitle := oapi.UserTitleMini{ - Ctime: &user_title.Ctime, + Ftime: &user_title.Ftime, Rate: user_title.Rate, ReviewId: user_title.ReviewID, Status: oapi_status, @@ -457,7 +457,7 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl } oapi_usertitle := oapi.UserTitleMini{ - Ctime: &user_title.Ctime, + Ftime: &user_title.Ftime, Rate: user_title.Rate, ReviewId: user_title.ReviewID, Status: oapi_status, @@ -487,7 +487,7 @@ func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleReque return oapi.GetUserTitle500Response{}, nil } oapi_usertitle := oapi.UserTitleMini{ - Ctime: &user_title.Ctime, + Ftime: &user_title.Ftime, Rate: user_title.Rate, ReviewId: user_title.ReviewID, Status: oapi_status, diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 19971e5..7117456 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -268,7 +268,7 @@ SELECT u.status as usertitle_status, u.rate as user_rate, u.review_id as review_id, - u.ctime as user_ctime, + u.ftime as user_ftime, i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( @@ -370,7 +370,7 @@ WHERE AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) GROUP BY - t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id + t.id, u.user_id, u.status, u.rate, u.review_id, u.ftime, i.id, s.id ORDER BY CASE WHEN sqlc.arg('forward')::boolean THEN @@ -400,7 +400,7 @@ FROM reviews WHERE review_id = sqlc.arg('review_id')::bigint; -- name: InsertUserTitle :one -INSERT INTO usertitles (user_id, title_id, status, rate, review_id, ctime) +INSERT INTO usertitles (user_id, title_id, status, rate, review_id, ftime) VALUES ( sqlc.arg('user_id')::bigint, sqlc.arg('title_id')::bigint, @@ -409,7 +409,7 @@ VALUES ( sqlc.narg('review_id')::bigint, sqlc.narg('ftime')::timestamptz ) -RETURNING user_id, title_id, status, rate, review_id, ctime; +RETURNING user_id, title_id, status, rate, review_id, ftime; -- name: UpdateUserTitle :one -- Fails with sql.ErrNoRows if (user_id, title_id) not found @@ -417,7 +417,7 @@ UPDATE usertitles SET status = COALESCE(sqlc.narg('status')::usertitle_status_t, status), rate = COALESCE(sqlc.narg('rate')::int, rate), - ctime = COALESCE(sqlc.narg('ftime')::timestamptz, ctime) + ftime = COALESCE(sqlc.narg('ftime')::timestamptz, ftime) WHERE user_id = sqlc.arg('user_id') AND title_id = sqlc.arg('title_id') diff --git a/modules/frontend/src/api/types.gen.ts b/modules/frontend/src/api/types.gen.ts index d0ca425..1352aa6 100644 --- a/modules/frontend/src/api/types.gen.ts +++ b/modules/frontend/src/api/types.gen.ts @@ -125,7 +125,7 @@ export type UserTitle = { status: UserTitleStatus; rate?: number; review_id?: number; - ctime?: string; + ftime?: string; }; export type UserTitleMini = { @@ -134,7 +134,7 @@ export type UserTitleMini = { status: UserTitleStatus; rate?: number; review_id?: number; - ctime?: string; + ftime?: string; }; export type Review = { diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 415b9af..57aa238 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -86,7 +86,6 @@ CREATE TABLE usertitles ( status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), review_id bigint REFERENCES reviews (id) ON DELETE SET NULL, - ctime timestamptz NOT NULL DEFAULT now(), ftime timestamptz NOT NULL DEFAULT now() -- // TODO: series status ); diff --git a/sql/models.go b/sql/models.go index c299609..3a25d7d 100644 --- a/sql/models.go +++ b/sql/models.go @@ -284,5 +284,5 @@ type Usertitle struct { Status UsertitleStatusT `json:"status"` Rate *int32 `json:"rate"` ReviewID *int64 `json:"review_id"` - Ctime time.Time `json:"ctime"` + Ftime time.Time `json:"ftime"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 0384ccd..0c863e8 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -54,7 +54,7 @@ const deleteUserTitle = `-- name: DeleteUserTitle :one DELETE FROM usertitles WHERE user_id = $1 AND title_id = $2 -RETURNING user_id, title_id, status, rate, review_id, ctime +RETURNING user_id, title_id, status, rate, review_id, ftime ` type DeleteUserTitleParams struct { @@ -71,7 +71,7 @@ func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams &i.Status, &i.Rate, &i.ReviewID, - &i.Ctime, + &i.Ftime, ) return i, err } @@ -352,7 +352,7 @@ func (q *Queries) GetUserByNickname(ctx context.Context, nickname string) (User, const getUserTitleByID = `-- name: GetUserTitleByID :one SELECT - ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime + ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ftime FROM usertitles as ut WHERE ut.title_id = $1::bigint AND ut.user_id = $2::bigint ` @@ -371,7 +371,7 @@ func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDPara &i.Status, &i.Rate, &i.ReviewID, - &i.Ctime, + &i.Ftime, ) return i, err } @@ -438,7 +438,7 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams } const insertUserTitle = `-- name: InsertUserTitle :one -INSERT INTO usertitles (user_id, title_id, status, rate, review_id, ctime) +INSERT INTO usertitles (user_id, title_id, status, rate, review_id, ftime) VALUES ( $1::bigint, $2::bigint, @@ -447,7 +447,7 @@ VALUES ( $5::bigint, $6::timestamptz ) -RETURNING user_id, title_id, status, rate, review_id, ctime +RETURNING user_id, title_id, status, rate, review_id, ftime ` type InsertUserTitleParams struct { @@ -475,7 +475,7 @@ func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams &i.Status, &i.Rate, &i.ReviewID, - &i.Ctime, + &i.Ftime, ) return i, err } @@ -786,7 +786,7 @@ SELECT u.status as usertitle_status, u.rate as user_rate, u.review_id as review_id, - u.ctime as user_ctime, + u.ftime as user_ftime, i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( @@ -888,7 +888,7 @@ WHERE AND ($13::release_season_t IS NULL OR t.release_season = $13::release_season_t) GROUP BY - t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id + t.id, u.user_id, u.status, u.rate, u.review_id, u.ftime, i.id, s.id ORDER BY CASE WHEN $2::boolean THEN @@ -946,7 +946,7 @@ type SearchUserTitlesRow struct { UsertitleStatus UsertitleStatusT `json:"usertitle_status"` UserRate *int32 `json:"user_rate"` ReviewID *int64 `json:"review_id"` - UserCtime time.Time `json:"user_ctime"` + UserFtime time.Time `json:"user_ftime"` TitleStorageType *StorageTypeT `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` TagNames json.RawMessage `json:"tag_names"` @@ -994,7 +994,7 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara &i.UsertitleStatus, &i.UserRate, &i.ReviewID, - &i.UserCtime, + &i.UserFtime, &i.TitleStorageType, &i.TitleImagePath, &i.TagNames, @@ -1065,11 +1065,11 @@ UPDATE usertitles SET status = COALESCE($1::usertitle_status_t, status), rate = COALESCE($2::int, rate), - ctime = COALESCE($3::timestamptz, ctime) + ftime = COALESCE($3::timestamptz, ftime) WHERE user_id = $4 AND title_id = $5 -RETURNING user_id, title_id, status, rate, review_id, ctime +RETURNING user_id, title_id, status, rate, review_id, ftime ` type UpdateUserTitleParams struct { @@ -1096,7 +1096,7 @@ func (q *Queries) UpdateUserTitle(ctx context.Context, arg UpdateUserTitleParams &i.Status, &i.Rate, &i.ReviewID, - &i.Ctime, + &i.Ftime, ) return i, err } From 103a872be21c015e3b286c975c8c81431bf82193 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 6 Dec 2025 09:53:02 +0300 Subject: [PATCH 217/224] fix --- modules/backend/handlers/users.go | 3 +++ sql/migrations/000001_init.up.sql | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index eecd82f..c0f0f55 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -386,6 +386,9 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque // fmt.Println(pgErr.Code) // => 42601 if pgErr.Code == pgErrDuplicateKey { //duplicate key value return oapi.AddUserTitle409Response{}, nil + } else { + log.Errorf("%v", err) + return oapi.AddUserTitle500Response{}, nil } } else { log.Errorf("%v", err) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 415b9af..57aa238 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -86,7 +86,6 @@ CREATE TABLE usertitles ( status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), review_id bigint REFERENCES reviews (id) ON DELETE SET NULL, - ctime timestamptz NOT NULL DEFAULT now(), ftime timestamptz NOT NULL DEFAULT now() -- // TODO: series status ); From 541d0fce2704d39aa7ec7aea0987d3ac5d7af694 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sat, 6 Dec 2025 09:55:42 +0300 Subject: [PATCH 218/224] fix: ftime now set to now() if not received in backend --- modules/backend/handlers/users.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 5bbffea..100835a 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -71,7 +71,10 @@ func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { func oapiDate2sql(t *time.Time) pgtype.Timestamptz { if t == nil { - return pgtype.Timestamptz{Valid: false} + return pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + } } return pgtype.Timestamptz{ Time: *t, From 8056946f0373c2607af6eb4369a646272cc5245a Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Sat, 6 Dec 2025 10:14:05 +0300 Subject: [PATCH 219/224] fix: media path --- .../components/cards/TitleCardHorizontal.tsx | 2 +- .../src/components/cards/TitleCardSquare.tsx | 2 +- .../cards/UserTitleCardHorizontal.tsx | 2 +- .../components/cards/UserTitleCardSquare.tsx | 2 +- .../src/pages/SettingsPage/SettingsPage.tsx | 154 ++++++++++++++++++ .../src/pages/TitlePage/TitlePage.tsx | 2 +- 6 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 modules/frontend/src/pages/SettingsPage/SettingsPage.tsx diff --git a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx index b848702..4a020b7 100644 --- a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx +++ b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx @@ -10,7 +10,7 @@ export function TitleCardHorizontal({ title }: { title: Title }) { borderRadius: 8 }}> {title.poster?.image_path && ( - <img src={title.poster.image_path} width={80} /> + <img src={"media/" + title.poster.image_path} width={80} /> )} <div> <h3>{title.title_names["en"]}</h3> diff --git a/modules/frontend/src/components/cards/TitleCardSquare.tsx b/modules/frontend/src/components/cards/TitleCardSquare.tsx index 0bcb49d..6a7a071 100644 --- a/modules/frontend/src/components/cards/TitleCardSquare.tsx +++ b/modules/frontend/src/components/cards/TitleCardSquare.tsx @@ -10,7 +10,7 @@ export function TitleCardSquare({ title }: { title: Title }) { textAlign: "center" }}> {title.poster?.image_path && ( - <img src={title.poster.image_path} width={140} /> + <img src={"media/" + title.poster.image_path} width={140} /> )} <div> <h4>{title.title_names["en"]}</h4> diff --git a/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx b/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx index ad7d5df..aec30d2 100644 --- a/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx +++ b/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx @@ -10,7 +10,7 @@ export function UserTitleCardHorizontal({ title }: { title: UserTitle }) { borderRadius: 8 }}> {title.title?.poster?.image_path && ( - <img src={title.title?.poster.image_path} width={80} /> + <img src={"media/" + title.title?.poster.image_path} width={80} /> )} <div> <h3>{title.title?.title_names["en"]}</h3> diff --git a/modules/frontend/src/components/cards/UserTitleCardSquare.tsx b/modules/frontend/src/components/cards/UserTitleCardSquare.tsx index edcf1d5..a9424f6 100644 --- a/modules/frontend/src/components/cards/UserTitleCardSquare.tsx +++ b/modules/frontend/src/components/cards/UserTitleCardSquare.tsx @@ -10,7 +10,7 @@ export function UserTitleCardSquare({ title }: { title: UserTitle }) { textAlign: "center" }}> {title.title?.poster?.image_path && ( - <img src={title.title?.poster.image_path} width={140} /> + <img src={"media/" + title.title?.poster.image_path} width={140} /> )} <div> <h4>{title.title?.title_names["en"]}</h4> diff --git a/modules/frontend/src/pages/SettingsPage/SettingsPage.tsx b/modules/frontend/src/pages/SettingsPage/SettingsPage.tsx new file mode 100644 index 0000000..16c7e9e --- /dev/null +++ b/modules/frontend/src/pages/SettingsPage/SettingsPage.tsx @@ -0,0 +1,154 @@ +// import React, { useEffect, useState } from "react"; +// import { updateUser, getUsersId } from "../../api"; +// import { useNavigate } from "react-router-dom"; + +// export const SettingsPage: React.FC = () => { +// const navigate = useNavigate(); + +// const userId = Number(localStorage.getItem("user_id")); +// const initialNickname = localStorage.getItem("user_name") || ""; +// const [mail, setMail] = useState(""); +// const [nickname, setNickname] = useState(initialNickname); +// const [dispName, setDispName] = useState(""); +// const [userDesc, setUserDesc] = useState(""); +// const [avatarId, setAvatarId] = useState<number | null>(null); + +// const [loading, setLoading] = useState(false); +// const [success, setSuccess] = useState<string | null>(null); +// const [error, setError] = useState<string | null>(null); + +// useEffect(() => { +// const fetch = async () => { +// const res = await getUsersId({ +// path: { user_id: String(userId) }, +// }); + +// setProfile(res.data); +// }; + +// fetch(); +// }, [userId]); + +// const saveSettings = async (e: React.FormEvent) => { +// e.preventDefault(); +// setLoading(true); +// setSuccess(null); +// setError(null); + +// try { +// const res = await updateUser({ +// path: { user_id: userId }, +// body: { +// ...(mail ? { mail } : {}), +// ...(nickname ? { nickname } : {}), +// ...(dispName ? { disp_name: dispName } : {}), +// ...(userDesc ? { user_desc: userDesc } : {}), +// ...(avatarId !== undefined ? { avatar_id: avatarId } : {}), +// } +// }); + +// // Обновляем локальное отображение username +// if (nickname) { +// localStorage.setItem("user_name", nickname); +// window.dispatchEvent(new Event("storage")); // чтобы Header обновился +// } + +// setSuccess("Settings updated!"); +// setTimeout(() => navigate("/profile"), 800); + +// } catch (err: any) { +// console.error(err); +// setError(err?.message || "Failed to update settings"); +// } finally { +// setLoading(false); +// } +// }; + +// return ( +// <div className="max-w-2xl mx-auto p-6"> +// <h1 className="text-3xl font-bold mb-6">User Settings</h1> + +// {success && <div className="text-green-600 mb-4">{success}</div>} +// {error && <div className="text-red-600 mb-4">{error}</div>} + +// <form className="space-y-6" onSubmit={saveSettings}> +// {/* Email */} +// <div> +// <label className="block text-sm font-medium mb-1">Email</label> +// <input +// type="email" +// value={mail} +// onChange={(e) => setMail(e.target.value)} +// placeholder="example@mail.com" +// className="w-full px-4 py-2 border rounded-lg" +// /> +// </div> + +// {/* Nickname */} +// <div> +// <label className="block text-sm font-medium mb-1">Nickname</label> +// <input +// type="text" +// value={nickname} +// onChange={(e) => setNickname(e.target.value)} +// className="w-full px-4 py-2 border rounded-lg" +// /> +// </div> + +// {/* Display name */} +// <div> +// <label className="block text-sm font-medium mb-1">Display name</label> +// <input +// type="text" +// value={dispName} +// onChange={(e) => setDispName(e.target.value)} +// placeholder="Shown name" +// className="w-full px-4 py-2 border rounded-lg" +// /> +// </div> + +// {/* Bio */} +// <div> +// <label className="block text-sm font-medium mb-1">Bio</label> +// <textarea +// value={userDesc} +// onChange={(e) => setUserDesc(e.target.value)} +// rows={4} +// className="w-full px-4 py-2 border rounded-lg resize-none" +// /> +// </div> + +// {/* Avatar ID */} +// <div> +// <label className="block text-sm font-medium mb-1">Avatar ID</label> +// <input +// type="number" +// value={avatarId ?? ""} +// onChange={(e) => { +// const v = e.target.value; +// setAvatarId(v === "" ? null : Number(v)); +// }} +// placeholder="Image ID" +// className="w-full px-4 py-2 border rounded-lg" +// /> + +// <button +// type="button" +// onClick={() => setAvatarId(null)} +// className="text-sm text-blue-600 mt-1 hover:underline" +// > +// Remove avatar +// </button> +// </div> + +// <button +// type="submit" +// disabled={loading} +// className="w-full bg-blue-600 text-white py-2 rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50" +// > +// {loading ? "Saving..." : "Save changes"} +// </button> +// </form> +// </div> +// ); +// }; diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index 0d9e297..02149bf 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -46,7 +46,7 @@ export default function TitlePage() { {/* Poster + status buttons */} <div className="flex flex-col items-center"> <img - src={title.poster?.image_path || "/default-poster.png"} + src={"media/" + title.poster?.image_path || "/default-poster.png"} alt={title.title_names?.en?.[0] || "Title poster"} className="w-48 h-72 object-cover rounded-lg mb-4" /> From 490443b63feb929b6615b0a6ecd7a6f701c07f7d Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Fri, 19 Dec 2025 22:52:49 +0300 Subject: [PATCH 220/224] fix: rerender header on user login --- modules/frontend/src/components/Header/Header.tsx | 10 +++++++++- modules/frontend/src/pages/LoginPage/LoginPage.tsx | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/modules/frontend/src/components/Header/Header.tsx b/modules/frontend/src/components/Header/Header.tsx index 3c86802..36cbd5a 100644 --- a/modules/frontend/src/components/Header/Header.tsx +++ b/modules/frontend/src/components/Header/Header.tsx @@ -14,8 +14,16 @@ export const Header: React.FC = () => { // Listen for localStorage changes to update username dynamically useEffect(() => { const handleStorage = () => setUsername(localStorage.getItem("user_name")); + // This catches changes from OTHER tabs window.addEventListener("storage", handleStorage); - return () => window.removeEventListener("storage", handleStorage); + + // This catches changes in the CURRENT tab if you use dispatchEvent + window.addEventListener("local-storage-update", handleStorage); + + return () => { + window.removeEventListener("storage", handleStorage); + window.removeEventListener("local-storage-update", handleStorage); + }; }, []); const handleLogout = async () => { diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx index 4932a3b..55ae730 100644 --- a/modules/frontend/src/pages/LoginPage/LoginPage.tsx +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -21,6 +21,7 @@ export const LoginPage: React.FC = () => { if (res.data?.user_id && res.data?.user_name) { localStorage.setItem("user_id", res.data.user_id.toString()); localStorage.setItem("user_name", res.data.user_name); + window.dispatchEvent(new Event("storage")); navigate("/profile"); } else { setError("Login failed"); @@ -34,6 +35,7 @@ export const LoginPage: React.FC = () => { if (loginRes.data?.user_id && loginRes.data?.user_name) { localStorage.setItem("user_id", loginRes.data.user_id.toString()); localStorage.setItem("user_name", loginRes.data.user_name); + window.dispatchEvent(new Event("storage")); navigate("/profile"); } } else { From b33997772bf948f91ed7da1f051489fa5eefcd18 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Fri, 19 Dec 2025 23:03:03 +0300 Subject: [PATCH 221/224] cicd: slightly refactored python Dockerfiles --- Dockerfiles/Dockerfile_etl | 8 -------- Dockerfiles/Dockerfile_image_storage | 12 +----------- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/Dockerfiles/Dockerfile_etl b/Dockerfiles/Dockerfile_etl index c721b51..7ddbfe2 100644 --- a/Dockerfiles/Dockerfile_etl +++ b/Dockerfiles/Dockerfile_etl @@ -1,13 +1,6 @@ FROM python:3.12-slim WORKDIR /app/modules/anime_etl - -# RUN apt-get update && apt-get install -y --no-install-recommends \ -# build-essential \ -# libpq-dev \ -# ca-certificates \ -# && rm -rf /var/lib/apt/lists/* - COPY modules/anime_etl/pyproject.toml modules/anime_etl/uv.lock ./ RUN pip install --no-cache-dir uv \ @@ -17,5 +10,4 @@ COPY modules/anime_etl ./ ENV NYANIMEDB_MEDIA_ROOT=/media -# было: CMD ["python", "-m", "rabbit_worker"] CMD ["uv", "run", "python", "-m", "rabbit_worker"] diff --git a/Dockerfiles/Dockerfile_image_storage b/Dockerfiles/Dockerfile_image_storage index 34d7496..e0f60b5 100644 --- a/Dockerfiles/Dockerfile_image_storage +++ b/Dockerfiles/Dockerfile_image_storage @@ -1,25 +1,15 @@ FROM python:3.12-slim -# каталог внутри контейнера WORKDIR /app/modules/image_storage - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -# 1. зависимости через uv COPY modules/image_storage/pyproject.toml modules/image_storage/uv.lock ./ RUN pip install --no-cache-dir uv \ && uv sync --frozen --no-dev -# 2. сам код COPY modules/image_storage ./ -# 3. где будем хранить файлы внутри контейнера ENV NYANIMEDB_MEDIA_ROOT=/media EXPOSE 8000 -# 4. поднимаем FastAPI-приложение (app/main.py → app.main:app) -CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file From 82842b3bf355a3f51e2e9f88c706be3121ef3455 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Fri, 19 Dec 2025 23:35:30 +0300 Subject: [PATCH 222/224] fix: redirect user to profile after login --- modules/frontend/src/App.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index a92cc17..de7101c 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useState, useEffect } from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./pages/UserPage/UserPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; @@ -11,8 +12,22 @@ import { Header } from "./components/Header/Header"; // OpenAPI.WITH_CREDENTIALS = true const App: React.FC = () => { - // const username = localStorage.getItem("username") || undefined; - const userId = localStorage.getItem("user_id"); + const [userId, setUserId] = useState<string | null>(localStorage.getItem("user_id")); + + // 2. Listen for the same event the Header uses + useEffect(() => { + const handleAuthChange = () => { + setUserId(localStorage.getItem("user_id")); + }; + + window.addEventListener("storage", handleAuthChange); + window.addEventListener("local-storage-update", handleAuthChange); + + return () => { + window.removeEventListener("storage", handleAuthChange); + window.removeEventListener("local-storage-update", handleAuthChange); + }; + }, []); return ( <Router> From 522e7554fedd69837d0c130afdd5b89498ab68ab Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Fri, 19 Dec 2025 23:40:15 +0300 Subject: [PATCH 223/224] cicd: light pipeline for front branch --- .forgejo/workflows/front.yml | 54 ++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .forgejo/workflows/front.yml diff --git a/.forgejo/workflows/front.yml b/.forgejo/workflows/front.yml new file mode 100644 index 0000000..275464d --- /dev/null +++ b/.forgejo/workflows/front.yml @@ -0,0 +1,54 @@ +name: Build and Deploy (frontend build only) + +on: + push: + branches: + - front + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # Build frontend + - uses: actions/setup-node@v5 + with: + node-version-file: modules/frontend/package.json + cache: npm + cache-dependency-path: modules/frontend/package-lock.json + + - name: Build frontend + env: + VITE_BACKEND_API_BASE_URL: ${{ vars.BACKEND_API_BASE_URL }} + run: | + cd modules/frontend + npm install + npm run build + tar -czvf nyanimedb-frontend.tar.gz dist/ + + - name: Upload built frontend to artifactory + uses: actions/upload-artifact@v3 + with: + name: nyanimedb-frontend.tar.gz + path: modules/frontend/nyanimedb-frontend.tar.gz + + # Build Docker images + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: ${{ vars.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push frontend image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfiles/Dockerfile_frontend + push: true + tags: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest \ No newline at end of file From 1b40ebdbd9de084a3a81483c32eb7a1675ef18de Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Fri, 19 Dec 2025 23:46:21 +0300 Subject: [PATCH 224/224] cicd: pipeline for dev-ars branch --- .forgejo/workflows/dev-ars.yml | 49 ++++++++++++++++++++++++++++++++++ .forgejo/workflows/front.yml | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .forgejo/workflows/dev-ars.yml diff --git a/.forgejo/workflows/dev-ars.yml b/.forgejo/workflows/dev-ars.yml new file mode 100644 index 0000000..3bcfb7b --- /dev/null +++ b/.forgejo/workflows/dev-ars.yml @@ -0,0 +1,49 @@ +name: Build (backend build only) + +on: + push: + branches: + - dev-ars + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # Build backend + - uses: actions/setup-go@v6 + with: + go-version: '^1.25' + + - name: Build backend + run: | + cd modules/backend + go build -o nyanimedb . + tar -czvf nyanimedb-backend.tar.gz nyanimedb + + - name: Upload built backend to artifactory + uses: actions/upload-artifact@v3 + with: + name: nyanimedb-backend.tar.gz + path: modules/backend/nyanimedb-backend.tar.gz + + # Build Docker images + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: ${{ vars.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push backend image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfiles/Dockerfile_backend + push: true + tags: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest \ No newline at end of file diff --git a/.forgejo/workflows/front.yml b/.forgejo/workflows/front.yml index 275464d..08a24ec 100644 --- a/.forgejo/workflows/front.yml +++ b/.forgejo/workflows/front.yml @@ -1,4 +1,4 @@ -name: Build and Deploy (frontend build only) +name: Build (frontend build only) on: push: