Initial commit

This commit is contained in:
Hayden 2022-08-29 18:30:36 -08:00
commit 29f583e936
135 changed files with 18463 additions and 0 deletions

46
backend/app/api/app.go Normal file
View file

@ -0,0 +1,46 @@
package main
import (
"time"
"github.com/hay-kot/git-web-template/backend/ent"
"github.com/hay-kot/git-web-template/backend/internal/config"
"github.com/hay-kot/git-web-template/backend/internal/repo"
"github.com/hay-kot/git-web-template/backend/internal/services"
"github.com/hay-kot/git-web-template/backend/pkgs/logger"
"github.com/hay-kot/git-web-template/backend/pkgs/mailer"
"github.com/hay-kot/git-web-template/backend/pkgs/server"
)
type app struct {
conf *config.Config
logger *logger.Logger
mailer mailer.Mailer
db *ent.Client
server *server.Server
repos *repo.AllRepos
services *services.AllServices
}
func NewApp(conf *config.Config) *app {
s := &app{
conf: conf,
}
s.mailer = mailer.Mailer{
Host: s.conf.Mailer.Host,
Port: s.conf.Mailer.Port,
Username: s.conf.Mailer.Username,
Password: s.conf.Mailer.Password,
From: s.conf.Mailer.From,
}
return s
}
func (a *app) StartReoccurringTasks(t time.Duration, fn func()) {
for {
a.server.Background(fn)
time.Sleep(t)
}
}

View file

@ -0,0 +1,48 @@
package base
import (
"net/http"
"github.com/hay-kot/git-web-template/backend/internal/types"
"github.com/hay-kot/git-web-template/backend/pkgs/logger"
"github.com/hay-kot/git-web-template/backend/pkgs/server"
)
type ReadyFunc func() bool
type BaseController struct {
log *logger.Logger
svr *server.Server
}
func NewBaseController(log *logger.Logger, svr *server.Server) *BaseController {
h := &BaseController{
log: log,
svr: svr,
}
return h
}
// HandleBase godoc
// @Summary Retrieves the basic information about the API
// @Tags Base
// @Produce json
// @Success 200 {object} server.Result{item=types.ApiSummary}
// @Router /status [GET]
func (ctrl *BaseController) HandleBase(ready ReadyFunc, versions ...string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := types.ApiSummary{
Healthy: ready(),
Versions: versions,
Title: "Go API Template",
Message: "Welcome to the Go API Template Application!",
}
err := server.Respond(w, http.StatusOK, server.Wrap(data))
if err != nil {
ctrl.log.Error(err, nil)
server.RespondInternalServerError(w)
}
}
}

View file

@ -0,0 +1,35 @@
package base
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/hay-kot/git-web-template/backend/internal/mocks"
)
func GetTestHandler(t *testing.T) *BaseController {
return NewBaseController(mocks.GetStructLogger(), nil)
}
func TestHandlersv1_HandleBase(t *testing.T) {
// Setup
hdlrFunc := GetTestHandler(t).HandleBase(func() bool { return true }, "v1")
// Call Handler Func
rr := httptest.NewRecorder()
hdlrFunc(rr, nil)
// Validate Status Code
if rr.Code != http.StatusOK {
t.Errorf("Expected status code to be %d, got %d", http.StatusOK, rr.Code)
}
// Validate Json Payload
expected := `{"item":{"health":true,"versions":["v1"],"title":"Go API Template","message":"Welcome to the Go API Template Application!"}}`
if rr.Body.String() != expected {
t.Errorf("Expected json to be %s, got %s", expected, rr.Body.String())
}
}

View file

