Skip to content

Commit

Permalink
Merge pull request #966 from teamhanko/feat-add-user-admin-endpoint
Browse files Browse the repository at this point in the history
Feat add create user admin endpoint
  • Loading branch information
FreddyDevelop authored Aug 14, 2023
2 parents 1ba2ab1 + 7b861ae commit 6c9dad3
Show file tree
Hide file tree
Showing 7 changed files with 293 additions and 2 deletions.
6 changes: 6 additions & 0 deletions backend/dto/admin/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,9 @@ func FromEmailModel(email *models.Email) *Email {
UpdatedAt: email.UpdatedAt,
}
}

type CreateEmail struct {
Address string `json:"address" validate:"required,email"`
IsPrimary bool `json:"is_primary"`
IsVerified bool `json:"is_verified"`
}
6 changes: 6 additions & 0 deletions backend/dto/admin/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,9 @@ func FromUserModel(model models.User) User {
UpdatedAt: model.UpdatedAt,
}
}

type CreateUser struct {
ID uuid.UUID `json:"id"`
Emails []CreateEmail `json:"emails" validate:"required,gte=1,unique=Address,dive"`
CreatedAt time.Time `json:"created_at"`
}
4 changes: 4 additions & 0 deletions backend/dto/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ func (cv *CustomValidator) Validate(i interface{}) error {
vErrs[i] = fmt.Sprintf("%s must be a valid uuid4", err.Field())
case "url":
vErrs[i] = fmt.Sprintf("%s must be a valid URL", err.Field())
case "gte":
vErrs[i] = fmt.Sprintf("length of %s must be greater or equal to %v", err.Field(), err.Param())
case "unique":
vErrs[i] = fmt.Sprintf("%s entries are not unique", err.Field())
default:
vErrs[i] = fmt.Sprintf("something wrong on %s; %s", err.Field(), err.Tag())
}
Expand Down
1 change: 1 addition & 0 deletions backend/handler/admin_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func NewAdminRouter(cfg *config.Config, persister persistence.Persister, prometh

user := g.Group("/users")
user.GET("", userHandler.List)
user.POST("", userHandler.Create)
user.GET("/:id", userHandler.Get)
user.DELETE("/:id", userHandler.Delete)

Expand Down
122 changes: 122 additions & 0 deletions backend/handler/user_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@ package handler

import (
"fmt"
"github.com/go-sql-driver/mysql"
"github.com/gobuffalo/pop/v6"
"github.com/gofrs/uuid"
"github.com/jackc/pgconn"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/teamhanko/hanko/backend/dto"
"github.com/teamhanko/hanko/backend/dto/admin"
"github.com/teamhanko/hanko/backend/pagination"
"github.com/teamhanko/hanko/backend/persistence"
"github.com/teamhanko/hanko/backend/persistence/models"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)

type UserHandlerAdmin struct {
Expand Down Expand Up @@ -130,3 +136,119 @@ func (h *UserHandlerAdmin) Get(c echo.Context) error {

return c.JSON(http.StatusOK, admin.FromUserModel(*user))
}

func (h *UserHandlerAdmin) Create(c echo.Context) error {
var body admin.CreateUser
if err := (&echo.DefaultBinder{}).BindBody(c, &body); err != nil {
return dto.ToHttpError(err)
}

if err := c.Validate(body); err != nil {
return dto.ToHttpError(err)
}

// if no userID is provided, create a new one
if body.ID.IsNil() {
userId, err := uuid.NewV4()
if err != nil {
return fmt.Errorf("failed to create new userId: %w", err)
}
body.ID = userId
}

// check that only one email is marked as primary
primaryEmails := 0
for _, email := range body.Emails {
if email.IsPrimary {
primaryEmails++
}
}

if primaryEmails == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "at least one primary email must be provided")
} else if primaryEmails > 1 {
return echo.NewHTTPError(http.StatusBadRequest, "only one primary email is allowed")
}

err := h.persister.GetConnection().Transaction(func(tx *pop.Connection) error {
u := models.User{
ID: body.ID,
CreatedAt: body.CreatedAt,
}

err := tx.Create(&u)
if err != nil {
var pgErr *pgconn.PgError
var mysqlErr *mysql.MySQLError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
return echo.NewHTTPError(http.StatusConflict, fmt.Errorf("failed to create user with id '%v': %w", u.ID, fmt.Errorf("user already exists")))
}
} else if errors.As(err, &mysqlErr) {
if mysqlErr.Number == 1062 {
return echo.NewHTTPError(http.StatusConflict, fmt.Errorf("failed to create user with id '%v': %w", u.ID, fmt.Errorf("user already exists")))
}
}
return fmt.Errorf("failed to create user with id '%v': %w", u.ID, err)
}

now := time.Now()
for _, email := range body.Emails {
emailId, _ := uuid.NewV4()
mail := models.Email{
ID: emailId,
UserID: &u.ID,
Address: strings.ToLower(email.Address),
Verified: email.IsVerified,
CreatedAt: now,
UpdatedAt: now,
}

err := tx.Create(&mail)
if err != nil {
var pgErr *pgconn.PgError
var mysqlErr *mysql.MySQLError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
return echo.NewHTTPError(http.StatusConflict, fmt.Errorf("failed to create email '%s' for user '%v': %w", mail.Address, u.ID, fmt.Errorf("email already exists")))
}
} else if errors.As(err, &mysqlErr) {
if mysqlErr.Number == 1062 {
return echo.NewHTTPError(http.StatusConflict, fmt.Errorf("failed to create email '%s' for user '%v': %w", mail.Address, u.ID, fmt.Errorf("email already exists")))
}
}
return fmt.Errorf("failed to create email '%s' for user '%v': %w", mail.Address, u.ID, err)
}

