diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 3c473d2..b82fb3d 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -111,6 +111,12 @@ 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 }} + AUTH_ENABLED: ${{ vars.AUTH_ENABLED }} steps: - name: Checkout code diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 225e7cd..5b6f731 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 @@ -150,8 +197,6 @@ paths: description: User not found '500': description: Unknown server error - security: - - JwtAuthCookies: [] patch: operationId: updateUser summary: Partially update a user account @@ -160,7 +205,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 +273,7 @@ paths: '500': description: Unknown server error security: - - JwtAuthCookies: [] + - XsrfAuthHeader: [] '/users/{user_id}/titles': get: operationId: getUserTitles @@ -405,14 +449,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 +496,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 +526,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 @@ -640,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: @@ -780,3 +792,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/api.gen.go b/api/api.gen.go index 62450e0..ff37ed9 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. @@ -112,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"` @@ -174,12 +178,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 @@ -206,6 +204,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"` @@ -229,17 +236,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"` @@ -292,12 +288,15 @@ 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) // 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) @@ -475,6 +474,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) { @@ -524,46 +565,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 +574,7 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { } } - siw.Handler.UpdateUser(c, userId, params) + siw.Handler.UpdateUser(c, userId) } // GetUserTitles operation middleware @@ -745,6 +747,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 +815,8 @@ func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) { return } + c.Set(XsrfAuthHeaderScopes, []string{}) + for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -850,6 +856,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) @@ -955,6 +962,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 @@ -999,7 +1052,6 @@ func (response GetUsersId500Response) VisitGetUsersIdResponse(w http.ResponseWri type UpdateUserRequestObject struct { UserId int64 `json:"user_id"` - Params UpdateUserParams Body *UpdateUserJSONRequestBody } @@ -1357,6 +1409,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) @@ -1447,6 +1502,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 @@ -1476,11 +1558,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/openapi.yaml b/api/openapi.yaml index 08a4d54..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: @@ -23,3 +25,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..701df6b 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/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 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: 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/deploy/docker-compose.yml b/deploy/docker-compose.yml index 0ae97c6..1119335 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -47,6 +47,10 @@ services: environment: LOG_LEVEL: ${LOG_LEVEL} DATABASE_URL: ${DATABASE_URL} + 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/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 39067a6..2c4ee6c 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -71,7 +71,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken //TODO: add created_at } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - accessToken, err = at.SignedString(s.JwtPrivateKey) + accessToken, err = at.SignedString([]byte(s.JwtPrivateKey)) if err != nil { return "", "", "", err } @@ -81,7 +81,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 } @@ -150,7 +150,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/backend/handlers/common.go b/modules/backend/handlers/common.go index cad4f0f..7f2807f 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, } } @@ -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) 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/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/main.go b/modules/backend/main.go index aab1287..e7e6ec8 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) @@ -46,31 +46,28 @@ func main() { r := gin.Default() - r.Use(middleware.CSRFMiddleware()) - // jwt middle will be here - queries := sqlc.New(pool) - - // === RabbitMQ setup === - rmqURL := os.Getenv("RABBITMQ_URL") - if rmqURL == "" { - rmqURL = "amqp://guest:guest@rabbitmq:5672/" + if len(AppConfig.AuthEnabled) > 0 && AppConfig.AuthEnabled != "false" { + r.Use(middleware.CSRFMiddleware()) + r.Use(middleware.JWTAuthMiddleware(AppConfig.JwtPrivateKey)) } - rmqConn, err := amqp091.Dial(rmqURL) + queries := sqlc.New(pool) + + rmqConn, err := amqp091.Dial(AppConfig.RmqURL) if err != nil { log.Fatalf("Failed to connect to RabbitMQ: %v", err) } 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{"*"}, // allow all origins, change to specific domains in production + 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, @@ -78,7 +75,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/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 * 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()) } } } diff --git a/modules/backend/types.go b/modules/backend/types.go index 20d3158..ceaec4e 100644 --- a/modules/backend/types.go +++ b/modules/backend/types.go @@ -1,12 +1,11 @@ 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"` + AuthEnabled string `toml:"AuthEnabled" env:"AUTH_ENABLED"` } diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 95b59e3..84c9086 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/client.gen.ts b/modules/frontend/src/api/client.gen.ts new file mode 100644 index 0000000..2de06ac --- /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 = (override?: Config) => Config & T>; + +export const client = createClient(createConfig({ baseUrl: 'http://10.1.0.65:8081/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 & { + body?: any; + headers: ReturnType; +}; + +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) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = + (method: Uppercase) => 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, + 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 + extends Omit, + 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, + | '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; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + 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 + ? TData[keyof TData] + : TData + : { + data: TData extends Record + ? TData[keyof TData] + : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? + | (TData extends Record + ? TData[keyof TData] + : TData) + | undefined + : ( + | { + data: TData extends Record + ? TData[keyof TData] + : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record + ? 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, 'method'>, +) => RequestResult; + +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'> & + Pick< + Required>, + 'method' + >, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: TData & Options, +) => string; + +export type Client = CoreClient< + RequestFn, + Config, + MethodFn, + BuildUrlFn, + SseFn +> & { + interceptors: Middleware; +}; + +/** + * 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 = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); 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 = ({ + 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, + ...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 => { + 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 & { + 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, 'security'> & + Pick & { + 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['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 = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise; + +type ReqInterceptor = ( + request: Req, + options: Options, +) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + 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 { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & 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; - 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 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 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 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 = (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: '/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, +): Promise => { + 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; + +export type BodySerializer = (body: any) => any; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; + +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: | Array>>( + 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: (body: T): string => + JSON.stringify(body, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: | Array>>( + 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; + /** + * 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; + /** + * 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>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $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; + path: Record; + query: Record; +} + +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, + 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)[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)[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)[ + 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)[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 + extends SerializePrimitiveOptions, + SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @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 & { + 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 & { + value: Record | 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 => { + 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 = {}; + + 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 = (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/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 = Omit< + RequestInit, + 'method' +> & + Pick & { + /** + * 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; + /** + * 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) => 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; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult< + TData = unknown, + TReturn = void, + TNext = unknown, +> = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export const createSseClient = ({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult => { + 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 | 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 = []; + 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; +} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined 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; + 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, + 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; + query?: Record; + 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 c1e9cdc..c352c10 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -1,31 +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 { 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'; -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; 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; 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; 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>; - 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; -}; - 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/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/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 = Options2 & { + /** + * 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; +}; + +/** + * Get titles + */ +export const getTitles = (options?: Options) => (options?.client ?? client).get({ + querySerializer: { parameters: { status: { array: { explode: false } } } }, + url: '/titles', + ...options +}); + +/** + * Get title description + */ +export const getTitle = (options: Options) => (options.client ?? client).get({ url: '/titles/{title_id}', ...options }); + +/** + * Get user info + */ +export const getUsersId = (options: Options) => (options.client ?? client).get({ 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 = (options: Options) => (options.client ?? client).patch({ + 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 = (options: Options) => (options.client ?? client).get({ + 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 = (options: Options) => (options.client ?? client).post({ + 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 = (options: Options) => (options.client ?? client).delete({ + security: [{ name: 'X-XSRF-TOKEN', type: 'apiKey' }], + url: '/users/{user_id}/titles/{title_id}', + ...options +}); + +/** + * Get user title + */ +export const getUserTitle = (options: Options) => (options.client ?? client).get({ url: '/users/{user_id}/titles/{title_id}', ...options }); + +/** + * Update a usertitle + * + * User updating title list of watched + */ +export const updateUserTitle = (options: Options) => (options.client ?? client).patch({ + 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 f3d803d..0000000 --- a/modules/frontend/src/api/services/DefaultService.ts +++ /dev/null @@ -1,392 +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, - rating?: number, - releaseYear?: number, - releaseSeason?: ReleaseSeason, - limit: number = 10, - offset?: number, - fields: string = 'all', - ): CancelablePromise<{ - /** - * List of titles - */ - data: Array; - 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 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: { - /** - * 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, - }, - headers: { - 'X-XSRF-TOKEN': xXsrfToken, - }, - 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 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 - * @returns UserTitleMini Title successfully updated - * @throws ApiError - */ - public static updateUserTitle( - xXsrfToken: string, - 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, - }, - headers: { - 'X-XSRF-TOKEN': xXsrfToken, - }, - 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 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> { - return __request(OpenAPI, { - method: 'DELETE', - url: '/users/{user_id}/titles/{title_id}', - path: { - '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`, - 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/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 4fb535a..98fa868 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -1,5 +1,6 @@ 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'; @@ -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 }[] = [ @@ -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,13 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { try { // 1) Если кликнули на текущий статус — DELETE if (currentStatus === status) { - await DefaultService.deleteUserTitle(xsrfToken, userId, titleId); + // await DefaultService.deleteUserTitle(userId, titleId); + await deleteUserTitle({path: { + user_id: userId, + title_id: titleId, + }, + headers: { "X-XSRF-TOKEN": xsrfToken }, + }) setCurrentStatus(null); return; } @@ -54,15 +65,29 @@ 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}, + headers: { "X-XSRF-TOKEN": xsrfToken }, }); - setCurrentStatus(added.status); + + setCurrentStatus(added.data?.status ?? null); } else { // уже есть запись — PATCH - const updated = await DefaultService.updateUserTitle(xsrfToken, 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/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>, ) 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..727e072 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, + }; }; // Инициализация: загружаем сразу две страницы @@ -133,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/" + alt="Loading animation" + className="size-100 object-contain" + /> + </div> + )} {!loading && titles.length === 0 && ( <div className="mt-20 font-medium text-black">No titles found.</div> diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 7cc0db5..1a8ba1e 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; diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index cda8d71..9bf99dc 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -45,6 +45,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, diff --git a/sql/models.go b/sql/models.go index ee30f58..1395a19 100644 --- a/sql/models.go +++ b/sql/models.go @@ -247,6 +247,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 7fd8765..e12619e 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -142,7 +142,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( @@ -170,6 +170,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"` @@ -198,6 +199,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, @@ -651,6 +653,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