reorganization of _examples and add some new examples such as iris+groupcache+mysql+docker

Former-commit-id: ed635ee95de7160cde11eaabc0c1dcb0e460a620
This commit is contained in:
Gerasimos (Makis) Maropoulos 2020-06-07 15:26:06 +03:00
parent 9fdcb4c7fb
commit ed45c77be5
328 changed files with 4262 additions and 41621 deletions

View File

@ -159,7 +159,7 @@ Prior to this version the `iris.Context` was the only one dependency that has be
| `float, float32, float64`, | |
| `bool`, | |
| `slice` | [Path Parameter](https://github.com/kataras/iris/wiki/Routing-path-parameter-types) |
| Struct | [Request Body](https://github.com/kataras/iris/tree/master/_examples/http_request) of `JSON`, `XML`, `YAML`, `Form`, `URL Query`, `Protobuf`, `MsgPack` |
| Struct | [Request Body](https://github.com/kataras/iris/tree/master/_examples/request-body) of `JSON`, `XML`, `YAML`, `Form`, `URL Query`, `Protobuf`, `MsgPack` |
Here is a preview of what the new Hero handlers look like:
@ -393,7 +393,7 @@ Other Improvements:
- New `iris.WithLowercaseRouting` option which forces all routes' paths to be lowercase and converts request paths to their lowercase for matching.
- New `app.Validator { Struct(interface{}) error }` field and `app.Validate` method were added. The `app.Validator = ` can be used to integrate a 3rd-party package such as [go-playground/validator](https://github.com/go-playground/validator). If set-ed then Iris `Context`'s `ReadJSON`, `ReadXML`, `ReadMsgPack`, `ReadYAML`, `ReadForm`, `ReadQuery`, `ReadBody` methods will return the validation error on data validation failures. The [read-json-struct-validation](_examples/http_request/read-json-struct-validation) example was updated.
- New `app.Validator { Struct(interface{}) error }` field and `app.Validate` method were added. The `app.Validator = ` can be used to integrate a 3rd-party package such as [go-playground/validator](https://github.com/go-playground/validator). If set-ed then Iris `Context`'s `ReadJSON`, `ReadXML`, `ReadMsgPack`, `ReadYAML`, `ReadForm`, `ReadQuery`, `ReadBody` methods will return the validation error on data validation failures. The [read-json-struct-validation](_examples/request-body/read-json-struct-validation) example was updated.
- A result of <T> can implement the new `hero.PreflightResult` interface which contains a single method of `Preflight(iris.Context) error`. If this method exists on a custom struct value which is returned from a handler then it will fire that `Preflight` first and if not errored then it will cotninue by sending the struct value as JSON(by-default) response body.
@ -415,7 +415,7 @@ New Package-level Variables:
New Context Methods:
- `Context.GzipReader(enable bool)` method and `iris.GzipReader` middleware to enable future request read body calls to decompress data using gzip, [example](_examples/http_request/read-gzip).
- `Context.GzipReader(enable bool)` method and `iris.GzipReader` middleware to enable future request read body calls to decompress data using gzip, [example](_examples/request-body/read-gzip).
- `Context.RegisterDependency(v interface{})` and `Context.RemoveDependency(typ reflect.Type)` to register/remove struct dependencies on serve-time through a middleware.
- `Context.SetID(id interface{})` and `Context.GetID() interface{}` added to register a custom unique indetifier to the Context, if necessary.
- `Context.GetDomain() string` returns the domain.
@ -476,7 +476,6 @@ Implement **new** `SetRegisterRule(iris.RouteOverride, RouteSkip, RouteError)` t
New Examples:
- [_examples/Docker](_examples/Docker)
- [_examples/routing/route-register-rule](_examples/routing/route-register-rule)
# We, 05 February 2020 | v12.1.6
@ -570,10 +569,10 @@ Navigate through: https://github.com/kataras/iris/wiki/Sitemap for more.
## New Examples
2. [_examples/i18n](_examples/i18n)
1. [_examples/sitemap](_examples/sitemap)
3. [_examples/desktop-app/blink](_examples/desktop-app/blink)
4. [_examples/desktop-app/lorca](_examples/desktop-app/lorca)
5. [_examples/desktop-app/webview](_examples/desktop-app/webview)
1. [_examples/sitemap](_examples/routing/sitemap)
3. [_examples/desktop/blink](_examples/desktop/blink)
4. [_examples/desktop/lorca](_examples/desktop/lorca)
5. [_examples/desktop/webview](_examples/desktop/webview)
# Sa, 26 October 2019 | v12.0.0
@ -603,7 +602,7 @@ The iris-contrib/middleare and examples are updated to use the new `github.com/k
# Fr, 16 August 2019 | v11.2.8
- Set `Cookie.SameSite` to `Lax` when subdomains sessions share is enabled[*](https://github.com/kataras/iris/commit/6bbdd3db9139f9038641ce6f00f7b4bab6e62550)
- Add and update all [experimental handlers](https://github.com/kataras/iris/tree/master/_examples/experimental-handlers)
- Add and update all [experimental handlers](https://github.com/iris-contrib/middleware)
- New `XMLMap` function which wraps a `map[string]interface{}` and converts it to a valid xml content to render through `Context.XML` method
- Add new `ProblemOptions.XML` and `RenderXML` fields to render the `Problem` as XML(application/problem+xml) instead of JSON("application/problem+json) and enrich the `Negotiate` to easily accept the `application/problem+xml` mime.
@ -657,7 +656,7 @@ Commit log: https://github.com/kataras/iris/compare/v11.2.3...v11.2.4
- [New Feature: Handle different parameter types in the same path](https://github.com/kataras/iris/issues/1315)
- [New Feature: Content Negotiation](https://github.com/kataras/iris/issues/1319)
- [Context.ReadYAML](https://github.com/kataras/iris/tree/master/_examples/http_request/read-yaml)
- [Context.ReadYAML](https://github.com/kataras/iris/tree/master/_examples/request-body/read-yaml)
- Fixes https://github.com/kataras/neffos/issues/1#issuecomment-515698536
# We, 24 July 2019 | v11.2.2

View File

@ -108,7 +108,7 @@ Registro de commits: https://github.com/kataras/iris/compare/v11.2.3...v11.2.4
- [Nueva característica: Manejar diferentes tipos de parámetros en la misma ruta](https://github.com/kataras/iris/issues/1315)
- [Nueva característica: Negociación de contenido](https://github.com/kataras/iris/issues/1319)
- [Context.ReadYAML](https://github.com/kataras/iris/tree/master/_examples/http_request/read-yaml)
- [Context.ReadYAML](https://github.com/kataras/iris/tree/master/_examples/request-body/read-yaml)
- Ajustes https://github.com/kataras/neffos/issues/1#issuecomment-515698536
# Miércoles, 24 de julio 2019 | v11.2.2

View File

@ -1,31 +1,30 @@
# Table of Contents
* Tutorials
* [Dockerize](tutorial/docker)
* [Caddy](tutorial/caddy)
* [MongoDB](tutorial/mongodb)
* [Dropzone.js](tutorial/dropzonejs)
* [URL Shortener](tutorial/url-shortener/main.go)
* [Online Visitors](tutorial/online-visitors/main.go)
* [REST API for Apache Kafka](tutorial/api-for-apache-kafka)
* [Vue.js Todo (MVC)](tutorial/vuejs-todo-mvc)
* [gRPC (MVC)](mvc/grpc-compatible)
* HTTP Listening
* [HOST:PORT](http-listening/listen-addr/main.go)
* [Public Test Domain](http-listening/listen-addr-public/main.go)
* [UNIX socket file](http-listening/listen-unix/main.go)
* [TLS](http-listening/listen-tls/main.go)
* [Letsencrypt (Automatic Certifications)](http-listening/listen-letsencrypt/main.go)
* [Graceful Shutdown](http-listening/graceful-shutdown/default-notifier/main.go)
* [Notify on shutdown](http-listening/notify-on-shutdown/main.go)
* [REST API for Apache Kafka](kafka-api)
* [URL Shortener](url-shortener)
* [Dropzone.js](dropzonejs)
* [Caddy](caddy)
* Database
* [MySQL, Groupcache & Docker](database/mysql)
* [MongoDB](database/mongodb)
* [Xorm](database/orm/xorm/main.go)
* [Gorm](database/orm/gorm/main.go)
* HTTP Server
* [HOST:PORT](http-server/listen-addr/main.go)
* [Public Test Domain](http-server/listen-addr-public/main.go)
* [UNIX socket file](http-server/listen-unix/main.go)
* [TLS](http-server/listen-tls/main.go)
* [Letsencrypt (Automatic Certifications)](http-server/listen-letsencrypt/main.go)
* [Graceful Shutdown](http-server/graceful-shutdown/default-notifier/main.go)
* [Notify on shutdown](http-server/notify-on-shutdown/main.go)
* Custom TCP Listener
* [Common net.Listener](http-listening/custom-listener/main.go)
* [SO_REUSEPORT for unix systems](http-listening/custom-listener/unix-reuseport/main.go)
* [Common net.Listener](http-server/custom-listener/main.go)
* [SO_REUSEPORT for unix systems](http-server/custom-listener/unix-reuseport/main.go)
* Custom HTTP Server
* [Pass a custom Server](http-listening/custom-httpserver/easy-way/main.go)
* [Use Iris as a single http.Handler](http-listening/custom-httpserver/std-way/main.go)
* [Multi Instances](http-listening/custom-httpserver/multi/main.go)
* [HTTP/3 Quic](http-listening/http3-quic)
* [Pass a custom Server](http-server/custom-httpserver/easy-way/main.go)
* [Use Iris as a single http.Handler](http-server/custom-httpserver/std-way/main.go)
* [Multi Instances](http-server/custom-httpserver/multi/main.go)
* [HTTP/3 Quic](http-server/http3-quic)
* Configuration
* [Functional](configuration/functional/main.go)
* [Configuration Struct](configuration/from-configuration-structure/main.go)
@ -56,19 +55,27 @@
* Custom Context
* [Method Overriding](routing/custom-context/method-overriding/main.go)
* [New Implementation](routing/custom-context/new-implementation/main.go)
* Subdomains
* [Single](subdomains/single/main.go)
* [Multi](subdomains/multi/main.go)
* [Wildcard](subdomains/wildcard/main.go)
* [WWW](subdomains/www/main.go)
* [Redirection](subdomains/redirect/main.go)
* API Versioning
* [How it works](https://github.com/kataras/iris/wiki/API-versioning)
* [Example](versioning/main.go)
* Subdomains
* [Single](routing/subdomains/single/main.go)
* [Multi](routing/subdomains/multi/main.go)
* [Wildcard](routing/subdomains/wildcard/main.go)
* [WWW](routing/subdomains/www/main.go)
* [Redirection](routing/subdomains/redirect/main.go)
* [HTTP Method Override](https://github.com/kataras/iris/blob/master/middleware/methodoverride/methodoverride_test.go)
* [API Versioning](routing/versioning/main.go)
* [Sitemap](routing/sitemap/main.go)
* Logging
* [Request Logger](logging/request-logger/main.go)
* [Log Requests to a File](logging/request-logger/request-logger-file/main.go)
* [Log Requests to a JSON File](logging/request-logger/request-logger-file-json/main.go)
* [Application File Logger](logging/file-logger/main.go)
* [Application JSON Logger](logging/json-logger/main.go)
* API Documentation
* [yaag](apidoc/yaag/main.go)
* Testing
* [Example](testing/httptest/main_test.go)
* [Yaag](apidoc/yaag/main.go)
* [Swagger](https://github.com/iris-contrib/swagger/tree/master/example)
* [Testing](testing/httptest/main_test.go)
* [Recovery](recover/main.go)
* [Profiling](pprof/main.go)
* File Server
* [Favicon](file-server/favicon/main.go)
* [Basic](file-server/basic/main.go)
@ -79,6 +86,8 @@
* [Basic SPA](file-server/single-page-application/basic/main.go)
* [Embedded Single Page Application](file-server/single-page-application/embedded-single-page-application/main.go)
* [Embedded Single Page Application with other routes](file-server/single-page-application/embedded-single-page-application-with-other-routes/main.go)
* [Upload File](file-server/upload-file/main.go)
* [Upload Multiple Files](file-server/upload-files/main.go)
* View
* [Overview](view/overview/main.go)
* [Basic](view/template_html_0/main.go)
@ -93,51 +102,51 @@
* [Pug: `Actions`](view/template_pug_1)
* [Pug: `Includes`](view/template_pug_2)
* [Pug: `Extends`](view/template_pug_3)
* [Jet Template](/view/template_jet_0)
* [Jet Template](view/template_jet_0)
* [Jet Embedded](view/template_jet_1_embedded)
* [Jet 'urlpath' tmpl func](/view/template_jet_2)
* [Jet Template Funcs from Struct](/view/template_jet_3)
* [Jet 'urlpath' tmpl func](view/template_jet_2)
* [Jet Template Funcs from Struct](view/template_jet_3)
* Third-Parties
* [Render `valyala/quicktemplate` templates](http_responsewriter/quicktemplate)
* [Render `shiyanhui/hero` templates](http_responsewriter/herotemplate)
* [Render `valyala/quicktemplate` templates](view/quicktemplate)
* [Render `shiyanhui/hero` templates](view/herotemplate)
* [Request ID](https://github.com/kataras/iris/blob/master/middleware/requestid/requestid_test.go)
* [Request Rate Limit](request-ratelimit/main.go)
* [Request Referrer](request-referrer/main.go)
* [Webassembly](webassembly/basic/main.go)
* Request Body
* [Bind JSON](http_request/read-json/main.go)
* * [Struct Validation](http_request/read-json-struct-validation/main.go)
* [Bind XML](http_request/read-xml/main.go)
* [Bind MsgPack](http_request/read-msgpack/main.go)
* [Bind YAML](http_request/read-yaml/main.go)
* [Bind Form](http_request/read-form/main.go)
* [Bind Query](http_request/read-query/main.go)
* [Bind Body](http_request/read-body/main.go)
* [Bind Custom per type](http_request/read-custom-per-type/main.go)
* [Bind Custom via Unmarshaler](http_request/read-custom-via-unmarshaler/main.go)
* [Bind Many times](http_request/read-many/main.go)
* [Read/Bind Gzip compressed data](http_request/read-gzip/main.go)
* [Upload/Read File](http_request/upload-file/main.go)
* [Upload multiple Files](http_request/upload-files/main.go)
* [Extract Referrer](http_request/extract-referer/main.go)
* [Bind JSON](request-body/read-json/main.go)
* * [Struct Validation](request-body/read-json-struct-validation/main.go)
* [Bind XML](request-body/read-xml/main.go)
* [Bind MsgPack](request-body/read-msgpack/main.go)
* [Bind YAML](request-body/read-yaml/main.go)
* [Bind Form](request-body/read-form/main.go)
* [Bind Query](request-body/read-query/main.go)
* [Bind Body](request-body/read-body/main.go)
* [Bind Custom per type](request-body/read-custom-per-type/main.go)
* [Bind Custom via Unmarshaler](request-body/read-custom-via-unmarshaler/main.go)
* [Bind Many times](request-body/read-many/main.go)
* [Read/Bind Gzip compressed data](request-body/read-gzip/main.go)
* Response Writer
* [Content Negotiation](http_responsewriter/content-negotiation)
* [Text, Markdown, YAML, HTML, JSON, JSONP, Msgpack, XML and Binary](http_responsewriter/write-rest/main.go)
* [Write Gzip](http_responsewriter/write-gzip/main.go)
* [Stream Writer](http_responsewriter/stream-writer/main.go)
* [Transactions](http_responsewriter/transactions/main.go)
* [SSE](http_responsewriter/sse/main.go)
* [SSE (third-party package usage for server sent events)](http_responsewriter/sse-third-party/main.go)
* [Webassembly](webassembly/basic/main.go)
* Cache
* [Simple](cache/simple/main.go)
* [Client-Side (304)](cache/client-side/main.go)
* [Content Negotiation](response-writer/content-negotiation)
* [Text, Markdown, YAML, HTML, JSON, JSONP, Msgpack, XML and Binary](response-writer/write-rest/main.go)
* [Write Gzip](response-writer/write-gzip/main.go)
* [Stream Writer](response-writer/stream-writer/main.go)
* [Transactions](response-writer/transactions/main.go)
* [SSE](response-writer/sse/main.go)
* [SSE (third-party package usage for server sent events)](response-writer/sse-third-party/main.go)
* Cache
* [Simple](response-writer/simple/main.go)
* [Client-Side (304)](response-writer/client-side/main.go)
* Localization and Internationalization
* [i18n](i18n/main.go)
* Sitemaps
* [Sitemap](sitemap/main.go)
* Authentication
* [Basic Authentication](authentication/basicauth/main.go)
* [JWT](miscellaneous/jwt/main.go)
* [JWT (community edition)](experimental-handlers/jwt/main.go)
* [OAUth2](authentication/oauth2/main.go)
* [Manage Permissions](permissions/main.go)
* Authentication, Authorization & Bot Detection
* [Basic Authentication](auth/basicauth/main.go)
* [JWT](auth/jwt/main.go)
* [JWT (community edition)](https://github.com/iris-contrib/middleware/tree/v12/jwt/_example/main.go)
* [OAUth2](auth/goth/main.go)
* [Manage Permissions](auth/permissions/main.go)
* [Google reCAPTCHA](auth/recaptcha/main.go)
* [hCaptcha](auth/hcaptcha/main.go)
* Cookies
* [Basic](cookies/basic/main.go)
* [Options](cookies/options/main.go)
@ -160,6 +169,7 @@
* [Browser NPM Client (browserify)](websocket/basic/browserify/app.js)
* [Native Messages](websocket/native-messages/main.go)
* [TLS](websocket/secure/README.md)
* [Online Visitors](websocket/online-visitors/main.go)
* Dependency Injection
* [Overview (Movies Service)](ependency-injection/overview/main.go)
* [Basic](dependency-injection/basic/main.go)
@ -167,9 +177,9 @@
* [Sessions](dependency-injection/sessions/main.go)
* [Smart Contract](dependency-injection/smart-contract/main.go)
* [JWT](dependency-injection/jwt/main.go)
* [JWT (iris-contrib)](dependency-injection/jwt/contrib/main.go)
* MVC
* [Overview - Repository and Service layers](mvc/overview)
* [Login - Repository and Service layers](mvc/login)
* [Hello world](mvc/hello-world/main.go)
* [Basic](mvc/basic/main.go)
* [Wildcard](mvc/basic/wildcard/main.go)
@ -179,27 +189,13 @@
* [Authenticated Controller](mvc/authenticated-controller/main.go)
* [Websocket Controller](mvc/websocket)
* [Register Middleware](mvc/middleware)
* Object-Relational Mapping
* [Using `go-xorm/xorm` (Mysql, MyMysql, Postgres, Tidb, SQLite, MsSql, MsSql, Oracle)](orm/xorm/main.go)
* [Using `jinzhu/gorm`](orm/gorm/main.go)
* Project Structure
* [Bootstrapper](structuring/bootstrap)
* [MVC with Repository and Service layer Overview](structuring/mvc-plus-repository-and-service-layers)
* [Login (MVC with Single Responsibility package)](structuring/login-mvc-single-responsibility-package)
* [Login (MVC with Datamodels, Datasource, Repository and Service layer)](structuring/login-mvc)
* [gRPC](mvc/grpc-compatible)
* [Login (Repository and Service layers)](mvc/login)
* [Login (Single Responsibility)](mvc/login-mvc-single-responsibility)
* [Vue.js Todo App](mvc/vuejs-todo-mvc)
* [Bootstrapper](bootstrap)
* Desktop Applications
* [The blink package](desktop-app/blink)
* [The lorca package](desktop-app/lorca)
* [The webview package](desktop-app/webview)
* Middlewares (Builtin)
* [JWT](miscellaneous/jwt/main.go)
* [Rate Limit](miscellaneous/ratelimit/main.go)
* [HTTP Method Override](https://github.com/kataras/iris/blob/master/middleware/methodoverride/methodoverride_test.go)
* [Request Logger](http_request/request-logger/main.go)
* [Log Requests to a File](http_request/request-logger/request-logger-file/main.go)
* [Recovery](miscellaneous/recover/main.go)
* [Profiling (pprof)](miscellaneous/pprof/main.go)
* [Internal Application File Logger](miscellaneous/file-logger/main.go)
* [Google reCAPTCHA](miscellaneous/recaptcha/main.go)
* [hCaptcha](miscellaneous/hcaptcha/main.go)
* [The blink package](desktop/blink)
* [The lorca package](desktop/lorca)
* [The webview package](desktop/webview)
* Middlewares [(Community)](https://github.com/iris-contrib/middleware)

View File

@ -0,0 +1,3 @@
# Swagger 2.0
Visit https://github.com/iris-contrib/swagger instead.

View File

@ -1,3 +1,3 @@
{{range $key,$value:=.Providers}}
<p><a href="/auth/{{$value}}">Log in with {{index $.ProvidersMap $value}}</a></p>
{{range $key,$value:=.Providers}}
<p><a href="/auth/{{$value}}">Log in with {{index $.ProvidersMap $value}}</a></p>
{{end}}

View File

@ -1,11 +1,11 @@
<p><a href="/logout/{{.Provider}}">logout</a></p>
<p>Name: {{.Name}} [{{.LastName}}, {{.FirstName}}]</p>
<p>Email: {{.Email}}</p>
<p>NickName: {{.NickName}}</p>
<p>Location: {{.Location}}</p>
<p>AvatarURL: {{.AvatarURL}} <img src="{{.AvatarURL}}"></p>
<p>Description: {{.Description}}</p>
<p>UserID: {{.UserID}}</p>
<p>AccessToken: {{.AccessToken}}</p>
<p>ExpiresAt: {{.ExpiresAt}}</p>
<p><a href="/logout/{{.Provider}}">logout</a></p>
<p>Name: {{.Name}} [{{.LastName}}, {{.FirstName}}]</p>
<p>Email: {{.Email}}</p>
<p>NickName: {{.NickName}}</p>
<p>Location: {{.Location}}</p>
<p>AvatarURL: {{.AvatarURL}} <img src="{{.AvatarURL}}"></p>
<p>Description: {{.Description}}</p>
<p>UserID: {{.UserID}}</p>
<p>AccessToken: {{.AccessToken}}</p>
<p>ExpiresAt: {{.ExpiresAt}}</p>
<p>RefreshToken: {{.RefreshToken}}</p>

View File

@ -19,7 +19,7 @@ func main() {
// or defaults to "secret" and "itsa16bytesecret" respectfully.
//
// Use the `jwt.New` instead for more flexibility, if necessary.
j := jwt.DefaultHMAC(15*time.Minute, "secret", "itsa16bytesecret")
j := jwt.HMAC(15*time.Minute, "secret", "itsa16bytesecret")
app := iris.New()
app.Logger().SetLevel("debug")
@ -68,7 +68,7 @@ func main() {
/*
func default_RSA_Example() {
j := jwt.DefaultRSA(1 * time.Minute)
j := jwt.RSA(15*time.Minute)
}
Same as:
@ -115,7 +115,7 @@ func hmac_Example() {
/*
func load_From_File_With_Password_Example() {
b, err := ioutil.ReadFile("./private_rsa.pem")
b, err := ioutil.ReadFile("./rsa_password_protected.key")
if err != nil {
panic(err)
}

View File

@ -1,7 +0,0 @@
# Authentication
- [Basic Authentication](basicauth/main.go)
- [OAUth2](oauth2/main.go)
- [JWT](https://github.com/kataras/iris/tree/master/_examples/miscellaneous/jwt)
- [JWT (community edition)](https://github.com/iris-contrib/middleware/blob/master/jwt)
- [Sessions](https://github.com/kataras/iris/tree/master/_examples/sessions)

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -1,9 +1,9 @@
package main
import (
"github.com/kataras/iris/v12/_examples/structuring/bootstrap/bootstrap"
"github.com/kataras/iris/v12/_examples/structuring/bootstrap/middleware/identity"
"github.com/kataras/iris/v12/_examples/structuring/bootstrap/routes"
"github.com/kataras/iris/v12/_examples/bootstrap/bootstrap"
"github.com/kataras/iris/v12/_examples/bootstrap/middleware/identity"
"github.com/kataras/iris/v12/_examples/bootstrap/routes"
)
func newApp() *bootstrap.Bootstrapper {

View File

@ -14,11 +14,11 @@ func TestApp(t *testing.T) {
// test our routes
e.GET("/").Expect().Status(httptest.StatusOK)
e.GET("/follower/42").Expect().Status(httptest.StatusOK).
Body().Equal("from /follower/{id:long} with ID: 42")
Body().Equal("from /follower/{id:int64} with ID: 42")
e.GET("/following/52").Expect().Status(httptest.StatusOK).
Body().Equal("from /following/{id:long} with ID: 52")
Body().Equal("from /following/{id:int64} with ID: 52")
e.GET("/like/64").Expect().Status(httptest.StatusOK).
Body().Equal("from /like/{id:long} with ID: 64")
Body().Equal("from /like/{id:int64} with ID: 64")
// test not found
e.GET("/notfound").Expect().Status(httptest.StatusNotFound)

View File

@ -5,7 +5,7 @@ import (
"github.com/kataras/iris/v12"
"github.com/kataras/iris/v12/_examples/structuring/bootstrap/bootstrap"
"github.com/kataras/iris/v12/_examples/bootstrap/bootstrap"
)
// New returns a new handler which adds some headers and view data

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,13 @@
package routes
import (
"github.com/kataras/iris/v12/_examples/bootstrap/bootstrap"
)
// Configure registers the necessary routes to the app.
func Configure(b *bootstrap.Bootstrapper) {
b.Get("/", GetIndexHandler)
b.Get("/follower/{id:int64}", GetFollowerHandler)
b.Get("/following/{id:int64}", GetFollowingHandler)
b.Get("/like/{id:int64}", GetLikeHandler)
}

View File

@ -1,5 +1,5 @@
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
<h3>{{.Err.status}}</h3>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
<h3>{{.Err.status}}</h3>
<h4>{{.Err.message}}</h4>

View File

@ -1,23 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" />
<title>{{.Title}} - {{.AppName}}</title>
</head>
<body>
<div>
<!-- Render the current template here -->
{{ yield }}
<hr />
<footer>
<p>&copy; 2017 - {{.AppOwner}}</p>
</footer>
</div>
</body>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" />
<title>{{.Title}} - {{.AppName}}</title>
</head>
<body>
<div>
<!-- Render the current template here -->
{{ yield }}
<hr />
<footer>
<p>&copy; 2017 - {{.AppOwner}}</p>
</footer>
</div>
</body>
</html>

View File

@ -1,9 +1,9 @@
example.com {
header / Server "Iris"
proxy / example.com:9091 # localhost:9091
}
api.example.com {
header / Server "Iris"
proxy / api.example.com:9092 # localhost:9092
example.com {
header / Server "Iris"
proxy / example.com:9091 # localhost:9091
}
api.example.com {
header / Server "Iris"
proxy / api.example.com:9092 # localhost:9092
}

View File

@ -1,24 +1,24 @@
# Caddy loves Iris
The `Caddyfile` shows how you can use caddy to listen on ports 80 & 443 and sit in front of iris webserver(s) that serving on a different port (9091 and 9092 in this case; see Caddyfile).
## Running our two web servers
1. Go to `$GOPATH/src/github.com/kataras/iris/_examples/tutorial/caddy/server1`
2. Open a terminal window and execute `go run main.go`
3. Go to `$GOPATH/src/github.com/kataras/iris/_examples/tutorial/caddy/server2`
4. Open a new terminal window and execute `go run main.go`
## Caddy installation
1. Download caddy: https://caddyserver.com/download
2. Extract its contents where the `Caddyfile` is located, the `$GOPATH/src/github.com/kataras/iris/_examples/tutorial/caddy` in this case
3. Open, read and modify the `Caddyfile` to see by yourself how easy it is to configure the servers
4. Run `caddy` directly or open a terminal window and execute `caddy`
5. Go to `https://example.com` and `https://api.example.com/user/42`
## Notes
Iris has the `app.Run(iris.AutoTLS(":443", "example.com", "mail@example.com"))` which does
# Caddy loves Iris
The `Caddyfile` shows how you can use caddy to listen on ports 80 & 443 and sit in front of iris webserver(s) that serving on a different port (9091 and 9092 in this case; see Caddyfile).
## Running our two web servers
1. Go to `$GOPATH/src/github.com/kataras/iris/_examples/caddy/server1`
2. Open a terminal window and execute `go run main.go`
3. Go to `$GOPATH/src/github.com/kataras/iris/_examples/caddy/server2`
4. Open a new terminal window and execute `go run main.go`
## Caddy installation
1. Download caddy: https://caddyserver.com/download
2. Extract its contents where the `Caddyfile` is located, the `$GOPATH/src/github.com/kataras/iris/_examples/caddy` in this case
3. Open, read and modify the `Caddyfile` to see by yourself how easy it is to configure the servers
4. Run `caddy` directly or open a terminal window and execute `caddy`
5. Go to `https://example.com` and `https://api.example.com/user/42`
## Notes
Iris has the `app.Run(iris.AutoTLS(":443", "example.com", "mail@example.com"))` which does
the exactly same thing but caddy is a great tool that helps you when you run multiple web servers from one host machine, i.e iris, apache, tomcat.

View File

@ -1,3 +1,3 @@
<div>
{{.Message}}
<div>
{{.Message}}
</div>

View File

@ -1,11 +1,11 @@
<html>
<head>
<title>{{.Layout.Title}}</title>
</head>
<body>
{{ yield }}
</body>
<html>
<head>
<title>{{.Layout.Title}}</title>
</head>
<body>
{{ yield }}
</body>
</html>

View File

@ -35,7 +35,7 @@ func (c *UserController) Get() string {
// User is our test User model, nothing tremendous here.
type User struct{ ID int64 }
// GetBy handles GET /user/42, equal to .Get("/user/{id:long}")
// GetBy handles GET /user/42, equal to .Get("/user/{id:int64}")
func (c *UserController) GetBy(id int64) User {
// Select User by ID == $id.
return User{id}

View File

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View 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"]

View File

@ -7,11 +7,17 @@ Article is coming soon, follow and stay tuned
Read [the fully functional example](main.go).
## Run
### Docker
Install [Docker](https://www.docker.com/) and execute the command below
```sh
$ go get -u go.mongodb.org/mongo-driver/...
$ go get -u github.com/joho/godotenv
$ docker-compose up
```
### Manually
```sh
# .env file contents
@ -22,7 +28,7 @@ DSN=mongodb://localhost:27017
```sh
$ go run main.go
> 2019/01/28 05:17:59 Loading environment variables from file: .env
> 2019/01/28 05:17:59 ◽ PORT=8080
> 2019/01/28 05:17:59 ◽ Port=8080
> 2019/01/28 05:17:59 ◽ DSN=mongodb://localhost:27017
> Now listening on: http://localhost:8080
```

View File

@ -1,8 +1,8 @@
package storeapi
import (
"github.com/kataras/iris/v12/_examples/tutorial/mongodb/httputil"
"github.com/kataras/iris/v12/_examples/tutorial/mongodb/store"
"myapp/httputil"
"myapp/store"
"github.com/kataras/iris/v12"
)

View File

@ -0,0 +1,18 @@
version: "3.1"
services:
app:
build: .
environment:
Port: 8080
DSN: db:27017
ports:
- 8080:8080
depends_on:
- db
db:
image: mongo
environment:
MONGO_INITDB_DATABASE: store
ports:
- 27017:27017

View File

@ -22,6 +22,9 @@ var (
func parse() {
Port = getDefault("PORT", "8080")
DSN = getDefault("DSN", "mongodb://localhost:27017")
log.Printf("• Port=%s\n", Port)
log.Printf("• DSN=%s\n", DSN)
}
// Load loads environment variables that are being used across the whole app.
@ -34,28 +37,31 @@ func parse() {
// After `Load` the callers can get an environment variable via `os.Getenv`.
func Load(envFileName string) {
if args := os.Args; len(args) > 1 && args[1] == "help" {
fmt.Fprintln(os.Stderr, "https://github.com/kataras/iris/blob/master/_examples/tutorials/mongodb/README.md")
fmt.Fprintln(os.Stderr, "https://github.com/kataras/iris/blob/master/_examples/database/mongodb/README.md")
os.Exit(-1)
}
log.Printf("Loading environment variables from file: %s\n", envFileName)
// If more than one filename passed with comma separated then load from all
// of these, a env file can be a partial too.
envFiles := strings.Split(envFileName, ",")
for i := range envFiles {
if filepath.Ext(envFiles[i]) == "" {
envFiles[i] += ".env"
for _, envFile := range envFiles {
if filepath.Ext(envFile) == "" {
envFile += ".env"
}
if fileExists(envFile) {
log.Printf("Loading environment variables from file: %s\n", envFile)
if err := godotenv.Load(envFile); err != nil {
panic(fmt.Sprintf("error loading environment variables from [%s]: %v", envFile, err))
}
}
}
if err := godotenv.Load(envFiles...); err != nil {
panic(fmt.Sprintf("error loading environment variables from [%s]: %v", envFileName, err))
}
envMap, _ := godotenv.Read(envFiles...)
for k, v := range envMap {
log.Printf("◽ %s=%s\n", k, v)
}
// envMap, _ := godotenv.Read(envFiles...)
// for k, v := range envMap {
// log.Printf("◽ %s=%s\n", k, v)
// }
parse()
}
@ -69,3 +75,11 @@ func getDefault(key string, def string) string {
return value
}
func fileExists(filename string) bool {
info, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return !info.IsDir()
}

View File

@ -0,0 +1,9 @@
module myapp
go 1.14
require (
github.com/joho/godotenv v1.3.0
github.com/kataras/iris/v12 v12.2.0
go.mongodb.org/mongo-driver v1.3.4
)

View File

@ -11,11 +11,11 @@ import (
"os"
// APIs
storeapi "github.com/kataras/iris/v12/_examples/tutorial/mongodb/api/store"
storeapi "myapp/api/store"
//
"github.com/kataras/iris/v12/_examples/tutorial/mongodb/env"
"github.com/kataras/iris/v12/_examples/tutorial/mongodb/store"
"myapp/env"
"myapp/store"
"github.com/kataras/iris/v12"
@ -30,7 +30,7 @@ func init() {
flagset := flag.CommandLine
flagset.StringVar(&envFileName, "env", envFileName, "the env file which web app will use to extract its environment variables")
flag.CommandLine.Parse(os.Args[1:])
flagset.Parse(os.Args[1:])
env.Load(envFileName)
}

View 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"]

View 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)

View 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)
}
}

View 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})
}
}

View 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"))
}

View 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 == "")
}

View 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)
}

View 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))
}

View 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

View 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...]
}
)*/

View 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...]
}
)
*/

View 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
)

View 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
}

View File

@ -0,0 +1,5 @@
{
"title": "computer-internet",
"position": 2,
"image_url": "https://bp.pstatic.gr/public/dist/images/1mOPxYtw1k.webp"
}

View File

@ -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"
}]

View File

@ -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"
}

View File

@ -0,0 +1,3 @@
{
"title": "computers-technology"
}

View 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": {}
}

View File

@ -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"
}

View File

@ -0,0 +1,3 @@
{
"title": "product-19-new-title"
}

View File

@ -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"
}

View 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;

View 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)
}

View 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)
}
}

View 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)
}

View 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...)
}

View 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
}

View 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
}

View File

@ -0,0 +1,66 @@
package main
import (
"github.com/kataras/iris/v12"
"github.com/iris-contrib/middleware/jwt"
)
var secret = []byte("My Secret Key")
func main() {
app := iris.New()
app.ConfigureContainer(register)
app.Listen(":8080")
}
func register(api *iris.APIContainer) {
j := jwt.New(jwt.Config{
// Extract by "token" url parameter.
Extractor: jwt.FromFirst(jwt.FromParameter("token"), jwt.FromAuthHeader),
ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) {
return secret, nil
},
SigningMethod: jwt.SigningMethodHS256,
})
api.Get("/authenticate", writeToken)
// This works as usually:
api.Get("/restricted", j.Serve, restrictedPage)
// You can also bind the *jwt.Token (see `verifiedWithBindedTokenPage`)
// by registering a *jwt.Token dependency.
//
// api.RegisterDependency(func(ctx iris.Context) *jwt.Token {
// if err := j.CheckJWT(ctx); err != nil {
// ctx.StopWithStatus(iris.StatusUnauthorized)
// return nil
// }
//
// token := j.Get(ctx)
// return token
// })
// ^ You can do the same with MVC too, as the container is shared and works
// the same way in both functions-as-handlers and structs-as-controllers.
//
// api.Get("/", restrictedPageWithBindedTokenPage)
}
func writeToken() string {
token := jwt.NewTokenWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"foo": "bar",
})
tokenString, _ := token.SignedString(secret)
return tokenString
}
func restrictedPage() string {
return "This page can only be seen by verified clients"
}
func restrictedPageWithBindedTokenPage(token *jwt.Token) string {
// Token[foo] value: bar
return "Token[foo] value: " + token.Claims.(jwt.MapClaims)["foo"].(string)
}

View File

@ -1,13 +1,12 @@
package main
import (
"time"
"github.com/kataras/iris/v12"
"github.com/iris-contrib/middleware/jwt"
"github.com/kataras/iris/v12/middleware/jwt"
)
var secret = []byte("My Secret Key")
func main() {
app := iris.New()
app.ConfigureContainer(register)
@ -16,51 +15,36 @@ func main() {
}
func register(api *iris.APIContainer) {
j := jwt.New(jwt.Config{
// Extract by "token" url parameter.
Extractor: jwt.FromFirst(jwt.FromParameter("token"), jwt.FromAuthHeader),
ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) {
return secret, nil
},
SigningMethod: jwt.SigningMethodHS256,
j := jwt.HMAC(15*time.Minute, "secret", "secretforencrypt")
api.RegisterDependency(func(ctx iris.Context) (claims userClaims) {
if err := j.VerifyToken(ctx, &claims); err != nil {
ctx.StopWithError(iris.StatusUnauthorized, err)
return
}
return
})
api.Get("/token", writeToken)
// This works as usually:
api.Get("/", j.Serve, verifiedPage)
// You can also bind the *jwt.Token (see `verifiedWithBindedTokenPage`)
// by registering a *jwt.Token dependency.
//
// api.RegisterDependency(func(ctx iris.Context) *jwt.Token {
// if err := j.CheckJWT(ctx); err != nil {
// ctx.StopWithStatus(iris.StatusUnauthorized)
// return nil
// }
//
// token := j.Get(ctx)
// return token
// })
// ^ You can do the same with MVC too, as the container is shared and works
// the same way in both functions-as-handlers and structs-as-controllers.
//
// api.Get("/", verifiedWithBindedTokenPage)
api.Get("/authenticate", writeToken(j))
api.Get("/restricted", restrictedPage)
}
func writeToken() string {
token := jwt.NewTokenWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"foo": "bar",
})
tokenString, _ := token.SignedString(secret)
return tokenString
type userClaims struct {
jwt.Claims
Username string
}
func verifiedPage() string {
return "This page can only be seen by verified clients"
func writeToken(j *jwt.JWT) iris.Handler {
return func(ctx iris.Context) {
j.WriteToken(ctx, userClaims{
Claims: j.Expiry(jwt.Claims{Issuer: "an-issuer"}),
Username: "kataras",
})
}
}
func verifiedWithBindedTokenPage(token *jwt.Token) string {
// Token[foo] value: bar
return "Token[foo] value: " + token.Claims.(jwt.MapClaims)["foo"].(string)
func restrictedPage(claims userClaims) string {
// userClaims.Username: kataras
return "userClaims.Username: " + claims.Username
}

View File

@ -1,168 +1,168 @@
# Articles
* [How to build a file upload form using DropzoneJS and Go](https://hackernoon.com/how-to-build-a-file-upload-form-using-dropzonejs-and-go-8fb9f258a991)
* [How to display existing files on server using DropzoneJS and Go](https://hackernoon.com/how-to-display-existing-files-on-server-using-dropzonejs-and-go-53e24b57ba19)
# Content
This is the part 1 of 2 in DropzoneJS + Go series.
- [Part 1: How to build a file upload form](README.md)
- [Part 2: How to display existing files on server](README_PART2.md)
# DropzoneJS + Go: How to build a file upload form
[DropzoneJS](https://github.com/enyo/dropzone) is an open source library that provides drag'n'drop file uploads with image previews. It is a great JavaScript library which actually does not even rely on JQuery.
In this tutorial, we are building a multiple file upload form using DropzoneJS, and the backend will be handled by Go and [Iris](https://iris-go.com).
## Table Of Content
- [Preparation](#preparation)
- [Work with DropzoneJS](#work-with-dropzonejs)
- [Work with Go](#work-with-go)
## Preparation
1. Download [Go(Golang)](https://golang.org/dl), setup your computer as shown there and continue to 2.
2. Install [Iris](https://github.com/kataras/iris); open a terminal and execute `go get -u github.com/kataras/iris`
3. Download DropzoneJS from [this URL](https://raw.githubusercontent.com/enyo/dropzone/master/dist/dropzone.js). DropzoneJS does not rely on JQuery, you will not have to worry that, upgrading JQuery version breaks your application.
4. Download dropzone.css from [this URL](https://raw.githubusercontent.com/enyo/dropzone/master/dist/dropzone.css), if you want some already made css.
5. Create a folder "./public/uploads", this is for storing uploaded files.
6. Create a file "./views/upload.html", this is for the front form page.
7. Create a file "./main.go", this is for handling backend file upload process.
Your folder&file structure should look like this after the preparation:
![folder&file structure](folder_structure.png)
## Work with DropzoneJS
Open file "./views/upload.html" and let us create a DropzoneJs form.
Copy the content below to "./views/upload.html" and we will go through each line of code individually.
```html
<!-- /views/upload.html -->
<html>
<head>
<title>DropzoneJS Uploader</title>
<!-- 1 -->
<link href="/public/css/dropzone.css" type="text/css" rel="stylesheet" />
<!-- 2 -->
<script src="/public/js/dropzone.js"></script>
</head>
<body>
<!-- 3 -->
<form action="/upload" method="POST" class="dropzone" id="my-dropzone">
<div class="fallback">
<input name="file" type="file" multiple />
<input type="submit" value="Upload" />
</div>
</form>
</body>
</html>
```
1. Include the CSS Stylesheet.
2. Include DropzoneJS JavaScript library.
3. Create an upload form with css class "dropzone" and "action" is the route path "/upload". Note that we did create an input filed for fallback mode. This is all handled by DropzoneJS library itself. All we need to do is assign css class "dropzone" to the form. By default, DropzoneJS will find all forms with class "dropzone" and automatically attach itself to it.
## Work with Go
Now you have come to Last part of the tutorial. In this section, we will store files sent from DropzoneJS to the "./public/uploads" folder.
Open "main.go" and copy the code below:
```go
// main.go
package main
import (
"os"
"io"
"strings"
"github.com/kataras/iris/v12"
)
const uploadsDir = "./public/uploads/"
func main() {
app := iris.New()
// Register templates
app.RegisterView(iris.HTML("./views", ".html"))
// Make the /public route path to statically serve the ./public/... contents
app.HandleDir("/public", "./public")
// Render the actual form
// GET: http://localhost:8080
app.Get("/", func(ctx iris.Context) {
ctx.View("upload.html")
})
// Upload the file to the server
// POST: http://localhost:8080/upload
app.Post("/upload", iris.LimitRequestBodySize(10<<20), func(ctx iris.Context) {
// Get the file from the dropzone request
file, info, err := ctx.FormFile("file")
if err != nil {
ctx.StatusCode(iris.StatusInternalServerError)
ctx.Application().Logger().Warnf("Error while uploading: %v", err.Error())
return
}
defer file.Close()
fname := info.Filename
// Create a file with the same name
// assuming that you have a folder named 'uploads'
out, err := os.OpenFile(uploadsDir+fname,
os.O_WRONLY|os.O_CREATE, 0666)
if err != nil {
ctx.StatusCode(iris.StatusInternalServerError)
ctx.Application().Logger().Warnf("Error while preparing the new file: %v", err.Error())
return
}
defer out.Close()
io.Copy(out, file)
})
// Start the server at http://localhost:8080
app.Listen(":8080")
}
```
1. Create a new Iris app.
2. Register and load templates from the "views" folder.
3. Make the "/public" route path to statically serve the ./public/... folder's contents
4. Create a route to serve the upload form.
5. Create a route to handle the POST form data from the DropzoneJS' form
6. Declare a variable for destination folder.
7. If file is sent to the page, store the file object to a temporary "file" variable.
8. Move uploaded file to destination based on the uploadsDir+uploaded file's name.
### Running the server
Open the terminal at the current project's folder and execute:
```bash
$ go run main.go
Now listening on: http://localhost:8080
Application started. Press CTRL+C to shut down.
```
Now go to browser, and navigate to http://localhost:8080, you should be able to see a page as below:
![no files screenshot](no_files.png)
# Articles
* [How to build a file upload form using DropzoneJS and Go](https://hackernoon.com/how-to-build-a-file-upload-form-using-dropzonejs-and-go-8fb9f258a991)
* [How to display existing files on server using DropzoneJS and Go](https://hackernoon.com/how-to-display-existing-files-on-server-using-dropzonejs-and-go-53e24b57ba19)
# Content
This is the part 1 of 2 in DropzoneJS + Go series.
- [Part 1: How to build a file upload form](README.md)
- [Part 2: How to display existing files on server](README_PART2.md)
# DropzoneJS + Go: How to build a file upload form
[DropzoneJS](https://github.com/enyo/dropzone) is an open source library that provides drag'n'drop file uploads with image previews. It is a great JavaScript library which actually does not even rely on JQuery.
In this tutorial, we are building a multiple file upload form using DropzoneJS, and the backend will be handled by Go and [Iris](https://iris-go.com).
## Table Of Content
- [Preparation](#preparation)
- [Work with DropzoneJS](#work-with-dropzonejs)
- [Work with Go](#work-with-go)
## Preparation
1. Download [Go(Golang)](https://golang.org/dl), setup your computer as shown there and continue to 2.
2. Install [Iris](https://github.com/kataras/iris); open a terminal and execute `go get -u github.com/kataras/iris`
3. Download DropzoneJS from [this URL](https://raw.githubusercontent.com/enyo/dropzone/master/dist/dropzone.js). DropzoneJS does not rely on JQuery, you will not have to worry that, upgrading JQuery version breaks your application.
4. Download dropzone.css from [this URL](https://raw.githubusercontent.com/enyo/dropzone/master/dist/dropzone.css), if you want some already made css.
5. Create a folder "./public/uploads", this is for storing uploaded files.
6. Create a file "./views/upload.html", this is for the front form page.
7. Create a file "./main.go", this is for handling backend file upload process.
Your folder&file structure should look like this after the preparation:
![folder&file structure](folder_structure.png)
## Work with DropzoneJS
Open file "./views/upload.html" and let us create a DropzoneJs form.
Copy the content below to "./views/upload.html" and we will go through each line of code individually.
```html
<!-- /views/upload.html -->
<html>
<head>
<title>DropzoneJS Uploader</title>
<!-- 1 -->
<link href="/public/css/dropzone.css" type="text/css" rel="stylesheet" />
<!-- 2 -->
<script src="/public/js/dropzone.js"></script>
</head>
<body>
<!-- 3 -->
<form action="/upload" method="POST" class="dropzone" id="my-dropzone">
<div class="fallback">
<input name="file" type="file" multiple />
<input type="submit" value="Upload" />
</div>
</form>
</body>
</html>
```
1. Include the CSS Stylesheet.
2. Include DropzoneJS JavaScript library.
3. Create an upload form with css class "dropzone" and "action" is the route path "/upload". Note that we did create an input filed for fallback mode. This is all handled by DropzoneJS library itself. All we need to do is assign css class "dropzone" to the form. By default, DropzoneJS will find all forms with class "dropzone" and automatically attach itself to it.
## Work with Go
Now you have come to Last part of the tutorial. In this section, we will store files sent from DropzoneJS to the "./public/uploads" folder.
Open "main.go" and copy the code below:
```go
// main.go
package main
import (
"os"
"io"
"strings"
"github.com/kataras/iris/v12"
)
const uploadsDir = "./public/uploads/"
func main() {
app := iris.New()
// Register templates
app.RegisterView(iris.HTML("./views", ".html"))
// Make the /public route path to statically serve the ./public/... contents
app.HandleDir("/public", "./public")
// Render the actual form
// GET: http://localhost:8080
app.Get("/", func(ctx iris.Context) {
ctx.View("upload.html")
})
// Upload the file to the server
// POST: http://localhost:8080/upload
app.Post("/upload", iris.LimitRequestBodySize(10<<20), func(ctx iris.Context) {
// Get the file from the dropzone request
file, info, err := ctx.FormFile("file")
if err != nil {
ctx.StatusCode(iris.StatusInternalServerError)
ctx.Application().Logger().Warnf("Error while uploading: %v", err.Error())
return
}
defer file.Close()
fname := info.Filename
// Create a file with the same name
// assuming that you have a folder named 'uploads'
out, err := os.OpenFile(uploadsDir+fname,
os.O_WRONLY|os.O_CREATE, 0666)
if err != nil {
ctx.StatusCode(iris.StatusInternalServerError)
ctx.Application().Logger().Warnf("Error while preparing the new file: %v", err.Error())
return
}
defer out.Close()
io.Copy(out, file)
})
// Start the server at http://localhost:8080
app.Listen(":8080")
}
```
1. Create a new Iris app.
2. Register and load templates from the "views" folder.
3. Make the "/public" route path to statically serve the ./public/... folder's contents
4. Create a route to serve the upload form.
5. Create a route to handle the POST form data from the DropzoneJS' form
6. Declare a variable for destination folder.
7. If file is sent to the page, store the file object to a temporary "file" variable.
8. Move uploaded file to destination based on the uploadsDir+uploaded file's name.
### Running the server
Open the terminal at the current project's folder and execute:
```bash
$ go run main.go
Now listening on: http://localhost:8080
Application started. Press CTRL+C to shut down.
```
Now go to browser, and navigate to http://localhost:8080, you should be able to see a page as below:
![no files screenshot](no_files.png)
![with uploaded files screenshot](with_files.png)

View File

@ -1,310 +1,310 @@
# Articles
* [How to build a file upload form using DropzoneJS and Go](https://hackernoon.com/how-to-build-a-file-upload-form-using-dropzonejs-and-go-8fb9f258a991)
* [How to display existing files on server using DropzoneJS and Go](https://hackernoon.com/how-to-display-existing-files-on-server-using-dropzonejs-and-go-53e24b57ba19)
# Content
This is the part 2 of 2 in DropzoneJS + Go series.
- [Part 1: How to build a file upload form](README.md)
- [Part 2: How to display existing files on server](README_PART2.md)
# DropzoneJS + Go: How to display existing files on server
In this tutorial, we will show you how to display existing files on the server when using DropzoneJS and Go. This tutorial is based on [How to build a file upload form using DropzoneJS and Go](README.md). Make sure you have read it before proceeding to content in this tutorial.
## Table Of Content
- [Preparation](#preparation)
- [Modify the Server side](#modify-the-server-side)
- [Modify the Client side](#modify-the-client-side)
- [References](#references)
- [The End](#the-end)
## Preparation
Install the go package "github.com/nfnt/resize" with `go get github.com/nfnt/resize`, we need it to create thumbnails.
In previous [tutorial](README.md). We have already set up a proper working DropzoneJs upload form. There is no additional file needed for this tutorial. What we need to do is to make some modifications to file below:
1. main.go
2. views/upload.html
Let us get started!
## Modify the Server side
In previous tutorial. All "/upload" does is to store uploaded files to the server directory "./public/uploads". So we need to add a piece of code to retrieve stored files' information (name and size), and return it in JSON format.
Copy the content below to "main.go". Read comments for details.
```go
// main.go
package main
import (
"image/jpeg"
"image/png"
"io"
"os"
"path"
"path/filepath"
"strings"
"sync"
"github.com/kataras/iris/v12"
"github.com/nfnt/resize" // $ go get -u github.com/nfnt/resize
)
const uploadsDir = "./public/uploads/"
type uploadedFile struct {
// {name: "", size: } are the dropzone's only requirements.
Name string `json:"name"`
Size int64 `json:"size"`
}
type uploadedFiles struct {
dir string
items []uploadedFile
mu sync.RWMutex // slices are safe but RWMutex is a good practise for you.
}
// scan the ./public/uploads folder for any files
// add them to a new uploadedFiles list.
func scanUploads(dir string) *uploadedFiles {
f := new(uploadedFiles)
lindex := dir[len(dir)-1]
if lindex != os.PathSeparator && lindex != '/' {
dir += string(os.PathSeparator)
}
// create directories if necessary
// and if, then return empty uploaded files; skipping the scan.
if err := os.MkdirAll(dir, os.FileMode(0666)); err != nil {
return f
}
// otherwise scan the given "dir" for files.
f.scan(dir)
return f
}
func (f *uploadedFiles) scan(dir string) {
f.dir = dir
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
// if it's directory or a thumbnail we saved earlier, skip it.
if info.IsDir() || strings.HasPrefix(info.Name(), "thumbnail_") {
return nil
}
f.add(info.Name(), info.Size())
return nil
})
}
// add the file's Name and Size to the uploadedFiles memory list
func (f *uploadedFiles) add(name string, size int64) uploadedFile {
uf := uploadedFile{
Name: name,
Size: size,
}
f.mu.Lock()
f.items = append(f.items, uf)
f.mu.Unlock()
return uf
}
// create thumbnail 100x100
// and save that to the ./public/uploads/thumbnail_$FILENAME
func (f *uploadedFiles) createThumbnail(uf uploadedFile) {
file, err := os.Open(path.Join(f.dir, uf.Name))
if err != nil {
return
}
defer file.Close()
name := strings.ToLower(uf.Name)
out, err := os.OpenFile(f.dir+"thumbnail_"+uf.Name,
os.O_WRONLY|os.O_CREATE, 0666)
if err != nil {
return
}
defer out.Close()
if strings.HasSuffix(name, ".jpg") {
// decode jpeg into image.Image
img, err := jpeg.Decode(file)
if err != nil {
return
}
// write new image to file
resized := resize.Thumbnail(180, 180, img, resize.Lanczos3)
jpeg.Encode(out, resized,
&jpeg.Options{Quality: jpeg.DefaultQuality})
} else if strings.HasSuffix(name, ".png") {
img, err := png.Decode(file)
if err != nil {
return
}
// write new image to file
resized := resize.Thumbnail(180, 180, img, resize.Lanczos3) // slower but better res
png.Encode(out, resized)
}
// and so on... you got the point, this code can be simplify, as a practise.
}
func main() {
app := iris.New()
app.RegisterView(iris.HTML("./views", ".html"))
app.HandleDir("/public", "./public")
app.Get("/", func(ctx iris.Context) {
ctx.View("upload.html")
})
files := scanUploads(uploadsDir)
app.Get("/uploads", func(ctx iris.Context) {
ctx.JSON(files.items)
})
app.Post("/upload", iris.LimitRequestBodySize(10<<20), func(ctx iris.Context) {
// Get the file from the dropzone request
file, info, err := ctx.FormFile("file")
if err != nil {
ctx.StatusCode(iris.StatusInternalServerError)
ctx.Application().Logger().Warnf("Error while uploading: %v", err.Error())
return
}
defer file.Close()
fname := info.Filename
// Create a file with the same name
// assuming that you have a folder named 'uploads'
out, err := os.OpenFile(uploadsDir+fname,
os.O_WRONLY|os.O_CREATE, 0666)
if err != nil {
ctx.StatusCode(iris.StatusInternalServerError)
ctx.Application().Logger().Warnf("Error while preparing the new file: %v", err.Error())
return
}
defer out.Close()
io.Copy(out, file)
// optionally, add that file to the list in order to be visible when refresh.
uploadedFile := files.add(fname, info.Size)
go files.createThumbnail(uploadedFile)
})
// start the server at http://localhost:8080
app.Listen(":8080")
}
```
## Modify the Client side
Copy content below to "./views/upload.html". We will go through modifications individually.
```html
<!-- /views/upload.html -->
<html>
<head>
<title>DropzoneJS Uploader</title>
<!-- 1 -->
<link href="/public/css/dropzone.css" type="text/css" rel="stylesheet" />
<!-- 2 -->
<script src="/public/js/dropzone.js"></script>
<!-- 4 -->
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
<!-- 5 -->
<script>
Dropzone.options.myDropzone = {
paramName: "file", // The name that will be used to transfer the file
init: function () {
thisDropzone = this;
// 6
$.get('/uploads', function (data) {
if (data == null) {
return;
}
// 7
$.each(data, function (key, value) {
var mockFile = { name: value.name, size: value.size };
thisDropzone.emit("addedfile", mockFile);
thisDropzone.options.thumbnail.call(thisDropzone, mockFile, '/public/uploads/thumbnail_' + value.name);
// Make sure that there is no progress bar, etc...
thisDropzone.emit("complete", mockFile);
});
});
}
};
</script>
</head>
<body>
<!-- 3 -->
<form action="/upload" method="POST" class="dropzone" id="my-dropzone">
<div class="fallback">
<input name="file" type="file" multiple />
<input type="submit" value="Upload" />
</div>
</form>
</body>
</html>
```
1. We added Jquery library into our page. This actually not for DropzoneJs directly. We are using Jquery's ajax function **$.get** only. You will see below
2. We added an ID element (my-dropzone) to the form. This is needed because we need to pass configuration values to Dropzone. And to do it, we must have an ID reference of it. So that we can configure it by assigning values to Dropzone.options.myDropzone. A lot of people face confusion when configuring Dropzone. To put it in a simple way. Do not take Dropzone as a Jquery plugin, it has its own syntax and you need to follow it.
3. This starts the main part of modification. What we did here is to pass a function to listen to Dropzone's init event. This event is called when Dropzone is initialized.
4. Retrieve files details from the new "/uploads" via ajax.
5. Create mockFile using values from server. mockFile is simply JavaScript objects with properties of name and size. Then we call Dropzone's **addedfile** and **thumbnail** functions explicitly to put existing files to Dropzone upload area and generate its thumbnail.
### Running the server
Open the terminal at the current project's folder and execute:
```bash
$ go run main.go
Now listening on: http://localhost:8080
Application started. Press CTRL+C to shut down.
```
If you have done it successfully. Now go and upload some images and reload the upload page. Already uploaded files should auto display in Dropzone area.
![with uploaded files screenshot](with_files.png)
## References
- http://www.dropzonejs.com/#server-side-implementation
- https://www.startutorial.com/articles/view/how-to-build-a-file-upload-form-using-dropzonejs-and-php
- https://docs.iris-go.com
- https://github.com/kataras/iris/tree/master/_examples/tutorial/dropzonejs
## The end
Hopefully this simple tutorial helped you with your development.
# Articles
* [How to build a file upload form using DropzoneJS and Go](https://hackernoon.com/how-to-build-a-file-upload-form-using-dropzonejs-and-go-8fb9f258a991)
* [How to display existing files on server using DropzoneJS and Go](https://hackernoon.com/how-to-display-existing-files-on-server-using-dropzonejs-and-go-53e24b57ba19)
# Content
This is the part 2 of 2 in DropzoneJS + Go series.
- [Part 1: How to build a file upload form](README.md)
- [Part 2: How to display existing files on server](README_PART2.md)
# DropzoneJS + Go: How to display existing files on server
In this tutorial, we will show you how to display existing files on the server when using DropzoneJS and Go. This tutorial is based on [How to build a file upload form using DropzoneJS and Go](README.md). Make sure you have read it before proceeding to content in this tutorial.
## Table Of Content
- [Preparation](#preparation)
- [Modify the Server side](#modify-the-server-side)
- [Modify the Client side](#modify-the-client-side)
- [References](#references)
- [The End](#the-end)
## Preparation
Install the go package "github.com/nfnt/resize" with `go get github.com/nfnt/resize`, we need it to create thumbnails.
In previous [tutorial](README.md). We have already set up a proper working DropzoneJs upload form. There is no additional file needed for this tutorial. What we need to do is to make some modifications to file below:
1. main.go
2. views/upload.html
Let us get started!
## Modify the Server side
In previous tutorial. All "/upload" does is to store uploaded files to the server directory "./public/uploads". So we need to add a piece of code to retrieve stored files' information (name and size), and return it in JSON format.
Copy the content below to "main.go". Read comments for details.
```go
// main.go
package main
import (
"image/jpeg"
"image/png"
"io"
"os"
"path"
"path/filepath"
"strings"
"sync"
"github.com/kataras/iris/v12"
"github.com/nfnt/resize" // $ go get -u github.com/nfnt/resize
)
const uploadsDir = "./public/uploads/"
type uploadedFile struct {
// {name: "", size: } are the dropzone's only requirements.
Name string `json:"name"`
Size int64 `json:"size"`
}
type uploadedFiles struct {
dir string
items []uploadedFile
mu sync.RWMutex // slices are safe but RWMutex is a good practise for you.
}
// scan the ./public/uploads folder for any files
// add them to a new uploadedFiles list.
func scanUploads(dir string) *uploadedFiles {
f := new(uploadedFiles)
lindex := dir[len(dir)-1]
if lindex != os.PathSeparator && lindex != '/' {
dir += string(os.PathSeparator)
}
// create directories if necessary
// and if, then return empty uploaded files; skipping the scan.
if err := os.MkdirAll(dir, os.FileMode(0666)); err != nil {
return f
}
// otherwise scan the given "dir" for files.
f.scan(dir)
return f
}
func (f *uploadedFiles) scan(dir string) {
f.dir = dir
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
// if it's directory or a thumbnail we saved earlier, skip it.
if info.IsDir() || strings.HasPrefix(info.Name(), "thumbnail_") {
return nil
}
f.add(info.Name(), info.Size())
return nil
})
}
// add the file's Name and Size to the uploadedFiles memory list
func (f *uploadedFiles) add(name string, size int64) uploadedFile {
uf := uploadedFile{
Name: name,
Size: size,
}
f.mu.Lock()
f.items = append(f.items, uf)
f.mu.Unlock()
return uf
}
// create thumbnail 100x100
// and save that to the ./public/uploads/thumbnail_$FILENAME
func (f *uploadedFiles) createThumbnail(uf uploadedFile) {
file, err := os.Open(path.Join(f.dir, uf.Name))
if err != nil {
return
}
defer file.Close()
name := strings.ToLower(uf.Name)
out, err := os.OpenFile(f.dir+"thumbnail_"+uf.Name,
os.O_WRONLY|os.O_CREATE, 0666)
if err != nil {
return
}
defer out.Close()
if strings.HasSuffix(name, ".jpg") {
// decode jpeg into image.Image
img, err := jpeg.Decode(file)
if err != nil {
return
}
// write new image to file
resized := resize.Thumbnail(180, 180, img, resize.Lanczos3)
jpeg.Encode(out, resized,
&jpeg.Options{Quality: jpeg.DefaultQuality})
} else if strings.HasSuffix(name, ".png") {
img, err := png.Decode(file)
if err != nil {
return
}
// write new image to file
resized := resize.Thumbnail(180, 180, img, resize.Lanczos3) // slower but better res
png.Encode(out, resized)
}
// and so on... you got the point, this code can be simplify, as a practise.
}
func main() {
app := iris.New()
app.RegisterView(iris.HTML("./views", ".html"))
app.HandleDir("/public", "./public")
app.Get("/", func(ctx iris.Context) {
ctx.View("upload.html")
})
files := scanUploads(uploadsDir)
app.Get("/uploads", func(ctx iris.Context) {
ctx.JSON(files.items)
})
app.Post("/upload", iris.LimitRequestBodySize(10<<20), func(ctx iris.Context) {
// Get the file from the dropzone request
file, info, err := ctx.FormFile("file")
if err != nil {
ctx.StatusCode(iris.StatusInternalServerError)
ctx.Application().Logger().Warnf("Error while uploading: %v", err.Error())
return
}
defer file.Close()
fname := info.Filename
// Create a file with the same name
// assuming that you have a folder named 'uploads'
out, err := os.OpenFile(uploadsDir+fname,
os.O_WRONLY|os.O_CREATE, 0666)
if err != nil {
ctx.StatusCode(iris.StatusInternalServerError)
ctx.Application().Logger().Warnf("Error while preparing the new file: %v", err.Error())
return
}
defer out.Close()
io.Copy(out, file)
// optionally, add that file to the list in order to be visible when refresh.
uploadedFile := files.add(fname, info.Size)
go files.createThumbnail(uploadedFile)
})
// start the server at http://localhost:8080
app.Listen(":8080")
}
```
## Modify the Client side
Copy content below to "./views/upload.html". We will go through modifications individually.
```html
<!-- /views/upload.html -->
<html>
<head>
<title>DropzoneJS Uploader</title>
<!-- 1 -->
<link href="/public/css/dropzone.css" type="text/css" rel="stylesheet" />
<!-- 2 -->
<script src="/public/js/dropzone.js"></script>
<!-- 4 -->
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
<!-- 5 -->
<script>
Dropzone.options.myDropzone = {
paramName: "file", // The name that will be used to transfer the file
init: function () {
thisDropzone = this;
// 6
$.get('/uploads', function (data) {
if (data == null) {
return;
}
// 7
$.each(data, function (key, value) {
var mockFile = { name: value.name, size: value.size };
thisDropzone.emit("addedfile", mockFile);
thisDropzone.options.thumbnail.call(thisDropzone, mockFile, '/public/uploads/thumbnail_' + value.name);
// Make sure that there is no progress bar, etc...
thisDropzone.emit("complete", mockFile);
});
});
}
};
</script>
</head>
<body>
<!-- 3 -->
<form action="/upload" method="POST" class="dropzone" id="my-dropzone">
<div class="fallback">
<input name="file" type="file" multiple />
<input type="submit" value="Upload" />
</div>
</form>
</body>
</html>
```
1. We added Jquery library into our page. This actually not for DropzoneJs directly. We are using Jquery's ajax function **$.get** only. You will see below
2. We added an ID element (my-dropzone) to the form. This is needed because we need to pass configuration values to Dropzone. And to do it, we must have an ID reference of it. So that we can configure it by assigning values to Dropzone.options.myDropzone. A lot of people face confusion when configuring Dropzone. To put it in a simple way. Do not take Dropzone as a Jquery plugin, it has its own syntax and you need to follow it.
3. This starts the main part of modification. What we did here is to pass a function to listen to Dropzone's init event. This event is called when Dropzone is initialized.
4. Retrieve files details from the new "/uploads" via ajax.
5. Create mockFile using values from server. mockFile is simply JavaScript objects with properties of name and size. Then we call Dropzone's **addedfile** and **thumbnail** functions explicitly to put existing files to Dropzone upload area and generate its thumbnail.
### Running the server
Open the terminal at the current project's folder and execute:
```bash
$ go run main.go
Now listening on: http://localhost:8080
Application started. Press CTRL+C to shut down.
```
If you have done it successfully. Now go and upload some images and reload the upload page. Already uploaded files should auto display in Dropzone area.
![with uploaded files screenshot](with_files.png)
## References
- http://www.dropzonejs.com/#server-side-implementation
- https://www.startutorial.com/articles/view/how-to-build-a-file-upload-form-using-dropzonejs-and-php
- https://docs.iris-go.com
- https://github.com/kataras/iris/tree/master/_examples/dropzonejs
## The end
Hopefully this simple tutorial helped you with your development.
If you like my post, please follow me on [Twitter](https://twitter.com/makismaropoulos) and help spread the word. I need your support to continue.

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -1,53 +1,53 @@
<html>
<head>
<title>DropzoneJS Uploader</title>
<!-- 1 -->
<link href="/public/css/dropzone.css" type="text/css" rel="stylesheet" />
<!-- 2 -->
<script src="/public/js/dropzone.js"></script>
<!-- 4 -->
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
<!-- 5 -->
<script>
Dropzone.options.myDropzone = {
paramName: "file", // The name that will be used to transfer the file
init: function () {
thisDropzone = this;
// 6
$.get('/uploads', function (data) {
if (data == null) {
return;
}
// 7
$.each(data, function (key, value) {
var mockFile = { name: value.name, size: value.size };
thisDropzone.emit("addedfile", mockFile);
thisDropzone.options.thumbnail.call(thisDropzone, mockFile, '/public/uploads/thumbnail_' + value.name);
// thisDropzone.createThumbnailFromUrl(mockFile, '/public/uploads/' + value.name); <- doesn't work...
// Make sure that there is no progress bar, etc...
thisDropzone.emit("complete", mockFile);
});
});
}
};
</script>
</head>
<body>
<!-- 3 -->
<form action="/upload" method="POST" class="dropzone" id="my-dropzone">
<div class="fallback">
<input name="file" type="file" multiple />
<input type="submit" value="Upload" />
</div>
</form>
</body>
<html>
<head>
<title>DropzoneJS Uploader</title>
<!-- 1 -->
<link href="/public/css/dropzone.css" type="text/css" rel="stylesheet" />
<!-- 2 -->
<script src="/public/js/dropzone.js"></script>
<!-- 4 -->
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
<!-- 5 -->
<script>
Dropzone.options.myDropzone = {
paramName: "file", // The name that will be used to transfer the file
init: function () {
thisDropzone = this;
// 6
$.get('/uploads', function (data) {
if (data == null) {
return;
}
// 7
$.each(data, function (key, value) {
var mockFile = { name: value.name, size: value.size };
thisDropzone.emit("addedfile", mockFile);
thisDropzone.options.thumbnail.call(thisDropzone, mockFile, '/public/uploads/thumbnail_' + value.name);
// thisDropzone.createThumbnailFromUrl(mockFile, '/public/uploads/' + value.name); <- doesn't work...
// Make sure that there is no progress bar, etc...
thisDropzone.emit("complete", mockFile);
});
});
}
};
</script>
</head>
<body>
<!-- 3 -->
<form action="/upload" method="POST" class="dropzone" id="my-dropzone">
<div class="fallback">
<input name="file" type="file" multiple />
<input type="submit" value="Upload" />
</div>
</form>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More