Compare commits

...

7 Commits

Author SHA1 Message Date
Muyao CHEN
0ed626e351 Route: Use a trie to register routes 2024-09-16 21:04:18 +02:00
Muyao CHEN
d90e564620 Router: add prefix group interface and implementation 2024-09-16 12:05:35 +02:00
Muyao CHEN
5cb54ae560 Router: add static routers 2024-09-16 11:39:38 +02:00
Muyao CHEN
913e33a993 Context: Test context with timeout 2024-09-16 11:13:40 +02:00
Muyao CHEN
f076ebaa3a Context: hard foo controller mapping for test purpose 2024-09-16 11:13:40 +02:00
Muyao CHEN
33e9d1e613 Context: use timeout in JSON writer 2024-09-16 11:13:40 +02:00
Muyao CHEN
f804f175e0 Context: create a customized Context type.
To package the internal context and other common structs, especially
http.Request and http.ResponseWriter.

Provides helper functìons.
2024-09-15 21:38:31 +02:00
8 changed files with 660 additions and 12 deletions

268
framework/context.go Normal file
View File

@ -0,0 +1,268 @@
package framework
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"sync"
"time"
)
// Context type is the customized context of Araneae framework
//
// It packages the internal context.Context with basic "wr" couple.
type Context struct {
ctx context.Context
request *http.Request
responseWriter http.ResponseWriter
handler ControllerHandler
hasTimeout bool
writerMux *sync.Mutex
}
// NewContext create a new context
func NewContext(w http.ResponseWriter, r *http.Request) *Context {
return &Context{
ctx: r.Context(),
request: r,
responseWriter: w,
writerMux: &sync.Mutex{},
}
}
// {{{ Basic functions
// WriterMux returns the writer mutex.
//
// This is useful when goroutines concurrently write into responseWriter,
// while at the same time we are writing into the responseWriter for a
// panic or timeout.
// We can protect it at the context level.
func (ctx *Context) WriterMux() *sync.Mutex {
return ctx.writerMux
}
// GetRequest returns the original request
func (ctx *Context) GetRequest() *http.Request {
return ctx.request
}
// GetResponseWriter returns the original response writer
func (ctx *Context) GetResponseWriter() http.ResponseWriter {
return ctx.responseWriter
}
// SetHasTimeout indicates that the context has timeout.
//
// So that other goroutines won't write into the responseWriter anymore
func (ctx *Context) SetHasTimeout() {
ctx.hasTimeout = true
}
// HasTimeout returns whether the context has timeout.
func (ctx *Context) HasTimeout() bool {
return ctx.hasTimeout
}
// }}}
// {{{ Implements context interface
// BaseContext return a request default Context
func (ctx *Context) BaseContext() context.Context {
return ctx.request.Context()
}
// Done calls the base function
func (ctx *Context) Done() <-chan struct{} {
return ctx.BaseContext().Done()
}
// Deadline calls the base function
func (ctx *Context) Deadline() (deadline time.Time, ok bool) {
return ctx.BaseContext().Deadline()
}
// Err calls the base function
func (ctx *Context) Err() error {
return ctx.BaseContext().Err()
}
// Value calls the base function
func (ctx *Context) Value(key any) any {
return ctx.BaseContext().Value(key)
}
// }}}
// {{{ Implements request functions
// {{{ Request URI
// QueryInt gets an int value from the query request
func (ctx *Context) QueryInt(key string, defval int) (int, error) {
params, err := ctx.QueryAll()
if err != nil {
return defval, err
}
if vals, ok := params[key]; ok {
len := len(vals)
if len > 0 {
intval, err := strconv.Atoi(vals[len-1]) // return the last elem
if err != nil {
return defval, err
}
return intval, nil
}
}
return defval, errors.New("key not found")
}
// QueryString gets a string value from the query request
func (ctx *Context) QueryString(key string, defval string) (string, error) {
params, err := ctx.QueryAll()
if err != nil {
return defval, err
}
if vals, ok := params[key]; ok {
len := len(vals)
if len > 0 {
return vals[len-1], nil // return the last elem
}
}
return defval, errors.New("key not found")
}
// QueryArray gets an array of string values from the query request
func (ctx *Context) QueryArray(key string, defval []string) ([]string, error) {
params, err := ctx.QueryAll()
if err != nil {
return defval, err
}
if vals, ok := params[key]; ok {
return vals, nil // return the last elem
}
return defval, errors.New("key not found")
}
// QueryAll returns all queries in a request URL
func (ctx *Context) QueryAll() (url.Values, error) {
if ctx.request != nil {
return map[string][]string(ctx.request.URL.Query()), nil
}
return url.Values{}, errors.New("missing request in the context")
}
// }}}
// {{{ Post form
// FormInt gets an int value from the submitted form
func (ctx *Context) FormInt(key string, defval int) (int, error) {
vals, err := ctx.FormAll()
if err != nil {
return defval, err
}
valStrs, ok := vals[key]
if !ok {
return defval, errors.New("key not found")
}
valInt, err := strconv.Atoi(valStrs[0]) // Get the first one as result
if err != nil {
return defval, err
}
return valInt, nil
}
// FormString gets a string value from the submitted form
func (ctx *Context) FormString(key string, defval string) (string, error) {
vals, err := ctx.FormAll()
if err != nil {
return defval, err
}
valStrs, ok := vals[key]
if !ok {
return defval, errors.New("key not found")
}
return valStrs[0], nil
}
// FormArray gets an array of string values from the submitted form
func (ctx *Context) FormArray(key string, defval []string) ([]string, error) {
vals, err := ctx.FormAll()
if err != nil {
return defval, err
}
valStrs, ok := vals[key]
if !ok {
return defval, errors.New("key not found")
}
return valStrs, nil
}
// FormAll gets everything from the submitted form
func (ctx *Context) FormAll() (url.Values, error) {
if ctx.request != nil {
err := ctx.request.ParseForm()
if err != nil {
return url.Values{}, err
}
return ctx.request.PostForm, err
}
return url.Values{}, errors.New("missing request in the context")
}
// }}}
// {{{ application/json
// ReadJSON binds the request JSON body to an object.
//
// A pointer of obj should be passed.
func (ctx *Context) ReadJSON(obj any) error {
if ctx.request == nil {
return errors.New("missing request in the context")
}
dec := json.NewDecoder(ctx.request.Body)
err := dec.Decode(obj)
if err != nil {
return err
}
err = dec.Decode(&struct{}{})
if err != io.EOF {
return errors.New("body must have only a single JSON value")
}
return nil
}
// WriteJSON send back an object in JSON format with the status code
func (ctx *Context) WriteJSON(status int, obj any) error {
// There is a timeout, some error message data must have already been
// written to the output. Stop writing anything into the responseWriter.
if ctx.HasTimeout() {
return nil
}
data, err := json.Marshal(obj)
if err != nil {
return err
}
ctx.responseWriter.Header().Set("Content-type", "application/json")
ctx.responseWriter.WriteHeader(status)
_, err = ctx.responseWriter.Write(data)
if err != nil {
return err
}
return nil
}
// }}}
// }}}