@ -0,0 +1,558 @@
// Package docs GENERATED BY SWAG; DO NOT EDIT
// This file was generated by swaggo/swag
package docs
import "github.com/swaggo/swag"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"contact": {
"name": "Don't"
},
"license": {
"name": "MIT"
},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/status": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Base"
],
"summary": "Retrieves the basic information about the API",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"$ref": "#/definitions/types.ApiSummary"
}
}
}
]
}
}
}
}
},
"/v1/admin/users": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin: Users"
],
"summary": "Gets all users from the database",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"type": "array",
"items": {
"$ref": "#/definitions/types.UserOut"
}
}
}
}
]
}
}
}
},
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin: Users"
],
"summary": "Create a new user",
"parameters": [
{
"description": "User Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/types.UserCreate"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"$ref": "#/definitions/types.UserOut"
}
}
}
]
}
}
}
}
},
"/v1/admin/users/{id}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin: Users"
],
"summary": "Get a user from the database",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"$ref": "#/definitions/types.UserOut"
}
}
}
]
}
}
}
},
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin: Users"
],
"summary": "Update a User",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "User Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/types.UserUpdate"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"$ref": "#/definitions/types.UserOut"
}
}
}
]
}
}
}
},
"delete": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin: Users"
],
"summary": "Delete a User",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": ""
}
}
}
},
"/v1/users/login": {
"post": {
"consumes": [
"application/x-www-form-urlencoded",
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Authentication"
],
"summary": "User Login",
"parameters": [
{
"type": "string",
"example": "admin@admin.com",
"description": "string",
"name": "username",
"in": "formData"
},
{
"type": "string",
"example": "admin",
"description": "string",
"name": "password",
"in": "formData"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/types.TokenResponse"
}
}
}
}
},
"/v1/users/logout": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Authentication"
],
"summary": "User Logout",
"responses": {
"204": {
"description": ""
}
}
}
},
"/v1/users/refresh": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "handleAuthRefresh returns a handler that will issue a new token from an existing token.\nThis does not validate that the user still exists within the database.",
"tags": [
"Authentication"
],
"summary": "User Token Refresh",
"responses": {
"200": {
"description": ""
}
}
}
},
"/v1/users/self": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "Get the current user",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"$ref": "#/definitions/types.UserOut"
}
}
}
]
}
}
}
},
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "Update the current user",
"parameters": [
{
"description": "User Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/types.UserUpdate"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"$ref": "#/definitions/types.UserUpdate"
}
}
}
]
}
}
}
}
},
"/v1/users/self/password": {
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "Update the current user's password // TODO:",
"responses": {
"204": {
"description": ""
}
}
}
}
},
"definitions": {
"server.Result": {
"type": "object",
"properties": {
"details": {},
"error": {
"type": "boolean"
},
"item": {},
"message": {
"type": "string"
}
}
},
"types.ApiSummary": {
"type": "object",
"properties": {
"health": {
"type": "boolean"
},
"message": {
"type": "string"
},
"title": {
"type": "string"
},
"versions": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"types.TokenResponse": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string"
},
"token": {
"type": "string"
}
}
},
"types.UserCreate": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"isSuperuser": {
"type": "boolean"
},
"name": {
"type": "string"
},
"password": {
"type": "string"
}
}
},
"types.UserOut": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"id": {
"type": "string"
},
"isSuperuser": {
"type": "boolean"
},
"name": {
"type": "string"
}
}
},
"types.UserUpdate": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"name": {
"type": "string"
}
}
}
},
"securityDefinitions": {
"Bearer": {
"description": "\"Type 'Bearer TOKEN' to correctly set the API Key\"",
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "1.0",
Host: "",
BasePath: "/api",
Schemes: []string{},
Title: "Go API Templates",
Description: "This is a simple Rest API Server Template that implements some basic User and Authentication patterns to help you get started and bootstrap your next project!.",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
}
func init() {
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}

View file

@ -0,0 +1,534 @@
{
"swagger": "2.0",
"info": {
"description": "This is a simple Rest API Server Template that implements some basic User and Authentication patterns to help you get started and bootstrap your next project!.",
"title": "Go API Templates",
"contact": {
"name": "Don't"
},
"license": {
"name": "MIT"
},
"version": "1.0"
},
"basePath": "/api",
"paths": {
"/status": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Base"
],
"summary": "Retrieves the basic information about the API",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"$ref": "#/definitions/types.ApiSummary"
}
}
}
]
}
}
}
}
},
"/v1/admin/users": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin: Users"
],
"summary": "Gets all users from the database",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"type": "array",
"items": {
"$ref": "#/definitions/types.UserOut"
}
}
}
}
]
}
}
}
},
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin: Users"
],
"summary": "Create a new user",
"parameters": [
{
"description": "User Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/types.UserCreate"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"$ref": "#/definitions/types.UserOut"
}
}
}
]
}
}
}
}
},
"/v1/admin/users/{id}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin: Users"
],
"summary": "Get a user from the database",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"$ref": "#/definitions/types.UserOut"
}
}
}
]
}
}
}
},
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin: Users"
],
"summary": "Update a User",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "User Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/types.UserUpdate"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"$ref": "#/definitions/types.UserOut"
}
}
}
]
}
}
}
},
"delete": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin: Users"
],
"summary": "Delete a User",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": ""
}
}
}
},
"/v1/users/login": {
"post": {
"consumes": [
"application/x-www-form-urlencoded",
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Authentication"
],
"summary": "User Login",
"parameters": [
{
"type": "string",
"example": "admin@admin.com",
"description": "string",
"name": "username",
"in": "formData"
},
{
"type": "string",
"example": "admin",
"description": "string",
"name": "password",
"in": "formData"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/types.TokenResponse"
}
}
}
}
},
"/v1/users/logout": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Authentication"
],
"summary": "User Logout",
"responses": {
"204": {
"description": ""
}
}
}
},
"/v1/users/refresh": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "handleAuthRefresh returns a handler that will issue a new token from an existing token.\nThis does not validate that the user still exists within the database.",
"tags": [
"Authentication"
],
"summary": "User Token Refresh",
"responses": {
"200": {
"description": ""
}
}
}
},
"/v1/users/self": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "Get the current user",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"$ref": "#/definitions/types.UserOut"
}
}
}
]
}
}
}
},
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "Update the current user",
"parameters": [
{
"description": "User Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/types.UserUpdate"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Result"
},
{
"type": "object",
"properties": {
"item": {
"$ref": "#/definitions/types.UserUpdate"
}
}
}
]
}
}
}
}
},
"/v1/users/self/password": {
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "Update the current user's password // TODO:",
"responses": {
"204": {
"description": ""
}
}
}
}
},
"definitions": {
"server.Result": {
"type": "object",
"properties": {
"details": {},
"error": {
"type": "boolean"
},
"item": {},
"message": {
"type": "string"
}
}
},
"types.ApiSummary": {
"type": "object",
"properties": {
"health": {
"type": "boolean"
},
"message": {
"type": "string"
},
"title": {
"type": "string"
},
"versions": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"types.TokenResponse": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string"
},
"token": {
"type": "string"
}
}
},
"types.UserCreate": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"isSuperuser": {
"type": "boolean"
},
"name": {
"type": "string"
},
"password": {
"type": "string"
}
}
},
"types.UserOut": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"id": {
"type": "string"
},
"isSuperuser": {
"type": "boolean"
},
"name": {
"type": "string"
}
}
},
"types.UserUpdate": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"name": {
"type": "string"
}
}
}
},
"securityDefinitions": {
"Bearer": {
"description": "\"Type 'Bearer TOKEN' to correctly set the API Key\"",
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
}

View file

@ -0,0 +1,318 @@
basePath: /api
definitions:
server.Result:
properties:
details: {}
error:
type: boolean
item: {}
message:
type: string
type: object
types.ApiSummary:
properties:
health:
type: boolean
message:
type: string
title:
type: string
versions:
items:
type: string
type: array
type: object
types.TokenResponse:
properties:
expiresAt:
type: string
token:
type: string
type: object
types.UserCreate:
properties:
email:
type: string
isSuperuser:
type: boolean
name:
type: string
password:
type: string
type: object
types.UserOut:
properties:
email:
type: string
id:
type: string
isSuperuser:
type: boolean
name:
type: string
type: object
types.UserUpdate:
properties:
email:
type: string
name:
type: string
type: object
info:
contact:
name: Don't
description: This is a simple Rest API Server Template that implements some basic
User and Authentication patterns to help you get started and bootstrap your next
project!.
license:
name: MIT
title: Go API Templates
version: "1.0"
paths:
/status:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/server.Result'
- properties:
item:
$ref: '#/definitions/types.ApiSummary'
type: object
summary: Retrieves the basic information about the API
tags:
- Base
/v1/admin/users:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/server.Result'
- properties:
item:
items:
$ref: '#/definitions/types.UserOut'
type: array
type: object
security:
- Bearer: []
summary: Gets all users from the database
tags:
- 'Admin: Users'
post:
parameters:
- description: User Data
in: body
name: payload
required: true
schema:
$ref: '#/definitions/types.UserCreate'
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/server.Result'
- properties:
item:
$ref: '#/definitions/types.UserOut'
type: object
security:
- Bearer: []
summary: Create a new user
tags:
- 'Admin: Users'
/v1/admin/users/{id}:
delete:
parameters:
- description: User ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"204":
description: ""
security:
- Bearer: []
summary: Delete a User
tags:
- 'Admin: Users'
get:
parameters:
- description: User ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/server.Result'
- properties:
item:
$ref: '#/definitions/types.UserOut'
type: object
security:
- Bearer: []
summary: Get a user from the database
tags:
- 'Admin: Users'
put:
parameters:
- description: User ID
in: path
name: id
required: true
type: string
- description: User Data
in: body
name: payload
required: true
schema:
$ref: '#/definitions/types.UserUpdate'
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/server.Result'
- properties:
item:
$ref: '#/definitions/types.UserOut'
type: object
security:
- Bearer: []
summary: Update a User
tags:
- 'Admin: Users'
/v1/users/login:
post:
consumes:
- application/x-www-form-urlencoded
- application/json
parameters:
- description: string
example: admin@admin.com
in: formData
name: username
type: string
- description: string
example: admin
in: formData
name: password
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/types.TokenResponse'
summary: User Login
tags:
- Authentication
/v1/users/logout:
post:
responses:
"204":
description: ""
security:
- Bearer: []
summary: User Logout
tags:
- Authentication
/v1/users/refresh:
get:
description: |-
handleAuthRefresh returns a handler that will issue a new token from an existing token.
This does not validate that the user still exists within the database.
responses:
"200":
description: ""
security:
- Bearer: []
summary: User Token Refresh
tags:
- Authentication
/v1/users/self:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/server.Result'
- properties:
item:
$ref: '#/definitions/types.UserOut'
type: object
security:
- Bearer: []
summary: Get the current user
tags:
- User
put:
parameters:
- description: User Data
in: body
name: payload
required: true
schema:
$ref: '#/definitions/types.UserUpdate'
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/server.Result'
- properties:
item:
$ref: '#/definitions/types.UserUpdate'
type: object
security:
- Bearer: []
summary: Update the current user
tags:
- User
/v1/users/self/password:
put:
produces:
- application/json
responses:
"204":
description: ""
security:
- Bearer: []
summary: 'Update the current user''s password // TODO:'
tags:
- User
securityDefinitions:
Bearer:
description: '"Type ''Bearer TOKEN'' to correctly set the API Key"'
in: header
name: Authorization
type: apiKey
swagger: "2.0"

116
backend/app/api/main.go Normal file
View file

@ -0,0 +1,116 @@
package main
import (
"context"
"io"
"log"
"os"
"time"
"github.com/hay-kot/git-web-template/backend/app/api/docs"
"github.com/hay-kot/git-web-template/backend/ent"
"github.com/hay-kot/git-web-template/backend/internal/config"
"github.com/hay-kot/git-web-template/backend/internal/repo"
"github.com/hay-kot/git-web-template/backend/internal/services"
"github.com/hay-kot/git-web-template/backend/pkgs/logger"
"github.com/hay-kot/git-web-template/backend/pkgs/server"
_ "github.com/mattn/go-sqlite3"
)
// @title Go API Templates
// @version 1.0
// @description This is a simple Rest API Server Template that implements some basic User and Authentication patterns to help you get started and bootstrap your next project!.
// @contact.name Don't
// @license.name MIT
// @BasePath /api
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @description "Type 'Bearer TOKEN' to correctly set the API Key"
func main() {
cfgFile := "config.yml"
cfg, err := config.NewConfig(cfgFile)
if err != nil {
panic(err)
}
docs.SwaggerInfo.Host = cfg.Swagger.Host
if err := run(cfg); err != nil {
panic(err)
}
}
func run(cfg *config.Config) error {
app := NewApp(cfg)
// =========================================================================
// Setup Logger
var wrt io.Writer
wrt = os.Stdout
if app.conf.Log.File != "" {
f, err := os.OpenFile(app.conf.Log.File, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
log.Fatalf("error opening file: %v", err)
}
defer func(f *os.File) {
_ = f.Close()
}(f)
wrt = io.MultiWriter(wrt, f)
}
app.logger = logger.New(wrt, logger.LevelDebug)
// =========================================================================
// Initialize Database & Repos
c, err := ent.Open(cfg.Database.GetDriver(), cfg.Database.GetUrl())
if err != nil {
app.logger.Fatal(err, logger.Props{
"details": "failed to connect to database",
"database": cfg.Database.GetDriver(),
"url": cfg.Database.GetUrl(),
})
}
defer func(c *ent.Client) {
_ = c.Close()
}(c)
if err := c.Schema.Create(context.Background()); err != nil {
app.logger.Fatal(err, logger.Props{
"details": "failed to create schema",
})
}
app.db = c
app.repos = repo.EntAllRepos(c)
app.services = services.NewServices(app.repos)
// =========================================================================
// Start Server
app.conf.Print()
app.server = server.NewServer(app.conf.Web.Host, app.conf.Web.Port)
routes := app.newRouter(app.repos)
app.LogRoutes(routes)
app.EnsureAdministrator()
app.SeedDatabase(app.repos)
app.logger.Info("Starting HTTP Server", logger.Props{
"host": app.server.Host,
"port": app.server.Port,
})
// =========================================================================
// Start Reoccurring Tasks
go app.StartReoccurringTasks(time.Duration(24)*time.Hour, func() {
app.repos.AuthTokens.PurgeExpiredTokens(context.Background())
})
return app.server.Start(routes)
}

View file

@ -0,0 +1,117 @@
package main
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/hay-kot/git-web-template/backend/internal/config"
"github.com/hay-kot/git-web-template/backend/internal/services"
"github.com/hay-kot/git-web-template/backend/pkgs/hasher"
"github.com/hay-kot/git-web-template/backend/pkgs/logger"
"github.com/hay-kot/git-web-template/backend/pkgs/server"
)
func (a *app) setGlobalMiddleware(r *chi.Mux) {
// =========================================================================
// Middleware
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(mwStripTrailingSlash)
// Use struct logger in production for requests, but use
// pretty console logger in development.
if a.conf.Mode == config.ModeDevelopment {
r.Use(middleware.Logger)
} else {
r.Use(a.mwStructLogger)
}
r.Use(middleware.Recoverer)
// Set a timeout value on the request context (ctx), that will signal
// through ctx.Done() that the request has timed out and further
// processing should be stopped.
r.Use(middleware.Timeout(60 * time.Second))
}
// mwAuthToken is a middleware that will check the database for a stateful token
// and attach it to the request context with the user, or return a 401 if it doesn't exist.
func (a *app) mwAuthToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestToken := r.Header.Get("Authorization")
if requestToken == "" {
server.RespondUnauthorized(w)
return
}
requestToken = strings.TrimPrefix(requestToken, "Bearer ")
hash := hasher.HashToken(requestToken)
// Check the database for the token
usr, err := a.repos.AuthTokens.GetUserFromToken(r.Context(), hash)
if err != nil {
a.logger.Error(err, logger.Props{
"token": requestToken,
"hash": fmt.Sprintf("%x", hash),
})
server.RespondUnauthorized(w)
return
}
r = r.WithContext(services.SetUserCtx(r.Context(), &usr, requestToken))
next.ServeHTTP(w, r)
})
}
// mwAdminOnly is a middleware that extends the mwAuthToken middleware to only allow
// requests from superusers.
func (a *app) mwAdminOnly(next http.Handler) http.Handler {
mw := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
usr := services.UseUserCtx(r.Context())
if !usr.IsSuperuser {
server.RespondUnauthorized(w)
return
}
next.ServeHTTP(w, r)
})
return a.mwAuthToken(mw)
}
// mqStripTrailingSlash is a middleware that will strip trailing slashes from the request path.
func mwStripTrailingSlash(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.TrimSuffix(r.URL.Path, "/")
next.ServeHTTP(w, r)
})
}
func (a *app) mwStructLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
url := fmt.Sprintf("%s://%s%s %s", scheme, r.Host, r.RequestURI, r.Proto)
a.logger.Info(fmt.Sprintf("[%s] %s", r.Method, url), logger.Props{
"id": middleware.GetReqID(r.Context()),
"method": r.Method,
"url": url,
"remote": r.RemoteAddr,
})
next.ServeHTTP(w, r)
})
}

