diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index b82fb3d..3c473d2 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -111,12 +111,6 @@ 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 5b6f731..58dd890 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -122,53 +122,6 @@ 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 @@ -205,6 +158,8 @@ paths: Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. parameters: + - $ref: '#/components/parameters/accessToken' + - $ref: '#/components/parameters/csrfToken' - name: user_id in: path description: User ID (primary key) @@ -273,7 +228,7 @@ paths: '500': description: Unknown server error security: - - XsrfAuthHeader: [] + - JwtAuthCookies: [] '/users/{user_id}/titles': get: operationId: getUserTitles @@ -495,8 +450,6 @@ paths: description: User or Title not found '500': description: Internal server error - security: - - XsrfAuthHeader: [] delete: operationId: deleteUserTitle summary: Delete a usertitle @@ -525,10 +478,41 @@ paths: description: User or Title not found '500': description: Internal server error - security: - - 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 @@ -647,11 +631,6 @@ 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: @@ -792,11 +771,3 @@ 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 ff37ed9..62450e0 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -18,7 +18,6 @@ import ( const ( JwtAuthCookiesScopes = "JwtAuthCookies.Scopes" - XsrfAuthHeaderScopes = "XsrfAuthHeader.Scopes" ) // Defines values for ReleaseSeason. @@ -113,9 +112,6 @@ 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"` @@ -178,6 +174,12 @@ type UserTitleMini struct { // UserTitleStatus User's title status type UserTitleStatus string +// AccessToken defines model for accessToken. +type AccessToken = string + +// CsrfToken defines model for csrfToken. +type CsrfToken = string + // Cursor defines model for cursor. type Cursor = string @@ -204,15 +206,6 @@ 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"` @@ -236,6 +229,17 @@ type UpdateUserJSONBody struct { UserDesc *string `json:"user_desc,omitempty"` } +// UpdateUserParams defines parameters for UpdateUser. +type UpdateUserParams struct { + // AccessToken JWT access token. + AccessToken AccessToken `form:"access_token" json:"access_token"` + + // XSRFTOKEN Anti-CSRF token (Double Submit Cookie pattern). + // Stored in non-HttpOnly cookie, readable by JavaScript. + // Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). + XSRFTOKEN CsrfToken `form:"XSRF-TOKEN" json:"XSRF-TOKEN"` +} + // GetUserTitlesParams defines parameters for GetUserTitles. type GetUserTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -288,15 +292,12 @@ 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) + UpdateUser(c *gin.Context, userId int64, params UpdateUserParams) // Get user titles // (GET /users/{user_id}/titles) GetUserTitles(c *gin.Context, userId string, params GetUserTitlesParams) @@ -474,48 +475,6 @@ 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) { @@ -565,7 +524,46 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { return } - c.Set(XsrfAuthHeaderScopes, []string{}) + c.Set(JwtAuthCookiesScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params UpdateUserParams + + { + var cookie string + + if cookie, err = c.Cookie("access_token"); err == nil { + var value AccessToken + err = runtime.BindStyledParameterWithOptions("simple", "access_token", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter access_token: %w", err), http.StatusBadRequest) + return + } + params.AccessToken = value + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument access_token is required, but not found"), http.StatusBadRequest) + return + } + } + + { + var cookie string + + if cookie, err = c.Cookie("XSRF-TOKEN"); err == nil { + var value CsrfToken + err = runtime.BindStyledParameterWithOptions("simple", "XSRF-TOKEN", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter XSRF-TOKEN: %w", err), http.StatusBadRequest) + return + } + params.XSRFTOKEN = value + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument XSRF-TOKEN is required, but not found"), http.StatusBadRequest) + return + } + } for _, middleware := range siw.HandlerMiddlewares { middleware(c) @@ -574,7 +572,7 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { } } - siw.Handler.UpdateUser(c, userId) + siw.Handler.UpdateUser(c, userId, params) } // GetUserTitles operation middleware @@ -747,8 +745,6 @@ func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { return } - c.Set(XsrfAuthHeaderScopes, []string{}) - for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -815,8 +811,6 @@ func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) { return } - c.Set(XsrfAuthHeaderScopes, []string{}) - for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -856,7 +850,6 @@ 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) @@ -962,52 +955,6 @@ 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 @@ -1052,6 +999,7 @@ func (response GetUsersId500Response) VisitGetUsersIdResponse(w http.ResponseWri type UpdateUserRequestObject struct { UserId int64 `json:"user_id"` + Params UpdateUserParams Body *UpdateUserJSONRequestBody } @@ -1409,9 +1357,6 @@ 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) @@ -1502,33 +1447,6 @@ 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 @@ -1558,10 +1476,11 @@ func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetU } // UpdateUser operation middleware -func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { +func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64, params UpdateUserParams) { var request UpdateUserRequestObject request.UserId = userId + request.Params = params var body UpdateUserJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { diff --git a/api/openapi.yaml b/api/openapi.yaml index 0759a54..08a4d54 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -11,8 +11,6 @@ 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: @@ -25,5 +23,3 @@ 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 6249e7d..d2e12a8 100644 --- a/api/parameters/_index.yaml +++ b/api/parameters/_index.yaml @@ -1,4 +1,10 @@ cursor: $ref: "./cursor.yaml" title_sort: - $ref: "./title_sort.yaml" \ No newline at end of file + $ref: "./title_sort.yaml" +accessToken: + $ref: "./access_token.yaml" +csrfToken: + $ref: "./xsrf_token_cookie.yaml" +csrfTokenHeader: + $ref: "./xsrf_token_header.yaml" \ No newline at end of file diff --git a/api/parameters/access_token.yaml b/api/parameters/access_token.yaml new file mode 100644 index 0000000..a7e727e --- /dev/null +++ b/api/parameters/access_token.yaml @@ -0,0 +1,9 @@ +name: access_token +in: cookie +required: true +schema: + type: string + format: jwt +example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y" +description: | + JWT access token. diff --git a/api/parameters/xsrf_token_cookie.yaml b/api/parameters/xsrf_token_cookie.yaml new file mode 100644 index 0000000..37041e0 --- /dev/null +++ b/api/parameters/xsrf_token_cookie.yaml @@ -0,0 +1,11 @@ +name: xsrf_token +in: cookie +required: true +schema: + type: string + pattern: "^[a-zA-Z0-9_-]{32,64}$" +example: "abc123def456ghi789jkl012mno345pqr" +description: | + Anti-CSRF token (Double Submit Cookie pattern). + Stored in non-HttpOnly cookie, readable by JavaScript. + Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). \ No newline at end of file diff --git a/api/parameters/xsrf_token_header.yaml b/api/parameters/xsrf_token_header.yaml new file mode 100644 index 0000000..ac14dc1 --- /dev/null +++ b/api/parameters/xsrf_token_header.yaml @@ -0,0 +1,10 @@ +name: X-XSRF-TOKEN +in: header +required: true +schema: + type: string + pattern: "^[a-zA-Z0-9_-]{32,64}$" +description: | + Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + Required for all state-changing requests (POST/PUT/PATCH/DELETE). +example: "abc123def456ghi789jkl012mno345pqr" \ No newline at end of file diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml index 1da2b81..b4ad884 100644 --- a/api/paths/users-id-titles-id.yaml +++ b/api/paths/users-id-titles-id.yaml @@ -34,8 +34,6 @@ patch: summary: Update a usertitle description: User updating title list of watched operationId: updateUserTitle - security: - - XsrfAuthHeader: [] parameters: - in: path name: user_id @@ -83,8 +81,6 @@ delete: summary: Delete a usertitle description: User deleting title from list of watched operationId: deleteUserTitle - security: - - XsrfAuthHeader: [] parameters: - in: path name: user_id diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index 701df6b..0f2f367 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -28,15 +28,16 @@ get: patch: summary: Partially update a user account + security: + - JwtAuthCookies: [] description: | Update selected user profile fields (excluding password). Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. operationId: updateUser - security: - - XsrfAuthHeader: [] parameters: - # - $ref: '../parameters/xsrf_token_header.yaml' + - $ref: '../parameters/access_token.yaml' # ← для поля в UI и GoDoc + - $ref: '../parameters/xsrf_token_cookie.yaml' # ← для CSRF - name: user_id in: path required: true diff --git a/api/paths/users.yaml b/api/paths/users.yaml deleted file mode 100644 index 14fb0c0..0000000 --- a/api/paths/users.yaml +++ /dev/null @@ -1,46 +0,0 @@ -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 fac4a3f..877ee24 100644 --- a/api/schemas/Title.yaml +++ b/api/schemas/Title.yaml @@ -30,11 +30,6 @@ 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 deleted file mode 100644 index ecc0ff6..0000000 --- a/api/securitySchemes/_index.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# 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/auth/auth.gen.go b/auth/auth.gen.go index b7cd839..7276545 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -13,32 +13,32 @@ import ( strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" ) -// PostSignInJSONBody defines parameters for PostSignIn. -type PostSignInJSONBody struct { +// PostAuthSignInJSONBody defines parameters for PostAuthSignIn. +type PostAuthSignInJSONBody struct { Nickname string `json:"nickname"` Pass string `json:"pass"` } -// PostSignUpJSONBody defines parameters for PostSignUp. -type PostSignUpJSONBody struct { +// PostAuthSignUpJSONBody defines parameters for PostAuthSignUp. +type PostAuthSignUpJSONBody struct { Nickname string `json:"nickname"` Pass string `json:"pass"` } -// PostSignInJSONRequestBody defines body for PostSignIn for application/json ContentType. -type PostSignInJSONRequestBody PostSignInJSONBody +// PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. +type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody -// PostSignUpJSONRequestBody defines body for PostSignUp for application/json ContentType. -type PostSignUpJSONRequestBody PostSignUpJSONBody +// PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. +type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { // Sign in a user and return JWT - // (POST /sign-in) - PostSignIn(c *gin.Context) + // (POST /auth/sign-in) + PostAuthSignIn(c *gin.Context) // Sign up a new user - // (POST /sign-up) - PostSignUp(c *gin.Context) + // (POST /auth/sign-up) + PostAuthSignUp(c *gin.Context) } // ServerInterfaceWrapper converts contexts to parameters. @@ -50,8 +50,8 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) -// PostSignIn operation middleware -func (siw *ServerInterfaceWrapper) PostSignIn(c *gin.Context) { +// PostAuthSignIn operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { for _, middleware := range siw.HandlerMiddlewares { middleware(c) @@ -60,11 +60,11 @@ func (siw *ServerInterfaceWrapper) PostSignIn(c *gin.Context) { } } - siw.Handler.PostSignIn(c) + siw.Handler.PostAuthSignIn(c) } -// PostSignUp operation middleware -func (siw *ServerInterfaceWrapper) PostSignUp(c *gin.Context) { +// PostAuthSignUp operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { for _, middleware := range siw.HandlerMiddlewares { middleware(c) @@ -73,7 +73,7 @@ func (siw *ServerInterfaceWrapper) PostSignUp(c *gin.Context) { } } - siw.Handler.PostSignUp(c) + siw.Handler.PostAuthSignUp(c) } // GinServerOptions provides options for the Gin server. @@ -103,54 +103,54 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } - router.POST(options.BaseURL+"/sign-in", wrapper.PostSignIn) - router.POST(options.BaseURL+"/sign-up", wrapper.PostSignUp) + router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) + router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) } -type PostSignInRequestObject struct { - Body *PostSignInJSONRequestBody +type PostAuthSignInRequestObject struct { + Body *PostAuthSignInJSONRequestBody } -type PostSignInResponseObject interface { - VisitPostSignInResponse(w http.ResponseWriter) error +type PostAuthSignInResponseObject interface { + VisitPostAuthSignInResponse(w http.ResponseWriter) error } -type PostSignIn200JSONResponse struct { +type PostAuthSignIn200JSONResponse struct { UserId int64 `json:"user_id"` UserName string `json:"user_name"` } -func (response PostSignIn200JSONResponse) VisitPostSignInResponse(w http.ResponseWriter) error { +func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type PostSignIn401JSONResponse struct { +type PostAuthSignIn401JSONResponse struct { Error *string `json:"error,omitempty"` } -func (response PostSignIn401JSONResponse) VisitPostSignInResponse(w http.ResponseWriter) error { +func (response PostAuthSignIn401JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(401) return json.NewEncoder(w).Encode(response) } -type PostSignUpRequestObject struct { - Body *PostSignUpJSONRequestBody +type PostAuthSignUpRequestObject struct { + Body *PostAuthSignUpJSONRequestBody } -type PostSignUpResponseObject interface { - VisitPostSignUpResponse(w http.ResponseWriter) error +type PostAuthSignUpResponseObject interface { + VisitPostAuthSignUpResponse(w http.ResponseWriter) error } -type PostSignUp200JSONResponse struct { +type PostAuthSignUp200JSONResponse struct { UserId int64 `json:"user_id"` } -func (response PostSignUp200JSONResponse) VisitPostSignUpResponse(w http.ResponseWriter) error { +func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) @@ -160,11 +160,11 @@ func (response PostSignUp200JSONResponse) VisitPostSignUpResponse(w http.Respons // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Sign in a user and return JWT - // (POST /sign-in) - PostSignIn(ctx context.Context, request PostSignInRequestObject) (PostSignInResponseObject, error) + // (POST /auth/sign-in) + PostAuthSignIn(ctx context.Context, request PostAuthSignInRequestObject) (PostAuthSignInResponseObject, error) // Sign up a new user - // (POST /sign-up) - PostSignUp(ctx context.Context, request PostSignUpRequestObject) (PostSignUpResponseObject, error) + // (POST /auth/sign-up) + PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -179,11 +179,11 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } -// PostSignIn operation middleware -func (sh *strictHandler) PostSignIn(ctx *gin.Context) { - var request PostSignInRequestObject +// PostAuthSignIn operation middleware +func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { + var request PostAuthSignInRequestObject - var body PostSignInJSONRequestBody + var body PostAuthSignInJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { ctx.Status(http.StatusBadRequest) ctx.Error(err) @@ -192,10 +192,10 @@ func (sh *strictHandler) PostSignIn(ctx *gin.Context) { request.Body = &body handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostSignIn(ctx, request.(PostSignInRequestObject)) + return sh.ssi.PostAuthSignIn(ctx, request.(PostAuthSignInRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostSignIn") + handler = middleware(handler, "PostAuthSignIn") } response, err := handler(ctx, request) @@ -203,8 +203,8 @@ func (sh *strictHandler) PostSignIn(ctx *gin.Context) { if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostSignInResponseObject); ok { - if err := validResponse.VisitPostSignInResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(PostAuthSignInResponseObject); ok { + if err := validResponse.VisitPostAuthSignInResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -212,11 +212,11 @@ func (sh *strictHandler) PostSignIn(ctx *gin.Context) { } } -// PostSignUp operation middleware -func (sh *strictHandler) PostSignUp(ctx *gin.Context) { - var request PostSignUpRequestObject +// PostAuthSignUp operation middleware +func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { + var request PostAuthSignUpRequestObject - var body PostSignUpJSONRequestBody + var body PostAuthSignUpJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { ctx.Status(http.StatusBadRequest) ctx.Error(err) @@ -225,10 +225,10 @@ func (sh *strictHandler) PostSignUp(ctx *gin.Context) { request.Body = &body handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostSignUp(ctx, request.(PostSignUpRequestObject)) + return sh.ssi.PostAuthSignUp(ctx, request.(PostAuthSignUpRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostSignUp") + handler = middleware(handler, "PostAuthSignUp") } response, err := handler(ctx, request) @@ -236,8 +236,8 @@ func (sh *strictHandler) PostSignUp(ctx *gin.Context) { if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostSignUpResponseObject); ok { - if err := validResponse.VisitPostSignUpResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(PostAuthSignUpResponseObject); ok { + if err := validResponse.VisitPostAuthSignUpResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 5f3ebd6..239b03b 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -7,7 +7,7 @@ servers: - url: /auth paths: - /sign-up: + /auth/sign-up: post: summary: Sign up a new user tags: [Auth] @@ -38,7 +38,7 @@ paths: type: integer format: int64 - /sign-in: + /auth/sign-in: post: summary: Sign in a user and return JWT tags: [Auth] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1119335..0ae97c6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -47,10 +47,6 @@ 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 ac55abe..6fee512 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -53,7 +53,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken "exp": time.Now().Add(15 * time.Minute).Unix(), } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - accessToken, err = at.SignedString([]byte(s.JwtPrivateKey)) + accessToken, err = at.SignedString(s.JwtPrivateKey) if err != nil { return "", "", "", err } @@ -63,7 +63,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), } rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) - refreshToken, err = rt.SignedString([]byte(s.JwtPrivateKey)) + refreshToken, err = rt.SignedString(s.JwtPrivateKey) if err != nil { return "", "", "", err } @@ -78,7 +78,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken return accessToken, refreshToken, csrfToken, nil } -func (s Server) PostSignUp(ctx context.Context, req auth.PostSignUpRequestObject) (auth.PostSignUpResponseObject, error) { +func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { passhash, err := HashPassword(req.Body.Pass) if err != nil { log.Errorf("failed to hash password: %v", err) @@ -94,17 +94,17 @@ func (s Server) PostSignUp(ctx context.Context, req auth.PostSignUpRequestObject // TODO: check err and retyrn 400/500 } - return auth.PostSignUp200JSONResponse{ + return auth.PostAuthSignUp200JSONResponse{ UserId: user_id, }, nil } -func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject) (auth.PostSignInResponseObject, error) { +func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) if !ok { log.Print("failed to get gin context") // TODO: change to 500 - return auth.PostSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") + return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") } user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname) @@ -120,7 +120,7 @@ func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject } if !ok { err_msg := "invalid credentials" - return auth.PostSignIn401JSONResponse{ + return auth.PostAuthSignIn401JSONResponse{ Error: &err_msg, }, nil } @@ -135,9 +135,9 @@ 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, "/", "", false, false) + ginCtx.SetCookie("xsrf_token", csrfToken, 1209600, "/api", "", false, false) - result := auth.PostSignIn200JSONResponse{ + result := auth.PostAuthSignIn200JSONResponse{ UserId: user.ID, UserName: user.Nickname, } diff --git a/modules/auth/main.go b/modules/auth/main.go index 7305b7d..ef9b977 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -44,9 +44,8 @@ func main() { server := handlers.NewServer(queries, AppConfig.JwtPrivateKey) - log.Info("allow origins:", AppConfig.ServiceAddress) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, + AllowOrigins: []string{AppConfig.ServiceAddress}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 7f2807f..cad4f0f 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, rpcclient *rmq.RPCClient) *Server { +func NewServer(db *sqlc.Queries, publisher *rmq.Publisher, rpcclient *rmq.RPCClient) *Server { return &Server{ - db: db, - // publisher: publisher, + db: db, + publisher: publisher, RPCclient: rpcclient, } } @@ -73,14 +73,6 @@ 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 7aeeb11..300cc87 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -197,6 +197,7 @@ 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 995d5af..d6faade 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -485,39 +485,3 @@ 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 e7e6ec8..0cffdcf 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -46,28 +46,26 @@ func main() { r := gin.Default() - if len(AppConfig.AuthEnabled) > 0 && AppConfig.AuthEnabled != "false" { - r.Use(middleware.CSRFMiddleware()) - r.Use(middleware.JWTAuthMiddleware(AppConfig.JwtPrivateKey)) - } + r.Use(middleware.CSRFMiddleware()) + r.Use(middleware.JWTAuthMiddleware(AppConfig.JwtPrivateKey)) queries := sqlc.New(pool) - rmqConn, err := amqp091.Dial(AppConfig.RmqURL) + rmqConn, err := amqp091.Dial(AppConfig.rmqURL) if err != nil { log.Fatalf("Failed to connect to RabbitMQ: %v", err) } defer rmqConn.Close() + publisher := rmq.NewPublisher(rmqConn) rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second) - server := handlers.NewServer(queries, rpcClient) + server := handlers.NewServer(queries, publisher, rpcClient) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{AppConfig.ServiceAddress}, - // AllowOrigins: []string{"*"}, + AllowOrigins: []string{AppConfig.ServiceAddress}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, - AllowHeaders: []string{"Origin", "Content-Type", "Accept", "X-XSRF-TOKEN"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, MaxAge: 12 * time.Hour, diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 03502c4..ff41cb1 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -23,37 +23,6 @@ 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 25abbdb..52c1979 100644 --- a/modules/backend/rmq/rabbit.go +++ b/modules/backend/rmq/rabbit.go @@ -4,16 +4,13 @@ import ( "context" "encoding/json" "fmt" - "time" - oapi "nyanimedb/api" + "sync" + "time" 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"` @@ -23,6 +20,151 @@ 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 @@ -32,48 +174,37 @@ 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. Декларируем фиксированную очередь 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, + // 2. Создаём временную очередь для ответов + q, err := ch.QueueDeclare( + "", // auto name + false, // not durable + true, // exclusive + true, // auto-delete false, nil, ) if err != nil { - return fmt.Errorf("declare reply queue: %w", err) + return fmt.Errorf("reply queue: %w", err) } - // 4. Подписываемся на очередь ответов + // 3. Подписываемся на ответы msgs, err := ch.Consume( - replyQueue.Name, + q.Name, "", true, // auto-ack true, // exclusive @@ -82,28 +213,28 @@ func (c *RPCClient) Call( nil, ) if err != nil { - return fmt.Errorf("consume reply: %w", err) + return fmt.Errorf("consume: %w", err) } - // correlation ID - corrID := fmt.Sprintf("%d", time.Now().UnixNano()) + // 4. Готовим correlation ID + corrID := time.Now().UnixNano() - // 5. сериализация запроса + // 5. Сериализуем запрос body, err := json.Marshal(request) if err != nil { return fmt.Errorf("marshal request: %w", err) } - // 6. Публикация RPC-запроса + // 6. Публикуем запрос err = ch.Publish( "", - RPCQueueName, // ← фиксированная очередь! + requestQueue, false, false, amqp.Publishing{ ContentType: "application/json", - CorrelationId: corrID, - ReplyTo: replyQueue.Name, + CorrelationId: fmt.Sprintf("%d", corrID), + ReplyTo: q.Name, Timestamp: time.Now(), Body: body, }, @@ -113,17 +244,18 @@ func (c *RPCClient) Call( } // 7. Ждём ответ с таймаутом - timeoutCtx, cancel := context.WithTimeout(ctx, c.timeout) + ctx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() for { select { case msg := <-msgs: - if msg.CorrelationId == corrID { + if msg.CorrelationId == fmt.Sprintf("%d", corrID) { return json.Unmarshal(msg.Body, replyPayload) } - case <-timeoutCtx.Done(): - return fmt.Errorf("rpc timeout: %w", timeoutCtx.Err()) + // игнорируем другие сообщения (маловероятно, но возможно) + case <-ctx.Done(): + return ctx.Err() // timeout or cancelled } } } diff --git a/modules/backend/types.go b/modules/backend/types.go index ceaec4e..c4f70ed 100644 --- a/modules/backend/types.go +++ b/modules/backend/types.go @@ -6,6 +6,5 @@ type Config struct { DdUrl string `toml:"DbUrl" env:"DATABASE_URL"` JwtPrivateKey string `toml:"JwtPrivateKey" env:"JWT_PRIVATE_KEY"` LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` - RmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"` - AuthEnabled string `toml:"AuthEnabled" env:"AUTH_ENABLED"` + rmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"` } diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index d2b5573..40bb520 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -13,7 +13,6 @@ "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", - "react-cookie": "^8.0.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.4", "tailwindcss": "^4.1.17" @@ -1869,18 +1868,6 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, - "node_modules/@types/hoist-non-react-statics": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", - "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", - "license": "MIT", - "dependencies": { - "hoist-non-react-statics": "^3.3.0" - }, - "peerDependencies": { - "@types/react": "*" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1903,6 +1890,7 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -2536,6 +2524,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -3271,15 +3260,6 @@ "node": ">= 0.4" } }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", - "dependencies": { - "react-is": "^16.7.0" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4088,20 +4068,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-cookie": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-8.0.1.tgz", - "integrity": "sha512-QNdAd0MLuAiDiLcDU/2s/eyKmmfMHtjPUKJ2dZ/5CcQ9QKUium4B3o61/haq6PQl/YWFqC5PO8GvxeHKhy3GFA==", - "license": "MIT", - "dependencies": { - "@types/hoist-non-react-statics": "^3.3.6", - "hoist-non-react-statics": "^3.3.2", - "universal-cookie": "^8.0.0" - }, - "peerDependencies": { - "react": ">= 16.3.0" - } - }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", @@ -4115,12 +4081,6 @@ "react": "^19.2.0" } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4521,15 +4481,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/universal-cookie": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-8.0.1.tgz", - "integrity": "sha512-B6ks9FLLnP1UbPPcveOidfvB9pHjP+wekP2uRYB9YDfKVpvcjKgy1W5Zj+cEXJ9KTPnqOKGfVDQBmn8/YCQfRg==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.2" - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/modules/frontend/package.json b/modules/frontend/package.json index af07b41..e0b65ba 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -15,7 +15,6 @@ "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", - "react-cookie": "^8.0.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.4", "tailwindcss": "^4.1.17" diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 84c9086..95b59e3 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -6,10 +6,6 @@ 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 deleted file mode 100644 index 2de06ac..0000000 --- a/modules/frontend/src/api/client.gen.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 deleted file mode 100644 index c2a5190..0000000 --- a/modules/frontend/src/api/client/client.gen.ts +++ /dev/null @@ -1,301 +0,0 @@ -// 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 deleted file mode 100644 index b295ede..0000000 --- a/modules/frontend/src/api/client/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -// 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 deleted file mode 100644 index b4a499c..0000000 --- a/modules/frontend/src/api/client/types.gen.ts +++ /dev/null @@ -1,241 +0,0 @@ -// 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 deleted file mode 100644 index 4c48a9e..0000000 --- a/modules/frontend/src/api/client/utils.gen.ts +++ /dev/null @@ -1,332 +0,0 @@ -// 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 new file mode 100644 index 0000000..ec7b16a --- /dev/null +++ b/modules/frontend/src/api/core/ApiError.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: any; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} diff --git a/modules/frontend/src/api/core/ApiRequestOptions.ts b/modules/frontend/src/api/core/ApiRequestOptions.ts new file mode 100644 index 0000000..93143c3 --- /dev/null +++ b/modules/frontend/src/api/core/ApiRequestOptions.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiRequestOptions = { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record; + readonly cookies?: Record; + readonly headers?: Record; + readonly query?: Record; + readonly formData?: Record; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record; +}; diff --git a/modules/frontend/src/api/core/ApiResult.ts b/modules/frontend/src/api/core/ApiResult.ts new file mode 100644 index 0000000..ee1126e --- /dev/null +++ b/modules/frontend/src/api/core/ApiResult.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiResult = { + readonly url: string; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly body: any; +}; diff --git a/modules/frontend/src/api/core/CancelablePromise.ts b/modules/frontend/src/api/core/CancelablePromise.ts new file mode 100644 index 0000000..d70de92 --- /dev/null +++ b/modules/frontend/src/api/core/CancelablePromise.ts @@ -0,0 +1,131 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export class CancelError extends Error { + + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise implements Promise { + #isResolved: boolean; + #isRejected: boolean; + #isCancelled: boolean; + readonly #cancelHandlers: (() => void)[]; + readonly #promise: Promise; + #resolve?: (value: T | PromiseLike) => void; + #reject?: (reason?: any) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: any) => void, + onCancel: OnCancel + ) => void + ) { + this.#isResolved = false; + this.#isRejected = false; + this.#isCancelled = false; + this.#cancelHandlers = []; + this.#promise = new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + + const onResolve = (value: T | PromiseLike): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isResolved = true; + if (this.#resolve) this.#resolve(value); + }; + + const onReject = (reason?: any): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isRejected = true; + if (this.#reject) this.#reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this.#isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this.#isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this.#isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + get [Symbol.toStringTag]() { + return "Cancellable Promise"; + } + + public then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike) | null + ): Promise { + return this.#promise.then(onFulfilled, onRejected); + } + + public catch( + onRejected?: ((reason: any) => TResult | PromiseLike) | null + ): Promise { + return this.#promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise { + return this.#promise.finally(onFinally); + } + + public cancel(): void { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isCancelled = true; + if (this.#cancelHandlers.length) { + try { + for (const cancelHandler of this.#cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this.#cancelHandlers.length = 0; + if (this.#reject) this.#reject(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this.#isCancelled; + } +} diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts new file mode 100644 index 0000000..185e5c3 --- /dev/null +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -0,0 +1,32 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; + +type Resolver = (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 deleted file mode 100644 index f8a7326..0000000 --- a/modules/frontend/src/api/core/auth.gen.ts +++ /dev/null @@ -1,42 +0,0 @@ -// 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 deleted file mode 100644 index 552b50f..0000000 --- a/modules/frontend/src/api/core/bodySerializer.gen.ts +++ /dev/null @@ -1,100 +0,0 @@ -// 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 deleted file mode 100644 index 602715c..0000000 --- a/modules/frontend/src/api/core/params.gen.ts +++ /dev/null @@ -1,176 +0,0 @@ -// 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 deleted file mode 100644 index 8d99931..0000000 --- a/modules/frontend/src/api/core/pathSerializer.gen.ts +++ /dev/null @@ -1,181 +0,0 @@ -// 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 deleted file mode 100644 index d3bb683..0000000 --- a/modules/frontend/src/api/core/queryKeySerializer.gen.ts +++ /dev/null @@ -1,136 +0,0 @@ -// 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 new file mode 100644 index 0000000..1dc6fef --- /dev/null +++ b/modules/frontend/src/api/core/request.ts @@ -0,0 +1,323 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; +import FormData from 'form-data'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isDefined = (value: T | null | undefined): value is Exclude => { + return value !== undefined && value !== null; +}; + +export const isString = (value: any): value is string => { + return typeof value === 'string'; +}; + +export const isStringWithValue = (value: any): value is string => { + return isString(value) && value !== ''; +}; + +export const isBlob = (value: any): value is Blob => { + return ( + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +export const isFormData = (value: any): value is FormData => { + return value instanceof FormData; +}; + +export const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + +export const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +export const getQueryString = (params: Record): string => { + const qs: string[] = []; + + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach(v => { + process(key, v); + }); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; + + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); + + if (qs.length > 0) { + return `?${qs.join('&')}`; + } + + return ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; +}; + +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver = (options: ApiRequestOptions) => Promise; + +export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { + if (typeof resolver === 'function') { + return (resolver as Resolver)(options); + } + return resolver; +}; + +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) + .filter(([_, value]) => isDefined(value)) + .reduce((headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), {} as Record); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return headers; +}; + +export const getRequestBody = (options: ApiRequestOptions): any => { + if (options.body) { + return options.body; + } + return undefined; +}; + +export const sendRequest = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Record, + onCancel: OnCancel, + axiosClient: AxiosInstance +): Promise> => { + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, + headers, + data: body ?? formData, + method: options.method, + withCredentials: config.WITH_CREDENTIALS, + withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, + cancelToken: source.token, + }; + + onCancel(() => source.cancel('The user aborted a request.')); + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError; + if (axiosError.response) { + return axiosError.response; + } + throw error; + } +}; + +export const getResponseHeader = (response: AxiosResponse, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; + } + } + return undefined; +}; + +export const getResponseBody = (response: AxiosResponse): any => { + if (response.status !== 204) { + return response.data; + } + return undefined; +}; + +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError(options, result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @param axiosClient The axios client instance to use + * @returns CancelablePromise + * @throws ApiError + */ +export const request = (config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + if (!onCancel.isCancelled) { + const response = await sendRequest(config, options, url, body, formData, headers, onCancel, axiosClient); + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/modules/frontend/src/api/core/serverSentEvents.gen.ts b/modules/frontend/src/api/core/serverSentEvents.gen.ts deleted file mode 100644 index f8fd78e..0000000 --- a/modules/frontend/src/api/core/serverSentEvents.gen.ts +++ /dev/null @@ -1,264 +0,0 @@ -// 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 deleted file mode 100644 index 643c070..0000000 --- a/modules/frontend/src/api/core/types.gen.ts +++ /dev/null @@ -1,118 +0,0 @@ -// 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 deleted file mode 100644 index 0b5389d..0000000 --- a/modules/frontend/src/api/core/utils.gen.ts +++ /dev/null @@ -1,143 +0,0 @@ -// 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 c352c10..9013fc7 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -1,4 +1,28 @@ -// This file is auto-generated by @hey-api/openapi-ts +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export { ApiError } from './core/ApiError'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI } from './core/OpenAPI'; +export type { OpenAPIConfig } from './core/OpenAPI'; -export type * from './types.gen'; -export * from './sdk.gen'; +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'; diff --git a/modules/frontend/src/api/models/CursorObj.ts b/modules/frontend/src/api/models/CursorObj.ts new file mode 100644 index 0000000..f54abb1 --- /dev/null +++ b/modules/frontend/src/api/models/CursorObj.ts @@ -0,0 +1,9 @@ +/* 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 new file mode 100644 index 0000000..887bf2f --- /dev/null +++ b/modules/frontend/src/api/models/Image.ts @@ -0,0 +1,11 @@ +/* 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 new file mode 100644 index 0000000..ad9f930 --- /dev/null +++ b/modules/frontend/src/api/models/ReleaseSeason.ts @@ -0,0 +1,8 @@ +/* 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 new file mode 100644 index 0000000..9b453b7 --- /dev/null +++ b/modules/frontend/src/api/models/Review.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Review = Record; diff --git a/modules/frontend/src/api/models/StorageType.ts b/modules/frontend/src/api/models/StorageType.ts new file mode 100644 index 0000000..f6d086b --- /dev/null +++ b/modules/frontend/src/api/models/StorageType.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Image storage type + */ +export type StorageType = 's3' | 'local'; diff --git a/modules/frontend/src/api/models/Studio.ts b/modules/frontend/src/api/models/Studio.ts new file mode 100644 index 0000000..062695a --- /dev/null +++ b/modules/frontend/src/api/models/Studio.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Image } from './Image'; +export type Studio = { + id: number; + name: string; + poster?: Image; + description?: string; +}; + diff --git a/modules/frontend/src/api/models/Tag.ts b/modules/frontend/src/api/models/Tag.ts new file mode 100644 index 0000000..665c724 --- /dev/null +++ b/modules/frontend/src/api/models/Tag.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * A localized tag: keys are language codes (ISO 639-1), values are tag names + */ +export type Tag = Record; diff --git a/modules/frontend/src/api/models/Tags.ts b/modules/frontend/src/api/models/Tags.ts new file mode 100644 index 0000000..748f066 --- /dev/null +++ b/modules/frontend/src/api/models/Tags.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Tag } from './Tag'; +/** + * Array of localized tags + */ +export type Tags = Array; diff --git a/modules/frontend/src/api/models/Title.ts b/modules/frontend/src/api/models/Title.ts new file mode 100644 index 0000000..9ffdeb6 --- /dev/null +++ b/modules/frontend/src/api/models/Title.ts @@ -0,0 +1,31 @@ +/* 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 new file mode 100644 index 0000000..1c9385e --- /dev/null +++ b/modules/frontend/src/api/models/TitleSort.ts @@ -0,0 +1,8 @@ +/* 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 new file mode 100644 index 0000000..72e0261 --- /dev/null +++ b/modules/frontend/src/api/models/TitleStatus.ts @@ -0,0 +1,8 @@ +/* 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 new file mode 100644 index 0000000..969023f --- /dev/null +++ b/modules/frontend/src/api/models/User.ts @@ -0,0 +1,33 @@ +/* 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 new file mode 100644 index 0000000..42b7919 --- /dev/null +++ b/modules/frontend/src/api/models/UserTitle.ts @@ -0,0 +1,15 @@ +/* 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 new file mode 100644 index 0000000..2b223ce --- /dev/null +++ b/modules/frontend/src/api/models/UserTitleMini.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { UserTitleStatus } from './UserTitleStatus'; +export type UserTitleMini = { + user_id: number; + title_id: number; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; +}; + diff --git a/modules/frontend/src/api/models/UserTitleStatus.ts b/modules/frontend/src/api/models/UserTitleStatus.ts new file mode 100644 index 0000000..0a29626 --- /dev/null +++ b/modules/frontend/src/api/models/UserTitleStatus.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * User's title status + */ +export type UserTitleStatus = 'finished' | 'planned' | 'dropped' | 'in-progress'; diff --git a/modules/frontend/src/api/models/cursor.ts b/modules/frontend/src/api/models/cursor.ts new file mode 100644 index 0000000..5788e14 --- /dev/null +++ b/modules/frontend/src/api/models/cursor.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type cursor = string; diff --git a/modules/frontend/src/api/models/title_sort.ts b/modules/frontend/src/api/models/title_sort.ts new file mode 100644 index 0000000..69b01a7 --- /dev/null +++ b/modules/frontend/src/api/models/title_sort.ts @@ -0,0 +1,6 @@ +/* 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 deleted file mode 100644 index 5359156..0000000 --- a/modules/frontend/src/api/sdk.gen.ts +++ /dev/null @@ -1,110 +0,0 @@ -// 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 new file mode 100644 index 0000000..6898c46 --- /dev/null +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -0,0 +1,371 @@ +/* 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 userId User ID (primary key) + * @param requestBody + * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). + * @throws ApiError + */ + public static updateUser( + userId: number, + requestBody: { + /** + * ID of the user avatar (references `images.id`); set to `null` to remove avatar + */ + avatar_id?: number | null; + /** + * User email (must be unique and valid) + */ + mail?: string; + /** + * Username (alphanumeric + `_` or `-`, 3–16 chars) + */ + nickname?: string; + /** + * Display name + */ + disp_name?: string; + /** + * User description / bio + */ + user_desc?: string; + }, + ): CancelablePromise<User> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{user_id}', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)`, + 401: `Unauthorized — missing or invalid authentication token`, + 403: `Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)`, + 404: `User not found`, + 409: `Conflict — e.g., requested \`nickname\` or \`mail\` already taken by another user`, + 422: `Unprocessable Entity — semantic errors not caught by schema (e.g., invalid \`avatar_id\`)`, + 500: `Unknown server error`, + }, + }); + } + /** + * Get user titles + * @param userId + * @param cursor + * @param sort + * @param sortForward + * @param word + * @param status List of title statuses to filter + * @param watchStatus + * @param rating + * @param myRate + * @param releaseYear + * @param releaseSeason + * @param limit + * @param fields + * @returns any List of user titles + * @throws ApiError + */ + public static getUserTitles( + userId: string, + cursor?: string, + sort?: TitleSort, + sortForward: boolean = true, + word?: string, + status?: Array<TitleStatus>, + watchStatus?: Array<UserTitleStatus>, + rating?: number, + myRate?: number, + releaseYear?: number, + releaseSeason?: ReleaseSeason, + limit: number = 10, + fields: string = 'all', + ): CancelablePromise<{ + data: Array<UserTitle>; + cursor: CursorObj; + }> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + query: { + 'cursor': cursor, + 'sort': sort, + 'sort_forward': sortForward, + 'word': word, + 'status': status, + 'watch_status': watchStatus, + 'rating': rating, + 'my_rate': myRate, + 'release_year': releaseYear, + 'release_season': releaseSeason, + 'limit': limit, + 'fields': fields, + }, + errors: { + 400: `Request params are not correct`, + 404: `User not found`, + 500: `Unknown server error`, + }, + }); + } + /** + * Add a title to a user + * User adding title to list af watched, status required + * @param userId ID of the user to assign the title to + * @param requestBody + * @returns UserTitleMini Title successfully added to user + * @throws ApiError + */ + public static addUserTitle( + userId: number, + requestBody: { + title_id: number; + status: UserTitleStatus; + rate?: number; + }, + ): CancelablePromise<UserTitleMini> { + return __request(OpenAPI, { + method: 'POST', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid request body (missing fields, invalid types, etc.)`, + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to assign titles to this user`, + 404: `User or Title not found`, + 409: `Conflict — title already assigned to user (if applicable)`, + 500: `Internal server error`, + }, + }); + } + /** + * Get user title + * @param userId + * @param titleId + * @returns UserTitleMini User titles + * @throws ApiError + */ + public static getUserTitle( + userId: number, + titleId: number, + ): CancelablePromise<UserTitleMini> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}/titles/{title_id}', + path: { + 'user_id': userId, + 'title_id': titleId, + }, + errors: { + 400: `Request params are not correct`, + 404: `User or title not found`, + 500: `Unknown server error`, + }, + }); + } + /** + * Update a usertitle + * User updating title list of watched + * @param userId + * @param titleId + * @param requestBody + * @returns UserTitleMini Title successfully updated + * @throws ApiError + */ + public static updateUserTitle( + userId: number, + titleId: number, + requestBody: { + status?: UserTitleStatus; + rate?: number; + }, + ): CancelablePromise<UserTitleMini> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{user_id}/titles/{title_id}', + path: { + 'user_id': userId, + 'title_id': titleId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid request body (missing fields, invalid types, etc.)`, + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to update title`, + 404: `User or Title not found`, + 500: `Internal server error`, + }, + }); + } + /** + * Delete a usertitle + * User deleting title from list of watched + * @param userId + * @param titleId + * @returns any Title successfully deleted + * @throws ApiError + */ + public static deleteUserTitle( + userId: number, + titleId: number, + ): CancelablePromise<any> { + return __request(OpenAPI, { + method: 'DELETE', + url: '/users/{user_id}/titles/{title_id}', + path: { + 'user_id': userId, + 'title_id': titleId, + }, + errors: { + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to delete title`, + 404: `User or Title not found`, + 500: `Internal server error`, + }, + }); + } +} diff --git a/modules/frontend/src/api/types.gen.ts b/modules/frontend/src/api/types.gen.ts deleted file mode 100644 index ce4db4b..0000000 --- a/modules/frontend/src/api/types.gen.ts +++ /dev/null @@ -1,570 +0,0 @@ -// 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 79aa305..2d0edf8 100644 --- a/modules/frontend/src/auth/core/OpenAPI.ts +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://10.1.0.65:8081/auth', + BASE: '/auth', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/auth/services/AuthService.ts b/modules/frontend/src/auth/services/AuthService.ts index 74a8fa7..94578d8 100644 --- a/modules/frontend/src/auth/services/AuthService.ts +++ b/modules/frontend/src/auth/services/AuthService.ts @@ -12,17 +12,19 @@ export class AuthService { * @returns any Sign-up result * @throws ApiError */ - public static postSignUp( + public static postAuthSignUp( requestBody: { nickname: string; pass: string; }, ): CancelablePromise<{ - user_id: number; + success?: boolean; + error?: string | null; + user_id?: string | null; }> { return __request(OpenAPI, { method: 'POST', - url: '/sign-up', + url: '/auth/sign-up', body: requestBody, mediaType: 'application/json', }); @@ -33,18 +35,19 @@ export class AuthService { * @returns any Sign-in result with JWT * @throws ApiError */ - public static postSignIn( + public static postAuthSignIn( requestBody: { nickname: string; pass: string; }, ): CancelablePromise<{ - user_id: number; - user_name: string; + error?: string | null; + user_id?: string | null; + user_name?: string | null; }> { return __request(OpenAPI, { method: 'POST', - url: '/sign-in', + url: '/auth/sign-in', body: requestBody, mediaType: 'application/json', errors: { diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index 98fa868..0c9c741 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -1,16 +1,12 @@ import { useEffect, useState } from "react"; -// import { DefaultService } from "../../api"; -import { addUserTitle, deleteUserTitle, getUserTitle, updateUserTitle } from "../../api"; +import { DefaultService } from "../../api"; import type { UserTitleStatus } from "../../api"; -import { useCookies } from 'react-cookie'; - import { ClockIcon, CheckCircleIcon, PlayCircleIcon, XCircleIcon, } from "@heroicons/react/24/solid"; -// import { stat } from "fs"; // Статусы с иконками и подписью const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [ @@ -21,9 +17,6 @@ const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: s ]; export function TitleStatusControls({ titleId }: { titleId: number }) { - const [cookies] = useCookies(['xsrf_token']); - const xsrfToken = cookies['xsrf_token'] || null; - const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); const [loading, setLoading] = useState(false); @@ -33,13 +26,10 @@ 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 --- @@ -51,13 +41,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { try { // 1) Если кликнули на текущий статус — DELETE if (currentStatus === status) { - // await DefaultService.deleteUserTitle(userId, titleId); - await deleteUserTitle({path: { - user_id: userId, - title_id: titleId, - }, - headers: { "X-XSRF-TOKEN": xsrfToken }, - }) + await DefaultService.deleteUserTitle(userId, titleId); setCurrentStatus(null); return; } @@ -65,29 +49,15 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { // 2) Если другой статус — POST или PATCH if (!currentStatus) { // ещё нет записи — POST - // const added = await DefaultService.addUserTitle(userId, { - // title_id: titleId, - // status, - // }); - const added = await addUserTitle({ - body: { + const added = await DefaultService.addUserTitle(userId, { title_id: titleId, - status: status, - }, - path: {user_id: userId}, - headers: { "X-XSRF-TOKEN": xsrfToken }, + status, }); - - setCurrentStatus(added.data?.status ?? null); + setCurrentStatus(added.status); } else { // уже есть запись — PATCH - //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); + const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); + setCurrentStatus(updated.status); } } finally { setLoading(false); diff --git a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx index b848702..cde6037 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"; +import type { Title } from "../../api/models/Title"; 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 0bcb49d..e21c258 100644 --- a/modules/frontend/src/components/cards/TitleCardSquare.tsx +++ b/modules/frontend/src/components/cards/TitleCardSquare.tsx @@ -1,4 +1,5 @@ -import type { Title } from "../../api"; +// TitleCardSquare.tsx +import type { Title } from "../../api/models/Title"; export function TitleCardSquare({ title }: { title: Title }) { return ( diff --git a/modules/frontend/src/main.tsx b/modules/frontend/src/main.tsx index c225a33..bef5202 100644 --- a/modules/frontend/src/main.tsx +++ b/modules/frontend/src/main.tsx @@ -1,13 +1,10 @@ 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> - <CookiesProvider> - <App /> - </CookiesProvider> + <App /> </StrictMode>, ) diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx index 928766e..89ee88c 100644 --- a/modules/frontend/src/pages/LoginPage/LoginPage.tsx +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -17,23 +17,23 @@ export const LoginPage: React.FC = () => { try { if (isLogin) { - const res = await AuthService.postSignIn({ nickname, pass: password }); + const res = await AuthService.postAuthSignIn({ nickname, pass: password }); if (res.user_id && res.user_name) { // Сохраняем user_id и username в localStorage - localStorage.setItem("userId", res.user_id.toString()); + localStorage.setItem("userId", res.user_id); localStorage.setItem("username", res.user_name); navigate("/profile"); // редирект на профиль } else { - setError("Login failed"); + setError(res.error || "Login failed"); } } else { // SignUp оставляем без сохранения данных - const res = await AuthService.postSignUp({ nickname, pass: password }); + const res = await AuthService.postAuthSignUp({ nickname, pass: password }); if (res.user_id) { setIsLogin(true); // переключаемся на login после регистрации } else { - setError("Sign up failed"); + setError(res.error || "Sign up failed"); } } } catch (err: any) { diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index 0d9e297..01f9c49 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 { getTitle, type Title } from "../../api"; +import { DefaultService } from "../../api/services/DefaultService"; +import type { Title } from "../../api"; import { TitleStatusControls } from "../../components/TitleStatusControls/TitleStatusControls"; export default function TitlePage() { @@ -19,9 +19,8 @@ export default function TitlePage() { const fetchTitle = async () => { setLoading(true); try { - // const data = await DefaultService.getTitle(titleId, "all"); - const data = await getTitle({path: {title_id: titleId}}) - setTitle(data?.data ?? null); + const data = await DefaultService.getTitle(titleId, "all"); + setTitle(data); 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 481d116..ed55d8d 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 { getTitles, type CursorObj, type Title, type TitleSort } from "../../api"; +import type { CursorObj, Title, TitleSort } from "../../api"; import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; import { Link } from "react-router-dom"; import { type TitlesFilter, TitlesFilterPanel } from "../../components/TitlesFilterPanel/TitlesFilterPanel"; @@ -32,31 +32,37 @@ export default function TitlesPage() { }); const fetchPage = async (cursorObj: CursorObj | null) => { - const cursorStr = cursorObj - ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") - : undefined; + const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') : ""; - 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", - }, - }); + 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" + ); - return { - items: response.data?.data ?? [], - nextCursor: response.data?.cursor ?? null, - }; + 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; + } }; // Инициализация: загружаем сразу две страницы diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 1a8ba1e..7cc0db5 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, type UserTitle, type CursorObj, type TitleSort, getUsersId, getUserTitles } from "../../api"; +import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; import { Link } from "react-router-dom"; const PAGE_SIZE = 10; @@ -42,9 +42,8 @@ export default function UserPage({ userId }: UserPageProps) { if (!id) return; setLoadingUser(true); try { - // const result = await DefaultService.getUsersId(id, "all"); - const result = await getUsersId({path: {user_id: id}}) - setUser(result?.data ?? null); + const result = await DefaultService.getUsersId(id, "all"); + setUser(result); setErrorUser(null); } catch (err: any) { console.error(err); @@ -64,41 +63,25 @@ export default function UserPage({ userId }: UserPageProps) { : ""; try { - 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" - // ); + 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?.data?.length) return { items: [], nextCursor: null }; + if (!result?.data?.length) return { items: [], nextCursor: null }; - return { items: result.data?.data, nextCursor: result.data?.cursor ?? null }; + return { items: result.data, nextCursor: result.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 d6353d6..3499fe2 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -47,8 +47,6 @@ 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 b1ea282..842d58c 100644 --- a/sql/models.go +++ b/sql/models.go @@ -246,7 +246,6 @@ 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 0c17599..1cca986 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -129,7 +129,7 @@ func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, er const getTitleByID = `-- name: GetTitleByID :one SELECT - t.id, t.title_names, t.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, + t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( @@ -157,7 +157,6 @@ 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"` @@ -186,7 +185,6 @@ 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, @@ -640,87 +638,6 @@ 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