From 517362aba5b2b1e50d3f440a8b6638df416cd531 Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Mon, 23 Jul 2018 22:58:00 -0700 Subject: [PATCH] First commit --- .gitignore | 3 + Gopkg.lock | 137 +++++++++++++++++++++++++++++++++++ Gopkg.toml | 46 ++++++++++++ README.md | 23 ++++++ apisprout.go | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++ logo.gvdesign | Bin 0 -> 6070 bytes 6 files changed, 402 insertions(+) create mode 100644 .gitignore create mode 100644 Gopkg.lock create mode 100644 Gopkg.toml create mode 100644 README.md create mode 100644 apisprout.go create mode 100644 logo.gvdesign diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c2d6c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +vendor +logo.png diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..edd764b --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,137 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/fsnotify/fsnotify" + packages = ["."] + revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" + version = "v1.4.7" + +[[projects]] + branch = "master" + name = "github.com/getkin/kin-openapi" + packages = [ + "jsoninfo", + "openapi3", + "openapi3filter", + "pathpattern" + ] + revision = "60e95f1b88c48a8c98824f41ea5cc3c8f2d54e82" + +[[projects]] + name = "github.com/ghodss/yaml" + packages = ["."] + revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/hashicorp/hcl" + packages = [ + ".", + "hcl/ast", + "hcl/parser", + "hcl/printer", + "hcl/scanner", + "hcl/strconv", + "hcl/token", + "json/parser", + "json/scanner", + "json/token" + ] + revision = "ef8a98b0bbce4a65b5aa4c368430a80ddc533168" + +[[projects]] + name = "github.com/inconshreveable/mousetrap" + packages = ["."] + revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" + version = "v1.0" + +[[projects]] + name = "github.com/magiconair/properties" + packages = ["."] + revision = "c2353362d570a7bfa228149c62842019201cfb71" + version = "v1.8.0" + +[[projects]] + branch = "master" + name = "github.com/mitchellh/mapstructure" + packages = ["."] + revision = "f15292f7a699fcc1a38a80977f80a046874ba8ac" + +[[projects]] + name = "github.com/pelletier/go-toml" + packages = ["."] + revision = "c01d1270ff3e442a8a57cddc1c92dc1138598194" + version = "v1.2.0" + +[[projects]] + name = "github.com/spf13/afero" + packages = [ + ".", + "mem" + ] + revision = "787d034dfe70e44075ccc060d346146ef53270ad" + version = "v1.1.1" + +[[projects]] + name = "github.com/spf13/cast" + packages = ["."] + revision = "8965335b8c7107321228e3e3702cab9832751bac" + version = "v1.2.0" + +[[projects]] + name = "github.com/spf13/cobra" + packages = ["."] + revision = "ef82de70bb3f60c65fb8eebacbb2d122ef517385" + version = "v0.0.3" + +[[projects]] + branch = "master" + name = "github.com/spf13/jwalterweatherman" + packages = ["."] + revision = "7c0cea34c8ece3fbeb2b27ab9b59511d360fb394" + +[[projects]] + name = "github.com/spf13/pflag" + packages = ["."] + revision = "583c0c0531f06d5278b7d917446061adc344b5cd" + version = "v1.0.1" + +[[projects]] + name = "github.com/spf13/viper" + packages = ["."] + revision = "b5e8006cbee93ec955a89ab31e0e3ce3204f3736" + version = "v1.0.2" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = ["unix"] + revision = "ac767d655b305d4e9612f5f6e33120b9176c4ad4" + +[[projects]] + name = "golang.org/x/text" + packages = [ + "internal/gen", + "internal/triegen", + "internal/ucd", + "transform", + "unicode/cldr", + "unicode/norm" + ] + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[[projects]] + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" + version = "v2.2.1" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "1c6c6bd1f05cfc68d7c065c8e8228fa034a2a2ed300fbff79e2f8c204e3526a9" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..2a2b148 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,46 @@ +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + + +[prune] + go-tests = true + unused-packages = true + +[[constraint]] + branch = "master" + name = "github.com/getkin/kin-openapi" + +[[constraint]] + name = "github.com/spf13/viper" + version = "1.0.2" + +[[constraint]] + name = "github.com/spf13/cobra" + version = "0.0.3" + +[[constraint]] + name = "gopkg.in/yaml.v2" + version = "2.2.1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..76bd485 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +![API Sprout](https://user-images.githubusercontent.com/106826/43119494-78be9224-8ecb-11e8-9d1a-9fc6f3014b91.png) + +A simple, quick, cross-platform API mock server that returns examples specified in an OpenAPI 3.x document. Usage is simple: + +```sh +apisprout my-api.yaml +``` + +## ToDo + +[x] OpenAPI 3.x support +[x] Return defined examples +[ ] Validate request payload +[ ] Take `Accept` header into account to return the right media type +[ ] Generate fake data from schema if no example is available +[ ] Release binaries for Windows / Mac / Linux +[ ] Public Docker image + +## License + +Copyright © 2018 Daniel G. Taylor + +http://dgt.mit-license.org/ diff --git a/apisprout.go b/apisprout.go new file mode 100644 index 0000000..4580fcd --- /dev/null +++ b/apisprout.go @@ -0,0 +1,193 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "math/rand" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/spf13/cobra" + "github.com/spf13/viper" + yaml "gopkg.in/yaml.v2" +) + +var ( + // ErrNoExample is sent when no example was found for an operation. + ErrNoExample = errors.New("No example found") + + // ErrCannotMarshal is set when an example cannot be marshalled. + ErrCannotMarshal = errors.New("Cannot marshal example") +) + +func main() { + rand.Seed(time.Now().UnixNano()) + + // Load configuration from file(s) if provided. + viper.SetConfigName("config") + viper.AddConfigPath("/etc/apisprout/") + viper.AddConfigPath("$HOME/.apisprout/") + viper.ReadInConfig() + + // Load configuration from the environment if provided. Flags below get + // transformed automatically, e.g. `foo-bar` -> `SPROUT_FOO_BAR`. + viper.SetEnvPrefix("SPROUT") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + viper.AutomaticEnv() + + // Build the root command. This is the application's entry point. + cmd := filepath.Base(os.Args[0]) + root := &cobra.Command{ + Use: fmt.Sprintf("%s [flags] FILE", cmd), + Version: "1.0", + Args: cobra.MinimumNArgs(1), + Run: server, + Example: fmt.Sprintf(" %s openapi.yaml", cmd), + } + + // Set up global options. + flags := root.PersistentFlags() + + viper.SetDefault("port", 8000) + flags.IntP("port", "p", 8000, "HTTP port") + viper.BindPFlag("port", flags.Lookup("port")) + + viper.SetDefault("validate-server", false) + flags.BoolP("validate-server", "", false, "Check hostname against configured servers") + viper.BindPFlag("validate-server", flags.Lookup("validate-server")) + + // Run the app! + root.Execute() +} + +// getTypedExample will return an example from a given media type, if such an +// example exists. If multiple examples are given, then one is selected at +// random. +func getTypedExample(mt *openapi3.MediaType) (interface{}, error) { + if mt.Example != nil { + return mt.Example, nil + } + + if len(mt.Examples) > 0 { + // Choose a random example to return. + keys := make([]string, 0, len(mt.Examples)) + for k := range mt.Examples { + keys = append(keys, k) + } + + selected := keys[rand.Intn(len(keys))] + return mt.Examples[selected].Value, nil + } + + // TODO: generate data from JSON schema, if available? + + return nil, ErrNoExample +} + +// getExample tries to return an example for a given operation. +func getExample(op *openapi3.Operation) (int, string, interface{}, error) { + for s, response := range op.Responses { + + status, _ := strconv.Atoi(s) + + // Prefer successful status codes, if available. + if status >= 200 && status < 300 { + for mime, content := range response.Value.Content { + example, err := getTypedExample(content) + if err == nil { + return status, mime, example, nil + } + } + } + + // TODO: support other status codes. + } + + return 0, "", nil, ErrNoExample +} + +func server(cmd *cobra.Command, args []string) { + data, err := ioutil.ReadFile(args[0]) + if err != nil { + log.Fatal(err) + } + + loader := openapi3.NewSwaggerLoader() + var swagger *openapi3.Swagger + if strings.HasSuffix(args[0], ".yaml") || strings.HasSuffix(args[0], ".yml") { + swagger, err = loader.LoadSwaggerFromYAMLData(data) + } else { + swagger, err = loader.LoadSwaggerFromData(data) + } + if err != nil { + log.Fatal(err) + } + + if !viper.GetBool("validate-server") { + // Clear the server list so no validation happens. Note: this has a side + // effect of no longer parsing any server-declared parameters. + swagger.Servers = make([]*openapi3.Server, 0) + } + + var router = openapi3filter.NewRouter().WithSwagger(swagger) + + http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + info := fmt.Sprintf("%s %v", req.Method, req.URL) + route, _, err := router.FindRoute(req.Method, req.URL) + if err != nil { + log.Printf("ERROR: %s => %v", info, err) + w.WriteHeader(404) + return + } + + status, mime, example, err := getExample(route.Operation) + if err != nil { + log.Printf("%s => Missing example", info) + w.WriteHeader(http.StatusTeapot) + w.Write([]byte("No example available.")) + return + } + + log.Printf("%s => %d (%s)", info, status, mime) + + var encoded []byte + + if s, ok := example.(string); ok { + encoded = []byte(s) + } else if _, ok := example.([]byte); ok { + encoded = example.([]byte) + } else { + switch mime { + case "application/json": + encoded, err = json.MarshalIndent(example, "", " ") + case "application/x-yaml", "application/yaml", "text/x-yaml", "text/yaml", "text/vnd.yaml": + encoded, err = yaml.Marshal(example) + default: + log.Printf("Cannot marshal as %s!", mime) + err = ErrCannotMarshal + } + + if err != nil { + w.WriteHeader(500) + w.Write([]byte("Unable to marshal response")) + return + } + } + + w.Header().Add("Content-Type", mime) + w.WriteHeader(status) + w.Write(encoded) + }) + + fmt.Printf("Starting server on port %d\n", viper.GetInt("port")) + http.ListenAndServe(fmt.Sprintf(":%d", viper.GetInt("port")), nil) +} diff --git a/logo.gvdesign b/logo.gvdesign new file mode 100644 index 0000000000000000000000000000000000000000..cffe768fd457df2a1bafc093908d94a30848f7b9 GIT binary patch literal 6070 zcmV;n7fI+JiwFP!000021MQpnkE2+!@Bfui&eQuP^H#AhC0*&V8epIfpQkTRjUN=j)63KUdHV9x zKd&Qa_w;e#RF~cPt4rEw8St~R364)++PVu~p3ZHvT=3J=)~`e@0$du9|9iRU@5_rx z2^DlW7pPkUpF1i>0ucX?xXw$Y%%)7d= z;T>%RT<^{I*&YUs>>crunW^jZN#jkPk=>nE?=-AAtt3M{ZzkBn^X{fDqP(i%NuWQ} zedJ_lpK+794E^%U%TpbM?btqjDY`7X{^k~bRMwYCSw3y+aCrlKf^Y;tP=thti`(8*$M}uS4&d3F9o=-alJD`p0>3_KErXPaq7uKp^@8BET~o`@Uy=;Ey7R#2*Pl^ z@cN|o$5i0dc;)OiBYSKHs4+5B)p`>_PfZcsz4%?whYRQ#@*Z^A@#UL|*(Fvca}EX6S(56?2BbJRr;fv%CydF7==%!L ztTxvQ@aKjaTcCy)sA2SvLro4&>aW}OG;>`Tjoxvf*!{*U)U*&rXeEDu8lbhR63~J> zgKIB___iMk&ht`0EcT5AWIqOVc{)T|jfG4~-N`t?P7v&XLp6DW>D#dxauiF)GP{%5 zEE{ffHyv$7!}KBbsW&q6bFtH!E%*#@uVmP&xzT`~)Vx;si@J3@S)587JG3Ka%ZwP}CQjefjdbKRs3r*Dt{#C-seNXP3b?70EGTHOQlcDp-y1-&T@%R6 z?of*eleMY@5l|ke9!BT38_AoxxdKBC+YM%VSI;S=*Wlne2B^QP_m`(upJjn>cEg$S zkIrsu%7RoS12Wly?;JDb1L%a|GNjuR)JaQ*{T^zHLnMNcw-r4T#8K-HgATO-qKE6l zDeBLV!=^hz@tFqM>oc6nJhs1LKy4Ptz-jvqG6Y0w8tMDkD{+67AL z^>U4tRWF(C_V~U6kQQ>S0Do2QgPaUhg7(os5Y{m4r0hTk;)$_pZ;&_6?gP|xEsy6p zIZ(-=;UpX7sXka44g?vc$olm9$Dl6w9jkrb80n+F9%i(4V`Qfu+Y++z0a%Ai8Qh;i zZC-mt{zgW*sQ~Rc%_HtkMp1oUX^@7`+>JYnQ6UYTIN`Cc)0t0_tg0L|S2pW&g@QgD zkdU{R>N7B9;!T}OdPYNd>(&um)W@N#G~At)x1MAm_8V>L3-!_72rgD?Ta`x;iL&w# zE9}^@q@vZ4#7SH3bW?|+XGfc!kJ_WNANG}SNrdp$*)x4`&_>!GAIpzKvuq7ohi$zD zF-Ar7k|s=eCO0Ug8RYzynS=5yX@Conk2TX#Sh_UwX!hrCQ~n**8;9+IXX7csD`yiM zG8MpDu3HCNpt|!u6+eK^Ok-*WBuuP)ccl^4*_1(`Qj%H9*bd#ue;9PTb<-)NX<~q6 zWHTnj938@qcG!^(acXS)iUBRV8x2eHcaU{{3j#>io-s`S1aKI)LkyZUPKtc zk(^qqN8llu8#xtIZnb4wGyO5BeH%V%5S!`T9kOBrRZO|ZV2qG~)7l*YLpjB)$$QHfGcJCd`E;gxUbg!6`F>v}%MTjl=G8-8O1z#XFl!|SW@H%b$eZ4o zkjVlQPk^eto%wtlI%~v1s~^4}nCG2_(^Hndsn>a-jnA3$SbllA#_$^C`bsw<(Ce#$ zm-BT}j^%!nM7VcT_q@Qq98OOU-x+CwG+=YmfxqsYETcJAVYX?i>rep_TN*Y7J3FQn zMrg3zIXUwIbXMZ|(A)^7c}#*~Kl(}w#4rVuV!G*Fk^V8z&Gm0U=R~WQe0XUZIl^yKp3kC4-kgDCvp62y(4ks zy(jVIn~Bj9l!YzG1H8smrL~zbI1w^3D)&IeZLIlQ72vPx?XQ~lywvHPuQqYZ58J@# zuxtWaj}TTGGkk#Bk1&(NvH;gwRCA7_kdmONA~GDCkCT|%(8OtQoTE*Oc-=TJff#{t&hja zFwf4^pM;n<_vFKFY5%Q<-DN<#$gx~|9(49$ z7j&6R!nvzXL$`AZ5h9KUt80xj8U+?r%eO4laZqY=7cy_IU!?$dPd)DbXD)v*RIe(G zUbN`-E>yJD(95Sv%Cb6>yy}yK^j8B6YuS2|ef02dURwt(5IUs)S;jF9o@fbMdeyih5;u#ALZ6y>W5` z+r6_(RQWN@O}Hlad3QqD%ukhMO43j@Q9BgJSx<25tx{br=RV}$a+UX4p#i!CJ^IWx zh*pOw-+6(oY?RT5N6uTNHZTTf!K#Yz7R{Nbu}f2Vk4BdpNSi(%J>V`CRTVWyra-5j3vb5I2FT zEI~DIjhk1yX5HSUUe)n-$hFT*5t{GSpSM}c-PO4m?>3lK5{^onY*Z+6r?82o2A0Y0 z*UDQ5b(KP7Dt>d66hKcyNQQVg3PyZ{l{JVT4LF}hmUsP~Dk^H_kY;6d%*`tmCMykj zj+uMM--y#!lj=_aJ|=192nU9CxvhHY$`zd|3NGsO20YzkUMv?Pg%488K{|Wa#LXOU z>nkYAtIe<^(RNx!E4Nd>TE{3sfmgeVu~!;stJ{ZNtWxp`O^&_3n+1dtAUE=oErO+# z66{#(Z3dqunb9!UQ{P;md2iOT4^k>w^727OY!n7Og4*kQ%Bca%5FFE>RF=O*}z>!p7d%Zs;(fJq~ z8qL{R4JUTka9ebJi{{m`2GF-?^s|F&px0p!DH)Lzy2(4wI;d&U*5V*BC>GcfC0nD| zz3mBl?LhA=FY4>LqwYf&bz$B!Upcyy+C=opu2wxe-&-TQ2O~^#A@fXn&DQ4Lo>pEh z-#L06z_s=G$d#OMM||p2y(0a@a&3iRnJ!52kn#?x-5+1=@cpxf8|&y%+w(5Kg=K&7 z=~JvroUO!Lve)za04q+S75y!m>!t_J+vv~#{1PNyKmvSO9Qfl63!yMTV};3PP<Fl04H>a?#1Qa8{tHcULOKH>ibJ>7#)0Ybf&$ZY$Xz2o z9t5HL_&BlKXOZdpQY)wj$fdm%x19IRp<;Fi8h}{hf5R9>QJi+)koUj}9kIe$V-Af( zu!rp2a2N%xj~YwZytP!6z%qBs2L&Ib99L`&v#ioo6?81wyI{RWEtoxKtMgjfTQ{IM zF-h;17pb+!`dP|xk}AN;8_As3SU9CgBeS;UTRDG~%AY@Cerm{v6z?BaEVADegyfWdIN5ExuF@5Q z4m=zTX3j@#fEyU zx)oIP$Gr#@45+?60=r4w(*CDj$0)k2;TPgxxM8zl&1|b^dqp7hYU_KOx!IUj4f38> z1|DC1SjK3Yh+(iP>`7P34h7R)?v&>2qQ{jMoW$iI{5$#jPP%@ZEg1Ywx?pG#F5Eo0 ze97!wm)$jzH@CzSy|kMGokNp?crxMo{dP|Ur~sj5ZpFOVf- ztREW0$(TyqSIEk)BQRWDI(fgnPQlga%M-P|G-hF0wEG}FMavKZ0Ivf^J7pKoin7k# z>~=w;!5A&^f9h9Uq#Ed3>l;LEfR!Q000iW>NdfJqRAA%FlO1X&i~g|y#}f*`qQ zAqWnDB!DbPF3S@6grO*iLkI+5@B$oiS(eRlktOig1$rfHL#K&5A5}EblXmMaCnQac z+)+{sZT8J@cu(Vgbp{oL2^704aL(3N^S~ZWEn@T|v@{}@FF*bx^j{Y+hA;c@f;)l# z@-0Z!3gqE_8w^WQ{GG&wb2uE-a}Q8fuC-nM8%T|}webfy1+G8;DNerw=c~j2Dxa^e z|A+a+;ZGO_kr;%-06`KceB~2|K0z3SAs`Bn2nrMMzW^vi0H0t4$3YMxL1I}1zu}Yk zHlDe>+Dy)r%0+E5dfC&hNgBOnmlQK5l1snG(?6Z5?TMV79M3j`4%n@|>=ZCm_dY4`NJ6|^Sr%NY5OP7w}a!db~Q4IPw?8qOY`@KE+&EQ>% zFWa#WHUvDzJ1mOMng;Ye9gb1%r704L0hWhPzkW#3Eg#@XPWjD54!3M96#5SP?3&>{Rv=-L#0IfPRHhgG?ci(_# z8EG#Nyssq7NPGe1Td}xAV&&cTRO|%5Ee7A%a@N?I-Z5BPn{+xpV$lP?b&FH>gY%33 zTjm!=Uv96*q){9v2q_n)Bes{liFYtdyX&lN(~vb?)4o~lkaLknN6p%_`%gm{-?r3fssRjWXAP1MweX`N=+ZT zF{6%avg6kBx5F%(O(l>Ly4op8DzQA(3>-IG;k7M z(Jia=-_BQfp zgpBe_*}J}N?{B}81dJ%1h=dzM2c0uK+tilK4~zx1+}ZH`UR;v!%RLQ)+xpS6^Iwd! zeA#j?4bpNRAW@RQFTK@2;^Q?yN%Oa;y_<7hwSuOXSm4}aLj_j787HH{Ue5X$j9JYt z!`e+&mJ>3dxEReIB37%wx%LDmX34?JF)>&k;Z;`Q^K2tIaTmly+n!&Sf%qhCFk0c!oq7{!L)wsWOqm94oh-~tl%*zcg>)^iS4~U36BdJT= zG+rP2Wu%ZI6(Tp8Z~6{{WGrRxPaWQn`#pJC3`F0U9zw=Y(vjZO+GL|?2}oAS+}EZ+ zUgpT)gBjVyQw{Zp1VDSkpfu2rII8ZX?Fd~|$ii1MdDfuqsp=(MXpvRY4A#M_PNsu* zm+w!^6^~KqHWP?#ke#rL@HSN!__0Lo?W{~9;|0ET|cr2qf` literal 0 HcmV?d00001