82
backend/app/api/routes.go Normal file
View file

@ -0,0 +1,82 @@
package main
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/hay-kot/git-web-template/backend/app/api/base"
_ "github.com/hay-kot/git-web-template/backend/app/api/docs"
v1 "github.com/hay-kot/git-web-template/backend/app/api/v1"
"github.com/hay-kot/git-web-template/backend/internal/repo"
httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware
)
const prefix = "/api"
// registerRoutes registers all the routes for the API
func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
r := chi.NewRouter()
a.setGlobalMiddleware(r)
// =========================================================================
// Base Routes
r.Get("/swagger/*", httpSwagger.Handler(
httpSwagger.URL(fmt.Sprintf("%s://%s/swagger/doc.json", a.conf.Swagger.Scheme, a.conf.Swagger.Host)),
))
// Server Favicon
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/favicon.ico")
})
baseHandler := base.NewBaseController(a.logger, a.server)
r.Get(prefix+"/status", baseHandler.HandleBase(func() bool { return true }, "v1"))
// =========================================================================
// API Version 1
v1Base := v1.BaseUrlFunc(prefix)
v1Handlers := v1.NewControllerV1(a.logger, a.services)
r.Post(v1Base("/users/login"), v1Handlers.HandleAuthLogin())
r.Group(func(r chi.Router) {
r.Use(a.mwAuthToken)
r.Get(v1Base("/users/self"), v1Handlers.HandleUserSelf())
r.Put(v1Base("/users/self"), v1Handlers.HandleUserUpdate())
r.Put(v1Base("/users/self/password"), v1Handlers.HandleUserUpdatePassword())
r.Post(v1Base("/users/logout"), v1Handlers.HandleAuthLogout())
r.Get(v1Base("/users/refresh"), v1Handlers.HandleAuthRefresh())
})
r.Group(func(r chi.Router) {
r.Use(a.mwAdminOnly)
r.Get(v1Base("/admin/users"), v1Handlers.HandleAdminUserGetAll())
r.Post(v1Base("/admin/users"), v1Handlers.HandleAdminUserCreate())
r.Get(v1Base("/admin/users/{id}"), v1Handlers.HandleAdminUserGet())
r.Put(v1Base("/admin/users/{id}"), v1Handlers.HandleAdminUserUpdate())
r.Delete(v1Base("/admin/users/{id}"), v1Handlers.HandleAdminUserDelete())
})
return r
}
// LogRoutes logs the routes of the server that are registered within Server.registerRoutes(). This is useful for debugging.
// See https://github.com/go-chi/chi/issues/332 for details and inspiration.
func (a *app) LogRoutes(r *chi.Mux) {
desiredSpaces := 10
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
text := "[" + method + "]"
for len(text) < desiredSpaces {
text = text + " "
}
fmt.Printf("Registered Route: %s%s\n", text, route)
return nil
}
if err := chi.Walk(r, walkFunc); err != nil {
fmt.Printf("Logging err: %s\n", err.Error())
}
}

