From a7b1654d2caa6165af3ca98b18c19bfd21e66c33 Mon Sep 17 00:00:00 2001 From: Kevin Gimbel Date: Tue, 23 Jan 2018 13:34:58 +0100 Subject: [PATCH] Initial commit --- .gitignore | 1 + .goreleaser.yml | 20 +++++ LICENSE | 22 ++++++ README.md | 127 ++++++++++++++++++++++++++++++ cmd/cmd.go | 77 ++++++++++++++++++ config.go | 50 ++++++++++++ example/html/index.html | 13 +++ example/json/from-file/dirty.json | 9 +++ example/srvc.yaml | 44 +++++++++++ magefile.go | 22 ++++++ server.go | 80 +++++++++++++++++++ srvc.yaml | 51 ++++++++++++ 12 files changed, 516 insertions(+) create mode 100644 .gitignore create mode 100644 .goreleaser.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/cmd.go create mode 100644 config.go create mode 100644 example/html/index.html create mode 100644 example/json/from-file/dirty.json create mode 100644 example/srvc.yaml create mode 100644 magefile.go create mode 100644 server.go create mode 100644 srvc.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c74ec3d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +test_files/* \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..6d9cf4f --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,20 @@ +builds: + - binary: srvc + ldflags: "-X cmd.version={{.Version}} -X cmd.buildDate={{.Date}} -X cmd.commit={{.Commit}}" + goos: + - windows + - darwin + - linux + goarch: + - amd64 +archive: + replacements: + amd64: 64-bit + darwin: macOS + +brew: + github: + owner: kevingimbel + name: homebrew-tap + homepage: https://github.com/kevingimbel/srvc + description: "Quick API prototyping tool" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81cc2f8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2018 Kevin Gimbel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..54794ec --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# `srvc` + +`srvc` is a command line tool which allows you to quickly spin up a webserver with configurable routes. These routes can be configured to return HTML pages, XML, JSON, other files, custom headers, and inline-content directly from the config file. + +## How to install + +### Homebrew + +On MacOS you can get the latest version from `brew`. + +```sh +$ brew install kevingimbel/tap/srvc +``` + +### Binary + +Alternatively you can grab the latest release from the [releases page](/releases) and place it somewhere in your `$PATH`. + +## Usage + +```sh +$ srvc +$ srvc [-port 1313] +``` + +`-port` is optional and takes a HTTP port to serve to. The default port is 8080. + +## Config + +`srvc` needs a YAML configuration file in the directory it is executed in. See the sample configuration file in the [example](/example/) directory. + +The same configuration file is shown below. + +```yaml +# global header config, added to each route +headers: + - key: "client" + value: "srvc-alpha1" + +# route based config +routes: +# For the route "/demo/html-page" display the "index.html" file from the "html" fodler + /demo/html-page: + headers: + - key: "Content-Type" + value: "text/html" + file: "./html/index.html" + + # display HTML content defined inline for /demo/html-inline + /demo/html-inline: + headers: + - key: "Content-Type" + value: "text/html" + content: | +

It works!

+

The content is defined inside the srvc.yaml config file + + # Display XML on /demo/xml + /demo/xml: + headers: + - key: "Content-Type" + value: "application/xml" + content: | + + + + Go to sub + Click here + + + + # "json/from-file" returns the content of a JSON file + /json/from-file: + headers: + - key: "Content-Type" + value: "application/json" + file: "./json/from-file/dirty.json" +``` + +### Headers + +Header can be defined on a global level or for each route. The global headers are added to each route, in the above example each route gets a `"client": "srvc-alpha1"` header. + +```yaml +headers: + - key: "client" + value: "srvc-alpha1" +``` + +### Routes + +Routes make up the second global config object. Here the different routes are defined. The "key" for each nested object is the route, for example "/hello/world" which makes `srvc` respond on `localhost:8080/hello/world`. + +```yaml +routes: + /hello/world: + headers: + - key: "custom" + value: "header for route /hello/world" + - key: "Content-Type" + value: "text/html" + content: | +

Hello, world!

+

This is inline content!

+``` + +This route will respond with a HTML page containing the following code. + +```html +

Hello, world!

+

This is inline content!

+``` + +A route can be configured to respond with the content of a file, as shown below. + +```html +routes: + /hello/world/file: + headers: + - key: "custom" + value: "header for route /hello/world" + - key: "Content-Type" + value: "text/html" + file: "./relative/path/to/file.html" +``` + +The route `/hello/world/file` now responds with the contents of the file located at `./relative/path/to/file.html`. \ No newline at end of file diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 0000000..d40c9e6 --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,77 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/signal" + "syscall" + + srvc "github.com/kevingimbel/srvc" +) + +var port string +var versionFlag bool + +var version string +var buildDate string +var commit string + +var usage = `USAGE: %s [-port] + +Arguments: +- port Port number, e.g. 1919, 1313, 1337 +` + +// osSignal captchers signals sent by the OS. This is used to close / exit the program +func osSignal(err chan<- error) { + osc := make(chan os.Signal) + signal.Notify(osc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill) + err <- fmt.Errorf("%s", <-osc) +} + +func init() { + flag.StringVar(&port, "port", "8080", "Assign a port to serve to") + flag.BoolVar(&versionFlag, "version", false, "Show version") +} + +func main() { + flag.Parse() + + if versionFlag { + fmt.Printf("Version %s", version) + if buildDate != "" { + fmt.Printf("Build date: %s\n", buildDate) + } + + if commit != "" { + fmt.Printf("Commit: %s", commit) + } + os.Exit(0) + } + + // If we have args there's something wrong. + // The only argument is "-port XXXX" which is (removed?) from + // flag.Args() or doesn't count towards it. + if len(flag.Args()) > 0 { + // os.Args[0] is the executable name + fmt.Printf(usage, os.Args[0]) + os.Exit(1) + } + + p := ":" + port + srv := srvc.New(p) + srv.CreateConfiguredHandlers() + + errch := make(chan error) + + go osSignal(errch) + + go func() { + fmt.Println("Server is running on port", port) + errch <- srv.Run() + }() + + exit := <-errch + fmt.Println("Stopping Service. Reason:", exit) +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..7895517 --- /dev/null +++ b/config.go @@ -0,0 +1,50 @@ +package srvc + +import ( + "fmt" + "io/ioutil" + + "gopkg.in/yaml.v2" +) + +var version string + +// Config represents the yaml config +type Config struct { + Headers []Header `yaml:"headers"` + Routes map[string]RouteConfig `yaml:"routes"` +} + +// Header defines +type Header struct { + Key string `yaml:"key"` + Value string `yaml:"value"` +} + +// RouteConfig represents the config for a single route +type RouteConfig struct { + Headers []Header `yaml:"headers"` + Content string `yaml:"content"` + File string `yaml:"file"` +} + +var config Config + +func init() { + f, err := ioutil.ReadFile("./srvc.yaml") + + if err != nil { + fmt.Println("Cannot open file srvc.yaml") + } + + err = yaml.Unmarshal(f, &config) + + if err != nil { + fmt.Println(err) + } +} + +// GetConfig returns the config +func GetConfig() Config { + return config +} diff --git a/example/html/index.html b/example/html/index.html new file mode 100644 index 0000000..a6483dd --- /dev/null +++ b/example/html/index.html @@ -0,0 +1,13 @@ + + + + + + + srvc demo + + +

It works!

+

The srvc cli tool works if this website can be accessed at localhost:<port|8080>/html/demo, as configured in the srvc.yaml file.

+ + \ No newline at end of file diff --git a/example/json/from-file/dirty.json b/example/json/from-file/dirty.json new file mode 100644 index 0000000..ab7768c --- /dev/null +++ b/example/json/from-file/dirty.json @@ -0,0 +1,9 @@ +{ + "name": "Dirty JSON", + "id": "dirty-json", + "type": "test_file", + "content": { + "id": 12, + "created_at": "22-01-2018" + } +} \ No newline at end of file diff --git a/example/srvc.yaml b/example/srvc.yaml new file mode 100644 index 0000000..375f16b --- /dev/null +++ b/example/srvc.yaml @@ -0,0 +1,44 @@ +# global header config, added to each route +headers: + - + key: "client" + value: "srvc-alpha1" + +# route based config +routes: +# For the route "/demo/html-page" display the "index.html" file from the "html" fodler + /demo/html-page: + headers: + - key: "Content-Type" + value: "text/html" + file: "./html/index.html" + + # display HTML content defined inline for /demo/html-inline + /demo/html-inline: + headers: + - key: "Content-Type" + value: "text/html" + content: | +

It works!

+

The content is defined inside the srvc.yaml config file + + # Display XML on /demo/xml + /demo/xml: + headers: + - key: "Content-Type" + value: "application/xml" + content: | + + + + Go to sub + Click here + + + + # "json/from-file" returns the content of a JSON file + /json/from-file: + headers: + - key: "Content-Type" + value: "application/json" + file: "./json/from-file/dirty.json" \ No newline at end of file diff --git a/magefile.go b/magefile.go new file mode 100644 index 0000000..3a90a33 --- /dev/null +++ b/magefile.go @@ -0,0 +1,22 @@ +// +build mage + +package main + +import ( + "github.com/magefile/mage/sh" +) + +// Create a full release with goreleaser. Builds binaries and pushes them to GitHub +func Release() error { + return sh.Run("goreleaser", "--rm-dist") +} + +// Create a pre-release with goreleaser. Builds the binaries but does not push them. +func PreRelease() error { + return sh.Run("goreleaser", "--rm-dist", "--snapshot") +} + +// Build a binary named "microcorn-dev" and place it in /usr/local/bin +func BuildDev() error { + return sh.Run("go", "build", "-ldflags=-X github.com/kevingimbel/srvc/cmd/cmd.version=local-dev", "-i", "-o", "/usr/local/bin/srvc-dev", "./cmd/") +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..6ea5295 --- /dev/null +++ b/server.go @@ -0,0 +1,80 @@ +package srvc + +import ( + "fmt" + "io/ioutil" + "log" + "net/http" +) + +// Server represents a webserver +type Server struct { + Port string +} + +var registered = make(map[string]bool) + +// New creates a webserver +func New(p string) Server { + return Server{Port: p} +} + +// Run starts a webserver +func (s *Server) Run() error { + return http.ListenAndServe(s.Port, nil) +} + +// AddHandler adds a new handler +func (s *Server) AddHandler(pattern string, handler func(w http.ResponseWriter, r *http.Request)) { + registered[pattern] = true + + http.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { + setConfiguredHeaders(w, pattern) + handler(w, r) + }) +} + +func setConfiguredHeaders(w http.ResponseWriter, p string) { + c := GetConfig() + headers := c.Headers + + for _, v := range headers { + w.Header().Set(v.Key, v.Value) + } + + for _, v := range c.Routes[p].Headers { + w.Header().Set(v.Key, v.Value) + } +} + +// CreateConfiguredHandlers takes the configuration file and assigns handlers +func (s *Server) CreateConfiguredHandlers() { + c := GetConfig() + for k, v := range c.Routes { + if !registered[k] { + // This anonymous function is used to scope the k and v variables + // See https://stackoverflow.com/questions/44044245/golang-register-multiple-routes-using-range-for-loop-slices-map + func(p string, v RouteConfig) { + s.AddHandler(p, func(w http.ResponseWriter, r *http.Request) { + setConfiguredHeaders(w, p) + if len(v.Content) <= 0 { + v.Content = "" + } + + if v.File != "" { + fb, err := ioutil.ReadFile(v.File) + if err != nil { + log.Fatal(err) + } + v.Content = string(fb) + } + + w.Write([]byte(v.Content)) + }) + fmt.Println(fmt.Sprintf("Registered handler for http://localhost%s%s", s.Port, p)) + }(k, v) + } else { + fmt.Printf("[INFO] Route %s already registered. Skipping.\n", k) + } + } +} diff --git a/srvc.yaml b/srvc.yaml new file mode 100644 index 0000000..7ef73e2 --- /dev/null +++ b/srvc.yaml @@ -0,0 +1,51 @@ +# global header config, added to each route +headers: + - + key: "client" + value: "srvc-alpha1" + +# route based config +routes: +# display HTML on home page and add a "hello: world" header + /: + headers: + - key: "hello" + value: "world" + - key: "Content-Type" + value: "text/html" + content: | +

Routes:

+ + + # display HTML content on /config + /config/: + headers: + - key: "Content-Type" + value: "text/html" + content: | + Hello, world! +

This is a test

+ + # Display XML on /a/bc + a proper header + /a/bc: + headers: + - key: "Content-Type" + value: "application/xml" + content: | + + + + Go to sub + Click here + + + + /dirty.json: + headers: + - key: "Content-Type" + value: "application/json" + file: "./test_files/dirty.json" \ No newline at end of file