Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat add create user admin endpoint #966

Merged
merged 4 commits into from
Aug 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -40,6 +40,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 @@ -85,6 +85,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 @@ -245,6 +294,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 @@ -276,7 +334,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 @@ -401,7 +459,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
Loading