98
backend/app/api/seed.go Normal file
View file

@ -0,0 +1,98 @@
package main
import (
"context"
"github.com/google/uuid"
"github.com/hay-kot/git-web-template/backend/internal/repo"
"github.com/hay-kot/git-web-template/backend/internal/types"
"github.com/hay-kot/git-web-template/backend/pkgs/hasher"
"github.com/hay-kot/git-web-template/backend/pkgs/logger"
)
const (
DefaultName = "Admin"
DefaultEmail = "admin@admin.com"
DefaultPassword = "admin"
)
// EnsureAdministrator ensures that there is at least one superuser in the database
// if one isn't found a default is generate using the default credentials
func (a *app) EnsureAdministrator() {
superusers, err := a.repos.Users.GetSuperusers(context.Background())
if err != nil {
a.logger.Error(err, nil)
}
if len(superusers) > 0 {
return
}
pw, _ := hasher.HashPassword(DefaultPassword)
newSuperUser := types.UserCreate{
Name: DefaultName,
Email: DefaultEmail,
IsSuperuser: true,
Password: pw,
}
a.logger.Info("creating default superuser", logger.Props{
"name": newSuperUser.Name,
"email": newSuperUser.Email,
})
_, err = a.repos.Users.Create(context.Background(), newSuperUser)
if err != nil {
a.logger.Fatal(err, nil)
}
}
func (a *app) SeedDatabase(repos *repo.AllRepos) {
if !a.conf.Seed.Enabled {
return
}
for _, user := range a.conf.Seed.Users {
// Check if User Exists
usr, _ := repos.Users.GetOneEmail(context.Background(), user.Email)
if usr.ID != uuid.Nil {
a.logger.Info("seed user already exists", logger.Props{
"user": user.Name,
})
continue
}
hashedPw, err := hasher.HashPassword(user.Password)
if err != nil {
a.logger.Error(err, logger.Props{
"details": "failed to hash password",
"user": user.Name,
})
}
_, err = repos.Users.Create(context.Background(), types.UserCreate{
Name: user.Name,
Email: user.Email,
IsSuperuser: user.IsSuperuser,
Password: hashedPw,
})
if err != nil {
a.logger.Error(err, logger.Props{
"details": "failed to create seed user",
"name": user.Name,
})
}
a.logger.Info("creating seed user", logger.Props{
"name": user.Name,
})
}
}