3
framework/controller.go Normal file
View File

@ -0,0 +1,3 @@
package framework
type ControllerHandler func(c *Context) error

View File

@ -1,18 +1,106 @@
package framework
import (
"log"
"net/http"
"strings"
)
// Core is the core struct of the framework
type Core struct{}
type Core struct {
router map[string]*Trie
}
// NewCore initialize the Core.
func NewCore() *Core {
return &Core{}
getRouter := NewTrie()
postRouter := NewTrie()
putRouter := NewTrie()
deleteRouter := NewTrie()
router := map[string]*Trie{}
router["GET"] = getRouter
router["POST"] = postRouter
router["PUT"] = putRouter
router["DELETE"] = deleteRouter
return &Core{router: router}
}
// Get is a simple get router
func (c *Core) Get(url string, handler ControllerHandler) {
upperUrl := strings.ToUpper(url)
if err := c.router["GET"].AddRouter(upperUrl, handler); err != nil{
log.Println(err)
}
}
// Post is a simple post router
func (c *Core) Post(url string, handler ControllerHandler) {
upperUrl := strings.ToUpper(url)
if err := c.router["POST"].AddRouter(upperUrl, handler); err != nil{
log.Println(err)
}
}
// Put is a simple put router
func (c *Core) Put(url string, handler ControllerHandler) {
upperUrl := strings.ToUpper(url)
if err := c.router["PUT"].AddRouter(upperUrl, handler); err != nil{
log.Println(err)
}
}
// Delete is a simple delete router
func (c *Core) Delete(url string, handler ControllerHandler) {
upperUrl := strings.ToUpper(url)
if err := c.router["DELETE"].AddRouter(upperUrl, handler); err != nil{
log.Println(err)
}
}
// FindRouteByRequest finds route using the request
func (c *Core) FindRouteByRequest(r *http.Request) ControllerHandler {
upperUri := strings.ToUpper(r.URL.Path)
upperMethod := strings.ToUpper(r.Method)
mapper, ok := c.router[upperMethod]
if !ok {
log.Printf("Method %q is not recognized\n", upperMethod)
return nil
}
controller := mapper.FindRoute(upperUri)
if controller == nil {
log.Printf("URI %q is not recognized\n", r.URL.Path)
return nil
}
return controller
}
func (c *Core) Group(prefix string) IGroup {
return &Group{
core: c,
prefix: prefix,
}
}
// ServeHTTP implements the Handler interface
func (c *Core) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// TODO
log.Println("Welcome to the Araneae framework")
ctx := NewContext(w, r)
router := c.FindRouteByRequest(r)
if router == nil {
ctx.WriteJSON(http.StatusNotFound, "Request not found")
return
}
err := router(ctx)
if err != nil {
ctx.WriteJSON(http.StatusInternalServerError, "Internal error")
return
}
}

