first version

This commit is contained in:
Jay
2021-09-16 10:19:48 +08:00
commit 05967cdb98
24 changed files with 1669 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
package api
import (
"context"
"rpirelay/config"
"rpirelay/internal/api/relay"
rt "rpirelay/internal/api/relay/transport"
cc "rpirelay/internal/context"
"rpirelay/internal/middleware"
"rpirelay/internal/server"
"github.com/stianeikeland/go-rpio/v4"
)
func Start(ctx context.Context, cfg *config.Config) chan error {
errChan := make(chan error, 0)
go func() {
if err := startAPIServer(ctx, cfg); err != nil {
errChan <- err
return
}
close(errChan)
}()
return errChan
}
func startAPIServer(ctx context.Context, cfg *config.Config) error {
var err error
// init gpio
err = rpio.Open()
if err != nil {
return err
}
defer rpio.Close()
// set gpio pin
relayPin := rpio.Pin(cfg.Server.RelayPin)
// set pin mode output
relayPin.Output()
defer func() {
relayPin.Low()
}()
// new middleware
mw := middleware.Initialize(cfg.Server.Secret)
svc := server.New(cfg)
apiGroup := svc.Group("/api")
apiGroup.Use(cc.PatchContext(mw.APIPanicCatch()))
{
rt.NewHTTP(relay.Initialize(relayPin), apiGroup, mw.Authorization())
}
svc.Start(ctx)
return nil
}
+23
View File
@@ -0,0 +1,23 @@
package relay
import cc "rpirelay/internal/context"
var _ Service = (*Relay)(nil)
func (r *Relay) SwitchOn(c *cc.C) (err error) {
r.pin.High()
return nil
}
func (r *Relay) SwitchOff(c *cc.C) (err error) {
r.pin.Low()
return nil
}
func (r *Relay) GetState(c *cc.C) (open bool, err error) {
state := r.pin.Read()
return state == 1, nil
}
+21
View File
@@ -0,0 +1,21 @@
package relay
import (
cc "rpirelay/internal/context"
"github.com/stianeikeland/go-rpio/v4"
)
func Initialize(pin rpio.Pin) *Relay {
return &Relay{pin: pin}
}
type Relay struct {
pin rpio.Pin
}
type Service interface {
SwitchOn(c *cc.C) (err error)
SwitchOff(c *cc.C) (err error)
GetState(c *cc.C) (open bool, err error)
}
+86
View File
@@ -0,0 +1,86 @@
package transport
import (
"rpirelay/internal/api/relay"
cc "rpirelay/internal/context"
apierr "rpirelay/internal/errors"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)
type HTTP struct {
svc relay.Service
}
func NewHTTP(svc relay.Service, e *gin.RouterGroup, authorizationMW cc.CustomHandler) {
h := &HTTP{svc: svc}
patch := cc.PatchContext
g := e.Group("/relay")
g.Use(patch(authorizationMW))
// swagger:route POST /api/relay/on relay switchOn
// set relay on
// security:
// api_key:
// responses:
// default: respDefault
g.POST("/on", patch(h.switchOn()))
// swagger:route POST /api/relay/off relay switchOff
// set relay off
// security:
// api_key:
// responses:
// default: respDefault
g.POST("/off", patch(h.switchOff()))
// swagger:route GET /api/relay relay getState
// get relay state
// security:
// api_key:
// responses:
// 200: relayStateResp
// default: respDefault
g.GET("/", patch(h.getState()))
g.GET("", patch(h.getState()))
}
func (h *HTTP) switchOn() cc.CustomHandler {
return func(c *cc.C) {
if err := h.svc.SwitchOn(c); err != nil {
panic(apierr.ErrInternalError.SetErr(errors.WithStack(err)))
}
c.Success()
}
}
func (h *HTTP) switchOff() cc.CustomHandler {
return func(c *cc.C) {
if err := h.svc.SwitchOff(c); err != nil {
panic(apierr.ErrInternalError.SetErr(errors.WithStack(err)))
}
c.Success()
}
}
type relayStateResp struct {
Open bool `json:"open"`
}
func (h *HTTP) getState() cc.CustomHandler {
return func(c *cc.C) {
open, err := h.svc.GetState(c)
if err != nil {
panic(apierr.ErrInternalError.SetErr(errors.WithStack(err)))
}
resp := relayStateResp{Open: open}
c.Success(resp)
}
}
+7
View File
@@ -0,0 +1,7 @@
package transport
// swagger:response relayStateResp
type swaggRelayStateResp struct {
// in: body
Body relayStateResp
}
+26
View File
@@ -0,0 +1,26 @@
// RPI Relay API.
//
// Terms Of Service:
//
// there are no TOS at this moment, use at your own risk we take no responsibility
//
// Schemes: http, https
// Host: localhost
// BasePath: /
// Version: 0.0.1
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// SecurityDefinitions:
// api_key:
// type: apiKey
// name: X-Lawsnote-Key
// in: header
// description: secret
//
// swagger:meta
package api
+70
View File
@@ -0,0 +1,70 @@
package context
import (
"rpirelay/internal/response"
"github.com/gin-gonic/gin"
)
type C struct {
*gin.Context
}
type CustomHandler func(c *C)
func PatchContext(handler CustomHandler) gin.HandlerFunc {
return func(ctx *gin.Context) {
c := &C{
Context: ctx,
}
handler(c)
}
}
// Success
// send success body to client
func (c *C) Success(i ...interface{}) {
r := response.Get(response.RespSuccess)
var resp interface{} = r.Body
if len(i) > 0 {
resp = i[0]
}
c.AbortWithStatusJSON(r.Status, resp)
}
func (c *C) DataFormat(code ...response.MessageCode) {
r := response.Get(response.RespDataFormat, code...)
c.AbortWithStatusJSON(r.Status, r.Body)
}
func (c *C) NotFound(code ...response.MessageCode) {
r := response.Get(response.RespNotFound, code...)
c.AbortWithStatusJSON(r.Status, r.Body)
}
func (c *C) Forbidden(code ...response.MessageCode) {
r := response.Get(response.RespNotFound, code...)
c.AbortWithStatusJSON(r.Status, r.Body)
}
func (c *C) ServerError(code ...response.MessageCode) {
r := response.Get(response.RespInternalError, code...)
c.AbortWithStatusJSON(r.Status, r.Body)
}
func (c *C) CustomResp(rt response.RespType, code response.MessageCode, data ...interface{}) {
r := response.Get(rt)
if code != r.Body.MessageCode {
r.Body.MessageCode, r.Body.Message = response.GetCodeMessage(code)
}
var resp interface{} = r.Body
if len(data) > 0 {
resp = data[0]
}
c.AbortWithStatusJSON(r.Status, resp)
}
+79
View File
@@ -0,0 +1,79 @@
package errors
import (
"fmt"
"rpirelay/internal/response"
)
var (
ErrDataFormat = New(response.RespDataFormat)
ErrUnauthorized = New(response.RespUnauthorized)
ErrNotFound = New(response.RespNotFound)
ErrInternalError = New(response.RespInternalError)
ErrForbidden = New(response.RespForbidden)
)
type APIError struct {
code *response.MessageCode
status response.RespType
message string
err error
}
func (e *APIError) Error() string {
s := fmt.Sprintf("Status: %s, Message: %s", e.status, e.message)
if e.code != nil {
c, m := response.GetCodeMessage(*e.code)
s = fmt.Sprintf("%s, Code: %d, CodeMessage: %s", s, c, m)
}
if e.err != nil {
s += fmt.Sprintf("\n%+v", e.err)
}
return s
}
func (e *APIError) SetCode(code response.MessageCode) *APIError {
e = &APIError{
status: e.status,
code: &code,
message: e.message,
err: e.err,
}
return e
}
func (e *APIError) SetMessage(s string) *APIError {
e = &APIError{
status: e.status,
code: e.code,
message: s,
err: e.err,
}
return e
}
func (e *APIError) SetErr(err error) *APIError {
e = &APIError{
status: e.status,
code: e.code,
message: e.message,
err: err,
}
return e
}
func (e *APIError) Code() *response.MessageCode { return e.code }
func (e *APIError) Status() response.RespType { return e.status }
func New(status response.RespType, code ...response.MessageCode) *APIError {
e := &APIError{
status: status,
}
if len(code) > 0 {
e.code = &code[0]
}
return e
}
+96
View File
@@ -0,0 +1,96 @@
package middleware
import (
"bytes"
"fmt"
"io"
cc "rpirelay/internal/context"
apierr "rpirelay/internal/errors"
"rpirelay/internal/response"
"runtime"
"strings"
"github.com/pkg/errors"
)
func Initialize(secret string) *Middleware {
return &Middleware{secret: secret}
}
type Middleware struct {
secret string
}
func (m *Middleware) APIPanicCatch() cc.CustomHandler {
return func(c *cc.C) {
defer func() {
if err := recover(); err != nil {
// build error
// *****
builder := new(strings.Builder)
builder.WriteString("[API Error Catch]\n")
pc := make([]uintptr, 10)
n := runtime.Callers(1, pc)
frames := runtime.CallersFrames(pc[:n])
builder.WriteString("\n[Stack]\n")
for {
frame, more := frames.Next()
builder.WriteString(fmt.Sprintf("%s\n\t%s:%d\n", frame.Func.Name(), frame.File, frame.Line))
if !more {
break
}
}
builder.WriteString("\n[Error]\n")
builder.WriteString(fmt.Sprintf("%v\n", err))
fmt.Printf("%s\n", builder.String())
// *****
var e *apierr.APIError
if converted, ok := err.(*apierr.APIError); ok {
e = converted
} else {
e = apierr.ErrInternalError
}
code := make([]response.MessageCode, 0)
if c := e.Code(); c != nil {
code = append(code, *c)
}
resp := response.Get(e.Status(), code...)
c.AbortWithStatusJSON(resp.Status, resp.Body)
}
}()
// 是json body 才做複製
if c.GetHeader("content-type") == "application/json" {
data, err := c.GetRawData()
if err != nil {
panic(apierr.ErrInternalError.SetErr(err))
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(data))
c.Set("_RawBody", data)
}
c.Next()
}
}
func (m *Middleware) Authorization() cc.CustomHandler {
return func(c *cc.C) {
auth := c.GetHeader("x-lawsnote-key")
if auth == "" {
panic(apierr.ErrUnauthorized.SetErr(errors.New("no lawsnote header found")))
}
if auth != m.secret {
panic(apierr.ErrForbidden.SetErr(errors.New("secret not match")))
}
c.Next()
}
}
+59
View File
@@ -0,0 +1,59 @@
package response
type MessageCode int
type CodeMessage interface {
String() string
Code() int
}
const (
// HTTP Default Message
CodeSuccess MessageCode = 1000
CodeCreated MessageCode = 1001
CodeAccepted MessageCode = 1002
CodeNoContent MessageCode = 1003
CodeRedirect MessageCode = 1004
CodeDataFormat MessageCode = 1005
CodeUnauthorized MessageCode = 1006
CodeForbidden MessageCode = 1007
CodeNotFound MessageCode = 1008
CodeInternalError MessageCode = 1009
// Custom Message
)
func (mc MessageCode) String() string {
switch mc {
case CodeSuccess:
return "Success"
case CodeCreated:
return "Created"
case CodeAccepted:
return "Accepted"
case CodeNoContent:
return "No Content"
case CodeRedirect:
return "Moved Permanently"
case CodeDataFormat:
return "Data Format Error"
case CodeUnauthorized:
return "Unauthorized"
case CodeForbidden:
return "Forbidden"
case CodeNotFound:
return "Not Found"
case CodeInternalError:
return "Internal Error"
default:
return ""
}
}
func (mc MessageCode) Code() int {
return int(mc)
}
func GetCodeMessage(c MessageCode) (MessageCode, string) {
return c, c.String()
}
+81
View File
@@ -0,0 +1,81 @@
package response
type RespType string
const (
RespSuccess RespType = "success"
RespCreated RespType = "created"
RespAccepted RespType = "accepted"
RespNoContent RespType = "noContent"
RespRedirect RespType = "redirect"
RespDataFormat RespType = "dataFormat"
RespUnauthorized RespType = "unauthorized"
RespForbidden RespType = "forbidden"
RespNotFound RespType = "notFound"
RespInternalError RespType = "internalError"
)
type RespBody struct {
MessageCode MessageCode `json:"messageCode"`
Message string `json:"message"`
}
// default response
// swagger:response respDefault
type Resp struct {
// in: body
Body RespBody
Status int
}
func Get(key RespType, c ...MessageCode) Resp {
r := Resp{Body: RespBody{}}
switch key {
case RespSuccess:
r.Status = 200
r.Body.MessageCode, r.Body.Message = GetCodeMessage(CodeSuccess)
break
case RespCreated:
r.Status = 201
r.Body.MessageCode, r.Body.Message = GetCodeMessage(CodeCreated)
break
case RespAccepted:
r.Status = 202
r.Body.MessageCode, r.Body.Message = GetCodeMessage(CodeAccepted)
break
case RespNoContent:
r.Status = 204
r.Body.MessageCode, r.Body.Message = GetCodeMessage(CodeNoContent)
break
case RespRedirect:
r.Status = 301
r.Body.MessageCode, r.Body.Message = GetCodeMessage(CodeRedirect)
break
case RespDataFormat:
r.Status = 400
r.Body.MessageCode, r.Body.Message = GetCodeMessage(CodeDataFormat)
break
case RespUnauthorized:
r.Status = 401
r.Body.MessageCode, r.Body.Message = GetCodeMessage(CodeUnauthorized)
break
case RespForbidden:
r.Status = 403
r.Body.MessageCode, r.Body.Message = GetCodeMessage(CodeForbidden)
break
case RespNotFound:
r.Status = 404
r.Body.MessageCode, r.Body.Message = GetCodeMessage(CodeNotFound)
break
default:
r.Status = 500
r.Body.MessageCode, r.Body.Message = GetCodeMessage(CodeInternalError)
}
if len(c) > 0 {
r.Body.MessageCode, r.Body.Message = GetCodeMessage(c[0])
}
return r
}
+69
View File
@@ -0,0 +1,69 @@
package server
import (
"context"
"fmt"
"net/http"
"os"
"rpirelay/config"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
type Server struct {
*gin.Engine
cfg *config.Config
}
func New(cfg *config.Config) *Server {
e := gin.New()
e.Use(gin.Logger())
e.Use(gin.Recovery())
corsCfg := cors.DefaultConfig()
corsCfg.AllowOriginFunc = func(origin string) bool { return origin != "" }
corsCfg.AllowCredentials = true
corsCfg.AddAllowHeaders("Authorization")
e.Use(cors.New(corsCfg))
if os.Getenv("RELEASE") != "1" {
e.Use(SwaggerServe("/api-docs", nil))
e.GET("/api-docs.json", SwaggerSpecServe())
}
// set healthcheck path
e.GET("/", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
return &Server{
Engine: e,
cfg: cfg,
}
}
func (w *Server) Start(ctx context.Context) {
s := &http.Server{
Handler: w,
Addr: fmt.Sprintf(":%d", w.cfg.Server.Port),
}
go func() {
if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
s.ErrorLog.Fatal(err)
}
}()
<-ctx.Done()
c, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
if err := s.Shutdown(c); err != nil {
s.ErrorLog.Fatal(err)
}
}
+81
View File
@@ -0,0 +1,81 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"net/textproto"
"regexp"
rpiRelay "rpirelay"
"strings"
"github.com/gin-gonic/gin"
)
type SwaggerOptions struct {
Root string
}
var regex = regexp.MustCompile(`(src|href)="./`)
var specRegex = regexp.MustCompile(`url:.+`)
func SwaggerSpecServe() gin.HandlerFunc {
return func(c *gin.Context) {
b := rpiRelay.GetSpec()
var j map[string]interface{}
if err := json.Unmarshal(b, &j); err != nil {
c.String(http.StatusInternalServerError, "internal error")
return
}
if _, ok := j["host"]; ok {
j["host"] = c.Request.Host
}
if xfp := c.Request.Header.Get(textproto.CanonicalMIMEHeaderKey("X-Forwarded-Proto")); xfp != "" && xfp == "https" {
j["schemes"] = []string{xfp}
}
c.JSON(http.StatusOK, j)
}
}
func SwaggerServe(prefix string, opts *SwaggerOptions) gin.HandlerFunc {
return func(c *gin.Context) {
fp := c.Request.URL.Path
if prefix != "" {
if !strings.HasPrefix(fp, prefix) {
c.Next()
return
}
if fp = strings.Replace(fp, prefix, "", 1); fp == "" {
fp = "/"
}
}
// end of slash replace to index.html file
if strings.HasSuffix(fp, "/") {
fp += "index.html"
}
if b, mime, err := rpiRelay.GetWebFile(fp); err == nil {
// 如果是 index.html 要更新內部的連結 轉換成絕對路徑
if fp == "/index.html" {
replPrefix := prefix
if !strings.HasSuffix(replPrefix, "/") {
replPrefix += "/"
}
b = regex.ReplaceAll(b, []byte(fmt.Sprintf(`$1="%s`, replPrefix)))
b = specRegex.ReplaceAll(b, []byte(fmt.Sprintf(`url: "%s.json",`, prefix)))
}
c.Writer.Header().Set(textproto.CanonicalMIMEHeaderKey("Content-Type"), mime)
c.Status(http.StatusOK)
c.Writer.Write(b)
c.Abort()
}
}
}
+20
View File
@@ -0,0 +1,20 @@
package version
import (
"fmt"
"runtime"
)
var (
Version string
BuildDate string
)
func PrintCliVersion() string {
return fmt.Sprintf(
"version: %s, built on %s, %s",
Version,
BuildDate,
runtime.Version(),
)
}