View file

@ -0,0 +1,29 @@
package v1
import (
"github.com/hay-kot/git-web-template/backend/internal/services"
"github.com/hay-kot/git-web-template/backend/pkgs/logger"
)
type V1Controller struct {
log *logger.Logger
svc *services.AllServices
}
func BaseUrlFunc(prefix string) func(s string) string {
v1Base := prefix + "/v1"
prefixFunc := func(s string) string {
return v1Base + s
}
return prefixFunc
}
func NewControllerV1(log *logger.Logger, svc *services.AllServices) *V1Controller {
ctrl := &V1Controller{
log: log,
svc: svc,
}
return ctrl
}

View file

@ -0,0 +1,20 @@
package v1
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_NewHandlerV1(t *testing.T) {
v1Base := BaseUrlFunc("/testing/v1")
ctrl := NewControllerV1(mockHandler.log, mockHandler.svc)
assert.NotNil(t, ctrl)
assert.Equal(t, ctrl.log, mockHandler.log)
assert.Equal(t, "/testing/v1/v1/abc123", v1Base("/abc123"))
assert.Equal(t, "/testing/v1/v1/abc123", v1Base("/abc123"))
}

View file

@ -0,0 +1,51 @@
package v1
import (
"context"
"testing"
"github.com/hay-kot/git-web-template/backend/internal/mocks"
"github.com/hay-kot/git-web-template/backend/internal/mocks/factories"
"github.com/hay-kot/git-web-template/backend/internal/types"
)
var mockHandler = &V1Controller{}
var users = []types.UserOut{}
func userPool() func() {
create := []types.UserCreate{
factories.UserFactory(),
factories.UserFactory(),
factories.UserFactory(),
factories.UserFactory(),
}
userOut := []types.UserOut{}
for _, user := range create {
usrOut, _ := mockHandler.svc.Admin.Create(context.Background(), user)
userOut = append(userOut, usrOut)
}
users = userOut
purge := func() {
mockHandler.svc.Admin.DeleteAll(context.Background())
}
return purge
}
func TestMain(m *testing.M) {
// Set Handler Vars
mockHandler.log = mocks.GetStructLogger()
repos, closeDb := mocks.GetEntRepos()
mockHandler.svc = mocks.GetMockServices(repos)
defer closeDb()
purge := userPool()
defer purge()
m.Run()
}

View file