43
framework/group.go Normal file
View File

@ -0,0 +1,43 @@
package framework
// IGroup prefix routes
type IGroup interface {
Get(string, ControllerHandler)
Post(string, ControllerHandler)
Put(string, ControllerHandler)
Delete(string, ControllerHandler)
}
// Group is the implementation of IGroup interface
type Group struct {
core *Core
prefix string
}
// NewGroup create a new prefix group
func NewGroup(core *Core, prefix string) *Group {
return &Group{
core: core,
prefix: prefix,
}
}
// Get is a simple get router of the group
func (g *Group) Get(url string, handler ControllerHandler) {
g.core.Get(g.prefix+url, handler)
}
// Post is a simple post router of the group
func (g *Group) Post(url string, handler ControllerHandler) {
g.core.Post(g.prefix+url, handler)
}
// Put is a simple put router of the group
func (g *Group) Put(url string, handler ControllerHandler) {
g.core.Put(g.prefix+url, handler)
}
// Delete is a simple delete router of the group
func (g *Group) Delete(url string, handler ControllerHandler) {
g.core.Delete(g.prefix+url, handler)
}

154
framework/trie.go Normal file
View File

@ -0,0 +1,154 @@
package framework
import (
"errors"
"fmt"
"strings"
)
type Trie struct {
root *node
}
func NewTrie() *Trie {
return &Trie{root: newNode("")}
}
func (t *Trie) FindRoute(uri string) ControllerHandler {
uri = strings.TrimPrefix(uri, "/")
if uri == "" {
return t.root.handler
}
found := t.root.findRoute(uri)
if found == nil {
return nil
}
return found.handler
}
func (t *Trie) AddRouter(uri string, handler ControllerHandler) error {
uri = strings.TrimPrefix(uri, "/")
if uri == "" {
t.root.isLast = true
t.root.handler = handler
return nil
}
upperUri := strings.ToUpper(uri)
match := t.FindRoute(upperUri)
if match != nil {
// existing route
return fmt.Errorf("existing route for %q", uri)
}
// The route does not exist, add it to the tree
err := t.root.addRoute(upperUri, handler)
if err != nil {
return err
}
return nil
}
type node struct {
isLast bool
segment string
handler ControllerHandler
children []*node
}
func newNode(segment string) *node {
return &node{segment: segment}
}
func isWildcard(s string) bool {
return strings.HasPrefix(s, ":")
}
// /user/name
// /user/:id/name
// /user/3/name
// findRoute finds the handler for the uri if exists.
//
// we suppose that uri passed here doesn't begin with "/"
func (n *node) findRoute(uri string) *node {
splitted := strings.SplitN(uri, "/", 2)
splittedLen := len(splitted)
if isWildcard(splitted[0]) {
// input is a wildcard, check if this endpoint has already children
// if so, return the first one which is not nil.
nbChildren := len(n.children)
if nbChildren > 0 && n.children[0].segment != splitted[0] {
// several nodes exist, return the first one that is not nil
return n.children[0]
}
}
// try to find the value in childre
for _, child := range n.children {
if isWildcard(child.segment) || child.segment == splitted[0] {
if splittedLen == 1 {
// This is the last value, do the check and return
if child.isLast {
// if isLast, that means we have already registered the endpoint
return child
} else {
// otherwise, take it as not registered
return nil
}
}
// More segments to check
return child.findRoute(splitted[1])
}
}
// nothing found in the children
return nil
}
func (n *node) addRoute(uri string, handler ControllerHandler) error {
splitted := strings.SplitN(uri, "/", 2)
splittedLen := len(splitted)
isLast := splittedLen == 1
// try to find the value in childre
for _, child := range n.children {
if isWildcard(child.segment) || child.segment == splitted[0] {
if isLast {
// This is the last value, do the check and return
if child.isLast {
// if isLast, that means we have already registered the endpoint
return errors.New("node exists")
} else {
// otherwise, set the child
child.isLast = true
child.handler = handler
return nil
}
}
// More segments to check
return child.addRoute(splitted[1], handler)
}
}
// create a new node
new := newNode(splitted[0])
if isLast {
// this is the end
new.handler = handler
new.isLast = true
n.children = append(n.children, new)
return nil
}
// continue
new.isLast = false
err := new.addRoute(splitted[1], handler)
if err != nil {
return err
}
n.children = append(n.children, new)
return nil
}

