I have moderate Golang experience and lots of experience in other programming languages such as Java, Python, Rust, Scala, and others. I'm comfortable with building REST services and most of the other related topics, so the concept in general isn't new to me.
Background
I'm building a REST API in Golang using gorilla/mux
as an HTTP router, gorm
as an object-relational mapper to the database (which is MySQL 5.7), swaggo/swag
to auto-generate Swagger endpoints and models from code comments, and a few other libraries for JWT and other related boilerplate.
I have repeatedly run into circular import problems, they seem to be very easy to trigger, reminds me of Python but seems more easy to trigger, and so I've setup my codebase in a way where the only thing that is touching database models and Swagger models are the actual HTTP handlers themselves.
Directory Structure
My directory structure looks somewhat like this:
main.go
pkg/models/*.go (HTTP JSON models)
pkg/models/db/*.go (Gorm Database Models)
pkg/routes/*.go (HTTP Handlers)
HTTP Route Package Structure
I have a sort of nested routes structure, which has so far worked out for me. Every subpackage in routes has a function to configure the routes:
func ConfigureRoutes(r *mux.Router) {
r.Path("/create").HandlerFunc(CreateEventHandler).Methods("POST")
// register subpaths
bookings.ConfigureRoutes(r)
}
I have written middleware and a lot of little helper functions to make my life easier.
Generic Response
All JSON models are contained in a generic struct:
// Response The default wrapper response model used in all responses.
type Response struct {
// The HTTP status code of the response.
Status uint16 `json:"status" example:"200"`
// The error message, if any, associated with the response.
Message string `json:"message,omitempty" example:"Error message."`
// Data returned by the request, if any.
Data interface{} `json:"data,omitempty" example:""`
}
Database Models
Here is a database model in gorm
, existing at pkg/models/db/events.go
:
// Event An event booked at the hotel.
type Event struct {
Type uint8
Name string
Description string
Notes string
Start null.Time
End null.Time
MaxPerPerson uint32
Capacity uint32
Price decimal.Decimal // FIXME need something that uses MySQL DECIMAL type: https://dev.mysql.com/doc/refman/5.7/en/precision-math-decimal-characteristics.html
gorm.Model
}
HTTP Handlers
I have been able to build a lot of the API so far by just copying struct fields in HTTP handlers like so:
// EventCreateHandler godoc
// @Summary Create an event.
// @Accept json
// @Param event body models.AdminNewEventRequest true "New event data."
// @Produce json
// @Success 200 {object} models.Response{data=models.AdminNewEventResponse}
// @Failure 403 {object} models.Response
// @Security JWT
// @Router /admin/events/create [post]
func EventCreateHandler(w http.ResponseWriter, r *http.Request) {
if !request.IsAdmin(r) {
response.Unauthorized(w)
return
}
var reqBody models.AdminNewEventRequest
if err := request.JSONBody(r, &reqBody); err != nil {
response.BadRequest(w)
return
}
var event db.Event
event.Name = reqBody.Name
event.Description = reqBody.Description
event.Notes = reqBody.Notes
event.Start = null.TimeFrom(time.Time(reqBody.Start))
event.End = null.TimeFrom(time.Time(reqBody.End))
event.MaxPerPerson = reqBody.MaxPerPerson
event.Capacity = reqBody.Capacity
price, err := decimal.NewFromString(reqBody.Price)
if err != nil {
response.BadRequest(w, "Unable to parse price as decimal.")
return
}
event.Price = price
event.Type = reqBody.Type
conn := request.GetDB(r)
tx := conn.Save(&event)
if tx.RowsAffected == 0 {
w.WriteHeader(http.StatusInternalServerError)
return
}
response.Ok(w, models.AdminNewEventResponse{
ID: event.ID,
Name: event.Name,
Description: event.Description,
Notes: event.Notes,
Start: models.JSONDateTime(event.Start.ValueOrZero()),
End: models.JSONDateTime(event.End.ValueOrZero()),
MaxPerPerson: event.MaxPerPerson,
Capacity: event.Capacity,
Price: event.Price.String(),
Type: event.Type,
CreatedAt: models.JSONDateTime(event.CreatedAt),
UpdatedAt: models.JSONDateTime(event.UpdatedAt),
DeletedAt: models.JSONDateTime(event.DeletedAt.Time),
})
}
JSON Request/Response Models
For this particular data-type, I have many structs:
models.AdminNewEventRequest
: Contains all the fields except for ID and contains some fields which are optional, and usinggopkg.in/guregu/null.v4
for those fields which can be omitted.models.AdminNewEventResponse
: Contains all the fields including the ID and the auto-generated database timestamps (CreatedAt
,UpdatedAt
, etc.)models.AdminUpdateEventRequest
: Contains all the fields except for ID andCreatedAt
/UpdatedAt
, but every field definition is optional, as this is aPATCH
request, so each field is something likeName null.String
etc.models.AdminUpdateEventResponse
: Similar toAdminNewEventResponse
.models.AdminGetEventResponse
: Similar toAdminNewEventResponse
.models.AdminListEventsResponse
: A paginated container for a list of events.
Problem
- I have about 12 different data-types to model, so 12 different
gorm
models. - Each data-type has about four different HTTP handlers, so that means about 48 handlers at minimum, some have more than four.
- Each HTTP handler has at least one JSON model and up to two of them:
- For
GET
/DELETE
handlers, I only have a response model, likemodels.AdminGetEventResponse
- For
POST
/PATCH
handlers, I have both a request and a response model, likemodels.AdminUpdateEventRequest
andmodels.AdminUpdateEventResponse
- For
- The net result is that I have literally 88 JSON models.
Eighty-eight JSON models.
And because I fear triggering the circular import problem, in every handler, I'm copying every single field, line by line.
Updates are worse: I load the model from the database, and since every field in an update request is optional, I have to do this monstrosity:
var reqBody models.AdminUpdateEventRequest
// parse the json model from the request body
var dbModel db.Event
// load from the database
// oh lord here we go
if !reqBody.Name.IsZero() {
dbModel.Name = reqBody.Name.ValueOrZero()
}
// repeat for EVERY SINGLE FIELD
Question
Since I'm giving disparate views on every database model, I can't just de/serialize the database models to/from the request/responses; for example, a PATCH
request for updating a user includes an optional Password
field which will cause the KDF to generate a new salt/KDF config/hashed password and store it in the database, and this does not 1:1 match the database model, especially because every field is optional (null.String
, etc.). Since every JSON request/response model is not directly tied to the database model, I have to define every request/response model by hand, and in every HTTP handler, I'm literally just copying fields around everywhere and some of my database models have 25 fields.
Having 88 models for just the admin views on the 12 database models feels extremely excessive, and (optionally) copying every single field for these models for request to database model and database model to response is getting extremely tedious.
I might be able to reorganize things so that JSON models can refer to database models in part without circular imports, but I haven't done that yet because it'd be non-trivial refactor.
I feel like I'm doing something terribly wrong here, but I can't see another way to provide custom views on my database models to satisfy my API requirements. Perhaps I need to dive into reflection (*massive disappointment face*) or some kind of code generation system, but since this is my first big REST project in Golang, I don't know where to look or how these things are typically done.
- 12 data types.
- 88 admin request/response models.
- 58 HTTP handlers for various tasks.
It will only get worse as I add non-admin routes and JSON models.
Is there a better, faster way to be doing this?