diff --git a/actions/v2/users/address.go b/actions/v2/users/address.go new file mode 100644 index 000000000..dbde00573 --- /dev/null +++ b/actions/v2/users/address.go @@ -0,0 +1,56 @@ +package users + +import ( + "net/http" + + "github.com/bitcoin-sv/spv-wallet/api" + "github.com/bitcoin-sv/spv-wallet/engine/spverrors" + "github.com/bitcoin-sv/spv-wallet/engine/v2/addresses/addressesmodels" + "github.com/bitcoin-sv/spv-wallet/server/reqctx" + "github.com/gin-gonic/gin" +) + +// CreateAddress creates a new paymail address for the user. +func (s *APIUsers) CreateAddress(c *gin.Context) { + userContext := reqctx.GetUserContext(c) + userID, err := userContext.ShouldGetUserID() + if err != nil { + spverrors.ErrorResponse(c, err, reqctx.Logger(c)) + return + } + + var req api.RequestsCreatePaymailAddress + if err := c.ShouldBindJSON(&req); err != nil { + spverrors.ErrorResponse(c, err, reqctx.Logger(c)) + return + } + + fullAddress := req.Paymail.Alias + "@" + req.Paymail.Domain + + newAddress := &addressesmodels.NewAddress{ + UserID: userID, + Address: fullAddress, + CustomInstructions: nil, + } + + err = reqctx.Engine(c).AddressesService().Create(c.Request.Context(), newAddress) + if err != nil { + spverrors.ErrorResponse(c, err, reqctx.Logger(c)) + return + } + + c.JSON(http.StatusOK, &api.ResponsesPaymailAddress{ + Alias: req.Paymail.Alias, + Domain: req.Paymail.Domain, + Paymail: fullAddress, + PublicName: valueOrDefault(req.Paymail.PublicName, req.Paymail.Alias), + Avatar: valueOrDefault(req.Paymail.AvatarURL, ""), + }) +} + +func valueOrDefault(ptr *string, defaultValue string) string { + if ptr != nil { + return *ptr + } + return defaultValue +} diff --git a/actions/v2/users/address_test.go b/actions/v2/users/address_test.go new file mode 100644 index 000000000..92aabcdcb --- /dev/null +++ b/actions/v2/users/address_test.go @@ -0,0 +1,76 @@ +package users_test + +import ( + "testing" + + "github.com/bitcoin-sv/spv-wallet/actions/testabilities" + testengine "github.com/bitcoin-sv/spv-wallet/engine/testabilities" +) + +func TestCreateAddress(t *testing.T) { + givenForAllTests := testabilities.Given(t) + cleanup := givenForAllTests.StartedSPVWalletWithConfiguration( + testengine.WithV2(), + ) + defer cleanup() + + t.Run("create paymail address for user", func(t *testing.T) { + // given: + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForUser() + + res, _ := client.R(). + SetBody(`{ + "paymail": { + "alias": "test", + "domain": "spv-wallet.com", + "publicName": "Test User", + "avatarURL": "https://example.com/avatar.png" + } + }`). + Post("/api/v2/users/address") + + then.Response(res). + IsOK(). + WithJSONMatching(`{ + "alias": "test", + "domain": "spv-wallet.com", + "paymail": "test@spv-wallet.com", + "publicName": "Test User", + "avatar": "https://example.com/avatar.png", + "id": 0 + }`, nil) + }) + + t.Run("return unauthorized for admin", func(t *testing.T) { + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForAdmin() + + res, _ := client.R(). + SetBody(`{ + "paymail": { + "alias": "test", + "domain": "spv-wallet.com" + } + }`). + Post("/api/v2/users/address") + + then.Response(res).IsUnauthorizedForAdmin() + }) + + t.Run("return unauthorized for anonymous user", func(t *testing.T) { + given, then := testabilities.NewOf(givenForAllTests, t) + client := given.HttpClient().ForAnonymous() + + res, _ := client.R(). + SetBody(`{ + "paymail": { + "alias": "test", + "domain": "spv-wallet.com" + } + }`). + Post("/api/v2/users/address") + + then.Response(res).IsUnauthorized() + }) +} diff --git a/api/components/requests.yaml b/api/components/requests.yaml index 470789555..2f4c5eb4f 100644 --- a/api/components/requests.yaml +++ b/api/components/requests.yaml @@ -135,6 +135,14 @@ components: - to - satoshis + CreatePaymailAddress: + type: object + properties: + paymail: + $ref: "#/components/schemas/AddPaymail" + required: + - paymail + parameters: PageNumber: in: query diff --git a/api/components/responses.yaml b/api/components/responses.yaml index bb150398b..a3e9829f4 100644 --- a/api/components/responses.yaml +++ b/api/components/responses.yaml @@ -236,3 +236,10 @@ components: - $ref: "./errors.yaml#/components/schemas/BHSUnhealthy" - $ref: "./errors.yaml#/components/schemas/BHSBadURL" - $ref: "./errors.yaml#/components/schemas/BHSParsingResponse" + + PaymailAddress: + description: Paymail address created successfully + content: + application/json: + schema: + $ref: "./models.yaml#/components/schemas/Paymail" diff --git a/api/endpoints/user.yaml b/api/endpoints/user.yaml index 4557648c5..10d4be4cc 100644 --- a/api/endpoints/user.yaml +++ b/api/endpoints/user.yaml @@ -188,3 +188,28 @@ paths: $ref: "../components/responses.yaml#/components/responses/GetMerklerootsConflict" 500: $ref: "../components/responses.yaml#/components/responses/GetMerklerootsInternalServerError" + + /api/v2/users/address: + post: + summary: Create Paymail Address + operationId: createAddress + security: + - XPubAuth: + - user + requestBody: + required: true + content: + application/json: + schema: + $ref: "../components/requests.yaml#/components/schemas/CreatePaymailAddress" + responses: + "200": + $ref: "../components/responses.yaml#/components/responses/PaymailAddress" + "400": + $ref: "../components/responses.yaml#/components/responses/UserBadRequest" + "401": + $ref: "../components/responses.yaml#/components/responses/UserNotAuthorized" + "500": + $ref: "../components/responses.yaml#/components/responses/InternalServerError" + tags: + - User diff --git a/api/gen.api.go b/api/gen.api.go index 9cd33c609..fe58c83d1 100644 --- a/api/gen.api.go +++ b/api/gen.api.go @@ -43,6 +43,9 @@ type ServerInterface interface { // Create transaction outline // (POST /api/v2/transactions/outlines) CreateTransactionOutline(c *gin.Context, params CreateTransactionOutlineParams) + // Create Paymail Address + // (POST /api/v2/users/address) + CreateAddress(c *gin.Context) // Get current user // (GET /api/v2/users/current) CurrentUser(c *gin.Context) @@ -311,6 +314,21 @@ func (siw *ServerInterfaceWrapper) CreateTransactionOutline(c *gin.Context) { siw.Handler.CreateTransactionOutline(c, params) } +// CreateAddress operation middleware +func (siw *ServerInterfaceWrapper) CreateAddress(c *gin.Context) { + + c.Set(XPubAuthScopes, []string{"user"}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.CreateAddress(c) +} + // CurrentUser operation middleware func (siw *ServerInterfaceWrapper) CurrentUser(c *gin.Context) { @@ -363,5 +381,6 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/api/v2/operations/search", wrapper.SearchOperations) router.POST(options.BaseURL+"/api/v2/transactions", wrapper.RecordTransactionOutline) router.POST(options.BaseURL+"/api/v2/transactions/outlines", wrapper.CreateTransactionOutline) + router.POST(options.BaseURL+"/api/v2/users/address", wrapper.CreateAddress) router.GET(options.BaseURL+"/api/v2/users/current", wrapper.CurrentUser) } diff --git a/api/gen.api.yaml b/api/gen.api.yaml index ce2ac833d..d5beb9a6e 100644 --- a/api/gen.api.yaml +++ b/api/gen.api.yaml @@ -273,6 +273,30 @@ paths: summary: Create transaction outline tags: - Transactions + /api/v2/users/address: + post: + operationId: createAddress + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/requests_CreatePaymailAddress' + required: true + responses: + "200": + $ref: '#/components/responses/responses_PaymailAddress' + "400": + $ref: '#/components/responses/responses_UserBadRequest' + "401": + $ref: '#/components/responses/responses_UserNotAuthorized' + "500": + $ref: '#/components/responses/responses_InternalServerError' + security: + - XPubAuth: + - user + summary: Create Paymail Address + tags: + - User /api/v2/users/current: get: description: This endpoint return balance of current authenticated user @@ -468,6 +492,12 @@ components: schema: $ref: '#/components/schemas/errors_AdminAuthorization' description: Security requirements failed + responses_PaymailAddress: + content: + application/json: + schema: + $ref: '#/components/schemas/models_Paymail' + description: Paymail address created successfully responses_RecordTransactionBadRequest: content: application/json: @@ -1312,6 +1342,13 @@ components: - alias - domain type: object + requests_CreatePaymailAddress: + properties: + paymail: + $ref: '#/components/schemas/requests_AddPaymail' + required: + - paymail + type: object requests_CreateUser: properties: paymail: diff --git a/api/gen.models.go b/api/gen.models.go index aebde3e9d..d9dcd64a8 100644 --- a/api/gen.models.go +++ b/api/gen.models.go @@ -597,6 +597,11 @@ type RequestsAddPaymail struct { PublicName *string `json:"publicName,omitempty"` } +// RequestsCreatePaymailAddress defines model for requests_CreatePaymailAddress. +type RequestsCreatePaymailAddress struct { + Paymail RequestsAddPaymail `json:"paymail"` +} + // RequestsCreateUser defines model for requests_CreateUser. type RequestsCreateUser struct { Paymail *RequestsAddPaymail `json:"paymail,omitempty"` @@ -754,6 +759,9 @@ type ResponsesNotAuthorized = ErrorsAnyAuthorization // ResponsesNotAuthorizedToAdminEndpoint defines model for responses_NotAuthorizedToAdminEndpoint. type ResponsesNotAuthorizedToAdminEndpoint = ErrorsAdminAuthorization +// ResponsesPaymailAddress defines model for responses_PaymailAddress. +type ResponsesPaymailAddress = ModelsPaymail + // ResponsesRecordTransactionBadRequest defines model for responses_RecordTransactionBadRequest. type ResponsesRecordTransactionBadRequest struct { union json.RawMessage @@ -827,6 +835,9 @@ type RecordTransactionOutlineJSONRequestBody = RequestsTransactionOutline // CreateTransactionOutlineJSONRequestBody defines body for CreateTransactionOutline for application/json ContentType. type CreateTransactionOutlineJSONRequestBody = RequestsTransactionSpecification +// CreateAddressJSONRequestBody defines body for CreateAddress for application/json ContentType. +type CreateAddressJSONRequestBody = RequestsCreatePaymailAddress + // AsErrorsUserAuthOnNonUserEndpoint returns the union data inside the ErrorsAdminAuthorization as a ErrorsUserAuthOnNonUserEndpoint func (t ErrorsAdminAuthorization) AsErrorsUserAuthOnNonUserEndpoint() (ErrorsUserAuthOnNonUserEndpoint, error) { var body ErrorsUserAuthOnNonUserEndpoint diff --git a/api/manualtests/client/client.gen.go b/api/manualtests/client/client.gen.go index b1645aa4c..2e8f592d8 100644 --- a/api/manualtests/client/client.gen.go +++ b/api/manualtests/client/client.gen.go @@ -604,6 +604,11 @@ type RequestsAddPaymail struct { PublicName *string `json:"publicName,omitempty"` } +// RequestsCreatePaymailAddress defines model for requests_CreatePaymailAddress. +type RequestsCreatePaymailAddress struct { + Paymail RequestsAddPaymail `json:"paymail"` +} + // RequestsCreateUser defines model for requests_CreateUser. type RequestsCreateUser struct { Paymail *RequestsAddPaymail `json:"paymail,omitempty"` @@ -761,6 +766,9 @@ type ResponsesNotAuthorized = ErrorsAnyAuthorization // ResponsesNotAuthorizedToAdminEndpoint defines model for responses_NotAuthorizedToAdminEndpoint. type ResponsesNotAuthorizedToAdminEndpoint = ErrorsAdminAuthorization +// ResponsesPaymailAddress defines model for responses_PaymailAddress. +type ResponsesPaymailAddress = ModelsPaymail + // ResponsesRecordTransactionBadRequest defines model for responses_RecordTransactionBadRequest. type ResponsesRecordTransactionBadRequest struct { union json.RawMessage @@ -834,6 +842,9 @@ type RecordTransactionOutlineJSONRequestBody = RequestsTransactionOutline // CreateTransactionOutlineJSONRequestBody defines body for CreateTransactionOutline for application/json ContentType. type CreateTransactionOutlineJSONRequestBody = RequestsTransactionSpecification +// CreateAddressJSONRequestBody defines body for CreateAddress for application/json ContentType. +type CreateAddressJSONRequestBody = RequestsCreatePaymailAddress + // AsErrorsUserAuthOnNonUserEndpoint returns the union data inside the ErrorsAdminAuthorization as a ErrorsUserAuthOnNonUserEndpoint func (t ErrorsAdminAuthorization) AsErrorsUserAuthOnNonUserEndpoint() (ErrorsUserAuthOnNonUserEndpoint, error) { var body ErrorsUserAuthOnNonUserEndpoint @@ -2204,6 +2215,11 @@ type ClientInterface interface { CreateTransactionOutline(ctx context.Context, params *CreateTransactionOutlineParams, body CreateTransactionOutlineJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // CreateAddressWithBody request with any body + CreateAddressWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreateAddress(ctx context.Context, body CreateAddressJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // CurrentUser request CurrentUser(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) } @@ -2376,6 +2392,30 @@ func (c *Client) CreateTransactionOutline(ctx context.Context, params *CreateTra return c.Client.Do(req) } +func (c *Client) CreateAddressWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateAddressRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateAddress(ctx context.Context, body CreateAddressJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateAddressRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) CurrentUser(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewCurrentUserRequest(c.Server) if err != nil { @@ -2861,6 +2901,46 @@ func NewCreateTransactionOutlineRequestWithBody(server string, params *CreateTra return req, nil } +// NewCreateAddressRequest calls the generic CreateAddress builder with application/json body +func NewCreateAddressRequest(server string, body CreateAddressJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreateAddressRequestWithBody(server, "application/json", bodyReader) +} + +// NewCreateAddressRequestWithBody generates requests for CreateAddress with any type of body +func NewCreateAddressRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/users/address") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewCurrentUserRequest generates requests for CurrentUser func NewCurrentUserRequest(server string) (*http.Request, error) { var err error @@ -2969,6 +3049,11 @@ type ClientWithResponsesInterface interface { CreateTransactionOutlineWithResponse(ctx context.Context, params *CreateTransactionOutlineParams, body CreateTransactionOutlineJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateTransactionOutlineResponse, error) + // CreateAddressWithBodyWithResponse request with any body + CreateAddressWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateAddressResponse, error) + + CreateAddressWithResponse(ctx context.Context, body CreateAddressJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateAddressResponse, error) + // CurrentUserWithResponse request CurrentUserWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*CurrentUserResponse, error) } @@ -3321,6 +3406,41 @@ func (r CreateTransactionOutlineResponse) Bytes() []byte { return r.Body } +type CreateAddressResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ResponsesPaymailAddress + JSON400 *ResponsesUserBadRequest + JSON401 *ResponsesUserNotAuthorized + JSON500 *ResponsesInternalServerError +} + +// Status returns HTTPResponse.Status +func (r CreateAddressResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateAddressResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// HTTPResponse returns http.Response from which this response was parsed. +func (r CreateAddressResponse) Response() *http.Response { + return r.HTTPResponse +} + +// Bytes is a convenience method to retrieve the raw bytes from the HTTP response +func (r CreateAddressResponse) Bytes() []byte { + return r.Body +} + type CurrentUserResponse struct { Body []byte HTTPResponse *http.Response @@ -3477,6 +3597,23 @@ func (c *ClientWithResponses) CreateTransactionOutlineWithResponse(ctx context.C return ParseCreateTransactionOutlineResponse(rsp) } +// CreateAddressWithBodyWithResponse request with arbitrary body returning *CreateAddressResponse +func (c *ClientWithResponses) CreateAddressWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateAddressResponse, error) { + rsp, err := c.CreateAddressWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateAddressResponse(rsp) +} + +func (c *ClientWithResponses) CreateAddressWithResponse(ctx context.Context, body CreateAddressJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateAddressResponse, error) { + rsp, err := c.CreateAddress(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateAddressResponse(rsp) +} + // CurrentUserWithResponse request returning *CurrentUserResponse func (c *ClientWithResponses) CurrentUserWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*CurrentUserResponse, error) { rsp, err := c.CurrentUser(ctx, reqEditors...) @@ -3942,6 +4079,53 @@ func ParseCreateTransactionOutlineResponse(rsp *http.Response) (*CreateTransacti return response, nil } +// ParseCreateAddressResponse parses an HTTP response from a CreateAddressWithResponse call +func ParseCreateAddressResponse(rsp *http.Response) (*CreateAddressResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateAddressResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ResponsesPaymailAddress + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ResponsesUserBadRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ResponsesUserNotAuthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ResponsesInternalServerError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseCurrentUserResponse parses an HTTP response from a CurrentUserWithResponse call func ParseCurrentUserResponse(rsp *http.Response) (*CurrentUserResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body)