├── app ├── README.md ├── public │ ├── favicon.ico │ ├── manifest.json │ └── index.html ├── src │ ├── react-app-env.d.ts │ ├── index.css │ ├── index.tsx │ ├── Users.tsx │ ├── MyUrlField.tsx │ ├── App.tsx │ ├── Dashboard.tsx │ ├── Posts.tsx │ └── serviceWorker.ts ├── .gitignore ├── tsconfig.json └── package.json ├── GO_VERSION ├── Schema.png ├── ui-post.png ├── drone_ci_cd.png ├── ui-dashboard.png ├── model ├── paging.go ├── post.go ├── version.go ├── error.go └── user.go ├── Dockerfile.API ├── Dockerfile.UI ├── .gitignore ├── Makefile ├── .vscode └── launch.json ├── error ├── notfound.go └── handler.go ├── docker-compose.UI.yml ├── runner └── runner.go ├── docker-compose.API.yml ├── config.yml ├── .github └── FUNDING.yml ├── mode ├── mode_test.go └── mode.go ├── config └── config.go ├── test ├── asserts.go └── testdb │ └── database.go ├── test.http ├── api ├── internalutil.go ├── user_test.go ├── user.go └── post.go ├── database ├── post_test.go ├── database.go ├── database_test.go ├── user_test.go ├── post.go └── user.go ├── go.mod ├── docker-compose-express-mongo.yml ├── LICENSE ├── app.go ├── router └── router.go ├── .drone.yml ├── README.md └── go.sum /app/README.md: -------------------------------------------------------------------------------- 1 | # app -------------------------------------------------------------------------------- /GO_VERSION: -------------------------------------------------------------------------------- 1 | 1.12.0 2 | -------------------------------------------------------------------------------- /Schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kirk-Wang/Ten-Minutes-App/HEAD/Schema.png -------------------------------------------------------------------------------- /ui-post.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kirk-Wang/Ten-Minutes-App/HEAD/ui-post.png -------------------------------------------------------------------------------- /drone_ci_cd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kirk-Wang/Ten-Minutes-App/HEAD/drone_ci_cd.png -------------------------------------------------------------------------------- /ui-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kirk-Wang/Ten-Minutes-App/HEAD/ui-dashboard.png -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kirk-Wang/Ten-Minutes-App/HEAD/app/public/favicon.ico -------------------------------------------------------------------------------- /app/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare module "ra-data-json-server" 3 | declare module "react-admin" -------------------------------------------------------------------------------- /model/paging.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // Paging Model 4 | type Paging struct { 5 | Skip *int64 6 | Limit *int64 7 | SortKey string 8 | SortVal int 9 | Condition interface{} 10 | } 11 | -------------------------------------------------------------------------------- /Dockerfile.API: -------------------------------------------------------------------------------- 1 | FROM frolvlad/alpine-glibc:glibc-2.29 2 | 3 | WORKDIR /bin 4 | ADD release/linux/amd64/api-ten-minutes /bin/ 5 | ADD config.yml /bin/ 6 | 7 | EXPOSE 6868 8 | ENTRYPOINT ["/bin/api-ten-minutes"] 9 | -------------------------------------------------------------------------------- /Dockerfile.UI: -------------------------------------------------------------------------------- 1 | FROM node:10.15.1-alpine 2 | 3 | RUN apk add --no-cache tini && npm install http-server -g && mkdir /ten 4 | 5 | WORKDIR /ten 6 | 7 | COPY app/build . 8 | 9 | EXPOSE 3000 10 | 11 | ENTRYPOINT ["/sbin/tini", "--"] 12 | CMD [ "http-server", "-p", "3000" ] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | .DS_Store 15 | un 16 | release -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DOCKER_GO_BUILD=go build -mod=readonly -a -installsuffix cgo -ldflags "$$LD_FLAGS" 2 | 3 | build_linux_amd64: 4 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 ${DOCKER_GO_BUILD} -v -o release/linux/amd64/api-ten-minutes 5 | 6 | docker: 7 | docker build -t lotteryjs/api-ten-minutes . 8 | 9 | test: 10 | go test -v . -------------------------------------------------------------------------------- /app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // 使用 IntelliSense 了解相关属性。 3 | // 悬停以查看现有属性的描述。 4 | // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${fileDirname}", 13 | "env": {}, 14 | "args": [] 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /app/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /error/notfound.go: -------------------------------------------------------------------------------- 1 | package error 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/lotteryjs/ten-minutes-app/model" 8 | ) 9 | 10 | // NotFound creates a gin middleware for handling page not found. 11 | func NotFound() gin.HandlerFunc { 12 | return func(c *gin.Context) { 13 | c.JSON(http.StatusNotFound, &model.Error{ 14 | Error: http.StatusText(http.StatusNotFound), 15 | ErrorCode: http.StatusNotFound, 16 | ErrorDescription: "page not found", 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docker-compose.UI.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | ui-ten-minutes: 5 | image: lotteryjs/ui-ten-minutes 6 | networks: 7 | - web 8 | logging: 9 | options: 10 | max-size: "100k" 11 | max-file: "3" 12 | labels: 13 | - "traefik.docker.network=web" 14 | - "traefik.enable=true" 15 | - "traefik.basic.frontend.rule=Host:ten-minutes.lotteryjs.com" 16 | - "traefik.basic.port=3000" 17 | - "traefik.basic.protocol=http" 18 | 19 | networks: 20 | web: 21 | external: true 22 | -------------------------------------------------------------------------------- /runner/runner.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/lotteryjs/ten-minutes-app/config" 10 | ) 11 | 12 | // Run starts the http server 13 | func Run(engine *gin.Engine, conf *config.Configuration) { 14 | var httpHandler http.Handler = engine 15 | 16 | addr := fmt.Sprintf("%s:%d", conf.Server.ListenAddr, conf.Server.Port) 17 | fmt.Println("Started Listening for plain HTTP connection on " + addr) 18 | log.Fatal(http.ListenAndServe(addr, httpHandler)) 19 | } 20 | -------------------------------------------------------------------------------- /docker-compose.API.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | api-ten-minutes: 5 | image: lotteryjs/api-ten-minutes 6 | networks: 7 | - web 8 | logging: 9 | options: 10 | max-size: "100k" 11 | max-file: "3" 12 | labels: 13 | - "traefik.docker.network=web" 14 | - "traefik.enable=true" 15 | - "traefik.basic.frontend.rule=Host:api-ten-minutes.lotteryjs.com" 16 | - "traefik.basic.port=6868" 17 | - "traefik.basic.protocol=http" 18 | 19 | networks: 20 | web: 21 | external: true 22 | -------------------------------------------------------------------------------- /app/src/Users.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { List, Datagrid, TextField, EmailField } from 'react-admin'; 3 | import MyUrlField from './MyUrlField'; 4 | 5 | export const UserList = (props: any) => { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | }; -------------------------------------------------------------------------------- /model/post.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "go.mongodb.org/mongo-driver/bson/primitive" 5 | ) 6 | 7 | // The Post holds 8 | type Post struct { 9 | ID primitive.ObjectID `bson:"_id" json:"id"` 10 | UserID primitive.ObjectID `bson:"userId" json:"userId"` 11 | Title string `bson:"title" json:"title"` 12 | Body string `bson:"body" json:"body"` 13 | } 14 | 15 | // New is an instance 16 | func (p *Post) New() *Post { 17 | return &Post{ 18 | ID: primitive.NewObjectID(), 19 | UserID: p.UserID, 20 | Title: p.Title, 21 | Body: p.Body, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /model/version.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // VersionInfo Model 4 | // 5 | // swagger:model VersionInfo 6 | type VersionInfo struct { 7 | // The current version. 8 | // 9 | // required: true 10 | // example: 5.2.6 11 | Version string `json:"version"` 12 | // The git commit hash on which this binary was built. 13 | // 14 | // required: true 15 | // example: ae9512b6b6feea56a110d59a3353ea3b9c293864 16 | Commit string `json:"commit"` 17 | // The date on which this binary was built. 18 | // 19 | // required: true 20 | // example: 2018-02-27T19:36:10.5045044+01:00 21 | BuildDate string `json:"buildDate"` 22 | } 23 | -------------------------------------------------------------------------------- /app/src/MyUrlField.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withStyles } from '@material-ui/core/styles'; 3 | import LaunchIcon from '@material-ui/icons/Launch'; 4 | 5 | const styles = { 6 | link: { 7 | textDecoration: 'none', 8 | }, 9 | icon: { 10 | width: '0.5em', 11 | paddingLeft: 2, 12 | }, 13 | }; 14 | 15 | const MyUrlField = ({ record = {}, source, classes }:any) => 16 | 17 | {record[source]} 18 | 19 | ; 20 | 21 | export default withStyles(styles)(MyUrlField); 22 | -------------------------------------------------------------------------------- /model/error.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // Error Model 4 | // 5 | // The Error contains error relevant information. 6 | // 7 | // swagger:model Error 8 | type Error struct { 9 | // The general error message 10 | // 11 | // required: true 12 | // example: Unauthorized 13 | Error string `json:"error"` 14 | // The http error code. 15 | // 16 | // required: true 17 | // example: 401 18 | ErrorCode int `json:"errorCode"` 19 | // The http error code. 20 | // 21 | // required: true 22 | // example: you need to provide a valid access token or user credentials to access this api 23 | ErrorDescription string `json:"errorDescription"` 24 | } 25 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | # Example configuration file for the server. 2 | # Save it to `config.yml` when edited 3 | 4 | server: 5 | port: 6868 # the port the HTTP server will listen on 6 | 7 | responseheaders: # response headers are added to every response (default: none) 8 | Access-Control-Allow-Credentials: "true" 9 | Access-Control-Allow-Headers: "content-type" 10 | Access-Control-Allow-Methods: "GET,HEAD,PUT,PATCH,POST,DELETE" 11 | Access-Control-Allow-Origin: "https://ten-minutes.lotteryjs.com" 12 | Access-Control-Expose-Headers: "X-Total-Count" 13 | 14 | database: # for database see (configure database section) 15 | dbname: tenapi 16 | connection: mongodb://root:123456@mongo:27017 17 | 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ['https://paypal.me/wangzuowei']# Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /mode/mode_test.go: -------------------------------------------------------------------------------- 1 | package mode 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestDevMode(t *testing.T) { 11 | Set(Dev) 12 | assert.Equal(t, Get(), Dev) 13 | assert.True(t, IsDev()) 14 | assert.Equal(t, gin.Mode(), gin.DebugMode) 15 | } 16 | 17 | func TestTestDevMode(t *testing.T) { 18 | Set(TestDev) 19 | assert.Equal(t, Get(), TestDev) 20 | assert.True(t, IsDev()) 21 | assert.Equal(t, gin.Mode(), gin.TestMode) 22 | } 23 | 24 | func TestProdMode(t *testing.T) { 25 | Set(Prod) 26 | assert.Equal(t, Get(), Prod) 27 | assert.False(t, IsDev()) 28 | assert.Equal(t, gin.Mode(), gin.ReleaseMode) 29 | } 30 | 31 | func TestInvalidMode(t *testing.T) { 32 | assert.Panics(t, func() { 33 | Set("asdasda") 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/lotteryjs/configor" 5 | ) 6 | 7 | // Configuration is stuff that can be configured externally per env variables or config file (config.yml). 8 | type Configuration struct { 9 | Server struct { 10 | ListenAddr string `default:""` 11 | Port int `default:"80"` 12 | ResponseHeaders map[string]string 13 | } 14 | Database struct { 15 | Dbname string `default:""` 16 | Connection string `default:""` 17 | } 18 | } 19 | 20 | // Get returns the configuration extracted from env variables or config file. 21 | func Get() *Configuration { 22 | conf := new(Configuration) 23 | err := configor.New(&configor.Config{EnvironmentPrefix: "TenMinutesApi"}).Load(conf, "config.yml") 24 | if err != nil { 25 | panic(err) 26 | } 27 | return conf 28 | } 29 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/jest": "24.0.15", 7 | "@types/node": "12.6.2", 8 | "@types/react": "16.8.23", 9 | "@types/react-dom": "16.8.4", 10 | "ra-data-json-server": "^2.9.2", 11 | "react": "^16.8.6", 12 | "react-admin": "^2.9.2", 13 | "react-dom": "^16.8.6", 14 | "react-scripts": "3.0.1", 15 | "typescript": "3.5.3" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build", 20 | "test": "react-scripts test", 21 | "eject": "react-scripts eject" 22 | }, 23 | "eslintConfig": { 24 | "extends": "react-app" 25 | }, 26 | "browserslist": [ 27 | ">0.2%", 28 | "not dead", 29 | "not ie <= 11", 30 | "not op_mini all" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /test/asserts.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http/httptest" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // BodyEquals asserts the content from the response recorder with the encoded json of the provided instance. 12 | func BodyEquals(t assert.TestingT, obj interface{}, recorder *httptest.ResponseRecorder) { 13 | bytes, err := ioutil.ReadAll(recorder.Body) 14 | assert.Nil(t, err) 15 | actual := string(bytes) 16 | 17 | JSONEquals(t, obj, actual) 18 | } 19 | 20 | // JSONEquals asserts the content of the string with the encoded json of the provided instance. 21 | func JSONEquals(t assert.TestingT, obj interface{}, expected string) { 22 | bytes, err := json.Marshal(obj) 23 | assert.Nil(t, err) 24 | objJSON := string(bytes) 25 | 26 | assert.JSONEq(t, expected, objJSON) 27 | } 28 | -------------------------------------------------------------------------------- /app/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PostIcon from '@material-ui/icons/Book'; 3 | import UserIcon from '@material-ui/icons/Group'; 4 | import { Admin, Resource } from 'react-admin'; 5 | import jsonServerProvider from 'ra-data-json-server'; 6 | import Dashboard from './Dashboard'; 7 | import { PostList, PostEdit, PostCreate } from './Posts'; 8 | import { UserList } from './Users'; 9 | 10 | const dataProvider = jsonServerProvider("https://api-ten-minutes.lotteryjs.com"); 11 | const Title = () => (
Golang ❤️ MongoDB ❤️ React
) 12 | 13 | const App = () => ( 14 | } dashboard={Dashboard} dataProvider={dataProvider}> 15 | 16 | 17 | 18 | ) 19 | 20 | export default App; 21 | -------------------------------------------------------------------------------- /mode/mode.go: -------------------------------------------------------------------------------- 1 | package mode 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | const ( 6 | // Dev for development mode 7 | Dev = "dev" 8 | // Prod for production mode 9 | Prod = "prod" 10 | // TestDev used for tests 11 | TestDev = "testdev" 12 | ) 13 | 14 | var mode = Dev 15 | 16 | // Set sets the new mode. 17 | func Set(newMode string) { 18 | mode = newMode 19 | updateGinMode() 20 | } 21 | 22 | // Get returns the current mode. 23 | func Get() string { 24 | return mode 25 | } 26 | 27 | // IsDev returns true if the current mode is dev mode. 28 | func IsDev() bool { 29 | return Get() == Dev || Get() == TestDev 30 | } 31 | 32 | func updateGinMode() { 33 | switch Get() { 34 | case Dev: 35 | gin.SetMode(gin.DebugMode) 36 | case TestDev: 37 | gin.SetMode(gin.TestMode) 38 | case Prod: 39 | gin.SetMode(gin.ReleaseMode) 40 | default: 41 | panic("unknown mode") 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test.http: -------------------------------------------------------------------------------- 1 | 2 | ### typicode users 3 | GET http://jsonplaceholder.typicode.com/users?_end=5&_order=DESC&_sort=id&_start=0 4 | 5 | 6 | ### local 分页获取用户列表 7 | GET http://localhost:6868/users?_end=5&_order=DESC&_sort=id&_start=0 8 | 9 | ### local 获取用户,ID 不正确 10 | GET http://localhost:6868/users?id=11 11 | 12 | ### local 获取用户,ID 正确 13 | GET http://localhost:6868/users?id=5c933ae7a49cac27417def6f&id=5c933ae7a49cac27417def70 14 | 15 | ### local 删除用户,ID 正确 16 | DELETE http://localhost:6868/users/5c99bd941ba7b2304ad8c52b 17 | 18 | ### local 分页获取文章列表 19 | GET http://localhost:6868/posts?_end=5&_order=DESC&_sort=id&_start=0&userId=5c938131ca447e20e7b66974 20 | 21 | ### local 获取文章,ID 不正确 22 | GET http://localhost:6868/posts/11 23 | 24 | ### local 获取文章,ID 正确 25 | GET http://localhost:6868/posts/5c92e6199929adef73bceea1 26 | 27 | ### local 删除文章,ID 正确 28 | DELETE http://localhost:6868/posts/5c98678fbf0b9c5d8699e587 29 | 30 | -------------------------------------------------------------------------------- /api/internalutil.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "go.mongodb.org/mongo-driver/bson/primitive" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | func withID(ctx *gin.Context, name string, f func(id primitive.ObjectID)) { 11 | if id, err := primitive.ObjectIDFromHex(ctx.Param(name)); err == nil { 12 | f(id) 13 | } else { 14 | ctx.AbortWithError(400, errors.New("invalid id")) 15 | } 16 | } 17 | 18 | func withIDs(ctx *gin.Context, name string, f func(id []primitive.ObjectID)) { 19 | ids, b := ctx.GetQueryArray(name) 20 | objectIds := []primitive.ObjectID{} 21 | abort := errors.New("invalid id") 22 | if b { 23 | for _, id := range ids { 24 | if objID, err := primitive.ObjectIDFromHex(id); err == nil { 25 | objectIds = append(objectIds, objID) 26 | } else { 27 | ctx.AbortWithError(400, abort) 28 | } 29 | } 30 | f(objectIds) 31 | } else { 32 | ctx.AbortWithError(400, abort) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /database/post_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/lotteryjs/ten-minutes-app/model" 5 | "github.com/stretchr/testify/assert" 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | func (s *DatabaseSuite) TestCreatePost() { 10 | s.db.DB.Collection("posts").Drop(nil) 11 | 12 | user := s.db.GetUserByName("Graham") 13 | 14 | article := (&model.Post{ 15 | UserID: user.ID, 16 | Title: "title1", 17 | Body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto", 18 | }).New() 19 | 20 | s.db.CreatePost(article) 21 | post := s.db.GetPostByID(article.ID) 22 | 23 | assert.Equal(s.T(), post, article) 24 | } 25 | 26 | func (s *DatabaseSuite) TestCountPost() { 27 | assert.Equal(s.T(), "1", s.db.CountPost(nil)) 28 | } 29 | 30 | func (s *DatabaseSuite) TestGetPostByID() { 31 | id, _ := primitive.ObjectIDFromHex("5cc5ca2f6a670dd59ea3a590") 32 | post := s.db.GetPostByID(id) 33 | assert.Equal(s.T(), "title1", post.Title) 34 | } 35 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lotteryjs/ten-minutes-app 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3 // indirect 7 | github.com/gin-gonic/gin v1.3.0 8 | github.com/go-stack/stack v1.8.0 // indirect 9 | github.com/golang/protobuf v1.3.1 // indirect 10 | github.com/golang/snappy v0.0.1 // indirect 11 | github.com/google/go-cmp v0.2.0 // indirect 12 | github.com/gotify/server v1.2.1 13 | github.com/lotteryjs/configor v1.0.2 14 | github.com/mattn/go-isatty v0.0.7 // indirect 15 | github.com/stretchr/testify v1.3.0 16 | github.com/ugorji/go/codec v0.0.0-20190316192920-e2bddce071ad // indirect 17 | github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c // indirect 18 | github.com/xdg/stringprep v1.0.0 // indirect 19 | go.mongodb.org/mongo-driver v1.0.0 20 | golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a 21 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 // indirect 22 | golang.org/x/text v0.3.0 // indirect 23 | gopkg.in/go-playground/validator.v8 v8.18.2 24 | gopkg.in/yaml.v2 v2.2.2 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /docker-compose-express-mongo.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | 5 | mongo: 6 | image: mongo:4.0.6 7 | restart: always 8 | networks: 9 | - web 10 | ports: 11 | - 27017:27017 12 | volumes: 13 | - ./data:/data/db 14 | environment: 15 | MONGO_INITDB_ROOT_USERNAME: example 16 | MONGO_INITDB_ROOT_PASSWORD: example 17 | 18 | mongo-express: 19 | image: mongo-express 20 | restart: always 21 | networks: 22 | - web 23 | logging: 24 | options: 25 | max-size: "100k" 26 | max-file: "3" 27 | labels: 28 | - "traefik.docker.network=web" 29 | - "traefik.enable=true" 30 | - "traefik.basic.frontend.rule=Host:mongo-express.example.com" 31 | - "traefik.basic.port=8081" 32 | - "traefik.basic.protocol=http" 33 | environment: 34 | ME_CONFIG_BASICAUTH_USERNAME: example 35 | ME_CONFIG_BASICAUTH_PASSWORD: example 36 | ME_CONFIG_MONGODB_ADMINUSERNAME: example 37 | ME_CONFIG_MONGODB_ADMINPASSWORD: example 38 | 39 | networks: 40 | web: 41 | external: true -------------------------------------------------------------------------------- /app/src/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Title } from 'react-admin'; 3 | import { withStyles } from '@material-ui/core/styles'; 4 | import Card from '@material-ui/core/Card'; 5 | import CardContent from '@material-ui/core/CardContent'; 6 | import Avatar from '@material-ui/core/Avatar'; 7 | 8 | const styles = () => ({ 9 | content: { 10 | display: 'flex', 11 | }, 12 | avatar: { 13 | margin: 10, 14 | width: 400, 15 | height: 400, 16 | borderRadius: 0, 17 | } 18 | }) 19 | 20 | export default withStyles(styles, { withTheme: true })(({ classes }) => ( 21 | 22 | 23 | <CardContent className={classes.content}> 24 | <Avatar 25 | alt="GoLang" 26 | src="https://camo.githubusercontent.com/2cc07aaecc587c5eeae78e711a9d71048be9ef41/68747470733a2f2f63646e2d696d616765732d312e6d656469756d2e636f6d2f6d61782f313230302f312a796839306257386a4c346638704f545a5476627a71772e706e67" 27 | className={classes.avatar}/> 28 | </CardContent> 29 | </Card> 30 | )) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 jmattheis & lotteryjs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "time" 7 | 8 | "github.com/lotteryjs/ten-minutes-app/config" 9 | "github.com/lotteryjs/ten-minutes-app/database" 10 | "github.com/lotteryjs/ten-minutes-app/mode" 11 | "github.com/lotteryjs/ten-minutes-app/model" 12 | "github.com/lotteryjs/ten-minutes-app/router" 13 | "github.com/lotteryjs/ten-minutes-app/runner" 14 | ) 15 | 16 | var ( 17 | // Version the version of TMA. 18 | Version = "unknown" 19 | // Commit the git commit hash of this version. 20 | Commit = "unknown" 21 | // BuildDate the date on which this binary was build. 22 | BuildDate = "unknown" 23 | // Mode the build mode 24 | Mode = mode.Dev 25 | ) 26 | 27 | func main() { 28 | vInfo := &model.VersionInfo{Version: Version, Commit: Commit, BuildDate: BuildDate} 29 | mode.Set(Mode) 30 | 31 | fmt.Println("Starting TMA version", vInfo.Version+"@"+BuildDate) 32 | rand.Seed(time.Now().UnixNano()) 33 | conf := config.Get() 34 | 35 | db, err := database.New(conf.Database.Connection, conf.Database.Dbname) 36 | if err != nil { 37 | panic(err) 38 | } 39 | defer db.Close() 40 | 41 | engine := router.Create(db, vInfo, conf) 42 | runner.Run(engine, conf) 43 | } 44 | -------------------------------------------------------------------------------- /database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "go.mongodb.org/mongo-driver/mongo" 6 | "go.mongodb.org/mongo-driver/mongo/options" 7 | "go.mongodb.org/mongo-driver/mongo/readpref" 8 | "time" 9 | ) 10 | 11 | // New creates a new wrapper for the mongo-go-driver. 12 | func New(connection, dbname string) (*TenDatabase, error) { 13 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 14 | defer cancel() 15 | client, err := mongo.Connect(ctx, options.Client().ApplyURI(connection)) 16 | if err != nil { 17 | return nil, err 18 | } 19 | ctxping, cancel := context.WithTimeout(context.Background(), 5*time.Second) 20 | defer cancel() 21 | err = client.Ping(ctxping, readpref.Primary()) 22 | if err != nil { 23 | return nil, err 24 | } 25 | db := client.Database(dbname) 26 | return &TenDatabase{DB: db, Client: client, Context: ctx}, nil 27 | } 28 | 29 | // TenDatabase is a wrapper for the mongo-go-driver. 30 | type TenDatabase struct { 31 | DB *mongo.Database 32 | Client *mongo.Client 33 | Context context.Context 34 | } 35 | 36 | // Close closes the mongo-go-driver connection. 37 | func (d *TenDatabase) Close() { 38 | d.Client.Disconnect(d.Context) 39 | } 40 | -------------------------------------------------------------------------------- /api/user_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/lotteryjs/ten-minutes-app/mode" 10 | "github.com/lotteryjs/ten-minutes-app/test/testdb" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/suite" 13 | ) 14 | 15 | func TestUserSuite(t *testing.T) { 16 | suite.Run(t, new(UserSuite)) 17 | } 18 | 19 | type UserSuite struct { 20 | suite.Suite 21 | db *testdb.Database 22 | a *UserAPI 23 | ctx *gin.Context 24 | recorder *httptest.ResponseRecorder 25 | } 26 | 27 | func (s *UserSuite) BeforeTest(suiteName, testName string) { 28 | mode.Set(mode.TestDev) 29 | s.recorder = httptest.NewRecorder() 30 | s.ctx, _ = gin.CreateTestContext(s.recorder) 31 | s.db = testdb.NewDB(s.T()) 32 | s.a = &UserAPI{DB: s.db} 33 | } 34 | func (s *UserSuite) AfterTest(suiteName, testName string) { 35 | s.db.Close() 36 | } 37 | 38 | func (s *UserSuite) Test_GetUsers() { 39 | s.db.TenDatabase.DB.Collection("users").Drop(nil) 40 | 41 | for i := 1; i <= 5; i++ { 42 | s.db.NewUser(fmt.Sprintf("Big Brother_%d", i)) 43 | } 44 | assert.Equal(s.T(), 1, 1) 45 | // s.a.GetUsers(s.ctx) 46 | // assert.Equal(s.T(), 200, s.recorder.Code) 47 | } 48 | -------------------------------------------------------------------------------- /test/testdb/database.go: -------------------------------------------------------------------------------- 1 | package testdb 2 | 3 | import ( 4 | "github.com/lotteryjs/ten-minutes-app/model" 5 | "testing" 6 | 7 | "github.com/lotteryjs/ten-minutes-app/database" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // Database is the wrapper for the gorm database with sleek helper methods. 12 | type Database struct { 13 | *database.TenDatabase 14 | t *testing.T 15 | } 16 | 17 | // NewDB creates a new test db instance. 18 | func NewDB(t *testing.T) *Database { 19 | db, err := database.New("mongodb://root:123456@localhost:27017", "tenapi") 20 | assert.Nil(t, err) 21 | assert.NotNil(t, db) 22 | return &Database{TenDatabase: db, t: t} 23 | } 24 | 25 | // NewUser creates a user and returns the user. 26 | func (d *Database) NewUser(name string) *model.User { 27 | return d.NewUserWithName(name) 28 | } 29 | 30 | // NewUserWithName creates a user with a name and returns the user. 31 | func (d *Database) NewUserWithName(name string) *model.User { 32 | user := (&model.User{ 33 | Name: name, 34 | UserName: "Bret", 35 | Email: "Sincere@april.biz", 36 | Address: model.UserAddress{ 37 | Street: "Kulas Light", 38 | Suite: "Apt. 556", 39 | City: "Gwenborough", 40 | Zipcode: "92998-3874", 41 | Geo: model.UserAddressGeo{ 42 | Lat: "-37.3159", 43 | Lng: "81.1496", 44 | }, 45 | }, 46 | Phone: "1-770-736-8031 x56442", 47 | Website: "hildegard.org", 48 | Company: model.UserCompany{ 49 | Name: "Romaguera-Crona", 50 | CatchPhrase: "Multi-layered client-server neural-net", 51 | BS: "harness real-time e-markets", 52 | }, 53 | }).New() 54 | d.CreateUser(user) 55 | return user 56 | } 57 | -------------------------------------------------------------------------------- /database/database_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "fmt" 5 | "github.com/lotteryjs/ten-minutes-app/model" 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/suite" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | "testing" 10 | ) 11 | 12 | func TestDatabaseSuite(t *testing.T) { 13 | suite.Run(t, new(DatabaseSuite)) 14 | } 15 | 16 | type DatabaseSuite struct { 17 | suite.Suite 18 | db *TenDatabase 19 | } 20 | 21 | func (s *DatabaseSuite) BeforeTest(suiteName, testName string) { 22 | s.T().Log("--BeforeTest--") 23 | db, _ := New("mongodb://root:123456@localhost:27017", "tenapi") 24 | s.db = db 25 | } 26 | 27 | func (s *DatabaseSuite) AfterTest(suiteName, testName string) { 28 | s.db.Close() 29 | } 30 | 31 | func (s *DatabaseSuite) TestPost() { 32 | s.db.DB.Collection("posts").Drop(nil) 33 | 34 | var err error 35 | for i := 1; i <= 25; i++ { 36 | // user1 37 | UserID, _ := primitive.ObjectIDFromHex("5c99bd941ba7b2304ad8c52a") 38 | article := (&model.Post{ 39 | UserID: UserID, 40 | Title: fmt.Sprintf("tile%d", i), 41 | Body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto", 42 | }).New() 43 | s.db.CreatePost(article) 44 | } 45 | assert.Nil(s.T(), err) 46 | } 47 | 48 | func (s *DatabaseSuite) TestUpdatePost() { 49 | id, _ := primitive.ObjectIDFromHex("5c92e6199929adef73bceea1") 50 | userID, _ := primitive.ObjectIDFromHex("5c8f9a83da2c3fed4eee9dc1") 51 | 52 | post := &model.Post{ 53 | ID: id, 54 | UserID: userID, 55 | Title: "title1", 56 | Body: "title1bodytitle1body", 57 | } 58 | 59 | assert.Equal(s.T(), post, s.db.UpdatePost(post)) 60 | } 61 | -------------------------------------------------------------------------------- /app/public/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="utf-8" /> 5 | <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" /> 6 | <meta 7 | name="viewport" 8 | content="width=device-width, initial-scale=1, shrink-to-fit=no" 9 | /> 10 | <meta name="theme-color" content="#000000" /> 11 | <!-- 12 | manifest.json provides metadata used when your web app is installed on a 13 | user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ 14 | --> 15 | <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> 16 | <!-- 17 | Notice the use of %PUBLIC_URL% in the tags above. 18 | It will be replaced with the URL of the `public` folder during the build. 19 | Only files inside the `public` folder can be referenced from the HTML. 20 | 21 | Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will 22 | work correctly both with client-side routing and a non-root public URL. 23 | Learn how to configure a non-root public URL by running `npm run build`. 24 | --> 25 | <title>React App 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "net/http" 6 | 7 | "github.com/lotteryjs/ten-minutes-app/api" 8 | "github.com/lotteryjs/ten-minutes-app/config" 9 | "github.com/lotteryjs/ten-minutes-app/database" 10 | "github.com/lotteryjs/ten-minutes-app/error" 11 | "github.com/lotteryjs/ten-minutes-app/model" 12 | ) 13 | 14 | // Create creates the gin engine with all routes. 15 | func Create(db *database.TenDatabase, vInfo *model.VersionInfo, conf *config.Configuration) *gin.Engine { 16 | g := gin.New() 17 | 18 | g.Use(gin.Logger(), gin.Recovery(), error.Handler()) 19 | g.NoRoute(error.NotFound()) 20 | 21 | g.Use(func(ctx *gin.Context) { 22 | ctx.Header("Content-Type", "application/json") 23 | origin := ctx.Request.Header.Get("Origin") 24 | for header, value := range conf.Server.ResponseHeaders { 25 | if origin == "http://localhost:3000" && header == "Access-Control-Allow-Origin" { 26 | ctx.Header("Access-Control-Allow-Origin", "http://localhost:3000") 27 | } else { 28 | ctx.Header(header, value) 29 | } 30 | } 31 | if ctx.Request.Method == "OPTIONS" { 32 | ctx.AbortWithStatus(http.StatusNoContent) 33 | } 34 | }) 35 | 36 | userHandler := api.UserAPI{DB: db} 37 | postHandler := api.PostAPI{DB: db} 38 | 39 | postU := g.Group("/users") 40 | { 41 | postU.GET("", userHandler.GetUsers) 42 | postU.DELETE(":id", userHandler.DeleteUserByID) 43 | } 44 | 45 | postG := g.Group("/posts") 46 | { 47 | postG.GET("", postHandler.GetPosts) 48 | postG.POST("", postHandler.CreatePost) 49 | postG.GET(":id", postHandler.GetPostByID) 50 | postG.PUT(":id", postHandler.UpdatePostByID) 51 | postG.DELETE(":id", postHandler.DeletePostByID) 52 | } 53 | 54 | g.GET("version", func(ctx *gin.Context) { 55 | ctx.JSON(200, vInfo) 56 | }) 57 | 58 | return g 59 | } 60 | -------------------------------------------------------------------------------- /error/handler.go: -------------------------------------------------------------------------------- 1 | package error 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "unicode" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/gotify/server/model" 11 | "gopkg.in/go-playground/validator.v8" 12 | ) 13 | 14 | // Handler creates a gin middleware for handling errors. 15 | func Handler() gin.HandlerFunc { 16 | return func(c *gin.Context) { 17 | c.Next() 18 | 19 | if len(c.Errors) > 0 { 20 | for _, e := range c.Errors { 21 | switch e.Type { 22 | case gin.ErrorTypeBind: 23 | errs, ok := e.Err.(validator.ValidationErrors) 24 | 25 | if !ok { 26 | writeError(c, e.Error()) 27 | return 28 | } 29 | 30 | var stringErrors []string 31 | for _, err := range errs { 32 | stringErrors = append(stringErrors, validationErrorToText(err)) 33 | } 34 | writeError(c, strings.Join(stringErrors, "; ")) 35 | default: 36 | writeError(c, e.Err.Error()) 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | func validationErrorToText(e *validator.FieldError) string { 44 | runes := []rune(e.Field) 45 | runes[0] = unicode.ToLower(runes[0]) 46 | fieldName := string(runes) 47 | switch e.Tag { 48 | case "required": 49 | return fmt.Sprintf("Field '%s' is required", fieldName) 50 | case "max": 51 | return fmt.Sprintf("Field '%s' must be less or equal to %s", fieldName, e.Param) 52 | case "min": 53 | return fmt.Sprintf("Field '%s' must be more or equal to %s", fieldName, e.Param) 54 | } 55 | return fmt.Sprintf("Field '%s' is not valid", fieldName) 56 | } 57 | 58 | func writeError(ctx *gin.Context, errString string) { 59 | status := http.StatusBadRequest 60 | if ctx.Writer.Status() != http.StatusOK { 61 | status = ctx.Writer.Status() 62 | } 63 | ctx.JSON(status, &model.Error{Error: http.StatusText(status), ErrorCode: status, ErrorDescription: errString}) 64 | } 65 | -------------------------------------------------------------------------------- /model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "go.mongodb.org/mongo-driver/bson/primitive" 5 | "time" 6 | ) 7 | 8 | // The User holds 9 | type User struct { 10 | ID primitive.ObjectID `bson:"_id" json:"id"` 11 | Name string `bson:"name" json:"name"` 12 | UserName string `bson:"username" json:"username"` 13 | Email string `bson:"email" json:"email"` 14 | Address UserAddress `bson:"address" json:"address"` 15 | Phone string `bson:"phone" json:"phone"` 16 | Website string `bson:"website" json:"website"` 17 | Company UserCompany `bson:"company" json:"company"` 18 | Created time.Time `bson:"created" json:"created"` 19 | Updated time.Time `bson:"updated" json:"updated"` 20 | } 21 | 22 | // The UserAddress holds 23 | type UserAddress struct { 24 | Street string `bson:"street" json:"street"` 25 | Suite string `bson:"suite" json:"suite"` 26 | City string `bson:"city" json:"city"` 27 | Zipcode string `bson:"zipcode" json:"zipcode"` 28 | Geo UserAddressGeo `bson:"geo" json:"geo"` 29 | } 30 | 31 | // The UserAddressGeo holds 32 | type UserAddressGeo struct { 33 | Lat string `bson:"lat" json:"lat"` 34 | Lng string `bson:"lng" json:"lng"` 35 | } 36 | 37 | // The UserCompany holds 38 | type UserCompany struct { 39 | Name string `bson:"name" json:"name"` 40 | CatchPhrase string `bson:"catchPhrase" json:"catchPhrase"` 41 | BS string `bson:"bs" json:"bs"` 42 | } 43 | 44 | // New is 45 | func (u *User) New() *User { 46 | return &User{ 47 | ID: primitive.NewObjectID(), 48 | Name: u.Name, 49 | UserName: u.UserName, 50 | Email: u.Email, 51 | Address: u.Address, 52 | Phone: u.Phone, 53 | Website: u.Website, 54 | Company: u.Company, 55 | Created: time.Now(), 56 | Updated: time.Now(), 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/Posts.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | List, 4 | Datagrid, 5 | TextField, 6 | ReferenceField, 7 | EditButton, 8 | Edit, 9 | SimpleForm, 10 | DisabledInput, 11 | ReferenceInput, 12 | SelectInput, 13 | TextInput, 14 | LongTextInput, 15 | Create, 16 | Filter, 17 | Responsive, 18 | SimpleList, 19 | } from "react-admin"; 20 | 21 | const PostTitle = ({ record }:any) => { 22 | return Post {record ? `"${record.title}"` : ''}; 23 | }; 24 | 25 | const PostFilter = (props: any) => ( 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | 33 | export const PostList = (props: any) => ( 34 | }> 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | 47 | export const PostEdit = (props:any) => ( 48 | } {...props}> 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | ); 59 | 60 | export const PostCreate = (props: any) => ( 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ); 71 | -------------------------------------------------------------------------------- /database/user_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/lotteryjs/ten-minutes-app/model" 5 | "github.com/stretchr/testify/assert" 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | func (s *DatabaseSuite) TestCreateUser() { 10 | s.db.DB.Collection("users").Drop(nil) 11 | 12 | kirk := (&model.User{ 13 | Name: "Graham", 14 | UserName: "Bret", 15 | Email: "Sincere@april.biz", 16 | Address: model.UserAddress{ 17 | Street: "Kulas Light", 18 | Suite: "Apt. 556", 19 | City: "Gwenborough", 20 | Zipcode: "92998-3874", 21 | Geo: model.UserAddressGeo{ 22 | Lat: "-37.3159", 23 | Lng: "81.1496", 24 | }, 25 | }, 26 | Phone: "1-770-736-8031 x56442", 27 | Website: "hildegard.org", 28 | Company: model.UserCompany{ 29 | Name: "Romaguera-Crona", 30 | CatchPhrase: "Multi-layered client-server neural-net", 31 | BS: "harness real-time e-markets", 32 | }, 33 | }).New() 34 | err := s.db.CreateUser(kirk) 35 | assert.Nil(s.T(), err) 36 | } 37 | 38 | func (s *DatabaseSuite) TestGetUsers() { 39 | start := int64(0) 40 | limit := int64(10) 41 | sort := "_id" 42 | order := -1 43 | 44 | users := s.db.GetUsers(&model.Paging{ 45 | Skip: &start, 46 | Limit: &limit, 47 | SortKey: sort, 48 | SortVal: order, 49 | Condition: nil, 50 | }) 51 | 52 | assert.Len(s.T(), users, 1) 53 | } 54 | 55 | func (s *DatabaseSuite) TestGetUserByName() { 56 | user := s.db.GetUserByName("Graham") 57 | 58 | assert.Equal(s.T(), "Graham", user.Name) 59 | } 60 | 61 | func (s *DatabaseSuite) TestGetUserByIDs() { 62 | user := s.db.GetUserByName("Graham") 63 | objectIds := []primitive.ObjectID{user.ID} 64 | users := s.db.GetUserByIDs(objectIds) 65 | 66 | assert.Len(s.T(), users, 1) 67 | } 68 | 69 | func (s *DatabaseSuite) TestCountUser() { 70 | len := s.db.CountUser() 71 | assert.Equal(s.T(), len, "1") 72 | } 73 | 74 | func (s *DatabaseSuite) TestDeleteUserByID() { 75 | user := s.db.GetUserByName("Graham") 76 | s.db.DeleteUserByID(user.ID) 77 | len := s.db.CountUser() 78 | assert.Equal(s.T(), "0", len) 79 | } 80 | -------------------------------------------------------------------------------- /api/user.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "github.com/gin-gonic/gin" 6 | "github.com/lotteryjs/ten-minutes-app/model" 7 | "go.mongodb.org/mongo-driver/bson/primitive" 8 | "net/http" 9 | "strconv" 10 | ) 11 | 12 | // The UserDatabase interface for encapsulating database access. 13 | type UserDatabase interface { 14 | GetUserByIDs(ids []primitive.ObjectID) []*model.User 15 | DeleteUserByID(id primitive.ObjectID) error 16 | CreateUser(user *model.User) error 17 | GetUsers(paging *model.Paging) []*model.User 18 | CountUser() string 19 | } 20 | 21 | // The UserAPI provides handlers for managing users. 22 | type UserAPI struct { 23 | DB UserDatabase 24 | } 25 | 26 | // GetUserByIDs returns the user by id 27 | func (a *UserAPI) GetUserByIDs(ctx *gin.Context) { 28 | withIDs(ctx, "id", func(ids []primitive.ObjectID) { 29 | ctx.JSON(200, a.DB.GetUserByIDs(ids)) 30 | }) 31 | } 32 | 33 | // DeleteUserByID deletes the user by id 34 | func (a *UserAPI) DeleteUserByID(ctx *gin.Context) { 35 | withID(ctx, "id", func(id primitive.ObjectID) { 36 | if err := a.DB.DeleteUserByID(id); err == nil { 37 | ctx.JSON(200, http.StatusOK) 38 | } else { 39 | if err != nil { 40 | ctx.AbortWithError(500, err) 41 | } else { 42 | ctx.AbortWithError(404, errors.New("user does not exist")) 43 | } 44 | } 45 | }) 46 | } 47 | 48 | // GetUsers returns all the users 49 | // _end=5&_order=DESC&_sort=id&_start=0 adapt react-admin 50 | func (a *UserAPI) GetUsers(ctx *gin.Context) { 51 | var ( 52 | start int64 53 | end int64 54 | sort string 55 | order int 56 | ) 57 | id := ctx.DefaultQuery("id", "") 58 | if id != "" { 59 | a.GetUserByIDs(ctx) 60 | return 61 | } 62 | start, _ = strconv.ParseInt(ctx.DefaultQuery("_start", "0"), 10, 64) 63 | end, _ = strconv.ParseInt(ctx.DefaultQuery("_end", "10"), 10, 64) 64 | sort = ctx.DefaultQuery("_sort", "_id") 65 | order = 1 66 | 67 | if sort == "id" { 68 | sort = "_id" 69 | } 70 | 71 | if ctx.DefaultQuery("_order", "DESC") == "DESC" { 72 | order = -1 73 | } 74 | 75 | limit := end - start 76 | users := a.DB.GetUsers( 77 | &model.Paging{ 78 | Skip: &start, 79 | Limit: &limit, 80 | SortKey: sort, 81 | SortVal: order, 82 | Condition: nil, 83 | }) 84 | 85 | ctx.Header("X-Total-Count", a.DB.CountUser()) 86 | ctx.JSON(200, users) 87 | } 88 | -------------------------------------------------------------------------------- /database/post.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "github.com/lotteryjs/ten-minutes-app/model" 6 | "go.mongodb.org/mongo-driver/bson" 7 | "go.mongodb.org/mongo-driver/bson/primitive" 8 | "go.mongodb.org/mongo-driver/mongo/options" 9 | "strconv" 10 | ) 11 | 12 | // GetPosts returns all posts. 13 | // start, end int, order, sort string 14 | func (d *TenDatabase) GetPosts(paging *model.Paging) []*model.Post { 15 | posts := []*model.Post{} 16 | condition := bson.D{} 17 | if paging.Condition != nil { 18 | condition = (paging.Condition).(bson.D) 19 | } 20 | cursor, err := d.DB.Collection("posts"). 21 | Find(context.Background(), condition, 22 | &options.FindOptions{ 23 | Skip: paging.Skip, 24 | Sort: bson.D{bson.E{Key: paging.SortKey, Value: paging.SortVal}}, 25 | Limit: paging.Limit, 26 | }) 27 | if err != nil { 28 | return nil 29 | } 30 | defer cursor.Close(context.Background()) 31 | 32 | for cursor.Next(context.Background()) { 33 | post := &model.Post{} 34 | if err := cursor.Decode(post); err != nil { 35 | return nil 36 | } 37 | posts = append(posts, post) 38 | } 39 | 40 | return posts 41 | } 42 | 43 | // CreatePost creates a post. 44 | func (d *TenDatabase) CreatePost(post *model.Post) *model.Post { 45 | // Specifies the order in which to return results. 46 | upsert := true 47 | result := d.DB.Collection("posts"). 48 | FindOneAndReplace(context.Background(), 49 | bson.D{{Key: "_id", Value: post.ID}}, 50 | post, 51 | &options.FindOneAndReplaceOptions{ 52 | Upsert: &upsert, 53 | }, 54 | ) 55 | if result != nil { 56 | return post 57 | } 58 | return nil 59 | } 60 | 61 | // GetPostByID returns the post by the given id or nil. 62 | func (d *TenDatabase) GetPostByID(id primitive.ObjectID) *model.Post { 63 | var post *model.Post 64 | err := d.DB.Collection("posts"). 65 | FindOne(context.Background(), bson.D{{Key: "_id", Value: id}}). 66 | Decode(&post) 67 | if err != nil { 68 | return nil 69 | } 70 | return post 71 | } 72 | 73 | // DeletePostByID deletes a post by its id. 74 | func (d *TenDatabase) DeletePostByID(id primitive.ObjectID) error { 75 | _, err := d.DB.Collection("posts").DeleteOne(context.Background(), bson.D{{Key: "_id", Value: id}}) 76 | return err 77 | } 78 | 79 | // UpdatePost updates a post. 80 | func (d *TenDatabase) UpdatePost(post *model.Post) *model.Post { 81 | result := d.DB.Collection("posts"). 82 | FindOneAndReplace(context.Background(), 83 | bson.D{{Key: "_id", Value: post.ID}}, 84 | post, 85 | &options.FindOneAndReplaceOptions{}, 86 | ) 87 | if result != nil { 88 | return post 89 | } 90 | return nil 91 | } 92 | 93 | // CountPost returns the post count 94 | func (d *TenDatabase) CountPost(condition interface{}) string { 95 | cd := bson.D{} 96 | if condition != nil { 97 | cd = (condition).(bson.D) 98 | } 99 | total, err := d.DB.Collection("posts").CountDocuments(context.Background(), cd, &options.CountOptions{}) 100 | if err != nil { 101 | return "0" 102 | } 103 | return strconv.Itoa(int(total)) 104 | } 105 | -------------------------------------------------------------------------------- /database/user.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/lotteryjs/ten-minutes-app/model" 7 | "go.mongodb.org/mongo-driver/bson" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | "go.mongodb.org/mongo-driver/mongo/options" 10 | "strconv" 11 | ) 12 | 13 | // GetUsers returns all users. 14 | // start, end int, order, sort string 15 | func (d *TenDatabase) GetUsers(paging *model.Paging) []*model.User { 16 | users := []*model.User{} 17 | cursor, err := d.DB.Collection("users"). 18 | Find(context.Background(), bson.D{}, 19 | &options.FindOptions{ 20 | Skip: paging.Skip, 21 | Sort: bson.D{bson.E{Key: paging.SortKey, Value: paging.SortVal}}, 22 | Limit: paging.Limit, 23 | }) 24 | if err != nil { 25 | return nil 26 | } 27 | defer cursor.Close(context.Background()) 28 | 29 | for cursor.Next(context.Background()) { 30 | user := &model.User{} 31 | if err := cursor.Decode(user); err != nil { 32 | return nil 33 | } 34 | users = append(users, user) 35 | } 36 | 37 | return users 38 | } 39 | 40 | // CreateUser creates a user. 41 | func (d *TenDatabase) CreateUser(user *model.User) error { 42 | if _, err := d.DB.Collection("users"). 43 | InsertOne(context.Background(), user); err != nil { 44 | return err 45 | } 46 | return nil 47 | } 48 | 49 | // GetUserByName returns the user by the given name or nil. 50 | func (d *TenDatabase) GetUserByName(name string) *model.User { 51 | var user *model.User 52 | err := d.DB.Collection("users"). 53 | FindOne(context.Background(), bson.D{{Key: "name", Value: name}}). 54 | Decode(&user) 55 | if err != nil { 56 | return nil 57 | } 58 | return user 59 | } 60 | 61 | // GetUserByIDs returns the user by the given id or nil. 62 | func (d *TenDatabase) GetUserByIDs(ids []primitive.ObjectID) []*model.User { 63 | var users []*model.User 64 | cursor, err := d.DB.Collection("users"). 65 | Find(context.Background(), bson.D{{ 66 | Key: "_id", 67 | Value: bson.D{{ 68 | Key: "$in", 69 | Value: ids, 70 | }}, 71 | }}) 72 | if err != nil { 73 | return nil 74 | } 75 | defer cursor.Close(context.Background()) 76 | 77 | for cursor.Next(context.Background()) { 78 | user := &model.User{} 79 | if err := cursor.Decode(user); err != nil { 80 | return nil 81 | } 82 | users = append(users, user) 83 | } 84 | 85 | return users 86 | } 87 | 88 | // CountUser returns the user count 89 | func (d *TenDatabase) CountUser() string { 90 | total, err := d.DB.Collection("users").CountDocuments(context.Background(), bson.D{{}}, &options.CountOptions{}) 91 | if err != nil { 92 | return "0" 93 | } 94 | return strconv.Itoa(int(total)) 95 | } 96 | 97 | // DeleteUserByID deletes a user by its id. 98 | func (d *TenDatabase) DeleteUserByID(id primitive.ObjectID) error { 99 | if d.CountPost(bson.D{{Key: "userId", Value: id}}) == "0" { 100 | _, err := d.DB.Collection("users").DeleteOne(context.Background(), bson.D{{Key: "_id", Value: id}}) 101 | return err 102 | } 103 | return errors.New("the current user has posts published") 104 | } 105 | -------------------------------------------------------------------------------- /api/post.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "github.com/gin-gonic/gin" 6 | "github.com/lotteryjs/ten-minutes-app/model" 7 | "go.mongodb.org/mongo-driver/bson" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | "net/http" 10 | "strconv" 11 | ) 12 | 13 | // The PostDatabase interface for encapsulating database access. 14 | type PostDatabase interface { 15 | GetPosts(paging *model.Paging) []*model.Post 16 | GetPostByID(id primitive.ObjectID) *model.Post 17 | CreatePost(post *model.Post) *model.Post 18 | UpdatePost(post *model.Post) *model.Post 19 | DeletePostByID(id primitive.ObjectID) error 20 | CountPost(condition interface{}) string 21 | } 22 | 23 | // The PostAPI provides handlers for managing posts. 24 | type PostAPI struct { 25 | DB PostDatabase 26 | } 27 | 28 | // CreatePost creates a post. 29 | func (a *PostAPI) CreatePost(ctx *gin.Context) { 30 | var post = model.Post{} 31 | if err := ctx.ShouldBind(&post); err == nil { 32 | if result := a.DB.CreatePost(post.New()); result != nil { 33 | ctx.JSON(201, result) 34 | } else { 35 | ctx.AbortWithError(500, errors.New("CreatePost error")) 36 | } 37 | } else { 38 | ctx.AbortWithError(500, errors.New("ShouldBind error")) 39 | } 40 | } 41 | 42 | // GetPosts returns all the posts 43 | // _end=5&_order=DESC&_sort=id&_start=0 adapt react-admin 44 | func (a *PostAPI) GetPosts(ctx *gin.Context) { 45 | var ( 46 | start int64 47 | end int64 48 | sort string 49 | order int 50 | userID string 51 | ) 52 | 53 | start, _ = strconv.ParseInt(ctx.DefaultQuery("_start", "0"), 10, 64) 54 | end, _ = strconv.ParseInt(ctx.DefaultQuery("_end", "10"), 10, 64) 55 | userID = ctx.DefaultQuery("userId", "") 56 | sort = ctx.DefaultQuery("_sort", "_id") 57 | order = 1 58 | 59 | if sort == "id" { 60 | sort = "_id" 61 | } 62 | 63 | if ctx.DefaultQuery("_order", "DESC") == "DESC" { 64 | order = -1 65 | } 66 | 67 | condition := bson.D{} 68 | if userID != "" { 69 | coditionUserID, _ := primitive.ObjectIDFromHex(userID) 70 | condition = bson.D{{ 71 | Key: "userId", 72 | Value: coditionUserID, 73 | }} 74 | } 75 | 76 | limit := end - start 77 | posts := a.DB.GetPosts( 78 | &model.Paging{ 79 | Skip: &start, 80 | Limit: &limit, 81 | SortKey: sort, 82 | SortVal: order, 83 | Condition: condition, 84 | }) 85 | 86 | ctx.Header("X-Total-Count", a.DB.CountPost(nil)) 87 | ctx.JSON(200, posts) 88 | } 89 | 90 | // GetPostByID returns the post by id 91 | func (a *PostAPI) GetPostByID(ctx *gin.Context) { 92 | withID(ctx, "id", func(id primitive.ObjectID) { 93 | if post := a.DB.GetPostByID(id); post != nil { 94 | ctx.JSON(200, post) 95 | } else { 96 | ctx.AbortWithError(404, errors.New("post does not exist")) 97 | } 98 | }) 99 | } 100 | 101 | // DeletePostByID deletes the post by id 102 | func (a *PostAPI) DeletePostByID(ctx *gin.Context) { 103 | withID(ctx, "id", func(id primitive.ObjectID) { 104 | if err := a.DB.DeletePostByID(id); err == nil { 105 | ctx.JSON(200, http.StatusOK) 106 | } else { 107 | ctx.AbortWithError(404, errors.New("post does not exist")) 108 | } 109 | }) 110 | } 111 | 112 | // UpdatePostByID is 113 | func (a *PostAPI) UpdatePostByID(ctx *gin.Context) { 114 | withID(ctx, "id", func(id primitive.ObjectID) { 115 | var post = model.Post{} 116 | abort := errors.New("post does not exist") 117 | if err := ctx.ShouldBind(&post); err == nil { 118 | if result := a.DB.UpdatePost(&post); result != nil { 119 | ctx.JSON(200, result) 120 | } else { 121 | ctx.AbortWithError(404, abort) 122 | } 123 | } else { 124 | ctx.AbortWithError(404, abort) 125 | } 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | name: CI/CD for UI 3 | 4 | clone: 5 | depth: 50 6 | 7 | steps: 8 | - name: fetch tags 9 | image: docker:git 10 | commands: 11 | - git fetch --tags 12 | 13 | - name: build 14 | image: node:10.15.1 15 | volumes: 16 | - name: cache 17 | path: /tmp/cache 18 | commands: 19 | - cd app 20 | - npm install 21 | - npm run build 22 | 23 | - name: publish image 24 | image: plugins/docker:17.12 25 | settings: 26 | repo: lotteryjs/ui-ten-minutes 27 | auto_tag: true 28 | dockerfile: Dockerfile.UI 29 | username: 30 | from_secret: docker_username 31 | password: 32 | from_secret: docker_password 33 | 34 | - name: update docker-compose 35 | image: appleboy/drone-scp 36 | settings: 37 | host: 38 | from_secret: host 39 | port: 40 | from_secret: port 41 | username: 42 | from_secret: username 43 | password: 44 | from_secret: password 45 | target: /data/wwwroot/ten-minutes 46 | source: docker-compose.UI.yml 47 | 48 | - name: restart 49 | image: appleboy/drone-ssh 50 | pull: true 51 | settings: 52 | host: 53 | from_secret: host 54 | port: 55 | from_secret: port 56 | username: 57 | from_secret: username 58 | password: 59 | from_secret: password 60 | script: 61 | - cd /data/wwwroot/ten-minutes 62 | - docker-compose -f docker-compose.UI.yml pull ui-ten-minutes 63 | - docker-compose -f docker-compose.UI.yml up -d --force-recreate --no-deps ui-ten-minutes 64 | - docker images --quiet --filter=dangling=true | xargs --no-run-if-empty docker rmi -f 65 | 66 | 67 | volumes: 68 | - name: cache 69 | temp: {} 70 | 71 | trigger: 72 | event: 73 | - tag 74 | 75 | --- 76 | 77 | kind: pipeline 78 | name: CI/CD for API 79 | 80 | clone: 81 | depth: 50 82 | 83 | steps: 84 | - name: fetch tags 85 | image: docker:git 86 | commands: 87 | - git fetch --tags 88 | 89 | - name: build 90 | image: golang:1.12 91 | pull: true 92 | commands: 93 | - export LD_FLAGS="-w -s -X main.Version=$(git describe --tags | cut -c 2-) -X main.BuildDate=$(date "+%F-%T") -X main.Commit=$(git rev-parse --verify HEAD) -X main.Mode=prod" 94 | - make build_linux_amd64 95 | 96 | - name: publish image 97 | image: plugins/docker:17.12 98 | settings: 99 | repo: lotteryjs/api-ten-minutes 100 | auto_tag: true 101 | dockerfile: Dockerfile.API 102 | username: 103 | from_secret: docker_username 104 | password: 105 | from_secret: docker_password 106 | 107 | - name: update docker-compose 108 | image: appleboy/drone-scp 109 | settings: 110 | host: 111 | from_secret: host 112 | port: 113 | from_secret: port 114 | username: 115 | from_secret: username 116 | password: 117 | from_secret: password 118 | target: /data/wwwroot/tenapi 119 | source: docker-compose.API.yml 120 | 121 | - name: restart 122 | image: appleboy/drone-ssh 123 | pull: true 124 | settings: 125 | host: 126 | from_secret: host 127 | port: 128 | from_secret: port 129 | username: 130 | from_secret: username 131 | password: 132 | from_secret: password 133 | script: 134 | - cd /data/wwwroot/tenapi 135 | - docker-compose -f docker-compose.API.yml pull api-ten-minutes 136 | - docker-compose -f docker-compose.API.yml up -d --force-recreate --no-deps api-ten-minutes 137 | - docker images --quiet --filter=dangling=true | xargs --no-run-if-empty docker rmi -f 138 | 139 | trigger: 140 | event: 141 | - tag 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ten-Minutes-App 2 | 3 | 4 | ### [😀 haha~ demo](https://ten-minutes.lotteryjs.com/) 5 | 6 | #### Traefik + Docker Deploy 7 | 8 | 9 | 10 | 11 | [golang](https://golang.org/) v1.12.x + [mongo-go-driver](https://github.com/mongodb/mongo-go-driver) v1.x + [gin](https://github.com/gin-gonic/gin) v1.3.x + [mongodb](https://www.mongodb.com/) v4.0.6 + [JSONPlaceholder](http://jsonplaceholder.typicode.com/), [react-admin](https://github.com/marmelab/react-admin) 12 | 13 | [使用 Docker 一秒本地搭建 Mongodb&mongo-express 环境](https://github.com/Kirk-Wang/Hello-Gopher/tree/master/mongo) 14 | 15 | 16 | App 介绍: 17 | * 基于 [react-admin](https://github.com/marmelab/react-admin),它很好的抽象出了前端的各种 CRUD 操作,复杂的也可以自定义。 18 | * 前端示例采用 Typescript 作为主要开发语言。 19 | * 为什么选择 Typescript ? 相信 [这篇文章](https://juejin.im/post/59c46bc86fb9a00a4636f939) 会给你一些答案。 20 | * 使用 [Create React App](https://facebook.github.io/create-react-app/) 快速生成脚手架([Adding TypeScript 21 | ](https://facebook.github.io/create-react-app/docs/adding-typescript))。 22 | * [REST Client for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=humao.rest-client)。如果你用 VSCode,也许它比 Postman 更好用。 23 | * 使用 [DbSchema](https://www.dbschema.com/)(请支持正版😝) 对数据进行可视化建模 24 | * [Demo](https://o-o.ren/scaling-redux-apps/visual-data-modeling/2-tutorial/) 25 | 26 |
27 | 28 | 10分钟内快速构建一个完整的应用 29 | 30 |
    31 |
  1. Users:路由导航,用户列表,分页,全选,删除(2s 可撤销删除),导出 CSV 文件,点击表头排序
  2. 32 |
  3. Posts:路由导航,文章列表,分页,全选,删除(2s 可撤销删除),导出 CSV 文件,点击表头排序
  4. 33 |
  5. 添加文章(针对某个用户)
  6. 34 |
  7. 编辑文章(2s 可撤销编辑)
  8. 35 |
  9. 自定义首页(Dashboard)
  10. 36 |
  11. 添加 AuthProvider(登录自定义处理,适配后端登录,注销功能)并设置登录页。
  12. 37 |
  13. 添加 DataProvider(数据提供自定义处理,适配后端不同的 API 请求格式和响应)。
  14. 38 |
39 |
40 | 41 |

42 | 43 |    44 | 45 |    46 | 47 |    48 | 49 |    50 | 51 |    52 | 53 |    54 | 55 |    56 | 57 |    58 | 59 |

60 | 61 | ![Schema](./Schema.png) 62 | 63 | ![ui-dashboard](./ui-dashboard.png) 64 | 65 | ![ui-post](./ui-post.png) 66 | 67 | # CI/CD 68 | 69 | ![Drone-CI-CD](./drone_ci_cd.png) 70 | 71 | # Dev 72 | ```sh 73 | # api 74 | go run . 75 | # app 76 | cd app 77 | yarn & yarn start 78 | ``` 79 | 80 | # Refs 81 | 82 | * [mongo-go-dirver offical examples](https://github.com/mongodb/mongo-go-driver/blob/master/examples/documentation_examples/examples.go) 83 | * [Go by Example](https://gobyexample.com/) 84 | * [gotify/server](https://github.com/gotify/server) 85 | * [gin-jwt](https://github.com/appleboy/gin-jwt) 86 | 87 | # Q 88 | * [Composite literal uses unkeyed fields](https://stackoverflow.com/questions/54548441/composite-literal-uses-unkeyed-fields) 89 | * [Convert between int, int64 and string](https://yourbasic.org/golang/convert-int-to-string/) 90 | * [go test -run does not work for a particular test](https://github.com/stretchr/testify/issues/460) 91 | ```sh 92 | go test -v -run TestDatabaseSuite ./database -testify.m TestGetUsers 93 | ``` 94 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3 h1:t8FVkw33L+wilf2QiWkw0UV77qRpcH/JHPKGpKa2E8g= 6 | github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= 7 | github.com/gin-gonic/gin v1.3.0 h1:kCmZyPklC0gVdL728E6Aj20uYBJV93nj/TkwBTKhFbs= 8 | github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= 9 | github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= 10 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 11 | github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o= 12 | github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 13 | github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= 14 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 15 | github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= 16 | github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 17 | github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= 18 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 19 | github.com/gotify/server v1.2.1 h1:FfququCa1fDPFKwvsc5EC9CBwVNpMKYh61g4Ipo/n9g= 20 | github.com/gotify/server v1.2.1/go.mod h1:3LFlUpVSr0oTidH8LqKr49BBd2NWKofjk33HQwprgBY= 21 | github.com/lotteryjs/configor v1.0.2 h1:RdCsWfOUrLHFUGAtBdHvxu0oUL0vIfjIhPpmSTroIPk= 22 | github.com/lotteryjs/configor v1.0.2/go.mod h1:+ovhqshNCcdCE38IxUcIBvTL+Qq1wCI5MA5sbevYXlg= 23 | github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc= 24 | github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 25 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 26 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 27 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 28 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 30 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 31 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 32 | github.com/ugorji/go v1.1.2 h1:JON3E2/GPW2iDNGoSAusl1KDf5TRQ8k8q7Tp097pZGs= 33 | github.com/ugorji/go v1.1.2/go.mod h1:hnLbHMwcvSihnDhEfx2/BzKp2xb0Y+ErdfYcrs9tkJQ= 34 | github.com/ugorji/go/codec v0.0.0-20190316192920-e2bddce071ad h1:Zu1a3eNI3eJefas3yuL6HAKy6eMhRCQFdtZQLC21l6U= 35 | github.com/ugorji/go/codec v0.0.0-20190316192920-e2bddce071ad/go.mod h1:iT03XoTwV7xq/+UGwKO3UbC1nNNlopQiY61beSdrtOA= 36 | github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk= 37 | github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= 38 | github.com/xdg/stringprep v1.0.0 h1:d9X0esnoa3dFsV0FG35rAT0RIhYFlPq7MiP+DW89La0= 39 | github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= 40 | go.mongodb.org/mongo-driver v1.0.0 h1:KxPRDyfB2xXnDE2My8acoOWBQkfv3tz0SaWTRZjJR0c= 41 | go.mongodb.org/mongo-driver v1.0.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= 42 | golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a h1:YX8ljsm6wXlHZO+aRz9Exqr0evNhKRNe5K/gi+zKh4U= 43 | golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 44 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 h1:bjcUS9ztw9kFmmIxJInhon/0Is3p+EHBKNgquIzo1OI= 45 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 46 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 47 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8= 48 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 49 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 50 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 51 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 | gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ= 53 | gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= 54 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 55 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 56 | -------------------------------------------------------------------------------- /app/src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | (process as { env: { [key: string]: string } }).env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl) 112 | .then(response => { 113 | // Ensure service worker exists, and that we really are getting a JS file. 114 | const contentType = response.headers.get('content-type'); 115 | if ( 116 | response.status === 404 || 117 | (contentType != null && contentType.indexOf('javascript') === -1) 118 | ) { 119 | // No service worker found. Probably a different app. Reload the page. 120 | navigator.serviceWorker.ready.then(registration => { 121 | registration.unregister().then(() => { 122 | window.location.reload(); 123 | }); 124 | }); 125 | } else { 126 | // Service worker found. Proceed as normal. 127 | registerValidSW(swUrl, config); 128 | } 129 | }) 130 | .catch(() => { 131 | console.log( 132 | 'No internet connection found. App is running in offline mode.' 133 | ); 134 | }); 135 | } 136 | 137 | export function unregister() { 138 | if ('serviceWorker' in navigator) { 139 | navigator.serviceWorker.ready.then(registration => { 140 | registration.unregister(); 141 | }); 142 | } 143 | } 144 | --------------------------------------------------------------------------------