Compare commits

...

2 commits

Author SHA1 Message Date
34d9341e75 feat: cursor stub added 2025-11-19 03:58:46 +03:00
fbf3f1d3a2 feat: now use _build to build 2025-11-19 03:57:44 +03:00
9 changed files with 671 additions and 24 deletions

View file

@ -0,0 +1,6 @@
package: oapi
generate:
strict-server: true
gin-server: true
models: true
output: api/api.gen.go

436
api/_build/openapi.yaml Normal file
View file

@ -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

View file

@ -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")

View file

@ -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:

View file

@ -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
}

View file

@ -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,

View file

@ -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,

View file

@ -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"`
}

View file

@ -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
}