@ -0,0 +1,207 @@
package v1
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/hay-kot/git-web-template/backend/internal/services"
"github.com/hay-kot/git-web-template/backend/internal/types"
"github.com/hay-kot/git-web-template/backend/pkgs/hasher"
"github.com/hay-kot/git-web-template/backend/pkgs/logger"
"github.com/hay-kot/git-web-template/backend/pkgs/server"
)
// HandleAdminUserGetAll godoc
// @Summary Gets all users from the database
// @Tags Admin: Users
// @Produce json
// @Success 200 {object} server.Result{item=[]types.UserOut}
// @Router /v1/admin/users [get]
// @Security Bearer
func (ctrl *V1Controller) HandleAdminUserGetAll() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
users, err := ctrl.svc.Admin.GetAll(r.Context())
if err != nil {
server.RespondError(w, http.StatusInternalServerError, err)
return
}
server.Respond(w, http.StatusOK, server.Wrap(users))
}
}
// HandleAdminUserGet godoc
// @Summary Get a user from the database
// @Tags Admin: Users
// @Produce json
// @Param id path string true "User ID"
// @Success 200 {object} server.Result{item=types.UserOut}
// @Router /v1/admin/users/{id} [get]
// @Security Bearer
func (ctrl *V1Controller) HandleAdminUserGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
ctrl.log.Debug(err.Error(), logger.Props{
"scope": "admin",
"details": "failed to convert id to valid UUID",
})
server.RespondError(w, http.StatusBadRequest, err)
return
}
user, err := ctrl.svc.Admin.GetByID(r.Context(), uid)
if err != nil {
ctrl.log.Error(err, nil)
server.RespondError(w, http.StatusInternalServerError, err)
return
}
server.Respond(w, http.StatusOK, server.Wrap(user))
}
}
// HandleAdminUserCreate godoc
// @Summary Create a new user
// @Tags Admin: Users
// @Produce json
// @Param payload body types.UserCreate true "User Data"
// @Success 200 {object} server.Result{item=types.UserOut}
// @Router /v1/admin/users [POST]
// @Security Bearer
func (ctrl *V1Controller) HandleAdminUserCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
createData := types.UserCreate{}
if err := server.Decode(r, &createData); err != nil {
ctrl.log.Error(err, logger.Props{
"scope": "admin",
"details": "failed to decode user create data",
})
server.RespondError(w, http.StatusBadRequest, err)
return
}
err := createData.Validate()
if err != nil {
server.RespondError(w, http.StatusUnprocessableEntity, err)
return
}
hashedPw, err := hasher.HashPassword(createData.Password)
if err != nil {
ctrl.log.Error(err, logger.Props{
"scope": "admin",
"details": "failed to hash password",
})
server.RespondError(w, http.StatusInternalServerError, err)
return
}
createData.Password = hashedPw
userOut, err := ctrl.svc.Admin.Create(r.Context(), createData)
if err != nil {
ctrl.log.Error(err, logger.Props{
"scope": "admin",
"details": "failed to create user",
})
server.RespondError(w, http.StatusInternalServerError, err)
return
}
server.Respond(w, http.StatusCreated, server.Wrap(userOut))
}
}
// HandleAdminUserUpdate godoc
// @Summary Update a User
// @Tags Admin: Users
// @Param id path string true "User ID"
// @Param payload body types.UserUpdate true "User Data"
// @Produce json
// @Success 200 {object} server.Result{item=types.UserOut}
// @Router /v1/admin/users/{id} [PUT]
// @Security Bearer
func (ctrl *V1Controller) HandleAdminUserUpdate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
ctrl.log.Debug(err.Error(), logger.Props{
"scope": "admin",
"details": "failed to convert id to valid UUID",
})
}
updateData := types.UserUpdate{}
if err := server.Decode(r, &updateData); err != nil {
ctrl.log.Error(err, logger.Props{
"scope": "admin",
"details": "failed to decode user update data",
})
server.RespondError(w, http.StatusBadRequest, err)
return
}
newData, err := ctrl.svc.Admin.UpdateProperties(r.Context(), uid, updateData)
if err != nil {
ctrl.log.Error(err, logger.Props{
"scope": "admin",
"details": "failed to update user",
})
server.RespondError(w, http.StatusInternalServerError, err)
return
}
server.Respond(w, http.StatusOK, server.Wrap(newData))
}
}
// HandleAdminUserDelete godoc
// @Summary Delete a User
// @Tags Admin: Users
// @Param id path string true "User ID"
// @Produce json
// @Success 204
// @Router /v1/admin/users/{id} [DELETE]
// @Security Bearer
func (ctrl *V1Controller) HandleAdminUserDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
ctrl.log.Debug(err.Error(), logger.Props{
"scope": "admin",
"details": "failed to convert id to valid UUID",
})
}
actor := services.UseUserCtx(r.Context())
if actor.ID == uid {
server.RespondError(w, http.StatusBadRequest, errors.New("cannot delete yourself"))
return
}
err = ctrl.svc.Admin.Delete(r.Context(), uid)
if err != nil {
ctrl.log.Error(err, logger.Props{
"scope": "admin",
"details": "failed to delete user",
})
server.RespondError(w, http.StatusInternalServerError, err)
return
}
}
}

View file

@ -0,0 +1,109 @@
package v1
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/hay-kot/git-web-template/backend/internal/mocks/chimocker"
"github.com/hay-kot/git-web-template/backend/internal/mocks/factories"
"github.com/hay-kot/git-web-template/backend/internal/types"
"github.com/hay-kot/git-web-template/backend/pkgs/server"
"github.com/stretchr/testify/assert"
)
const (
UrlUser = "/api/v1/admin/users"
UrlUserId = "/api/v1/admin/users/%v"
UrlUserIdChi = "/api/v1/admin/users/{id}"
)
type usersResponse struct {
Users []types.UserOut `json:"item"`
}
type userResponse struct {
User types.UserOut `json:"item"`
}
func Test_HandleAdminUserGetAll_Success(t *testing.T) {
r := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, UrlUser, nil)
mockHandler.HandleAdminUserGetAll()(r, req)
response := usersResponse{
Users: []types.UserOut{},
}
_ = json.Unmarshal(r.Body.Bytes(), &response)
assert.Equal(t, http.StatusOK, r.Code)
assert.Equal(t, len(users), len(response.Users))
knowEmail := []string{
users[0].Email,
users[1].Email,
users[2].Email,
users[3].Email,
}
for _, user := range users {
assert.Contains(t, knowEmail, user.Email)
}
}
func Test_HandleAdminUserGet_Success(t *testing.T) {
targetUser := users[2]
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf(UrlUserId, targetUser.ID), nil)
req = chimocker.WithUrlParam(req, "id", fmt.Sprintf("%v", targetUser.ID))
mockHandler.HandleAdminUserGet()(res, req)
assert.Equal(t, http.StatusOK, res.Code)
response := userResponse{
User: types.UserOut{},
}
_ = json.Unmarshal(res.Body.Bytes(), &response)
assert.Equal(t, targetUser.ID, response.User.ID)
}
func Test_HandleAdminUserCreate_Success(t *testing.T) {
payload := factories.UserFactory()
r := httptest.NewRecorder()
body, err := json.Marshal(payload)
assert.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, UrlUser, bytes.NewBuffer(body))
req.Header.Set(server.ContentType, server.ContentJSON)
mockHandler.HandleAdminUserCreate()(r, req)
assert.Equal(t, http.StatusCreated, r.Code)
usr, err := mockHandler.svc.Admin.GetByEmail(context.Background(), payload.Email)
assert.NoError(t, err)
assert.Equal(t, payload.Email, usr.Email)
assert.Equal(t, payload.Name, usr.Name)
assert.NotEqual(t, payload.Password, usr.Password) // smoke test - check password is hashed
_ = mockHandler.svc.Admin.Delete(context.Background(), usr.ID)
}
func Test_HandleAdminUserUpdate_Success(t *testing.T) {
t.Skip()
}
func Test_HandleAdminUserUpdate_Delete(t *testing.T) {
t.Skip()
}

View file