if email.IsPrimary {
primary := models.PrimaryEmail{
UserID: u.ID,
EmailID: mail.ID,
}
err := tx.Create(&primary)
if err != nil {
return fmt.Errorf("failed to set email '%s' as primary for user '%v': %w", mail.Address, u.ID, err)
}
}
}
return nil
})

if httpError, ok := err.(*echo.HTTPError); ok {
return httpError
} else if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err)
}

p := h.persister.GetUserPersister()
user, err := p.Get(body.ID)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}

if user == nil {
return echo.NewHTTPError(http.StatusNotFound, "user not found")
}

return c.JSON(http.StatusOK, admin.FromUserModel(*user))
}
94 changes: 94 additions & 0 deletions backend/handler/user_admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/teamhanko/hanko/backend/test"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

Expand Down Expand Up @@ -153,3 +154,96 @@ func (s *userAdminSuite) TestUserHandlerAdmin_List_InvalidPaginationParam() {

s.Equal(http.StatusBadRequest, rec.Code)
}

func (s *userAdminSuite) TestUserHandlerAdmin_Create() {
if testing.Short() {
s.T().Skip("skipping test in short mode.")
}

e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil)

tests := []struct {
name string
body string
expectedStatusCode int
}{
{
name: "success",
body: `{"emails": [{"address": "[email protected]", "is_primary": true}]}`,
expectedStatusCode: http.StatusOK,
},
{
name: "success with user id",
body: `{"id": "98a46ea2-51f6-4e30-bd29-8272de77c8c8", "emails": [{"address": "[email protected]", "is_primary": true}]}`,
expectedStatusCode: http.StatusOK,
},
{
name: "success with multiple emails",
body: `{"emails": [{"address": "[email protected]", "is_primary": true}, {"address": "[email protected]"}]}`,
expectedStatusCode: http.StatusOK,
},
{
name: "success with created_at",
body: `{"emails": [{"address": "[email protected]", "is_primary": true}], "created_at": "2023-06-07T13:42:49.369489Z"}`,
expectedStatusCode: http.StatusOK,
},
{
name: "with already existing user id",
body: `{"id": "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5", "emails": [{"address": "[email protected]", "is_primary": true}]}`,
expectedStatusCode: http.StatusConflict,
},
{
name: "with non uuid v4 id",
body: `{"id": "customId", "emails": [{"address": "[email protected]", "is_primary": true}]}`,
expectedStatusCode: http.StatusBadRequest,
},
{
name: "with no emails",
body: `{"id": "98a46ea2-51f6-4e30-bd29-8272de77c8c8", "emails": []}`,
expectedStatusCode: http.StatusBadRequest,
},
{
name: "with missing emails",
body: `{"id": "98a46ea2-51f6-4e30-bd29-8272de77c8c8"}`,
expectedStatusCode: http.StatusBadRequest,
},
{
name: "with no primary email",
body: `{"emails": [{"address": "[email protected]"}]}`,
expectedStatusCode: http.StatusBadRequest,
},
{
name: "with multiple primary emails",
body: `{"emails": [{"address": "[email protected]", "is_primary": true"}, {"address": "[email protected]", "is_primary": true"}]}`,
expectedStatusCode: http.StatusBadRequest,
},
{
name: "with non unique emails",
body: `{"emails": [{"address": "[email protected]", "is_primary": true"}, {"address": "[email protected]"}]}`,
expectedStatusCode: http.StatusBadRequest,
},
{
name: "with already existing email",
body: `{"emails": [{"address": "[email protected]", "is_primary": true}]}`,
expectedStatusCode: http.StatusConflict,
},
}

