mirror of
https://github.com/kataras/iris.git
synced 2025-12-18 02:17:05 +00:00
reorganization of _examples and add some new examples such as iris+groupcache+mysql+docker
Former-commit-id: ed635ee95de7160cde11eaabc0c1dcb0e460a620
This commit is contained in:
17
_examples/database/mysql/Dockerfile
Normal file
17
_examples/database/mysql/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
# docker build -t myapp .
|
||||
# docker run --rm -it -p 8080:8080 myapp:latest
|
||||
FROM golang:latest AS builder
|
||||
RUN apt-get update
|
||||
ENV GO111MODULE=on \
|
||||
CGO_ENABLED=0 \
|
||||
GOOS=linux \
|
||||
GOARCH=amd64
|
||||
WORKDIR /go/src/app
|
||||
COPY go.mod .
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN go install
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /go/bin/myapp .
|
||||
ENTRYPOINT ["./myapp"]
|
||||
146
_examples/database/mysql/README.md
Normal file
146
_examples/database/mysql/README.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Iris, MySQL, Groupcache & Docker Example
|
||||
|
||||
## 📘 Endpoints
|
||||
|
||||
| Method | Path | Description | URL Parameters | Body | Auth Required |
|
||||
|--------|---------------------|------------------------|--------------- |----------------------------|---------------|
|
||||
| ANY | /token | Prints a new JWT Token | - | - | - |
|
||||
| GET | /category | Lists a set of Categories | offset, limit, order | - | - |
|
||||
| POST | /category | Creates a Category | - | JSON [Full Category](migration/api_category/create_category.json) | Token |
|
||||
| PUT | /category | Fully-Updates a Category | - | JSON [Full Category](migration/api_category/update_category.json) | Token |
|
||||
| PATCH | /category/{id} | Partially-Updates a Category | - | JSON [Partial Category](migration/api_category/update_partial_category.json) | Token |
|
||||
| GET | /category/{id} | Prints a Category | - | - | - |
|
||||
| DELETE | /category/{id} | Deletes a Category | - | - | Token |
|
||||
| GET | /category/{id}/products | Lists all Products from a Category | offset, limit, order | - | - |
|
||||
| POST | /category/{id}/products | (Batch) Assigns one or more Products to a Category | - | JSON [Products](migration/api_category/insert_products_category.json) | Token |
|
||||
| GET | /product | Lists a set of Products (cache) | offset, limit, order | - | - |
|
||||
| POST | /product | Creates a Product | - | JSON [Full Product](migration/api_product/create_product.json) | Token |
|
||||
| PUT | /product | Fully-Updates a Product | - | JSON [Full Product](migration/api_product/update_product.json) | Token |
|
||||
| PATCH | /product/{id} | Partially-Updates a Product | - | JSON [Partial Product](migration/api_product/update_partial_product.json) | Token |
|
||||
| GET | /product/{id} | Prints a Product (cache) | - | - | - |
|
||||
| DELETE | /product/{id} | Deletes a Product | - | - | Token |
|
||||
|
||||
|
||||
|
||||
## 📑 Responses
|
||||
|
||||
* **Content-Type** of `"application/json;charset=utf-8"`, snake_case naming (identical to the database columns)
|
||||
* **Status Codes**
|
||||
* 500 for server(db) errors,
|
||||
* 422 for validation errors, e.g.
|
||||
```json
|
||||
{
|
||||
"code": 422,
|
||||
"message": "required fields are missing",
|
||||
"timestamp": 1589306271
|
||||
}
|
||||
```
|
||||
* 400 for malformed syntax, e.g.
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "json: cannot unmarshal number -2 into Go struct field Category.position of type uint64",
|
||||
"timestamp": 1589306325
|
||||
}
|
||||
```
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "json: unknown field \"field_not_exists\"",
|
||||
"timestamp": 1589306367
|
||||
}
|
||||
```
|
||||
* 404 for entity not found, e.g.
|
||||
```json
|
||||
{
|
||||
"code": 404,
|
||||
"message": "entity does not exist",
|
||||
"timestamp": 1589306199
|
||||
}
|
||||
```
|
||||
* 304 for unaffected UPDATE or DELETE,
|
||||
* 201 for CREATE with the last inserted ID,
|
||||
* 200 for GET, UPDATE and DELETE
|
||||
|
||||
## ⚡ Get Started
|
||||
|
||||
Download the folder.
|
||||
|
||||
### Install (Docker)
|
||||
|
||||
Install [Docker](https://www.docker.com/) and execute the command below
|
||||
|
||||
```sh
|
||||
$ docker-compose up
|
||||
```
|
||||
|
||||
### Install (Manually)
|
||||
|
||||
Run `go build` or `go run main.go` and read below.
|
||||
|
||||
#### MySQL
|
||||
|
||||
Environment variables:
|
||||
|
||||
```sh
|
||||
MYSQL_USER=user_myapp
|
||||
MYSQL_PASSWORD=dbpassword
|
||||
MYSQL_HOST=localhost
|
||||
MYSQL_DATABASE=myapp
|
||||
```
|
||||
|
||||
Download the schema from [migration/myapp.sql](migration/myapp.sql) and execute it against your MySQL server instance.
|
||||
|
||||
```sql
|
||||
CREATE DATABASE IF NOT EXISTS myapp DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE myapp;
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
DROP TABLE IF EXISTS categories;
|
||||
CREATE TABLE categories (
|
||||
id int(11) NOT NULL AUTO_INCREMENT,
|
||||
title varchar(255) NOT NULL,
|
||||
position int(11) NOT NULL,
|
||||
image_url varchar(255) NOT NULL,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS products;
|
||||
CREATE TABLE products (
|
||||
id int(11) NOT NULL AUTO_INCREMENT,
|
||||
category_id int,
|
||||
title varchar(255) NOT NULL,
|
||||
image_url varchar(255) NOT NULL,
|
||||
price decimal(10,2) NOT NULL,
|
||||
description text NOT NULL,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id)
|
||||
);
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
```
|
||||
|
||||
### Requests
|
||||
|
||||
Some request bodies can be found at: [migration/api_category](migration/api_category) and [migration/api_product](migration/api_product). **However** I've provided a [postman.json](migration/myapp_postman.json) Collection that you can import to your [POSTMAN](https://learning.postman.com/docs/postman/collections/importing-and-exporting-data/#collections) and start playing with the API.
|
||||
|
||||
All write-access endpoints are "protected" via JWT, a client should "verify" itself. You'll need to manually take the **token** from the `http://localhost:8080/token` and put it on url parameter `?token=$token` or to the `Authentication: Bearer $token` request header.
|
||||
|
||||
### Unit or End-To-End Testing?
|
||||
|
||||
Testing is important. The code is written in a way that testing should be trivial (Pseudo/memory Database or SQLite local file could be integrated as well, for end-to-end tests a Docker image with MySQL and fire tests against that server). However, there is [nothing(?)](service/category_service_test.go) to see here.
|
||||
|
||||
## Packages
|
||||
|
||||
- https://github.com/dgrijalva/jwt-go (JWT parsing)
|
||||
- https://github.com/go-sql-driver/mysql (Go Driver for MySQL)
|
||||
- https://github.com/DATA-DOG/go-sqlmock (Testing DB see [service/category_service_test.go](service/category_service_test.go))
|
||||
- https://github.com/kataras/iris (HTTP)
|
||||
- https://github.com/mailgun/groupcache (Caching)
|
||||
97
_examples/database/mysql/api/api.go
Normal file
97
_examples/database/mysql/api/api.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Package api contains the handlers for our HTTP Endpoints.
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"myapp/service"
|
||||
"myapp/sql"
|
||||
|
||||
"github.com/kataras/iris/v12"
|
||||
"github.com/kataras/iris/v12/middleware/jwt"
|
||||
"github.com/kataras/iris/v12/middleware/requestid"
|
||||
)
|
||||
|
||||
// Router accepts any required dependencies and returns the main server's handler.
|
||||
func Router(db sql.Database, secret string) func(iris.Party) {
|
||||
return func(r iris.Party) {
|
||||
j := jwt.HMAC(15*time.Minute, secret)
|
||||
|
||||
r.Use(requestid.New())
|
||||
r.Use(verifyToken(j))
|
||||
// Generate a token for testing by navigating to
|
||||
// http://localhost:8080/token endpoint.
|
||||
// Copy-paste it to a ?token=$token url parameter or
|
||||
// open postman and put an Authentication: Bearer $token to get
|
||||
// access on create, update and delete endpoinds.
|
||||
|
||||
r.Get("/token", writeToken(j))
|
||||
|
||||
var (
|
||||
categoryService = service.NewCategoryService(db)
|
||||
productService = service.NewProductService(db)
|
||||
)
|
||||
|
||||
cat := r.Party("/category")
|
||||
{
|
||||
// TODO: new Use to add middlewares to specific
|
||||
// routes per METHOD ( we already have the per path through parties.)
|
||||
handler := NewCategoryHandler(categoryService)
|
||||
|
||||
cat.Get("/", handler.List)
|
||||
cat.Post("/", handler.Create)
|
||||
cat.Put("/", handler.Update)
|
||||
|
||||
cat.Get("/{id:int64}", handler.GetByID)
|
||||
cat.Patch("/{id:int64}", handler.PartialUpdate)
|
||||
cat.Delete("/{id:int64}", handler.Delete)
|
||||
/* You can also do something like that:
|
||||
cat.PartyFunc("/{id:int64}", func(c iris.Party) {
|
||||
c.Get("/", handler.GetByID)
|
||||
c.Post("/", handler.PartialUpdate)
|
||||
c.Delete("/", handler.Delete)
|
||||
})
|
||||
*/
|
||||
|
||||
cat.Get("/{id:int64}/products", handler.ListProducts)
|
||||
cat.Post("/{id:int64}/products", handler.InsertProducts(productService))
|
||||
}
|
||||
|
||||
prod := r.Party("/product")
|
||||
{
|
||||
handler := NewProductHandler(productService)
|
||||
|
||||
prod.Get("/", handler.List)
|
||||
prod.Post("/", handler.Create)
|
||||
prod.Put("/", handler.Update)
|
||||
|
||||
prod.Get("/{id:int64}", handler.GetByID)
|
||||
prod.Patch("/{id:int64}", handler.PartialUpdate)
|
||||
prod.Delete("/{id:int64}", handler.Delete)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func writeToken(j *jwt.JWT) iris.Handler {
|
||||
return func(ctx iris.Context) {
|
||||
claims := jwt.Claims{
|
||||
Issuer: "https://iris-go.com",
|
||||
Audience: jwt.Audience{requestid.Get(ctx)},
|
||||
}
|
||||
|
||||
j.WriteToken(ctx, claims)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyToken(j *jwt.JWT) iris.Handler {
|
||||
return func(ctx iris.Context) {
|
||||
// Allow all GET.
|
||||
if ctx.Method() == iris.MethodGet {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
|
||||
j.Verify(ctx)
|
||||
}
|
||||
}
|
||||
251
_examples/database/mysql/api/category_handler.go
Normal file
251
_examples/database/mysql/api/category_handler.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"myapp/entity"
|
||||
"myapp/service"
|
||||
"myapp/sql"
|
||||
|
||||
"github.com/kataras/iris/v12"
|
||||
)
|
||||
|
||||
// CategoryHandler is the http mux for categories.
|
||||
type CategoryHandler struct {
|
||||
// [...options]
|
||||
|
||||
service *service.CategoryService
|
||||
}
|
||||
|
||||
// NewCategoryHandler returns the main controller for the categories API.
|
||||
func NewCategoryHandler(service *service.CategoryService) *CategoryHandler {
|
||||
return &CategoryHandler{service}
|
||||
}
|
||||
|
||||
// GetByID fetches a single record from the database and sends it to the client.
|
||||
// Method: GET.
|
||||
func (h *CategoryHandler) GetByID(ctx iris.Context) {
|
||||
id := ctx.Params().GetInt64Default("id", 0)
|
||||
|
||||
var cat entity.Category
|
||||
err := h.service.GetByID(ctx.Request().Context(), &cat, id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
writeEntityNotFound(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
debugf("CategoryHandler.GetByID(id=%d): %v", id, err)
|
||||
writeInternalServerError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(cat)
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
type (
|
||||
List struct {
|
||||
Data interface{} `json:"data"`
|
||||
Order string `json:"order"`
|
||||
Next Range `json:"next,omitempty"`
|
||||
Prev Range `json:"prev,omitempty"`
|
||||
}
|
||||
|
||||
Range struct {
|
||||
Offset int64 `json:"offset"`
|
||||
Limit int64 `json:"limit`
|
||||
}
|
||||
)
|
||||
*/
|
||||
|
||||
// List lists a set of records from the database.
|
||||
// Method: GET.
|
||||
func (h *CategoryHandler) List(ctx iris.Context) {
|
||||
q := ctx.Request().URL.Query()
|
||||
opts := sql.ParseListOptions(q)
|
||||
|
||||
// initialize here in order to return an empty json array `[]` instead of `null`.
|
||||
categories := entity.Categories{}
|
||||
err := h.service.List(ctx.Request().Context(), &categories, opts)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
debugf("CategoryHandler.List(DB) (limit=%d offset=%d where=%s=%v): %v",
|
||||
opts.Limit, opts.Offset, opts.WhereColumn, opts.WhereValue, err)
|
||||
|
||||
writeInternalServerError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(categories)
|
||||
}
|
||||
|
||||
// Create adds a record to the database.
|
||||
// Method: POST.
|
||||
func (h *CategoryHandler) Create(ctx iris.Context) {
|
||||
var cat entity.Category
|
||||
if err := ctx.ReadJSON(&cat); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := h.service.Insert(ctx.Request().Context(), cat)
|
||||
if err != nil {
|
||||
if err == sql.ErrUnprocessable {
|
||||
ctx.StopWithJSON(iris.StatusUnprocessableEntity, newError(iris.StatusUnprocessableEntity, ctx.Request().Method, ctx.Path(), "required fields are missing"))
|
||||
return
|
||||
}
|
||||
|
||||
debugf("CategoryHandler.Create(DB): %v", err)
|
||||
writeInternalServerError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
// Send 201 with body of {"id":$last_inserted_id"}.
|
||||
ctx.StatusCode(iris.StatusCreated)
|
||||
ctx.JSON(iris.Map{cat.PrimaryKey(): id})
|
||||
}
|
||||
|
||||
// Update performs a full-update of a record in the database.
|
||||
// Method: PUT.
|
||||
func (h *CategoryHandler) Update(ctx iris.Context) {
|
||||
var cat entity.Category
|
||||
if err := ctx.ReadJSON(&cat); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
affected, err := h.service.Update(ctx.Request().Context(), cat)
|
||||
if err != nil {
|
||||
if err == sql.ErrUnprocessable {
|
||||
ctx.StopWithJSON(iris.StatusUnprocessableEntity, newError(iris.StatusUnprocessableEntity, ctx.Request().Method, ctx.Path(), "required fields are missing"))
|
||||
return
|
||||
}
|
||||
|
||||
debugf("CategoryHandler.Update(DB): %v", err)
|
||||
writeInternalServerError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
status := iris.StatusOK
|
||||
if affected == 0 {
|
||||
status = iris.StatusNotModified
|
||||
}
|
||||
|
||||
ctx.StatusCode(status)
|
||||
}
|
||||
|
||||
// PartialUpdate is the handler for partially update one or more fields of the record.
|
||||
// Method: PATCH.
|
||||
func (h *CategoryHandler) PartialUpdate(ctx iris.Context) {
|
||||
id := ctx.Params().GetInt64Default("id", 0)
|
||||
|
||||
var attrs map[string]interface{}
|
||||
if err := ctx.ReadJSON(&attrs); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
affected, err := h.service.PartialUpdate(ctx.Request().Context(), id, attrs)
|
||||
if err != nil {
|
||||
if err == sql.ErrUnprocessable {
|
||||
ctx.StopWithJSON(iris.StatusUnprocessableEntity, newError(iris.StatusUnprocessableEntity, ctx.Request().Method, ctx.Path(), "unsupported value(s)"))
|
||||
return
|
||||
}
|
||||
|
||||
debugf("CategoryHandler.PartialUpdate(DB): %v", err)
|
||||
writeInternalServerError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
status := iris.StatusOK
|
||||
if affected == 0 {
|
||||
status = iris.StatusNotModified
|
||||
}
|
||||
|
||||
ctx.StatusCode(status)
|
||||
}
|
||||
|
||||
// Delete removes a record from the database.
|
||||
// Method: DELETE.
|
||||
func (h *CategoryHandler) Delete(ctx iris.Context) {
|
||||
id := ctx.Params().GetInt64Default("id", 0)
|
||||
|
||||
affected, err := h.service.DeleteByID(ctx.Request().Context(), id)
|
||||
if err != nil {
|
||||
debugf("CategoryHandler.Delete(DB): %v", err)
|
||||
writeInternalServerError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
status := iris.StatusOK // StatusNoContent
|
||||
if affected == 0 {
|
||||
status = iris.StatusNotModified
|
||||
}
|
||||
|
||||
ctx.StatusCode(status)
|
||||
}
|
||||
|
||||
// Products.
|
||||
|
||||
// ListProducts lists products of a Category.
|
||||
// Example: from cheap to expensive:
|
||||
// http://localhost:8080/category/3/products?offset=0&limit=30&by=price&order=asc
|
||||
// Method: GET.
|
||||
func (h *CategoryHandler) ListProducts(ctx iris.Context) {
|
||||
id := ctx.Params().GetInt64Default("id", 0)
|
||||
|
||||
// NOTE: could add cache here too.
|
||||
|
||||
q := ctx.Request().URL.Query()
|
||||
opts := sql.ParseListOptions(q).Where("category_id", id)
|
||||
opts.Table = "products"
|
||||
if opts.OrderByColumn == "" {
|
||||
opts.OrderByColumn = "updated_at"
|
||||
}
|
||||
|
||||
var products entity.Products
|
||||
err := h.service.List(ctx.Request().Context(), &products, opts)
|
||||
if err != nil {
|
||||
debugf("CategoryHandler.ListProducts(DB) (table=%s where=%s=%v limit=%d offset=%d): %v",
|
||||
opts.Table, opts.WhereColumn, opts.WhereValue, opts.Limit, opts.Offset, err)
|
||||
|
||||
writeInternalServerError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(products)
|
||||
}
|
||||
|
||||
// InsertProducts assigns new products to a Category (accepts a list of products).
|
||||
// Method: POST.
|
||||
func (h *CategoryHandler) InsertProducts(productService *service.ProductService) iris.Handler {
|
||||
return func(ctx iris.Context) {
|
||||
categoryID := ctx.Params().GetInt64Default("id", 0)
|
||||
|
||||
var products []entity.Product
|
||||
if err := ctx.ReadJSON(&products); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range products {
|
||||
products[i].CategoryID = categoryID
|
||||
}
|
||||
|
||||
inserted, err := productService.BatchInsert(ctx.Request().Context(), products)
|
||||
if err != nil {
|
||||
if err == sql.ErrUnprocessable {
|
||||
ctx.StopWithJSON(iris.StatusUnprocessableEntity, newError(iris.StatusUnprocessableEntity, ctx.Request().Method, ctx.Path(), "required fields are missing"))
|
||||
return
|
||||
}
|
||||
|
||||
debugf("CategoryHandler.InsertProducts(DB): %v", err)
|
||||
writeInternalServerError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
if inserted == 0 {
|
||||
ctx.StatusCode(iris.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
// Send 201 with body of {"inserted":$inserted"}.
|
||||
ctx.StatusCode(iris.StatusCreated)
|
||||
ctx.JSON(iris.Map{"inserted": inserted})
|
||||
}
|
||||
}
|
||||
25
_examples/database/mysql/api/helper.go
Normal file
25
_examples/database/mysql/api/helper.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/kataras/iris/v12"
|
||||
)
|
||||
|
||||
const debug = true
|
||||
|
||||
func debugf(format string, args ...interface{}) {
|
||||
if !debug {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
|
||||
func writeInternalServerError(ctx iris.Context) {
|
||||
ctx.StopWithJSON(iris.StatusInternalServerError, newError(iris.StatusInternalServerError, ctx.Request().Method, ctx.Path(), ""))
|
||||
}
|
||||
|
||||
func writeEntityNotFound(ctx iris.Context) {
|
||||
ctx.StopWithJSON(iris.StatusNotFound, newError(iris.StatusNotFound, ctx.Request().Method, ctx.Path(), "entity does not exist"))
|
||||
}
|
||||
60
_examples/database/mysql/api/httperror.go
Normal file
60
_examples/database/mysql/api/httperror.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kataras/iris/v12"
|
||||
)
|
||||
|
||||
// Error holds the error sent by server to clients (JSON).
|
||||
type Error struct {
|
||||
StatusCode int `json:"code"`
|
||||
Method string `json:"-"`
|
||||
Path string `json:"-"`
|
||||
Message string `json:"message"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
func newError(statusCode int, method, path, format string, args ...interface{}) Error {
|
||||
msg := format
|
||||
if len(args) > 0 {
|
||||
// why we check for that? If the original error message came from our database
|
||||
// and it contains fmt-reserved words
|
||||
// like %s or %d we will get MISSING(=...) in our error message and we don't want that.
|
||||
msg = fmt.Sprintf(msg, args...)
|
||||
}
|
||||
|
||||
if msg == "" {
|
||||
msg = iris.StatusText(statusCode)
|
||||
}
|
||||
|
||||
return Error{
|
||||
StatusCode: statusCode,
|
||||
Method: method,
|
||||
Path: path,
|
||||
Message: msg,
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// Error implements the internal Go error interface.
|
||||
func (e Error) Error() string {
|
||||
return fmt.Sprintf("[%d] %s: %s: %s", e.StatusCode, e.Method, e.Path, e.Message)
|
||||
}
|
||||
|
||||
// Is implements the standard `errors.Is` internal interface.
|
||||
// Usage: errors.Is(e, target)
|
||||
func (e Error) Is(target error) bool {
|
||||
if target == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
err, ok := target.(Error)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return (err.StatusCode == e.StatusCode || e.StatusCode == 0) &&
|
||||
(err.Message == e.Message || e.Message == "")
|
||||
}
|
||||
0
_examples/database/mysql/api/middleware/.gitkeep
Normal file
0
_examples/database/mysql/api/middleware/.gitkeep
Normal file
173
_examples/database/mysql/api/product_handler.go
Normal file
173
_examples/database/mysql/api/product_handler.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"myapp/cache"
|
||||
"myapp/entity"
|
||||
"myapp/service"
|
||||
"myapp/sql"
|
||||
|
||||
"github.com/kataras/iris/v12"
|
||||
)
|
||||
|
||||
// ProductHandler is the http mux for products.
|
||||
type ProductHandler struct {
|
||||
service *service.ProductService
|
||||
cache *cache.Cache
|
||||
}
|
||||
|
||||
// NewProductHandler returns the main controller for the products API.
|
||||
func NewProductHandler(service *service.ProductService) *ProductHandler {
|
||||
return &ProductHandler{
|
||||
service: service,
|
||||
cache: cache.New(service, "products", time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
// GetByID fetches a single record from the database and sends it to the client.
|
||||
// Method: GET.
|
||||
func (h *ProductHandler) GetByID(ctx iris.Context) {
|
||||
id := ctx.Params().GetString("id")
|
||||
|
||||
var product []byte
|
||||
err := h.cache.GetByID(ctx.Request().Context(), id, &product)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
writeEntityNotFound(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
debugf("ProductHandler.GetByID(id=%v): %v", id, err)
|
||||
writeInternalServerError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.ContentType("application/json")
|
||||
ctx.Write(product)
|
||||
|
||||
// ^ Could use our simple `noCache` or implement a Cache-Control (see kataras/iris/cache for that)
|
||||
// but let's keep it simple.
|
||||
}
|
||||
|
||||
// List lists a set of records from the database.
|
||||
// Method: GET.
|
||||
func (h *ProductHandler) List(ctx iris.Context) {
|
||||
key := ctx.Request().URL.RawQuery
|
||||
|
||||
products := []byte("[]")
|
||||
err := h.cache.List(ctx.Request().Context(), key, &products)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
debugf("ProductHandler.List(DB) (%s): %v",
|
||||
key, err)
|
||||
|
||||
writeInternalServerError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.ContentType("application/json")
|
||||
ctx.Write(products)
|
||||
}
|
||||
|
||||
// Create adds a record to the database.
|
||||
// Method: POST.
|
||||
func (h *ProductHandler) Create(ctx iris.Context) {
|
||||
var product entity.Product
|
||||
if err := ctx.ReadJSON(&product); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := h.service.Insert(ctx.Request().Context(), product)
|
||||
if err != nil {
|
||||
if err == sql.ErrUnprocessable {
|
||||
ctx.StopWithJSON(iris.StatusUnprocessableEntity, newError(iris.StatusUnprocessableEntity, ctx.Request().Method, ctx.Path(), "required fields are missing"))
|
||||
return
|
||||
}
|
||||
|
||||
debugf("ProductHandler.Create(DB): %v", err)
|
||||
writeInternalServerError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
// Send 201 with body of {"id":$last_inserted_id"}.
|
||||
ctx.StatusCode(iris.StatusCreated)
|
||||
ctx.JSON(iris.Map{product.PrimaryKey(): id})
|
||||
}
|
||||
|
||||
// Update performs a full-update of a record in the database.
|
||||
// Method: PUT.
|
||||
func (h *ProductHandler) Update(ctx iris.Context) {
|
||||
var product entity.Product
|
||||
if err := ctx.ReadJSON(&product); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
affected, err := h.service.Update(ctx.Request().Context(), product)
|
||||
if err != nil {
|
||||
if err == sql.ErrUnprocessable {
|
||||
ctx.StopWithJSON(iris.StatusUnprocessableEntity, newError(iris.StatusUnprocessableEntity, ctx.Request().Method, ctx.Path(), "required fields are missing"))
|
||||
return
|
||||
}
|
||||
|
||||
debugf("ProductHandler.Update(DB): %v", err)
|
||||
writeInternalServerError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
status := iris.StatusOK
|
||||
if affected == 0 {
|
||||
status = iris.StatusNotModified
|
||||
}
|
||||
|
||||
ctx.StatusCode(status)
|
||||
}
|
||||
|
||||
// PartialUpdate is the handler for partially update one or more fields of the record.
|
||||
// Method: PATCH.
|
||||
func (h *ProductHandler) PartialUpdate(ctx iris.Context) {
|
||||
id := ctx.Params().GetInt64Default("id", 0)
|
||||
|
||||
var attrs map[string]interface{}
|
||||
if err := ctx.ReadJSON(&attrs); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
affected, err := h.service.PartialUpdate(ctx.Request().Context(), id, attrs)
|
||||
if err != nil {
|
||||
if err == sql.ErrUnprocessable {
|
||||
ctx.StopWithJSON(iris.StatusUnprocessableEntity, newError(iris.StatusUnprocessableEntity, ctx.Request().Method, ctx.Path(), "unsupported value(s)"))
|
||||
return
|
||||
}
|
||||
|
||||
debugf("ProductHandler.PartialUpdate(DB): %v", err)
|
||||
writeInternalServerError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
status := iris.StatusOK
|
||||
if affected == 0 {
|
||||
status = iris.StatusNotModified
|
||||
}
|
||||
|
||||
ctx.StatusCode(status)
|
||||
}
|
||||
|
||||
// Delete removes a record from the database.
|
||||
// Method: DELETE.
|
||||
func (h *ProductHandler) Delete(ctx iris.Context) {
|
||||
id := ctx.Params().GetInt64Default("id", 0)
|
||||
|
||||
affected, err := h.service.DeleteByID(ctx.Request().Context(), id)
|
||||
if err != nil {
|
||||
debugf("ProductHandler.Delete(DB): %v", err)
|
||||
writeInternalServerError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
status := iris.StatusOK // StatusNoContent
|
||||
if affected == 0 {
|
||||
status = iris.StatusNotModified
|
||||
}
|
||||
|
||||
ctx.StatusCode(status)
|
||||
}
|
||||
120
_examples/database/mysql/cache/groupcache.go
vendored
Normal file
120
_examples/database/mysql/cache/groupcache.go
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"myapp/entity"
|
||||
"myapp/sql"
|
||||
|
||||
"github.com/mailgun/groupcache/v2"
|
||||
)
|
||||
|
||||
// Service that cache will use to retrieve data.
|
||||
type Service interface {
|
||||
RecordInfo() sql.Record
|
||||
GetByID(ctx context.Context, dest interface{}, id int64) error
|
||||
List(ctx context.Context, dest interface{}, opts sql.ListOptions) error
|
||||
}
|
||||
|
||||
// Cache is a simple structure which holds the groupcache and the database service, exposes
|
||||
// `GetByID` and `List` which returns cached (or stores new) items.
|
||||
type Cache struct {
|
||||
service Service
|
||||
maxAge time.Duration
|
||||
group *groupcache.Group
|
||||
}
|
||||
|
||||
// Size default size to use on groupcache, defaults to 3MB.
|
||||
var Size int64 = 3 << (10 * 3)
|
||||
|
||||
// New returns a new cache service which exposes `GetByID` and `List` methods to work with.
|
||||
// The "name" should be unique, "maxAge" for cache expiration.
|
||||
func New(service Service, name string, maxAge time.Duration) *Cache {
|
||||
c := new(Cache)
|
||||
c.service = service
|
||||
c.maxAge = maxAge
|
||||
c.group = groupcache.NewGroup(name, Size, c)
|
||||
return c
|
||||
}
|
||||
|
||||
const (
|
||||
prefixID = "#"
|
||||
prefixList = "["
|
||||
)
|
||||
|
||||
// Get implements the groupcache.Getter interface.
|
||||
// Use `GetByID` and `List` instead.
|
||||
func (c *Cache) Get(ctx context.Context, key string, dest groupcache.Sink) error {
|
||||
if len(key) < 2 { // empty or missing prefix+key, should never happen.
|
||||
return sql.ErrUnprocessable
|
||||
}
|
||||
|
||||
var v interface{}
|
||||
|
||||
prefix := key[0:1]
|
||||
key = key[1:]
|
||||
switch prefix {
|
||||
case prefixID:
|
||||
// Get by ID.
|
||||
id, err := strconv.ParseInt(key, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
switch c.service.RecordInfo().(type) {
|
||||
case *entity.Category:
|
||||
v = new(entity.Category)
|
||||
case *entity.Product:
|
||||
v = new(entity.Product)
|
||||
}
|
||||
|
||||
err = c.service.GetByID(ctx, v, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case prefixList:
|
||||
// Get a set of records, list.
|
||||
q, err := url.ParseQuery(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts := sql.ParseListOptions(q)
|
||||
|
||||
switch c.service.RecordInfo().(type) {
|
||||
case *entity.Category:
|
||||
v = new(entity.Categories)
|
||||
case *entity.Product:
|
||||
v = new(entity.Products)
|
||||
}
|
||||
|
||||
err = c.service.List(ctx, v, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
default:
|
||||
return sql.ErrUnprocessable
|
||||
}
|
||||
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dest.SetBytes(b, time.Now().Add(c.maxAge))
|
||||
}
|
||||
|
||||
// GetByID binds an item to "dest" an item based on its "id".
|
||||
func (c *Cache) GetByID(ctx context.Context, id string, dest *[]byte) error {
|
||||
return c.group.Get(ctx, prefixID+id, groupcache.AllocatingByteSliceSink(dest))
|
||||
}
|
||||
|
||||
// List binds item to "dest" based on the "rawQuery" of `url.Values` for `ListOptions`.
|
||||
func (c *Cache) List(ctx context.Context, rawQuery string, dest *[]byte) error {
|
||||
return c.group.Get(ctx, prefixList+rawQuery, groupcache.AllocatingByteSliceSink(dest))
|
||||
}
|
||||
32
_examples/database/mysql/docker-compose.yml
Normal file
32
_examples/database/mysql/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
version: '3.1'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mysql
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: dbpassword
|
||||
MYSQL_DATABASE: myapp
|
||||
MYSQL_USER: user_myapp
|
||||
MYSQL_PASSWORD: dbpassword
|
||||
tty: true
|
||||
volumes:
|
||||
- ./migration:/docker-entrypoint-initdb.d
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- 8080:8080
|
||||
environment:
|
||||
PORT: 8080
|
||||
MYSQL_USER: user_myapp
|
||||
MYSQL_PASSWORD: dbpassword
|
||||
MYSQL_DATABASE: myapp
|
||||
MYSQL_HOST: db
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "tcp://db:3306"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
depends_on:
|
||||
- db
|
||||
89
_examples/database/mysql/entity/category.go
Normal file
89
_examples/database/mysql/entity/category.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Category represents the categories entity.
|
||||
// Each product belongs to a category, see `Product.CategoryID` field.
|
||||
// It implements the `sql.Record` and `sql.Sorted` interfaces.
|
||||
type Category struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Position uint64 `db:"position" json:"position"`
|
||||
ImageURL string `db:"image_url" json:"image_url"`
|
||||
|
||||
// We could use: sql.NullTime or unix time seconds (as int64),
|
||||
// note that the dsn parameter "parseTime=true" is required now in order to fill this field correctly.
|
||||
CreatedAt *time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName returns the database table name of a Category.
|
||||
func (c *Category) TableName() string {
|
||||
return "categories"
|
||||
}
|
||||
|
||||
// PrimaryKey returns the primary key of a Category.
|
||||
func (c *Category) PrimaryKey() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// SortBy returns the column name that
|
||||
// should be used as a fallback for sorting a set of Category.
|
||||
func (c *Category) SortBy() string {
|
||||
return "position"
|
||||
}
|
||||
|
||||
// Scan binds mysql rows to this Category.
|
||||
func (c *Category) Scan(rows *sql.Rows) error {
|
||||
c.CreatedAt = new(time.Time)
|
||||
c.UpdatedAt = new(time.Time)
|
||||
return rows.Scan(&c.ID, &c.Title, &c.Position, &c.ImageURL, &c.CreatedAt, &c.UpdatedAt)
|
||||
}
|
||||
|
||||
// Categories a list of categories. Implements the `Scannable` interface.
|
||||
type Categories []*Category
|
||||
|
||||
// Scan binds mysql rows to this Categories.
|
||||
func (cs *Categories) Scan(rows *sql.Rows) (err error) {
|
||||
cp := *cs
|
||||
for rows.Next() {
|
||||
c := new(Category)
|
||||
if err = c.Scan(rows); err != nil {
|
||||
return
|
||||
}
|
||||
cp = append(cp, c)
|
||||
}
|
||||
|
||||
if len(cp) == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
*cs = cp
|
||||
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
/*
|
||||
// The requests.
|
||||
type (
|
||||
CreateCategoryRequest struct {
|
||||
Title string `json:"title"` // all required.
|
||||
Position uint64 `json:"position"`
|
||||
ImageURL string `json:"imageURL"`
|
||||
}
|
||||
|
||||
UpdateCategoryRequest CreateCategoryRequest // at least 1 required.
|
||||
|
||||
GetCategoryRequest struct {
|
||||
ID int64 `json:"id"` // required.
|
||||
}
|
||||
|
||||
DeleteCategoryRequest GetCategoryRequest
|
||||
|
||||
GetCategoriesRequest struct {
|
||||
// [limit, offset...]
|
||||
}
|
||||
)*/
|
||||
95
_examples/database/mysql/entity/product.go
Normal file
95
_examples/database/mysql/entity/product.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Product represents the products entity.
|
||||
// It implements the `sql.Record` and `sql.Sorted` interfaces.
|
||||
type Product struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
CategoryID int64 `db:"category_id" json:"category_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
ImageURL string `db:"image_url" json:"image_url"`
|
||||
Price float32 `db:"price" json:"price"`
|
||||
Description string `db:"description" json:"description"`
|
||||
CreatedAt *time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName returns the database table name of a Product.
|
||||
func (p Product) TableName() string {
|
||||
return "products"
|
||||
}
|
||||
|
||||
// PrimaryKey returns the primary key of a Product.
|
||||
func (p *Product) PrimaryKey() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// SortBy returns the column name that
|
||||
// should be used as a fallback for sorting a set of Product.
|
||||
func (p *Product) SortBy() string {
|
||||
return "updated_at"
|
||||
}
|
||||
|
||||
// ValidateInsert simple check for empty fields that should be required.
|
||||
func (p *Product) ValidateInsert() bool {
|
||||
return p.CategoryID > 0 && p.Title != "" && p.ImageURL != "" && p.Price > 0 /* decimal* */ && p.Description != ""
|
||||
}
|
||||
|
||||
// Scan binds mysql rows to this Product.
|
||||
func (p *Product) Scan(rows *sql.Rows) error {
|
||||
p.CreatedAt = new(time.Time)
|
||||
p.UpdatedAt = new(time.Time)
|
||||
return rows.Scan(&p.ID, &p.CategoryID, &p.Title, &p.ImageURL, &p.Price, &p.Description, &p.CreatedAt, &p.UpdatedAt)
|
||||
}
|
||||
|
||||
// Products is a list of products. Implements the `Scannable` interface.
|
||||
type Products []*Product
|
||||
|
||||
// Scan binds mysql rows to this Categories.
|
||||
func (ps *Products) Scan(rows *sql.Rows) (err error) {
|
||||
cp := *ps
|
||||
for rows.Next() {
|
||||
p := new(Product)
|
||||
if err = p.Scan(rows); err != nil {
|
||||
return
|
||||
}
|
||||
cp = append(cp, p)
|
||||
}
|
||||
|
||||
if len(cp) == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
*ps = cp
|
||||
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
/*
|
||||
// The requests.
|
||||
type (
|
||||
CreateProductRequest struct { // all required.
|
||||
CategoryID int64 `json:"categoryID"`
|
||||
Title string `json:"title"`
|
||||
ImageURL string `json:"imageURL"`
|
||||
Price float32 `json:"price"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
UpdateProductRequest CreateProductRequest // at least 1 required.
|
||||
|
||||
GetProductRequest struct {
|
||||
ID int64 `json:"id"` // required.
|
||||
}
|
||||
|
||||
DeleteProductRequest GetProductRequest
|
||||
|
||||
GetProductsRequest struct {
|
||||
// [page, offset...]
|
||||
}
|
||||
)
|
||||
*/
|
||||
9
_examples/database/mysql/go.mod
Normal file
9
_examples/database/mysql/go.mod
Normal file
@@ -0,0 +1,9 @@
|
||||
module myapp
|
||||
|
||||
go 1.14
|
||||
|
||||
require (
|
||||
github.com/go-sql-driver/mysql v1.5.0
|
||||
github.com/kataras/iris/v12 v12.2.0
|
||||
github.com/mailgun/groupcache/v2 v2.1.0
|
||||
)
|
||||
44
_examples/database/mysql/main.go
Normal file
44
_examples/database/mysql/main.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"myapp/api"
|
||||
"myapp/sql"
|
||||
|
||||
"github.com/kataras/iris/v12"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci",
|
||||
getenv("MYSQL_USER", "user_myapp"),
|
||||
getenv("MYSQL_PASSWORD", "dbpassword"),
|
||||
getenv("MYSQL_HOST", "localhost"),
|
||||
getenv("MYSQL_DATABASE", "myapp"),
|
||||
)
|
||||
|
||||
db, err := sql.ConnectMySQL(dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("error connecting to the MySQL database: %v", err)
|
||||
}
|
||||
|
||||
secret := getenv("JWT_SECRET", "EbnJO3bwmX")
|
||||
|
||||
app := iris.New()
|
||||
subRouter := api.Router(db, secret)
|
||||
app.PartyFunc("/", subRouter)
|
||||
|
||||
addr := fmt.Sprintf(":%s", getenv("PORT", "8080"))
|
||||
app.Listen(addr)
|
||||
}
|
||||
|
||||
func getenv(key string, def string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "computer-internet",
|
||||
"position": 2,
|
||||
"image_url": "https://bp.pstatic.gr/public/dist/images/1mOPxYtw1k.webp"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
[{
|
||||
"title": "product-1",
|
||||
"image_url": "https://images.product1.png",
|
||||
"price": 42.42,
|
||||
"description": "a description for product-1"
|
||||
}, {
|
||||
"title": "product-2",
|
||||
"image_url": "https://images.product2.png",
|
||||
"price": 32.1,
|
||||
"description": "a description for product-2"
|
||||
}, {
|
||||
"title": "product-3",
|
||||
"image_url": "https://images.product3.png",
|
||||
"price": 52321321.32,
|
||||
"description": "a description for product-3"
|
||||
}, {
|
||||
"title": "product-4",
|
||||
"image_url": "https://images.product4.png",
|
||||
"price": 77.4221,
|
||||
"description": "a description for product-4"
|
||||
}, {
|
||||
"title": "product-5",
|
||||
"image_url": "https://images.product5.png",
|
||||
"price": 55.1,
|
||||
"description": "a description for product-5"
|
||||
}, {
|
||||
"title": "product-6",
|
||||
"image_url": "https://images.product6.png",
|
||||
"price": 53.32,
|
||||
"description": "a description for product-6"
|
||||
}]
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": 2,
|
||||
"position": 1,
|
||||
"title": "computers",
|
||||
"image_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Desktop_computer_clipart_-_Yellow_theme.svg/1200px-Desktop_computer_clipart_-_Yellow_theme.svg.png"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"title": "computers-technology"
|
||||
}
|
||||
484
_examples/database/mysql/migration/api_postman.json
Normal file
484
_examples/database/mysql/migration/api_postman.json
Normal file
@@ -0,0 +1,484 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "d3a2fdf6-9ebd-4e85-827d-385592a71fd6",
|
||||
"name": "myapp (api-test)",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Category",
|
||||
"item": [
|
||||
{
|
||||
"name": "Create",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk2MzkzNjd9.cYohwgUpe-Z7ac0LPpz4Adi5QXJmtwD1ZRpXrMUMPN0",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\r\n \"title\": \"computer-internet\",\r\n \"position\": 1,\r\n \"image_url\": \"https://bp.pstatic.gr/public/dist/images/1mOPxYtw1k.webp\"\r\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/category",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"category"
|
||||
]
|
||||
},
|
||||
"description": "Create a Category"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get By ID",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/category/1",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"category",
|
||||
"1"
|
||||
]
|
||||
},
|
||||
"description": "Get By ID"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "List",
|
||||
"protocolProfileBehavior": {
|
||||
"disableBodyPruning": true
|
||||
},
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": ""
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/category?offset=0&limit=30&order=asc",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"category"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "offset",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"key": "limit",
|
||||
"value": "30"
|
||||
},
|
||||
{
|
||||
"key": "order",
|
||||
"value": "asc"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Get many with limit offset"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Update (Full)",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\r\n\t\"id\": 1,\r\n\t\"position\": 3,\r\n \"title\": \"computers\",\r\n \"image_url\":\"https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Desktop_computer_clipart_-_Yellow_theme.svg/1200px-Desktop_computer_clipart_-_Yellow_theme.svg.png\"\r\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/category",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"category"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "",
|
||||
"value": null,
|
||||
"disabled": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Update a Category (full update)"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Delete By ID",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/category/1",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"category",
|
||||
"1"
|
||||
]
|
||||
},
|
||||
"description": "Delete a Category"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Update (Partial)",
|
||||
"request": {
|
||||
"method": "PATCH",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\r\n \"title\": \"computers-technology\"\r\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/category/1",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"category",
|
||||
"3"
|
||||
]
|
||||
},
|
||||
"description": "Update a Category partially, e.g. title only"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "List Products",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/category/1/products?offset=0&limit=30&by=price&order=asc",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"category",
|
||||
"3",
|
||||
"products"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "offset",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"key": "limit",
|
||||
"value": "30"
|
||||
},
|
||||
{
|
||||
"key": "by",
|
||||
"value": "price"
|
||||
},
|
||||
{
|
||||
"key": "order",
|
||||
"value": "asc"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Get products from cheap to expensive"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Insert Products",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "[{\r\n \"title\": \"product-1\",\r\n \"image_url\": \"https://images.product1.png\",\r\n \"price\": 42.42,\r\n \"description\": \"a description for product-1\"\r\n}, {\r\n \"title\": \"product-2\",\r\n \"image_url\": \"https://images.product2.png\",\r\n \"price\": 32.1,\r\n \"description\": \"a description for product-2\"\r\n}, {\r\n \"title\": \"product-3\",\r\n \"image_url\": \"https://images.product3.png\",\r\n \"price\": 52321321.32,\r\n \"description\": \"a description for product-3\"\r\n}, {\r\n \"title\": \"product-4\",\r\n \"image_url\": \"https://images.product4.png\",\r\n \"price\": 77.4221,\r\n \"description\": \"a description for product-4\"\r\n}, {\r\n \"title\": \"product-5\",\r\n \"image_url\": \"https://images.product5.png\",\r\n \"price\": 55.1,\r\n \"description\": \"a description for product-5\"\r\n}, {\r\n \"title\": \"product-6\",\r\n \"image_url\": \"https://images.product6.png\",\r\n \"price\": 53.32,\r\n \"description\": \"a description for product-6\"\r\n}]"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/category/1/products",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"category",
|
||||
"3",
|
||||
"products"
|
||||
]
|
||||
},
|
||||
"description": "Batch Insert Products to a Category"
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
],
|
||||
"protocolProfileBehavior": {}
|
||||
},
|
||||
{
|
||||
"name": "Product",
|
||||
"item": [
|
||||
{
|
||||
"name": "List",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/product?offset=0&limit=30&by=price&order=asc",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"product"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "offset",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"key": "limit",
|
||||
"value": "30"
|
||||
},
|
||||
{
|
||||
"key": "by",
|
||||
"value": "price"
|
||||
},
|
||||
{
|
||||
"key": "order",
|
||||
"value": "asc"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "List products"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get By ID",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/product/1",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"product",
|
||||
"1"
|
||||
]
|
||||
},
|
||||
"description": "Get a Product"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Delete By ID",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/product/3",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"product",
|
||||
"3"
|
||||
]
|
||||
},
|
||||
"description": "Delete a Product"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Create",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\r\n \"title\": \"product-1\",\r\n \"category_id\": 1,\r\n \"image_url\": \"https://images.product1.png\",\r\n \"price\": 42.42,\r\n \"description\": \"a description for product-1\"\r\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/product",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"product"
|
||||
]
|
||||
},
|
||||
"description": "Create a Product (and assign a category)"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Update (Full)",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\r\n\t\"id\":19,\r\n \"title\": \"product-9-new\",\r\n \"category_id\": 1,\r\n \"image_url\": \"https://images.product19.png\",\r\n \"price\": 20,\r\n \"description\": \"a description for product-9-new\"\r\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/product",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"product"
|
||||
]
|
||||
},
|
||||
"description": "Update a Product (full-update)"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Update (Partial)",
|
||||
"request": {
|
||||
"method": "PATCH",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODk1ODU1NjN9.PtfDS1niGoZ7pV6kplI-_q1fVKLnknQ3IwcrLZhoVCU",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\r\n \"title\": \"product-9-new-title\"\r\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/product/9",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"product",
|
||||
"9"
|
||||
]
|
||||
},
|
||||
"description": "Update a Product (partially)"
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
],
|
||||
"description": "Product Client API",
|
||||
"protocolProfileBehavior": {}
|
||||
},
|
||||
{
|
||||
"name": "Get Token",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/token",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"localhost"
|
||||
],
|
||||
"port": "8080",
|
||||
"path": [
|
||||
"token"
|
||||
]
|
||||
},
|
||||
"description": "Get Token to access \"write\" (create, update and delete) endpoints"
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
],
|
||||
"protocolProfileBehavior": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": "product-1",
|
||||
"category_id": 3,
|
||||
"image_url": "https://images.product1.png",
|
||||
"price": 42.42,
|
||||
"description": "a description for product-1"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"title": "product-19-new-title"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id":19,
|
||||
"title": "product-19",
|
||||
"category_id": 3,
|
||||
"image_url": "https://images.product19.png",
|
||||
"price": 20,
|
||||
"description": "a description for product-19"
|
||||
}
|
||||
33
_examples/database/mysql/migration/db.sql
Normal file
33
_examples/database/mysql/migration/db.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
CREATE DATABASE IF NOT EXISTS myapp DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE myapp;
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
DROP TABLE IF EXISTS categories;
|
||||
CREATE TABLE categories (
|
||||
id int(11) NOT NULL AUTO_INCREMENT,
|
||||
title varchar(255) NOT NULL,
|
||||
position int(11) NOT NULL,
|
||||
image_url varchar(255) NOT NULL,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS products;
|
||||
CREATE TABLE products (
|
||||
id int(11) NOT NULL AUTO_INCREMENT,
|
||||
category_id int,
|
||||
title varchar(255) NOT NULL,
|
||||
image_url varchar(255) NOT NULL,
|
||||
price decimal(10,2) NOT NULL,
|
||||
description text NOT NULL,
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id)
|
||||
);
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
74
_examples/database/mysql/service/category_service.go
Normal file
74
_examples/database/mysql/service/category_service.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"myapp/entity"
|
||||
"myapp/sql"
|
||||
)
|
||||
|
||||
// CategoryService represents the category entity service.
|
||||
// Note that the given entity (request) should be already validated
|
||||
// before service's calls.
|
||||
type CategoryService struct {
|
||||
*sql.Service
|
||||
}
|
||||
|
||||
// NewCategoryService returns a new category service to communicate with the database.
|
||||
func NewCategoryService(db sql.Database) *CategoryService {
|
||||
return &CategoryService{Service: sql.NewService(db, new(entity.Category))}
|
||||
}
|
||||
|
||||
// Insert stores a category to the database and returns its ID.
|
||||
func (s *CategoryService) Insert(ctx context.Context, e entity.Category) (int64, error) {
|
||||
if e.Title == "" || e.ImageURL == "" {
|
||||
return 0, sql.ErrUnprocessable
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`INSERT INTO %s (title, position, image_url)
|
||||
VALUES (?,?,?);`, e.TableName())
|
||||
|
||||
res, err := s.DB().Exec(ctx, q, e.Title, e.Position, e.ImageURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// Update updates a category based on its `ID`.
|
||||
func (s *CategoryService) Update(ctx context.Context, e entity.Category) (int, error) {
|
||||
if e.ID == 0 || e.Title == "" || e.ImageURL == "" {
|
||||
return 0, sql.ErrUnprocessable
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`UPDATE %s
|
||||
SET
|
||||
title = ?,
|
||||
position = ?,
|
||||
image_url = ?
|
||||
WHERE %s = ?;`, e.TableName(), e.PrimaryKey())
|
||||
|
||||
res, err := s.DB().Exec(ctx, q, e.Title, e.Position, e.ImageURL, e.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n := sql.GetAffectedRows(res)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// The updatable fields, separately from that we create for any possible future necessities.
|
||||
var categoryUpdateSchema = map[string]reflect.Kind{
|
||||
"title": reflect.String,
|
||||
"image_url": reflect.String,
|
||||
"position": reflect.Int,
|
||||
}
|
||||
|
||||
// PartialUpdate accepts a key-value map to
|
||||
// update the record based on the given "id".
|
||||
func (s *CategoryService) PartialUpdate(ctx context.Context, id int64, attrs map[string]interface{}) (int, error) {
|
||||
return s.Service.PartialUpdate(ctx, id, categoryUpdateSchema, attrs)
|
||||
}
|
||||
42
_examples/database/mysql/service/category_service_test.go
Normal file
42
_examples/database/mysql/service/category_service_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"myapp/entity"
|
||||
"myapp/sql"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestCategoryServiceInsert(t *testing.T) {
|
||||
conn, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
db := &sql.MySQL{Conn: conn}
|
||||
service := NewCategoryService(db)
|
||||
newCategory := entity.Category{
|
||||
Title: "computer-internet",
|
||||
Position: 2,
|
||||
ImageURL: "https://animage",
|
||||
}
|
||||
mock.ExpectExec("INSERT INTO categories (title, position, image_url) VALUES (?,?,?);").
|
||||
WithArgs(newCategory.Title, newCategory.Position, newCategory.ImageURL).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
id, err := service.Insert(context.TODO(), newCategory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if id != 1 {
|
||||
t.Fatalf("expected ID to be 1 as this is the first entry")
|
||||
}
|
||||
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
110
_examples/database/mysql/service/product_service.go
Normal file
110
_examples/database/mysql/service/product_service.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"myapp/entity"
|
||||
"myapp/sql"
|
||||
)
|
||||
|
||||
// ProductService represents the product entity service.
|
||||
// Note that the given entity (request) should be already validated
|
||||
// before service's calls.
|
||||
type ProductService struct {
|
||||
*sql.Service
|
||||
rec sql.Record
|
||||
}
|
||||
|
||||
// NewProductService returns a new product service to communicate with the database.
|
||||
func NewProductService(db sql.Database) *ProductService {
|
||||
return &ProductService{Service: sql.NewService(db, new(entity.Product))}
|
||||
}
|
||||
|
||||
// Insert stores a product to the database and returns its ID.
|
||||
func (s *ProductService) Insert(ctx context.Context, e entity.Product) (int64, error) {
|
||||
if !e.ValidateInsert() {
|
||||
return 0, sql.ErrUnprocessable
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`INSERT INTO %s (category_id, title, image_url, price, description)
|
||||
VALUES (?,?,?,?,?);`, e.TableName())
|
||||
|
||||
res, err := s.DB().Exec(ctx, q, e.CategoryID, e.Title, e.ImageURL, e.Price, e.Description)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// BatchInsert inserts one or more products at once and returns the total length created.
|
||||
func (s *ProductService) BatchInsert(ctx context.Context, products []entity.Product) (int, error) {
|
||||
if len(products) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var (
|
||||
valuesLines []string
|
||||
args []interface{}
|
||||
)
|
||||
|
||||
for _, p := range products {
|
||||
if !p.ValidateInsert() {
|
||||
// all products should be "valid", we don't skip, we cancel.
|
||||
return 0, sql.ErrUnprocessable
|
||||
}
|
||||
|
||||
valuesLines = append(valuesLines, "(?,?,?,?,?)")
|
||||
args = append(args, []interface{}{p.CategoryID, p.Title, p.ImageURL, p.Price, p.Description}...)
|
||||
}
|
||||
|
||||
q := fmt.Sprintf("INSERT INTO %s (category_id, title, image_url, price, description) VALUES %s;",
|
||||
s.RecordInfo().TableName(),
|
||||
strings.Join(valuesLines, ", "))
|
||||
|
||||
res, err := s.DB().Exec(ctx, q, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n := sql.GetAffectedRows(res)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Update updates a product based on its `ID` from the database
|
||||
// and returns the affected numbrer (0 when nothing changed otherwise 1).
|
||||
func (s *ProductService) Update(ctx context.Context, e entity.Product) (int, error) {
|
||||
q := fmt.Sprintf(`UPDATE %s
|
||||
SET
|
||||
category_id = ?,
|
||||
title = ?,
|
||||
image_url = ?,
|
||||
price = ?,
|
||||
description = ?
|
||||
WHERE %s = ?;`, e.TableName(), e.PrimaryKey())
|
||||
|
||||
res, err := s.DB().Exec(ctx, q, e.CategoryID, e.Title, e.ImageURL, e.Price, e.Description, e.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n := sql.GetAffectedRows(res)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
var productUpdateSchema = map[string]reflect.Kind{
|
||||
"category_id": reflect.Int,
|
||||
"title": reflect.String,
|
||||
"image_url": reflect.String,
|
||||
"price": reflect.Float32,
|
||||
"description": reflect.String,
|
||||
}
|
||||
|
||||
// PartialUpdate accepts a key-value map to
|
||||
// update the record based on the given "id".
|
||||
func (s *ProductService) PartialUpdate(ctx context.Context, id int64, attrs map[string]interface{}) (int, error) {
|
||||
return s.Service.PartialUpdate(ctx, id, productUpdateSchema, attrs)
|
||||
}
|
||||
123
_examples/database/mysql/sql/mysql.go
Normal file
123
_examples/database/mysql/sql/mysql.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql" // lint: mysql driver.
|
||||
)
|
||||
|
||||
// MySQL holds the underline connection of a MySQL (or MariaDB) database.
|
||||
// See the `ConnectMySQL` package-level function.
|
||||
type MySQL struct {
|
||||
Conn *sql.DB
|
||||
}
|
||||
|
||||
var _ Database = (*MySQL)(nil)
|
||||
|
||||
var (
|
||||
// DefaultCharset default charset parameter for new databases.
|
||||
DefaultCharset = "utf8mb4"
|
||||
// DefaultCollation default collation parameter for new databases.
|
||||
DefaultCollation = "utf8mb4_unicode_ci"
|
||||
)
|
||||
|
||||
// ConnectMySQL returns a new ready to use MySQL Database instance.
|
||||
// Accepts a single argument of "dsn", i.e:
|
||||
// username:password@tcp(localhost:3306)/myapp?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci
|
||||
func ConnectMySQL(dsn string) (*MySQL, error) {
|
||||
conn, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = conn.Ping()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MySQL{
|
||||
Conn: conn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateDatabase executes the CREATE DATABASE query.
|
||||
func (db *MySQL) CreateDatabase(database string) error {
|
||||
q := fmt.Sprintf("CREATE DATABASE %s DEFAULT CHARSET = %s COLLATE = %s;", database, DefaultCharset, DefaultCollation)
|
||||
_, err := db.Conn.Exec(q)
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop executes the DROP DATABASE query.
|
||||
func (db *MySQL) Drop(database string) error {
|
||||
q := fmt.Sprintf("DROP DATABASE %s;", database)
|
||||
_, err := db.Conn.Exec(q)
|
||||
return err
|
||||
}
|
||||
|
||||
// Select performs the SELECT query for this database (dsn database name is required).
|
||||
func (db *MySQL) Select(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
|
||||
rows, err := db.Conn.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if scannable, ok := dest.(Scannable); ok {
|
||||
return scannable.Scan(rows)
|
||||
}
|
||||
|
||||
if !rows.Next() {
|
||||
return ErrNoRows
|
||||
}
|
||||
return rows.Scan(dest)
|
||||
|
||||
/* Uncomment this and pass a slice if u want to see reflection powers <3
|
||||
v, ok := dest.(reflect.Value)
|
||||
if !ok {
|
||||
v = reflect.Indirect(reflect.ValueOf(dest))
|
||||
}
|
||||
|
||||
sliceTyp := v.Type()
|
||||
|
||||
if sliceTyp.Kind() != reflect.Slice {
|
||||
sliceTyp = reflect.SliceOf(sliceTyp)
|
||||
}
|
||||
|
||||
sliceElementTyp := deref(sliceTyp.Elem())
|
||||
for rows.Next() {
|
||||
obj := reflect.New(sliceElementTyp)
|
||||
obj.Interface().(Scannable).Scan(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
v.Set(reflect.Append(v, reflect.Indirect(obj)))
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
// Get same as `Select` but it moves the cursor to the first result.
|
||||
func (db *MySQL) Get(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
|
||||
rows, err := db.Conn.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return ErrNoRows
|
||||
}
|
||||
|
||||
if scannable, ok := dest.(Scannable); ok {
|
||||
return scannable.Scan(rows)
|
||||
}
|
||||
|
||||
return rows.Scan(dest)
|
||||
}
|
||||
|
||||
// Exec executes a query. It does not return any rows.
|
||||
// Use the first output parameter to count the affected rows on UPDATE, INSERT, or DELETE.
|
||||
func (db *MySQL) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
|
||||
return db.Conn.ExecContext(ctx, query, args...)
|
||||
}
|
||||
243
_examples/database/mysql/sql/service.go
Normal file
243
_examples/database/mysql/sql/service.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Service holder for common queries.
|
||||
// Note: each entity service keeps its own base Service instance.
|
||||
type Service struct {
|
||||
db Database
|
||||
rec Record // see `Count`, `List` and `DeleteByID` methods.
|
||||
}
|
||||
|
||||
// NewService returns a new (SQL) base service for common operations.
|
||||
func NewService(db Database, of Record) *Service {
|
||||
return &Service{db: db, rec: of}
|
||||
}
|
||||
|
||||
// DB exposes the database instance.
|
||||
func (s *Service) DB() Database {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// RecordInfo returns the record info provided through `NewService`.
|
||||
func (s *Service) RecordInfo() Record {
|
||||
return s.rec
|
||||
}
|
||||
|
||||
// ErrNoRows is returned when GET doesn't return a row.
|
||||
// A shortcut of sql.ErrNoRows.
|
||||
var ErrNoRows = sql.ErrNoRows
|
||||
|
||||
// GetByID binds a single record from the databases to the "dest".
|
||||
func (s *Service) GetByID(ctx context.Context, dest interface{}, id int64) error {
|
||||
q := fmt.Sprintf("SELECT * FROM %s WHERE %s = ? LIMIT 1", s.rec.TableName(), s.rec.PrimaryKey())
|
||||
err := s.db.Get(ctx, dest, q, id)
|
||||
return err
|
||||
// if err != nil {
|
||||
// if err == sql.ErrNoRows {
|
||||
// return false, nil
|
||||
// }
|
||||
|
||||
// return false, err
|
||||
// }
|
||||
|
||||
// return true, nil
|
||||
}
|
||||
|
||||
// Count returns the total records count in the table.
|
||||
func (s *Service) Count(ctx context.Context) (total int64, err error) {
|
||||
q := fmt.Sprintf("SELECT COUNT(DISTINCT %s) FROM %s", s.rec.PrimaryKey(), s.rec.TableName())
|
||||
if err = s.db.Select(ctx, &total, q); err == sql.ErrNoRows {
|
||||
err = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ListOptions holds the options to be passed on the `Service.List` method.
|
||||
type ListOptions struct {
|
||||
Table string // the table name.
|
||||
Offset uint64 // inclusive.
|
||||
Limit uint64
|
||||
OrderByColumn string
|
||||
Order string // "ASC" or "DESC" (could be a bool type instead).
|
||||
WhereColumn string
|
||||
WhereValue interface{}
|
||||
}
|
||||
|
||||
// Where accepts a column name and column value to set
|
||||
// on the WHERE clause of the result query.
|
||||
// It returns a new `ListOptions` value.
|
||||
// Note that this is a basic implementation which just takes care our current needs.
|
||||
func (opt ListOptions) Where(colName string, colValue interface{}) ListOptions {
|
||||
opt.WhereColumn = colName
|
||||
opt.WhereValue = colValue
|
||||
return opt
|
||||
}
|
||||
|
||||
// BuildQuery returns the query and the arguments that
|
||||
// should be form a SELECT command.
|
||||
func (opt ListOptions) BuildQuery() (q string, args []interface{}) {
|
||||
q = fmt.Sprintf("SELECT * FROM %s", opt.Table)
|
||||
|
||||
if opt.WhereColumn != "" && opt.WhereValue != nil {
|
||||
q += fmt.Sprintf(" WHERE %s = ?", opt.WhereColumn)
|
||||
args = append(args, opt.WhereValue)
|
||||
}
|
||||
|
||||
if opt.OrderByColumn != "" {
|
||||
q += fmt.Sprintf(" ORDER BY %s %s", opt.OrderByColumn, ParseOrder(opt.Order))
|
||||
}
|
||||
|
||||
if opt.Limit > 0 {
|
||||
q += fmt.Sprintf(" LIMIT %d", opt.Limit) // offset below.
|
||||
}
|
||||
|
||||
if opt.Offset > 0 {
|
||||
q += fmt.Sprintf(" OFFSET %d", opt.Offset)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// const defaultLimit = 30 // default limit if not set.
|
||||
|
||||
// ParseListOptions returns a `ListOptions` from a map[string][]string.
|
||||
func ParseListOptions(q url.Values) ListOptions {
|
||||
offset, _ := strconv.ParseUint(q.Get("offset"), 10, 64)
|
||||
limit, _ := strconv.ParseUint(q.Get("limit"), 10, 64)
|
||||
order := q.Get("order") // empty, asc(...) or desc(...).
|
||||
orderBy := q.Get("by") // e.g. price
|
||||
|
||||
return ListOptions{Offset: offset, Limit: limit, Order: order, OrderByColumn: orderBy}
|
||||
}
|
||||
|
||||
// List binds one or more records from the database to the "dest".
|
||||
// If the record supports ordering then it will sort by the `Sorted.OrderBy` column name(s).
|
||||
// Use the "order" input parameter to set a descending order ("DESC").
|
||||
func (s *Service) List(ctx context.Context, dest interface{}, opts ListOptions) error {
|
||||
// Set table and order by column from record info for `List` by options
|
||||
// so it can be more flexible to perform read-only calls of other table's too.
|
||||
if opts.Table == "" {
|
||||
// If missing then try to set it by record info.
|
||||
opts.Table = s.rec.TableName()
|
||||
}
|
||||
if opts.OrderByColumn == "" {
|
||||
if b, ok := s.rec.(Sorted); ok {
|
||||
opts.OrderByColumn = b.SortBy()
|
||||
}
|
||||
}
|
||||
|
||||
q, args := opts.BuildQuery()
|
||||
return s.db.Select(ctx, dest, q, args...)
|
||||
}
|
||||
|
||||
// DeleteByID removes a single record of "dest" from the database.
|
||||
func (s *Service) DeleteByID(ctx context.Context, id int64) (int, error) {
|
||||
q := fmt.Sprintf("DELETE FROM %s WHERE %s = ? LIMIT 1", s.rec.TableName(), s.rec.PrimaryKey())
|
||||
res, err := s.db.Exec(ctx, q, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return GetAffectedRows(res), nil
|
||||
}
|
||||
|
||||
// ErrUnprocessable indicates error caused by invalid entity (entity's key-values).
|
||||
// The syntax of the request entity is correct, but it was unable to process the contained instructions
|
||||
// e.g. empty or unsupported value.
|
||||
//
|
||||
// See `../service/XService.Insert` and `../service/XService.Update`
|
||||
// and `PartialUpdate`.
|
||||
var ErrUnprocessable = errors.New("invalid entity")
|
||||
|
||||
// PartialUpdate accepts a columns schema and a key-value map to
|
||||
// update the record based on the given "id".
|
||||
// Note: Trivial string, int and boolean type validations are performed here.
|
||||
func (s *Service) PartialUpdate(ctx context.Context, id int64, schema map[string]reflect.Kind, attrs map[string]interface{}) (int, error) {
|
||||
if len(schema) == 0 || len(attrs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var (
|
||||
keyLines []string
|
||||
values []interface{}
|
||||
)
|
||||
|
||||
for key, kind := range schema {
|
||||
v, ok := attrs[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch v.(type) {
|
||||
case string:
|
||||
if kind != reflect.String {
|
||||
return 0, ErrUnprocessable
|
||||
}
|
||||
case int:
|
||||
if kind != reflect.Int {
|
||||
return 0, ErrUnprocessable
|
||||
}
|
||||
case bool:
|
||||
if kind != reflect.Bool {
|
||||
return 0, ErrUnprocessable
|
||||
}
|
||||
}
|
||||
|
||||
keyLines = append(keyLines, fmt.Sprintf("%s = ?", key))
|
||||
values = append(values, v)
|
||||
}
|
||||
|
||||
if len(values) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
q := fmt.Sprintf("UPDATE %s SET %s WHERE %s = ?;",
|
||||
s.rec.TableName(), strings.Join(keyLines, ", "), s.rec.PrimaryKey())
|
||||
|
||||
res, err := s.DB().Exec(ctx, q, append(values, id)...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n := GetAffectedRows(res)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GetAffectedRows returns the number of affected rows after
|
||||
// a DELETE or UPDATE operation.
|
||||
func GetAffectedRows(result sql.Result) int {
|
||||
if result == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
n, _ := result.RowsAffected()
|
||||
return int(n)
|
||||
}
|
||||
|
||||
const (
|
||||
ascending = "ASC"
|
||||
descending = "DESC"
|
||||
)
|
||||
|
||||
// ParseOrder accept an order string and returns a valid mysql ORDER clause.
|
||||
// Defaults to "ASC". Two possible outputs: "ASC" and "DESC".
|
||||
func ParseOrder(order string) string {
|
||||
order = strings.TrimSpace(order)
|
||||
if len(order) >= 4 {
|
||||
if strings.HasPrefix(strings.ToUpper(order), descending) {
|
||||
return descending
|
||||
}
|
||||
}
|
||||
|
||||
return ascending
|
||||
}
|
||||
40
_examples/database/mysql/sql/sql.go
Normal file
40
_examples/database/mysql/sql/sql.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// Database is an interface which a database(sql) should implement.
|
||||
type Database interface {
|
||||
Get(ctx context.Context, dest interface{}, q string, args ...interface{}) error
|
||||
Select(ctx context.Context, dest interface{}, q string, args ...interface{}) error
|
||||
Exec(ctx context.Context, q string, args ...interface{}) (sql.Result, error)
|
||||
}
|
||||
|
||||
// Record should represent a database record.
|
||||
// It holds the table name and the primary key.
|
||||
// Entities should implement that
|
||||
// in order to use the BaseService's methods.
|
||||
type Record interface {
|
||||
TableName() string // the table name which record belongs to.
|
||||
PrimaryKey() string // the primary key of the record.
|
||||
}
|
||||
|
||||
// Sorted should represent a set of database records
|
||||
// that should be rendered with order.
|
||||
//
|
||||
// It does NOT support the form of
|
||||
// column1 ASC,
|
||||
// column2 DESC
|
||||
// The OrderBy method should return text in form of:
|
||||
// column1
|
||||
// or column1, column2
|
||||
type Sorted interface {
|
||||
SortBy() string // column names separated by comma.
|
||||
}
|
||||
|
||||
// Scannable for go structs to bind their fields.
|
||||
type Scannable interface {
|
||||
Scan(*sql.Rows) error
|
||||
}
|
||||
Reference in New Issue
Block a user