@ -0,0 +1,136 @@
package v1
import (
"errors"
"net/http"
"github.com/hay-kot/git-web-template/backend/internal/services"
"github.com/hay-kot/git-web-template/backend/internal/types"
"github.com/hay-kot/git-web-template/backend/pkgs/logger"
"github.com/hay-kot/git-web-template/backend/pkgs/server"
)
var (
HeaderFormData = "application/x-www-form-urlencoded"
HeaderJSON = "application/json"
)
// HandleAuthLogin godoc
// @Summary User Login
// @Tags Authentication
// @Accept x-www-form-urlencoded
// @Accept application/json
// @Param username formData string false "string" example(admin@admin.com)
// @Param password formData string false "string" example(admin)
// @Produce json
// @Success 200 {object} types.TokenResponse
// @Router /v1/users/login [POST]
func (ctrl *V1Controller) HandleAuthLogin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
loginForm := &types.LoginForm{}
if r.Header.Get("Content-Type") == HeaderFormData {
err := r.ParseForm()
if err != nil {
server.Respond(w, http.StatusBadRequest, server.Wrap(err))
return
}
loginForm.Username = r.PostFormValue("username")
loginForm.Password = r.PostFormValue("password")
} else if r.Header.Get("Content-Type") == HeaderJSON {
err := server.Decode(r, loginForm)
if err != nil {
server.Respond(w, http.StatusBadRequest, server.Wrap(err))
return
}
} else {
server.Respond(w, http.StatusBadRequest, errors.New("invalid content type"))
return
}
if loginForm.Username == "" || loginForm.Password == "" {
server.RespondError(w, http.StatusBadRequest, errors.New("username and password are required"))
return
}
newToken, err := ctrl.svc.User.Login(r.Context(), loginForm.Username, loginForm.Password)
if err != nil {
server.RespondError(w, http.StatusUnauthorized, err)
return
}
err = server.Respond(w, http.StatusOK, types.TokenResponse{
BearerToken: "Bearer " + newToken.Raw,
ExpiresAt: newToken.ExpiresAt,
})
if err != nil {
ctrl.log.Error(err, logger.Props{
"user": loginForm.Username,
})
return
}
}
}
// HandleAuthLogout godoc
// @Summary User Logout
// @Tags Authentication
// @Success 204
// @Router /v1/users/logout [POST]
// @Security Bearer
func (ctrl *V1Controller) HandleAuthLogout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := services.UseTokenCtx(r.Context())
if token == "" {
server.RespondError(w, http.StatusUnauthorized, errors.New("no token within request context"))
return
}
err := ctrl.svc.User.Logout(r.Context(), token)
if err != nil {
server.RespondError(w, http.StatusInternalServerError, err)
return
}
err = server.Respond(w, http.StatusNoContent, nil)
}
}
// HandleAuthLogout godoc
// @Summary User Token Refresh
// @Description handleAuthRefresh returns a handler that will issue a new token from an existing token.
// @Description This does not validate that the user still exists within the database.
// @Tags Authentication
// @Success 200
// @Router /v1/users/refresh [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleAuthRefresh() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
requestToken := services.UseTokenCtx(r.Context())
if requestToken == "" {
server.RespondError(w, http.StatusUnauthorized, errors.New("no user token found"))
return
}
newToken, err := ctrl.svc.User.RenewToken(r.Context(), requestToken)
if err != nil {
server.RespondUnauthorized(w)
return
}
err = server.Respond(w, http.StatusOK, newToken)
if err != nil {
return
}
}
}

View file

@ -0,0 +1,80 @@
package v1
import (
"errors"
"net/http"
"github.com/hay-kot/git-web-template/backend/internal/services"
"github.com/hay-kot/git-web-template/backend/internal/types"
"github.com/hay-kot/git-web-template/backend/pkgs/logger"
"github.com/hay-kot/git-web-template/backend/pkgs/server"
)
// HandleUserSelf godoc
// @Summary Get the current user
// @Tags User
// @Produce json
// @Success 200 {object} server.Result{item=types.UserOut}
// @Router /v1/users/self [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleUserSelf() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := services.UseTokenCtx(r.Context())
usr, err := ctrl.svc.User.GetSelf(r.Context(), token)
if usr.IsNull() || err != nil {
ctrl.log.Error(errors.New("no user within request context"), nil)
server.RespondInternalServerError(w)
return
}
_ = server.Respond(w, http.StatusOK, server.Wrap(usr))
}
}
// HandleUserUpdate godoc
// @Summary Update the current user
// @Tags User
// @Produce json
// @Param payload body types.UserUpdate true "User Data"
// @Success 200 {object} server.Result{item=types.UserUpdate}
// @Router /v1/users/self [PUT]
// @Security Bearer
func (ctrl *V1Controller) HandleUserUpdate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateData := types.UserUpdate{}
if err := server.Decode(r, &updateData); err != nil {
ctrl.log.Error(err, logger.Props{
"scope": "user",
"details": "failed to decode user update data",
})
server.RespondError(w, http.StatusBadRequest, err)
return
}
actor := services.UseUserCtx(r.Context())
newData, err := ctrl.svc.User.UpdateSelf(r.Context(), actor.ID, updateData)
if err != nil {
ctrl.log.Error(err, logger.Props{
"scope": "user",
"details": "failed to update user",
})
server.RespondError(w, http.StatusInternalServerError, err)
return
}
_ = server.Respond(w, http.StatusOK, server.Wrap(newData))
}
}
// HandleUserUpdatePassword godoc
// @Summary Update the current user's password // TODO:
// @Tags User
// @Produce json
// @Success 204
// @Router /v1/users/self/password [PUT]
// @Security Bearer
func (ctrl *V1Controller) HandleUserUpdatePassword() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
}
}

9
backend/app/cli/app.go Normal file
View file

@ -0,0 +1,9 @@
package main
import (
"github.com/hay-kot/git-web-template/backend/internal/repo"
)
type app struct {
repos *repo.AllRepos
}

View file

