experiments

All kinds of coding experiments
Log | Files | Refs | Submodules

routing.go (5027B)


      1 // Simple router abstraction.
      2 // Implements API routing logic with support for path parameters and query parameters.
      3 // Path parameters syntax: '/path-comp1/:param/path-comp3
      4 // Use the newOp function to create operations.
      5 
      6 package main
      7 
      8 import (
      9 	"fmt"
     10 	"io/ioutil"
     11 	"log"
     12 	"net/http"
     13 	"net/url"
     14 	"os"
     15 	"reflect"
     16 	"runtime"
     17 	"strings"
     18 )
     19 
     20 // API (/api)
     21 
     22 type operation struct {
     23 	name      string
     24 	method    string
     25 	pathComps []string
     26 	action    func(requestParams map[string]string) ([]byte, *apierror)
     27 	shouldLog bool
     28 }
     29 
     30 func newOp(method, path string, action func(requestParams map[string]string) ([]byte, *apierror), shouldLog bool) operation {
     31 	name := strings.Split(runtime.FuncForPC(reflect.ValueOf(action).Pointer()).Name(), ".")[1]
     32 	return operation{name, method, splitPath(path), action, shouldLog}
     33 }
     34 
     35 func splitPath(path string) []string {
     36 	urlString := strings.TrimPrefix(path, "/")
     37 	urlString = strings.TrimSuffix(urlString, "/")
     38 	components := strings.Split(urlString, "/")
     39 	var trimmed []string
     40 	for _, c := range components {
     41 		if c == "" {
     42 			continue
     43 		}
     44 		trimmed = append(trimmed, c)
     45 	}
     46 	return trimmed
     47 }
     48 
     49 func matchURL(apiPath string, rURL *url.URL, operationComps []string) (bool, map[string]string) {
     50 	urlComps := splitPath(strings.TrimPrefix(rURL.Path, apiPath))
     51 	if len(urlComps) != len(operationComps) {
     52 		return false, nil
     53 	}
     54 	// Read query params before path params, otherwise it would be possible to overwrite path params
     55 	params := make(map[string]string)
     56 	for k, v := range rURL.Query() {
     57 		if len(v) == 1 {
     58 			params[k] = v[0]
     59 		} else {
     60 			params[k] = strings.Join(v, ",")
     61 		}
     62 	}
     63 	for i := range urlComps {
     64 		// Operation components starting with ':' denote path params
     65 		if operationComps[i][0] == ':' {
     66 			paramName := operationComps[i][1:]
     67 			params[paramName] = urlComps[i]
     68 			continue
     69 		}
     70 		// Non-param path components must match exactly
     71 		if operationComps[i] != urlComps[i] {
     72 			return false, nil
     73 		}
     74 	}
     75 	return true, params
     76 }
     77 
     78 func getAPIHandler(path string, apiOps []operation) (string, func(w http.ResponseWriter, r *http.Request)) {
     79 	// Guarantee trailing slash, otherwise the handler may be registered incorrectly
     80 	if !strings.HasSuffix(path, "/") {
     81 		path += "/"
     82 	}
     83 	return path, func(w http.ResponseWriter, r *http.Request) {
     84 		var (
     85 			match      bool
     86 			matchingOp *operation
     87 			params     map[string]string
     88 		)
     89 		for _, op := range apiOps {
     90 			if op.method != r.Method {
     91 				continue
     92 			}
     93 			match, params = matchURL(path, r.URL, op.pathComps)
     94 			if match {
     95 				matchingOp = &op
     96 				break
     97 			}
     98 		}
     99 		if matchingOp == nil {
    100 			log.Println("404:", r.URL.Path)
    101 			w.WriteHeader(http.StatusNotFound)
    102 			return
    103 		}
    104 		// Add body as a parameter
    105 		body, err := ioutil.ReadAll(r.Body)
    106 		if err != nil {
    107 			log.Println("error when reading request body")
    108 		} else if string(body) != "" {
    109 			params["body"] = string(body)
    110 			defer r.Body.Close()
    111 		}
    112 		body, apierr := matchingOp.action(params)
    113 
    114 		if apierr != nil {
    115 			log.Printf("%v: %v: %v\n", matchingOp.name, params, apierr)
    116 			// TODO: Header should depend on error type
    117 			w.WriteHeader(apierr.status)
    118 			w.Write([]byte(apierr.message))
    119 			return
    120 		}
    121 		if matchingOp.shouldLog {
    122 			log.Printf("%v: %v\n", matchingOp.name, params)
    123 		}
    124 		if body != nil {
    125 			w.Write(body)
    126 		}
    127 	}
    128 }
    129 
    130 type apierror struct {
    131 	status  int
    132 	message string
    133 	err     error
    134 }
    135 
    136 func (e *apierror) Error() string {
    137 	switch e.status {
    138 	case http.StatusInternalServerError:
    139 		return fmt.Sprintf("apierror: internal server error: %v", e.err)
    140 	default:
    141 		return fmt.Sprintf("apierror: code=%v, message=%v", e.status, e.message)
    142 	}
    143 }
    144 
    145 func errOperationForbidden(clientName string, passphraseProtected bool) *apierror {
    146 	message := fmt.Sprintf("'%v' not allowed to perform operation", clientName)
    147 	if passphraseProtected {
    148 		message += " without the correct passphrase"
    149 	}
    150 	return &apierror{
    151 		status:  http.StatusForbidden,
    152 		message: message,
    153 	}
    154 }
    155 
    156 func errRequest(message string) *apierror {
    157 	return &apierror{
    158 		status:  http.StatusBadRequest,
    159 		message: message,
    160 	}
    161 }
    162 
    163 func errInternal(message string, err error) *apierror {
    164 	return &apierror{
    165 		status:  http.StatusInternalServerError,
    166 		message: message,
    167 		err:     err,
    168 	}
    169 }
    170 
    171 // Static assets (Everything other than /api)
    172 
    173 var fileTypeMap = map[string]string{
    174 	"css":  "text/css",
    175 	"html": "text/html",
    176 	"png":  "image/png",
    177 	"jpg":  "image/jpg",
    178 	"js":   "application/javascript",
    179 	"json": "application/json",
    180 }
    181 
    182 func handleSite(w http.ResponseWriter, r *http.Request) {
    183 	fmt.Println(r.URL.Path)
    184 	path := strings.TrimPrefix(r.URL.Path, "/")
    185 	if path == "" {
    186 		path = "index.html"
    187 	}
    188 	contents, err := readStaticFile(path)
    189 	if err != nil {
    190 		if os.IsNotExist(err) {
    191 			w.WriteHeader(http.StatusNotFound)
    192 			w.Write([]byte("404: Not found"))
    193 		} else {
    194 			w.WriteHeader(http.StatusInternalServerError)
    195 			w.Write([]byte("404: Not found"))
    196 		}
    197 		return
    198 	}
    199 	c := strings.Split(path, ".")
    200 	contentType, ok := fileTypeMap[c[len(c)-1]]
    201 	if ok {
    202 		w.Header().Add("Content-Type", contentType)
    203 	}
    204 	w.Write(contents)
    205 }