├── 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 |
24 |
28 |
29 |
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 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
15 |
16 |
25 | 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 | - Users:路由导航,用户列表,分页,全选,删除(2s 可撤销删除),导出 CSV 文件,点击表头排序
32 | - Posts:路由导航,文章列表,分页,全选,删除(2s 可撤销删除),导出 CSV 文件,点击表头排序
33 | - 添加文章(针对某个用户)
34 | - 编辑文章(2s 可撤销编辑)
35 | - 自定义首页(Dashboard)
36 | - 添加 AuthProvider(登录自定义处理,适配后端登录,注销功能)并设置登录页。
37 | - 添加 DataProvider(数据提供自定义处理,适配后端不同的 API 请求格式和响应)。
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | 
62 |
63 | 
64 |
65 | 
66 |
67 | # CI/CD
68 |
69 | 
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 |
--------------------------------------------------------------------------------