diff --git a/README.md b/README.md index 2bb4946..265f234 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,20 @@ Check this [wiki](https://github.com/nimblehq/gin-templates/wiki/Directories) fo make test ``` +## Development + +- Build the command with + + ```sh + go build -o + ``` + +- Run the build file follow by the `create` command and the prompt to create a Go project should appear + + ```sh + create + ``` + ## License This project is Copyright (c) 2014 and onwards Nimble. It is free software, diff --git a/cmd/create_test.go b/cmd/create_test.go index de2dddd..fcd7b03 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -663,19 +663,6 @@ var _ = Describe("Create template", func() { Expect(content).To(ContainSubstring(expectedContent)) }) - - It("contains Node 14 in README.md", func() { - cookiecutter := tests.Cookiecutter{ - AppName: "test-gin-templates", - Variant: tests.Web, - } - cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) - content := tests.ReadFile("README.md") - - expectedContent := "[Node - 14](https://nodejs.org/en/)" - - Expect(content).To(ContainSubstring(expectedContent)) - }) }) Context("given NO Web variant", func() { @@ -690,17 +677,6 @@ var _ = Describe("Create template", func() { Expect(os.IsNotExist(err)).To(BeTrue()) }) - It("does NOT contain .eslintrc.json file", func() { - cookiecutter := tests.Cookiecutter{ - AppName: "test-gin-templates", - Variant: tests.API, - } - cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) - _, err := os.Stat(".eslintrc.json") - - Expect(os.IsNotExist(err)).To(BeTrue()) - }) - It("does NOT contain .npmrc file", func() { cookiecutter := tests.Cookiecutter{ AppName: "test-gin-templates", @@ -712,17 +688,6 @@ var _ = Describe("Create template", func() { Expect(os.IsNotExist(err)).To(BeTrue()) }) - It("does NOT contain package.json file", func() { - cookiecutter := tests.Cookiecutter{ - AppName: "test-gin-templates", - Variant: tests.API, - } - cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) - _, err := os.Stat("package.json") - - Expect(os.IsNotExist(err)).To(BeTrue()) - }) - It("does NOT contain snowpack.config.js file", func() { cookiecutter := tests.Cookiecutter{ AppName: "test-gin-templates", @@ -781,32 +746,6 @@ var _ = Describe("Create template", func() { Expect(content).NotTo(ContainSubstring(expectedContent)) }) - - It("does NOT contain npm install in Makefile", func() { - cookiecutter := tests.Cookiecutter{ - AppName: "test-gin-templates", - Variant: tests.API, - } - cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) - content := tests.ReadFile("Makefile") - - expectedContent := "npm install" - - Expect(content).NotTo(ContainSubstring(expectedContent)) - }) - - It("does NOT contain Node 14 in README.md", func() { - cookiecutter := tests.Cookiecutter{ - AppName: "test-gin-templates", - Variant: tests.API, - } - cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) - content := tests.ReadFile("README.md") - - expectedContent := "[Node - 14](https://nodejs.org/en/)" - - Expect(content).NotTo(ContainSubstring(expectedContent)) - }) }) Context("given bootstrap add-on", func() { @@ -1012,4 +951,265 @@ var _ = Describe("Create template", func() { Expect(content).NotTo(ContainSubstring(expectedContent)) }) }) + + Context("given openapi add-on", func() { + Context("given only Web variant", func() { + It("does NOT contains openapi requirement in .eslintrc.json", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + Variant: tests.Web, + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + content := tests.ReadFile(".eslintrc.json") + + expectedContent := `plugin:yml/recommended` + + Expect(content).NotTo(ContainSubstring(expectedContent)) + }) + }) + + Context("given only API variant", func() { + It("contains openapi requirement in .eslintrc.json", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + Variant: tests.API, + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + content := tests.ReadFile(".eslintrc.json") + + expectedContent := `plugin:yml/recommended` + + Expect(content).To(ContainSubstring(expectedContent)) + }) + }) + + Context("given both variant", func() { + It("contains openapi requirement in .eslintrc.json", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + Variant: tests.Both, + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + content := tests.ReadFile(".eslintrc.json") + + expectedContent := `plugin:yml/recommended` + + Expect(content).To(ContainSubstring(expectedContent)) + }) + }) + + It("contains docs/openapi folder", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + _, err := os.Stat("docs/openapi") + + Expect(os.IsNotExist(err)).To(BeFalse()) + }) + + It("contains openapi instruction in README", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + content := tests.ReadFile("README.md") + + expectedContent := "Generate API documentation" + + Expect(content).To(ContainSubstring(expectedContent)) + }) + + It("contains .dockerignore file", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + _, err := os.Stat(".dockerignore") + + Expect(os.IsNotExist(err)).To(BeFalse()) + }) + + It("contains .eslintignore file", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + _, err := os.Stat(".eslintignore") + + Expect(os.IsNotExist(err)).To(BeFalse()) + }) + + It("contains .github/workflows/lint_docs.yml file", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + _, err := os.Stat(".github/workflows/lint_docs.yml") + + Expect(os.IsNotExist(err)).To(BeFalse()) + }) + + It("contains openapi requirement in .gitignore", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + content := tests.ReadFile(".gitignore") + + expectedContent := "/public/openapi.yml" + + Expect(content).To(ContainSubstring(expectedContent)) + }) + + It("contains .spectral.yaml file", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + _, err := os.Stat(".spectral.yaml") + + Expect(os.IsNotExist(err)).To(BeFalse()) + }) + + It("contains openapi requirement in .tool-versions", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + content := tests.ReadFile(".tool-versions") + + expectedContent := "nodejs 18.15.0" + + Expect(content).To(ContainSubstring(expectedContent)) + }) + + It("contains openapi requirement in Makefile", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + content := tests.ReadFile("Makefile") + + expectedContent := "npm run build:docs" + + Expect(content).To(ContainSubstring(expectedContent)) + }) + + It("contains openapi requirement in package.json", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + content := tests.ReadFile("package.json") + + expectedContent := `"build:docs": "swagger-cli bundle docs/openapi/openapi.yml --outfile public/openapi.yml --type yaml"` + + Expect(content).To(ContainSubstring(expectedContent)) + }) + + It("contains lib/api/docs folder", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + _, err := os.Stat("lib/api/docs") + + Expect(os.IsNotExist(err)).To(BeFalse()) + }) + + It("contains openapi requirement in go.mod", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + content := tests.ReadFile("go.mod") + + expectedContent := "github.com/gin-gonic/contrib" + + Expect(content).To(ContainSubstring(expectedContent)) + }) + + It("contains openapi requirement in router.go", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + content := tests.ReadFile("bootstrap/router.go") + + expectedContent := "apidocsrouter.CombineRoutes(r)" + + Expect(content).To(ContainSubstring(expectedContent)) + }) + }) + + Context("given mock_server add-on", func() { + It("contains deploy_mock_server.yml file", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + UseMockServer: tests.Yes, + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + _, err := os.Stat(".github/workflows/deploy_mock_server.yml") + + Expect(os.IsNotExist(err)).To(BeFalse()) + }) + + It("contains Dockerfile.mock file", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + UseMockServer: tests.Yes, + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + _, err := os.Stat("Dockerfile.mock") + + Expect(os.IsNotExist(err)).To(BeFalse()) + }) + + It("contains fly.toml file", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + UseMockServer: tests.Yes, + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + _, err := os.Stat("fly.toml") + + Expect(os.IsNotExist(err)).To(BeFalse()) + }) + }) + + Context("given NO mock_server add-on", func() { + It("does NOT contains deploy_mock_server.yml file", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + UseMockServer: tests.No, + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + _, err := os.Stat(".github/workflows/deploy_mock_server.yml") + + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + + It("does NOT contains Dockerfile.mock file", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + UseMockServer: tests.No, + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + _, err := os.Stat("Dockerfile.mock") + + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + + It("does NOT contains fly.toml file", func() { + cookiecutter := tests.Cookiecutter{ + AppName: "test-gin-templates", + UseMockServer: tests.No, + } + cookiecutter.CreateProjectFromGinTemplate(currentTemplatePath) + _, err := os.Stat("fly.toml") + + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + }) }) diff --git a/cookiecutter.json b/cookiecutter.json index 39c6878..1e7c3e3 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -4,6 +4,7 @@ "css_addon": ["Bootstrap", "Tailwind", "None"], "use_logrus": ["yes", "no"], "use_heroku": ["yes", "no"], + "use_mock_server": ["yes", "no"], "_api_variant": "no", "_web_variant": "no", diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index fcc92a8..458af8d 100644 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -5,7 +5,6 @@ # Get the root project directory PROJECT_DIRECTORY = os.path.realpath(os.path.curdir) - def remove_files(path): """ Removes files in path @@ -14,7 +13,6 @@ def remove_files(path): PROJECT_DIRECTORY, path )) - def remove_file(path): """ Removes file with path @@ -24,8 +22,6 @@ def remove_file(path): )) # Print log with color - - def print_log(message): """ Print log with color @@ -34,7 +30,6 @@ def print_log(message): CEND = '\033[0m' # END color print(CYELLOW + message + CEND) - def remove_empty_folders(): """ List all empty folders and remove them @@ -46,6 +41,12 @@ def remove_empty_folders(): print_log(f'Removing {dir} folder') os.rmdir(os.path.join(root, dir)) +def ensure_coverage_check_script(): + """ + Ensure coverage check script is executable + """ + print_log('Ensure coverage check is executable') + subprocess.call(['chmod', '+x', './bin/check-coverage.sh']) def init_git(message): """ @@ -57,7 +58,6 @@ def init_git(message): subprocess.call( ['git', 'commit', '-m', 'Initialize project using gin-templates']) - # Remove logrus add-on if not seleted if '{{ cookiecutter.use_logrus }}'.lower() == 'no': print_log('Removing logrus add-on') @@ -90,13 +90,34 @@ def init_git(message): remove_files('lib/web') # Config files - remove_file('.eslintrc.json') remove_file('.npmrc') - remove_file('package.json') remove_file('snowpack.config.js') remove_file('postcss.config.js') remove_file('tsconfig.json') +# Remove openapi if the project has web variant only +if '{{ cookiecutter._api_variant }}' == 'no': + print_log('Removing openapi') + + # docs folder + remove_files("docs") + remove_files("lib/api") + + # openapi related files + remove_file(".dockerignore") + remove_file(".eslintignore") + remove_file(".spectral.yaml") + remove_file(".github/workflows/lint_docs.yml") + +# Remove mock_server if not seleted +if '{{ cookiecutter.use_mock_server }}' == 'no': + print_log('Removing mock_server') + + # mock_server related files + remove_file("Dockerfile.mock") + remove_file("fly.toml") + remove_file(".github/workflows/deploy_mock_server.yml") + # Download the missing dependencies print_log('Downloading dependencies') subprocess.call(['go', 'mod', 'tidy']) @@ -108,5 +129,8 @@ def init_git(message): # Remove empty folders remove_empty_folders() +# Ensure coverage check script is executable CI +ensure_coverage_check_script() + # Initialize git init_git('Initializing git repository') diff --git a/hooks/pre_gen_project.py b/hooks/pre_gen_project.py index 679ccaa..51a4432 100644 --- a/hooks/pre_gen_project.py +++ b/hooks/pre_gen_project.py @@ -28,4 +28,13 @@ {{ cookiecutter.update({ '_tailwind_addon': 'yes' }) }} {% endif %} {% endif %} + +----------- +Mock Server +cookiecutter.use_mock_server +----------- +Only project with openapi: +{% if cookiecutter._api_variant == 'no' %} + {{ cookiecutter.update({ 'use_mock_server': 'no' }) }} +{% endif %} ''' diff --git a/tests/cookiecutter.go b/tests/cookiecutter.go index eead37e..57dc9c3 100644 --- a/tests/cookiecutter.go +++ b/tests/cookiecutter.go @@ -25,11 +25,12 @@ const ( // Field and order MUST be the same as cookiecutter.json type Cookiecutter struct { - AppName string - Variant Variants - CssAddon CssAddons - UseLogrus Choices - UseHeroku Choices + AppName string + Variant Variants + CssAddon CssAddons + UseLogrus Choices + UseHeroku Choices + UseMockServer Choices } func (c *Cookiecutter) fillDefaultValue() { @@ -45,6 +46,10 @@ func (c *Cookiecutter) fillDefaultValue() { c.UseHeroku = No } + if c.UseMockServer != Yes && c.UseMockServer != No { + c.UseMockServer = No + } + if c.CssAddon != Bootstrap && c.CssAddon != Tailwind && c.CssAddon != None { c.CssAddon = None } diff --git a/{{cookiecutter.app_name}}/.dockerignore b/{{cookiecutter.app_name}}/.dockerignore new file mode 100644 index 0000000..e205a7d --- /dev/null +++ b/{{cookiecutter.app_name}}/.dockerignore @@ -0,0 +1,5 @@ +.github/ +.gitignore +node_modules/ +docker-compose.dev.yml +README.md diff --git a/{{cookiecutter.app_name}}/.eslintignore b/{{cookiecutter.app_name}}/.eslintignore new file mode 100644 index 0000000..32652b7 --- /dev/null +++ b/{{cookiecutter.app_name}}/.eslintignore @@ -0,0 +1,2 @@ +/node_modules/** +/public/** diff --git a/{{cookiecutter.app_name}}/.eslintrc.json b/{{cookiecutter.app_name}}/.eslintrc.json index e66bffa..3414d75 100644 --- a/{{cookiecutter.app_name}}/.eslintrc.json +++ b/{{cookiecutter.app_name}}/.eslintrc.json @@ -1,4 +1,4 @@ -{ +{% if cookiecutter._web_variant == "yes" %}{ "rules": {}, "env": { "es6": true, @@ -11,10 +11,17 @@ }, "extends": [ "eslint:recommended", - "plugin:prettier/recommended" + "plugin:prettier/recommended"{% if cookiecutter._api_variant == "yes" %}, + "@nimblehq/eslint-config-nimble", + "plugin:yml/recommended"{% endif %} ], "globals": { "Atomics": "readonly", "SharedArrayBuffer": "readonly" } -} +}{% endif %}{% if cookiecutter._api_variant == "yes" and cookiecutter._web_variant == "no" %}{ + "extends": [ + "@nimblehq/eslint-config-nimble", + "plugin:yml/recommended" + ] +}{% endif %} diff --git a/{{cookiecutter.app_name}}/.github/workflows/deploy_mock_server.yml b/{{cookiecutter.app_name}}/.github/workflows/deploy_mock_server.yml new file mode 100644 index 0000000..d6823c8 --- /dev/null +++ b/{{cookiecutter.app_name}}/.github/workflows/deploy_mock_server.yml @@ -0,0 +1,22 @@ +name: Deploy Mock Server + +on: + push: + branches: + - develop + +jobs: + deploy: + name: Deploy mock server + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up flyctl + uses: superfly/flyctl-actions/setup-flyctl@master + + - name: Deploy to fly + run: flyctl deploy --remote-only + env: + FLY_API_TOKEN: {{ "${{ secrets.FLY_API_TOKEN }}" }} diff --git a/{{cookiecutter.app_name}}/.github/workflows/lint_docs.yml b/{{cookiecutter.app_name}}/.github/workflows/lint_docs.yml new file mode 100644 index 0000000..6ae056e --- /dev/null +++ b/{{cookiecutter.app_name}}/.github/workflows/lint_docs.yml @@ -0,0 +1,31 @@ +name: Lint OpenAPI docs + +on: pull_request + +concurrency: + group: {{ "${{ github.workflow }}-${{ github.ref }}" }} + cancel-in-progress: true + +jobs: + docs_lint: + name: Run lint for API docs + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up node and restore cached dependencies + uses: actions/setup-node@v3 + with: + node-version: "18.x" + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm install + + - name: Generate API docs + run: npm run build:docs + + - name: Run API docs linters + run: npm run lint:docs:public diff --git a/{{cookiecutter.app_name}}/.gitignore b/{{cookiecutter.app_name}}/.gitignore index e1097ed..4aeba4a 100644 --- a/{{cookiecutter.app_name}}/.gitignore +++ b/{{cookiecutter.app_name}}/.gitignore @@ -1,5 +1,6 @@ -{% if cookiecutter._web_variant == "yes" %}/node_modules -{% endif %}tmp/ +/node_modules +tmp/ +coverage/ .env {%- if cookiecutter.use_heroku == "yes" %} @@ -9,3 +10,8 @@ deploy/**/terraform.tfstate deploy/**/terraform.tfstate.backup deploy/**/*.tfvars {%- endif %} + +{%- if cookiecutter._api_variant == "yes" %} +## OpenAPI +/public/openapi.yml +{%- endif %} diff --git a/{{cookiecutter.app_name}}/.spectral.yaml b/{{cookiecutter.app_name}}/.spectral.yaml new file mode 100644 index 0000000..ae54600 --- /dev/null +++ b/{{cookiecutter.app_name}}/.spectral.yaml @@ -0,0 +1,6 @@ +extends: ["spectral:oas"] + +rules: + oas3-unused-component: false + operation-operationId: false + info-contact: false diff --git a/{{cookiecutter.app_name}}/.tool-versions b/{{cookiecutter.app_name}}/.tool-versions index f3ab914..151e0f7 100644 --- a/{{cookiecutter.app_name}}/.tool-versions +++ b/{{cookiecutter.app_name}}/.tool-versions @@ -1 +1,2 @@ golang 1.20 +nodejs 18.15.0 diff --git a/{{cookiecutter.app_name}}/Dockerfile.mock b/{{cookiecutter.app_name}}/Dockerfile.mock new file mode 100644 index 0000000..db70c4b --- /dev/null +++ b/{{cookiecutter.app_name}}/Dockerfile.mock @@ -0,0 +1,7 @@ +FROM stoplight/prism:latest + +COPY /docs /usr/src/prism/packages/cli/docs/ + +EXPOSE 80 + +CMD ["mock", "-h", "0.0.0.0", "-p", "80", "docs/openapi/openapi.yml"] diff --git a/{{cookiecutter.app_name}}/Makefile b/{{cookiecutter.app_name}}/Makefile index 56ff4fe..d5ccfe0 100644 --- a/{{cookiecutter.app_name}}/Makefile +++ b/{{cookiecutter.app_name}}/Makefile @@ -6,10 +6,10 @@ endif .PHONY: env-setup env-teardown db/migrate db/rollback migration/create migration/status dev install-dependencies test wait-for-postgres env-setup: - docker-compose -f docker-compose.dev.yml up -d + docker compose -f docker-compose.dev.yml up -d env-teardown: - docker-compose -f docker-compose.dev.yml down + docker compose -f docker-compose.dev.yml down db/migrate: make wait-for-postgres @@ -28,23 +28,35 @@ endif migration/status: goose -dir database/migrations -table "migration_versions" postgres "$(DATABASE_URL)" status +{% if cookiecutter._api_variant == "yes" %}doc/generate: + npm run build:docs +{%- endif %} + dev: make env-setup make db/migrate forego start -f Procfile.dev install-dependencies: - go install github.com/cosmtrek/air@v1.41.1 + go install github.com/cosmtrek/air@v1.43.0 go install github.com/pressly/goose/v3/cmd/goose@v3.9.0 go install github.com/ddollar/forego@latest go mod tidy - {% if cookiecutter._web_variant == "yes" %}npm install{% endif %} + npm install + {% if cookiecutter._api_variant == "yes" %}make doc/generate{%- endif %} test: - docker-compose -f docker-compose.test.yml up -d + docker compose -f docker-compose.test.yml up -d make wait-for-postgres - go test -v -p 1 -count=1 ./... - docker-compose -f docker-compose.test.yml down + go test -v -p 1 -count=1 ./... -coverprofile=coverage/coverage.out -ginkgo.reportFile=coverage/test-report.xml + docker compose -f docker-compose.test.yml down + +coverage: + go tool cover -html=coverage/coverage.out -o coverage/coverage.html + go tool cover -func=coverage/coverage.out + +coverage-check: coverage + ./bin/check-coverage.sh wait-for-postgres: $(shell DATABASE_URL=$(DATABASE_URL) ./bin/wait-for-postgres.sh) diff --git a/{{cookiecutter.app_name}}/README.md b/{{cookiecutter.app_name}}/README.md index 0546d08..b7da9ae 100644 --- a/{{cookiecutter.app_name}}/README.md +++ b/{{cookiecutter.app_name}}/README.md @@ -9,8 +9,7 @@ ### Prerequisites - [Go - 1.20](https://golang.org/doc/go1.20) or newer - -{% if cookiecutter._web_variant == "yes" %}- [Node - 14](https://nodejs.org/en/){% endif %} +- [Node - 18](https://nodejs.org/en/) ### Development @@ -50,6 +49,15 @@ Execute all unit tests: ```make make test ``` +{%- if cookiecutter._api_variant == "yes" %} +### API Documentation + +Generate API documentation: + +```make +make doc/generate +``` +{%- endif %} ### Migration diff --git a/{{cookiecutter.app_name}}/bin/check-coverage.sh b/{{cookiecutter.app_name}}/bin/check-coverage.sh new file mode 100755 index 0000000..57bcfc2 --- /dev/null +++ b/{{cookiecutter.app_name}}/bin/check-coverage.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +# go tool cover -func coverage/coverage.out results in the multiline text telling coverage percentage on each function in the following format +# github.com/nimblehq/gulf-approval-web/helpers/config.go:8: GetConfigPrefix 100.0% +# github.com/nimblehq/gulf-approval-web/helpers/config.go:28: GetStringConfig 100.0% +# github.com/nimblehq/gulf-approval-web/lib/api/v1/controllers/health.go:13: HealthStatus 100.0% +# total: (statements) 100.0% +# grep total to get the line start with `total` which contain the overall coverage percentage +# awk '{print substr($3, 1, length($3)-1)}' with the built-in variable `$3` to grab the 3rd part after the line is splited with space and substr to remove the % +# which will result in 100.0 +RED='\033[0;31m' +GREEN='\033[0;32m' + +coverage=$(go tool cover -func coverage/coverage.out | grep total | awk '{print substr($3, 1, length($3)-1)}') +expected=100 + +if [ "$(echo $coverage '<' $expected | bc -l)" -eq 1 ]; then\ + echo "${RED}Coverage percentage is too low ${coverage}%, the expected percentage is ${expected}%" + exit 1 +else\ + echo "${GREEN}Coverage percentage meet expectation ${coverage}%" + exit 0 +fi; diff --git a/{{cookiecutter.app_name}}/bootstrap/router.go b/{{cookiecutter.app_name}}/bootstrap/router.go index 72ac012..41bda01 100644 --- a/{{cookiecutter.app_name}}/bootstrap/router.go +++ b/{{cookiecutter.app_name}}/bootstrap/router.go @@ -1,17 +1,23 @@ package bootstrap import ( + {%- if cookiecutter._api_variant == "yes" -%}apidocsrouter "github.com/nimblehq/{{cookiecutter.app_name}}/lib/api/docs/routers"{%- endif %} apiv1router "github.com/nimblehq/{{cookiecutter.app_name}}/lib/api/v1/routers" {% if cookiecutter._web_variant == "yes" %}webrouter "github.com/nimblehq/{{cookiecutter.app_name}}/lib/web/routers" {% endif %} + + {%- if cookiecutter._api_variant == "yes" -%}"github.com/gin-gonic/contrib/static"{%- endif %} "github.com/gin-gonic/gin" ) func SetupRouter() *gin.Engine { r := gin.Default() - apiv1router.ComebineRoutes(r) - {% if cookiecutter._web_variant == "yes" %}webrouter.ComebineRoutes(r) + {% if cookiecutter._api_variant == "yes" -%}r.Use(static.Serve("/", static.LocalFile("./public", true))){%- endif %} + + apiv1router.CombineRoutes(r) + {% if cookiecutter._api_variant == "yes" -%}apidocsrouter.CombineRoutes(r){%- endif %} + {% if cookiecutter._web_variant == "yes" %}webrouter.CombineRoutes(r) {% endif %} return r } diff --git a/{{cookiecutter.app_name}}/database/database.go b/{{cookiecutter.app_name}}/database/database.go index 0d141a3..6fc606f 100644 --- a/{{cookiecutter.app_name}}/database/database.go +++ b/{{cookiecutter.app_name}}/database/database.go @@ -2,7 +2,6 @@ package database import ( "fmt" - "strings" {% if cookiecutter.use_logrus == "no" %}"log" {% endif %} @@ -13,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pressly/goose/v3" "github.com/spf13/viper" + "golang.org/x/text/cases" "gorm.io/driver/postgres" "gorm.io/gorm" ) @@ -25,7 +25,8 @@ func InitDatabase(databaseURL string) { log.Fatalf("Failed to connect to %v database: %v", gin.Mode(), err) } else { viper.Set("database", db) - log.Println(strings.Title(gin.Mode()) + " database connected successfully.") + caser := cases.Title(language.English) + log.Println(caser.String(gin.Mode()) + " database connected successfully.") } migrateDB(db) diff --git a/{{cookiecutter.app_name}}/docs/openapi/examples/requests/.gitkeep b/{{cookiecutter.app_name}}/docs/openapi/examples/requests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.app_name}}/docs/openapi/examples/responses/health.json b/{{cookiecutter.app_name}}/docs/openapi/examples/responses/health.json new file mode 100644 index 0000000..32abe7b --- /dev/null +++ b/{{cookiecutter.app_name}}/docs/openapi/examples/responses/health.json @@ -0,0 +1,24 @@ +{ + "ok": { + "value": { + "data": { + "id": "1", + "type": "health", + "attributes": { + "message": "OK" + } + } + } + }, + "error": { + "value": { + "errors": [ + { + "status": "500", + "title": "Internal Server Error", + "detail": "Something went wrong." + } + ] + } + } +} diff --git a/{{cookiecutter.app_name}}/docs/openapi/openapi.yml b/{{cookiecutter.app_name}}/docs/openapi/openapi.yml new file mode 100644 index 0000000..26c78e6 --- /dev/null +++ b/{{cookiecutter.app_name}}/docs/openapi/openapi.yml @@ -0,0 +1,31 @@ +--- +openapi: 3.0.0 +info: + title: API Documentation + description: This is the API documentation for the mock server. + version: 1.0.0 + +servers: + - url: http://localhost:8080/api/v1 + description: Development Base URL + +security: + - BearerAuth: [] + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + schemas: + $ref: "schemas.yml" + responses: + $ref: "responses.yml" + +paths: + /health: + $ref: "paths/health.yml" + +tags: + - name: Status + description: Status APIs of the project diff --git a/{{cookiecutter.app_name}}/docs/openapi/paths/health.yml b/{{cookiecutter.app_name}}/docs/openapi/paths/health.yml new file mode 100644 index 0000000..541229b --- /dev/null +++ b/{{cookiecutter.app_name}}/docs/openapi/paths/health.yml @@ -0,0 +1,20 @@ +--- +get: + tags: + - Status + security: [] + summary: Get the status of the application. + description: Call this API to get the status of the application. + + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '../schemas.yml#/responses_health' + examples: + $ref: '../examples/responses/health.json' + + default: + $ref: '../responses.yml#/responses_default_error' diff --git a/{{cookiecutter.app_name}}/docs/openapi/request_bodies/.gitkeep b/{{cookiecutter.app_name}}/docs/openapi/request_bodies/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.app_name}}/docs/openapi/responses.yml b/{{cookiecutter.app_name}}/docs/openapi/responses.yml new file mode 100644 index 0000000..6e4535e --- /dev/null +++ b/{{cookiecutter.app_name}}/docs/openapi/responses.yml @@ -0,0 +1,4 @@ +--- + +responses_default_error: + $ref: "responses/default_error.yml" diff --git a/{{cookiecutter.app_name}}/docs/openapi/responses/default_error.yml b/{{cookiecutter.app_name}}/docs/openapi/responses/default_error.yml new file mode 100644 index 0000000..8ea2d5e --- /dev/null +++ b/{{cookiecutter.app_name}}/docs/openapi/responses/default_error.yml @@ -0,0 +1,11 @@ +--- +description: Default Error + +content: + application/json: + schema: + $ref: '../schemas.yml#/error' + example: + errors: + - code: 'invalid_request' + detail: 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.' diff --git a/{{cookiecutter.app_name}}/docs/openapi/schemas.yml b/{{cookiecutter.app_name}}/docs/openapi/schemas.yml new file mode 100644 index 0000000..cf312d7 --- /dev/null +++ b/{{cookiecutter.app_name}}/docs/openapi/schemas.yml @@ -0,0 +1,12 @@ +--- +#### REQUESTS #### + +#### RESPONSES #### + +responses_health: + $ref: "schemas/responses/health.yml" + +#### REUSABLE SCHEMAS #### + +error: + $ref: "schemas/shared/error.yml" diff --git a/{{cookiecutter.app_name}}/docs/openapi/schemas/requests/.gitkeep b/{{cookiecutter.app_name}}/docs/openapi/schemas/requests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.app_name}}/docs/openapi/schemas/responses/health.yml b/{{cookiecutter.app_name}}/docs/openapi/schemas/responses/health.yml new file mode 100644 index 0000000..8d4a722 --- /dev/null +++ b/{{cookiecutter.app_name}}/docs/openapi/schemas/responses/health.yml @@ -0,0 +1,24 @@ +--- +type: object + +description: A response body for the health API + +properties: + data: + type: object + description: The data object + properties: + id: + type: string + description: The identifier of the health + example: "123" + type: + type: string + description: The type of the health + example: 'health' + attributes: + type: object + properties: + message: + type: string + description: The message description of the status diff --git a/{{cookiecutter.app_name}}/docs/openapi/schemas/shared/error.yml b/{{cookiecutter.app_name}}/docs/openapi/schemas/shared/error.yml new file mode 100644 index 0000000..49f6461 --- /dev/null +++ b/{{cookiecutter.app_name}}/docs/openapi/schemas/shared/error.yml @@ -0,0 +1,15 @@ +type: object + +properties: + errors: + type: array + maxItems: 10 + items: + type: object + properties: + code: + type: string + description: an application-specific error code + detail: + type: string + description: "a human-readable explanation specific to this occurrence of the problem. Like title, this field's value can be localized." diff --git a/{{cookiecutter.app_name}}/fly.toml b/{{cookiecutter.app_name}}/fly.toml new file mode 100644 index 0000000..ca69210 --- /dev/null +++ b/{{cookiecutter.app_name}}/fly.toml @@ -0,0 +1,13 @@ +app = "api-mock-server" +primary_region = "sin" + +[build] + dockerfile = "Dockerfile.mock" + +[http_service] + internal_port = 80 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] diff --git a/{{cookiecutter.app_name}}/go.mod b/{{cookiecutter.app_name}}/go.mod index 75eb4c4..423483d 100644 --- a/{{cookiecutter.app_name}}/go.mod +++ b/{{cookiecutter.app_name}}/go.mod @@ -11,4 +11,5 @@ require ( gorm.io/driver/postgres v1.0.8 gorm.io/gorm v1.20.12 {% if cookiecutter.use_logrus == "yes" -%}github.com/sirupsen/logrus v1.8.1{%- endif %} + {% if cookiecutter._api_variant == "yes" -%}github.com/gin-gonic/contrib v0.0.0-20221130124618-7e01895a63f2{%- endif %} ) diff --git a/{{cookiecutter.app_name}}/lib/api/docs/controllers/openapi.go b/{{cookiecutter.app_name}}/lib/api/docs/controllers/openapi.go new file mode 100644 index 0000000..dd89aaa --- /dev/null +++ b/{{cookiecutter.app_name}}/lib/api/docs/controllers/openapi.go @@ -0,0 +1,13 @@ +package controllers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type OpenAPIController struct{} + +func (OpenAPIController) Show(c *gin.Context) { + c.HTML(http.StatusOK, "show.html", gin.H{}) +} diff --git a/{{cookiecutter.app_name}}/lib/api/docs/routers/router.go b/{{cookiecutter.app_name}}/lib/api/docs/routers/router.go new file mode 100644 index 0000000..6ffeb84 --- /dev/null +++ b/{{cookiecutter.app_name}}/lib/api/docs/routers/router.go @@ -0,0 +1,14 @@ +package routers + +import ( + "github.com/nimblehq/{{cookiecutter.app_name}}/lib/api/docs/controllers" + + "github.com/gin-gonic/gin" +) + +func CombineRoutes(engine *gin.Engine) { + docs := engine.Group("/api/docs") + + engine.LoadHTMLFiles("lib/api/docs/views/openapi/show.html") + docs.GET("/openapi", controllers.OpenAPIController{}.Show) +} diff --git a/{{cookiecutter.app_name}}/lib/api/docs/views/openapi/show.html b/{{cookiecutter.app_name}}/lib/api/docs/views/openapi/show.html new file mode 100644 index 0000000..58fad03 --- /dev/null +++ b/{{cookiecutter.app_name}}/lib/api/docs/views/openapi/show.html @@ -0,0 +1,17 @@ + + + + + + + API documentation + + + + + + + + + + diff --git a/{{cookiecutter.app_name}}/lib/api/v1/routers/router.go b/{{cookiecutter.app_name}}/lib/api/v1/routers/router.go index 2b3dd9d..1ef30c1 100644 --- a/{{cookiecutter.app_name}}/lib/api/v1/routers/router.go +++ b/{{cookiecutter.app_name}}/lib/api/v1/routers/router.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" ) -func ComebineRoutes(engine *gin.Engine) { +func CombineRoutes(engine *gin.Engine) { v1 := engine.Group("/api/v1") v1.GET("/health", controllers.HealthController{}.HealthStatus) diff --git a/{{cookiecutter.app_name}}/lib/web/routers/router.go b/{{cookiecutter.app_name}}/lib/web/routers/router.go index f9bb78f..8f3077c 100644 --- a/{{cookiecutter.app_name}}/lib/web/routers/router.go +++ b/{{cookiecutter.app_name}}/lib/web/routers/router.go @@ -10,7 +10,7 @@ import ( eztemplate "github.com/michelloworld/ez-gin-template" ) -func ComebineRoutes(engine *gin.Engine) { +func CombineRoutes(engine *gin.Engine) { // Register HTML renderer htmlRender := eztemplate.New() htmlRender.Debug = gin.IsDebugging() diff --git a/{{cookiecutter.app_name}}/package.json b/{{cookiecutter.app_name}}/package.json index c3d848f..21d8e5f 100644 --- a/{{cookiecutter.app_name}}/package.json +++ b/{{cookiecutter.app_name}}/package.json @@ -6,11 +6,21 @@ "author": "NimbleHQ", "license": "MIT", "scripts": { + {%- if cookiecutter._web_variant == "yes" %} "dev": "NODE_ENV=development MODE=development snowpack build --watch --no-minify --no-bundle", "build": "snowpack build", - "clean": "rm -rf static" + "clean": "rm -rf static"{%- if cookiecutter._api_variant == "yes" %},{%- endif %} + {%- endif %} + {%- if cookiecutter._api_variant == "yes" %} + "lint:docs:yml": "eslint docs/openapi --ext .yml --color", + "lint:docs:openapi": "spectral lint docs/openapi/openapi.yml -F error", + "lint:docs:dev": "npm lint:docs:yml && npm lint:docs:openapi", + "lint:docs:public": "npm build:docs && eslint public/openapi.yml --color --no-ignore && spectral lint public/openapi.yml -F error", + "build:docs": "swagger-cli bundle docs/openapi/openapi.yml --outfile public/openapi.yml --type yaml" + {%- endif %} }, "devDependencies": { + {%- if cookiecutter._web_variant == "yes" %} "@snowpack/plugin-postcss": "1.4.3", "@snowpack/plugin-sass": "1.4.0", "@types/node": "15.12.5", @@ -22,6 +32,13 @@ "postcss": "8.3.5", "prettier": "2.3.2", "snowpack": "3.7.1"{% if cookiecutter._tailwind_addon == "yes" %}, - "tailwindcss": "2.2.7"{% endif %} + "tailwindcss": "2.2.7"{% endif %}{%- if cookiecutter._api_variant == "yes" %},{%- endif %} + {%- endif %} + {%- if cookiecutter._api_variant == "yes" %} + "@apidevtools/swagger-cli": "4.0.4", + "@nimblehq/eslint-config-nimble": "2.1.1", + "@stoplight/spectral-cli": "6.8.0", + "eslint-plugin-yml": "1.8.0" + {%- endif %} } }