83
handlers.go Normal file
View File

@ -0,0 +1,83 @@
package main
import (
"context"
"log"
"net/http"
"time"
"git.vinchent.xyz/vinchent/go-web/framework"
)
// func FooControllerHandler(ctx *framework.Context) error {
// return ctx.WriteJSON(http.StatusOK, map[string]any{
// "code": 0,
// })
// }
func FooControllerHandler(ctx *framework.Context) error {
durationCtx, cancel := context.WithTimeout(ctx.BaseContext(), time.Duration(1*time.Second))
defer cancel()
finish := make(chan struct{}, 1)
panicChan := make(chan interface{}, 1)
// some long task
go func() {
// Deal with the panic during the work
defer func() {
if p := recover(); p != nil {
panicChan <- p
}
}()
// do the business
time.Sleep(10 * time.Second)
ctx.WriteJSON(http.StatusOK, "ok")
finish <- struct{}{}
}()
select {
case <-panicChan:
// Protect ResponseWriter for concurrently writing from different
// goroutines if there are any
ctx.WriterMux().Lock()
defer ctx.WriterMux().Unlock()
log.Println("panicked")
ctx.WriteJSON(http.StatusInternalServerError, "panicked")
case <-finish:
log.Println("finished")
case <-durationCtx.Done():
ctx.WriterMux().Lock()
defer ctx.WriterMux().Unlock()
log.Println("Timeout")
ctx.WriteJSON(http.StatusInternalServerError, "time out")
ctx.SetHasTimeout()
}
return nil
}
func UserLoginController(ctx *framework.Context) error {
ctx.WriteJSON(http.StatusOK, "ok")
return nil
}
func SubjectDelController(ctx *framework.Context) error {
ctx.WriteJSON(http.StatusAccepted, "deleted")
return nil
}
func SubjectUpdateController(ctx *framework.Context) error {
ctx.WriteJSON(http.StatusAccepted, "updated")
return nil
}
func SubjectGetController(ctx *framework.Context) error {
ctx.WriteJSON(http.StatusAccepted, "got")
return nil
}
func SubjectListController(ctx *framework.Context) error {
ctx.WriteJSON(http.StatusAccepted, "list")
return nil
}

12
main.go
View File

@ -1,24 +1,18 @@
package main
import (
"fmt"
"html"
"log"
"net/http"
"git.vinchent.xyz/vinchent/go-web/framework"
)
type FooHandler struct{}
func (foo FooHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
}
func main() {
core := framework.NewCore()
registerRouter(core)
server := &http.Server{
Addr: ":8080",
Handler: framework.NewCore(),
Handler: core,
}
if err := server.ListenAndServe(); err != nil {

15
routes.go Normal file
View File

@ -0,0 +1,15 @@
package main
import "git.vinchent.xyz/vinchent/go-web/framework"
func registerRouter(core *framework.Core) {
core.Get("/user/login", UserLoginController)
subjectApi := core.Group("/subject")
{
subjectApi.Delete("/:id", SubjectDelController)
subjectApi.Put("/:id", SubjectUpdateController)
subjectApi.Get("/:id", SubjectGetController)
subjectApi.Get("/list/all", SubjectListController)
}
}