for _, currentTest := range tests {
s.Run(currentTest.name, func() {
s.Require().NoError(s.Storage.MigrateUp())

err := s.LoadFixtures("../test/fixtures/user_admin")
s.Require().NoError(err)

req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(currentTest.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()

e.ServeHTTP(rec, req)

s.Equal(currentTest.expectedStatusCode, rec.Code)
s.Require().NoError(s.Storage.MigrateDown(-1))
})
}
}
62 changes: 60 additions & 2 deletions docs/static/spec/admin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,55 @@ paths:
$ref: '#/components/responses/InternalServerError'
'400':
$ref: '#/components/responses/BadRequest'
post:
summary: Create a new user
operationId: createUser
tags:
- User Management
requestBody:
content:
application/json:
schema:
type: object
properties:
id:
description: The ID of the new user
allOf:
- $ref: '#/components/schemas/UUID4'
emails:
description: The email addresses of the new user
type: array
items:
type: object
properties:
address:
type: string
is_primary:
type: boolean
is_verified:
type: boolean
required:
- address
- is_primary
created_at:
description: Time of creation of the user
type: string
format: date-time
required:
- emails
responses:
'200':
description: 'Details of the newly created user'
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
$ref: '#/components/responses/BadRequest'
'409':
$ref: '#/components/responses/Conflict'
'500':
$ref: '#/components/responses/InternalServerError'
/users/{id}:
delete:
summary: 'Delete a user by ID'
Expand Down Expand Up @@ -265,6 +314,15 @@ components:
example:
code: 400
message: Bad Request
Conflict:
description: Conflict
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
code: 409
message: Conflict
InternalServerError:
description: Internal server error
content:
Expand Down Expand Up @@ -296,7 +354,7 @@ components:
allOf:
- $ref: '#/components/schemas/UUID4'
created_at:
description: Time of creation of the the user
description: Time of creation of the user
type: string
format: date-time
updated_at:
Expand Down Expand Up @@ -421,7 +479,7 @@ components:
type: string
format: email
created_at:
description: Time of creation of the the audit log
description: Time of creation of the audit log
type: string
format: date-time
example: 2022-09-14T12:15:09.788784Z
Expand Down

0 comments on commit 6c9dad3

Please sign in to comment.