@ -0,0 +1,105 @@
package main
import (
"context"
"fmt"
"os"
"text/tabwriter"
"github.com/google/uuid"
"github.com/hay-kot/git-web-template/backend/app/cli/reader"
"github.com/hay-kot/git-web-template/backend/internal/types"
"github.com/hay-kot/git-web-template/backend/pkgs/hasher"
"github.com/urfave/cli/v2"
)
func (a *app) UserCreate(c *cli.Context) error {
var defaultValidators = []reader.StringValidator{
reader.StringRequired,
reader.StringNoLeadingOrTrailingWhitespace,
}
// Get Flags
name := reader.ReadString("Name: ",
defaultValidators...,
)
password := reader.ReadString("Password: ",
defaultValidators...,
)
email := reader.ReadString("Email: ",
reader.StringRequired,
reader.StringNoLeadingOrTrailingWhitespace,
reader.StringContainsAt,
)
isSuper := reader.ReadBool("Is Superuser?")
pwHash, err := hasher.HashPassword(password)
if err != nil {
return err
}
usr := types.UserCreate{
Name: name,
Email: email,
Password: pwHash,
IsSuperuser: isSuper,
}
_, err = a.repos.Users.Create(context.Background(), usr)
if err == nil {
fmt.Println("Super user created")
}
return err
}
func (a *app) UserDelete(c *cli.Context) error {
// Get Flags
id := c.String("id")
uid := uuid.MustParse(id)
fmt.Printf("Deleting user with id: %s\n", id)
// Confirm Action
fmt.Printf("Are you sure you want to delete this user? (y/n) ")
var answer string
_, err := fmt.Scanln(&answer)
if answer != "y" || err != nil {
fmt.Println("Aborting")
return nil
}
err = a.repos.Users.Delete(context.Background(), uid)
if err == nil {
fmt.Printf("%v User(s) deleted (id=%v)\n", 1, id)
}
return err
}
func (a *app) UserList(c *cli.Context) error {
fmt.Println("Superuser List")
users, err := a.repos.Users.GetAll(context.Background())
if err != nil {
return err
}
tabWriter := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
defer func(tabWriter *tabwriter.Writer) {
_ = tabWriter.Flush()
}(tabWriter)
_, err = fmt.Fprintln(tabWriter, "Id\tName\tEmail\tIsSuper")
if err != nil {
return err
}
for _, u := range users {
_, _ = fmt.Fprintf(tabWriter, "%v\t%s\t%s\t%v\n", u.ID, u.Name, u.Email, u.IsSuperuser)
}
return nil
}

82
backend/app/cli/main.go Normal file
View file

@ -0,0 +1,82 @@
package main
import (
"context"
"log"
"os"
"github.com/hay-kot/git-web-template/backend/ent"
"github.com/hay-kot/git-web-template/backend/internal/config"
"github.com/hay-kot/git-web-template/backend/internal/repo"
_ "github.com/mattn/go-sqlite3"
"github.com/urfave/cli/v2"
)
func main() {
cfg, err := config.NewConfig("config.yml")
if err != nil {
panic(err)
}
if err := run(cfg); err != nil {
log.Fatal(err)
}
}
func run(cfg *config.Config) error {
// =========================================================================
// Initialize Database
c, err := ent.Open(cfg.Database.GetDriver(), cfg.Database.GetUrl())
if err != nil {
log.Fatalf("failed opening connection to sqlite: %v", err)
}
defer func(c *ent.Client) {
_ = c.Close()
}(c)
if err := c.Schema.Create(context.Background()); err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
// Create App
a := &app{
repos: repo.EntAllRepos(c),
}
app := &cli.App{
Commands: []*cli.Command{
{
Name: "users",
Aliases: []string{"u"},
Usage: "options to manage users",
Subcommands: []*cli.Command{
{
Name: "list",
Usage: "list users in database",
Action: a.UserList,
},
{
Name: "add",
Usage: "add a new user",
Action: a.UserCreate,
},
{
Name: "delete",
Usage: "delete user in database",
Action: a.UserDelete,
Flags: []cli.Flag{
&cli.IntFlag{
Name: "id",
Usage: "name of the user to add",
Required: true,
},
},
},
},
},
},
}
return app.Run(os.Args)
}

View file

@ -0,0 +1,65 @@
package reader
import "fmt"
type StringValidator func(s string) bool
func StringRequired(s string) bool {
return s != ""
}
func StringNoLeadingOrTrailingWhitespace(s string) bool {
return s != "" && len(s) > 0 && s[0] != ' ' && s[len(s)-1] != ' '
}
func StringContainsAt(s string) bool {
for _, c := range s {
if c == '@' {
return true
}
}
return false
}
func ReadString(message string, sv ...StringValidator) string {
for {
fmt.Print(message)
var input string
fmt.Scanln(&input)
if len(sv) == 0 {
return input
}
isValid := true
for _, validator := range sv {
if !validator(input) {
isValid = false
fmt.Println("Invalid input")
continue
}
}
if isValid {
return input
}
}
}
func ReadBool(message string) bool {
for {
fmt.Print(message + " (y/n) ")
var input string
fmt.Scanln(&input)
if input == "y" {
return true
} else if input == "n" {
return false
} else {
fmt.Println("Invalid input")
}
}
}

View file

@ -0,0 +1,72 @@
package main
import (
"time"
"github.com/google/uuid"
"github.com/hay-kot/git-web-template/backend/ent"
"github.com/hay-kot/git-web-template/backend/internal/types"
"github.com/hay-kot/git-web-template/backend/pkgs/automapper"
"github.com/tkrajina/typescriptify-golang-structs/typescriptify"
)
// generateMappers serialized the config file into a list of automapper struct
func generateMappers() []automapper.AutoMapper {
return []automapper.AutoMapper{
{
Package: "mapper",
Prefix: "users",
Name: "User Out",
Schema: automapper.Schema{
Type: types.UserOut{},
Prefix: "types",
},
Model: automapper.Model{
Type: ent.User{},
Prefix: "ent",
},
Imports: []string{},
},
}
}
func generateTypeScript() {
// Configuration
converter := typescriptify.New()
converter.CreateInterface = true
converter.ManageType(uuid.UUID{}, typescriptify.TypeOptions{TSType: "string"})
converter.ManageType(time.Time{}, typescriptify.TypeOptions{TSType: "Date", TSTransform: "new Date(__VALUE__)"})
// General
public := []any{
// Base Types
types.ApiSummary{},
// User Types
types.UserOut{},
types.UserCreate{},
types.UserIn{},
types.UserUpdate{},
// Auth Types
types.LoginForm{},
types.TokenResponse{},
}
for i := 0; i < len(public); i++ {
converter.Add(public[i])
}
// Creation
converter.ConvertToFile("./generated-types.ts")
}
func main() {
automappers := generateMappers()
conf := automapper.DefaultConf()
automapper.Generate(automappers, conf)
generateTypeScript()
}