diff --git a/go-prism/go.mod b/go-prism/go.mod new file mode 100644 index 000000000..435899483 --- /dev/null +++ b/go-prism/go.mod @@ -0,0 +1,19 @@ +module prism-go + +go 1.23.8 + +require ( + github.com/brianvoe/gofakeit/v6 v6.28.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/getkin/kin-openapi v0.132.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + golang.org/x/sys v0.13.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go-prism/go.sum b/go-prism/go.sum new file mode 100644 index 000000000..f3ed20a82 --- /dev/null +++ b/go-prism/go.sum @@ -0,0 +1,27 @@ +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk= +github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-prism/main.go b/go-prism/main.go new file mode 100644 index 000000000..83ad3e07c --- /dev/null +++ b/go-prism/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "flag" + "log" + + "prism-go/server" +) + +func main() { + spec := flag.String("spec", "", "path to openapi spec") + port := flag.Int("port", 4010, "port to serve") + dynamic := flag.Bool("dynamic", false, "dynamic responses") + ignoreExamples := flag.Bool("ignoreExamples", false, "ignore examples in static mode") + fillProps := flag.Bool("json-schema-faker-fillProperties", true, "fill optional properties") + watch := flag.Bool("watch", true, "watch spec for changes") + + flag.Parse() + + if *spec == "" { + log.Fatal("spec file is required") + } + + opts := server.Options{ + Port: *port, + Dynamic: *dynamic, + IgnoreExamples: *ignoreExamples, + FillProperties: *fillProps, + Watch: *watch, + } + + s, err := server.New(*spec, opts) + if err != nil { + log.Fatal(err) + } + + if err := s.Start(); err != nil { + log.Fatal(err) + } +} diff --git a/go-prism/server/server.go b/go-prism/server/server.go new file mode 100644 index 000000000..a0bb5229f --- /dev/null +++ b/go-prism/server/server.go @@ -0,0 +1,361 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/fsnotify/fsnotify" + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + legacyrouter "github.com/getkin/kin-openapi/routers/legacy" +) + +// Options defines server configuration +type Options struct { + Port int + Dynamic bool + IgnoreExamples bool + FillProperties bool + Watch bool +} + +// Server represents mock server +type Server struct { + specPath string + opts Options + + mu sync.RWMutex + doc *openapi3.T + rtr routers.Router + srv *http.Server + stop chan struct{} +} + +// New creates server instance +func New(spec string, opts Options) (*Server, error) { + s := &Server{specPath: spec, opts: opts, stop: make(chan struct{})} + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +func (s *Server) load() error { + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile(s.specPath) + if err != nil { + return err + } + if err := doc.Validate(context.Background()); err != nil { + return err + } + + if ext, ok := doc.Extensions["x-json-schema-faker"]; ok { + if m, ok := ext.(map[string]any); ok { + if fill, ok := m["fillProperties"].(bool); ok { + s.opts.FillProperties = fill + } + } + } + rtr, err := legacyrouter.NewRouter(doc) + if err != nil { + return err + } + + s.mu.Lock() + s.doc = doc + s.rtr = rtr + s.mu.Unlock() + return nil +} + +// Start runs the http server +func (s *Server) Start() error { + mux := http.NewServeMux() + mux.HandleFunc("/", s.handle) + s.srv = &http.Server{Addr: fmt.Sprintf(":%d", s.opts.Port), Handler: mux} + + if s.opts.Watch { + go s.watch() + } + + go func() { + if err := s.srv.ListenAndServe(); err != http.ErrServerClosed { + log.Printf("server error: %v", err) + } + }() + + <-s.stop + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return s.srv.Shutdown(ctx) +} + +func (s *Server) watch() { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("watcher error: %v", err) + return + } + defer watcher.Close() + + file := s.specPath + dir := filepath.Dir(file) + watcher.Add(dir) + + for { + select { + case ev := <-watcher.Events: + if ev.Op&(fsnotify.Write|fsnotify.Create) != 0 && filepath.Base(ev.Name) == filepath.Base(file) { + log.Println("reloading spec") + if err := s.load(); err != nil { + log.Printf("reload error: %v", err) + } + } + case err := <-watcher.Errors: + log.Printf("watcher error: %v", err) + case <-s.stop: + return + } + } +} + +func (s *Server) handle(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + rtr := s.rtr + s.mu.RUnlock() + + route, pathParams, err := rtr.FindRoute(r) + if err != nil { + http.NotFound(w, r) + return + } + + input := &openapi3filter.RequestValidationInput{ + Request: r, + PathParams: pathParams, + Route: route, + } + if err := openapi3filter.ValidateRequest(r.Context(), input); err != nil { + code := http.StatusUnprocessableEntity + if route.Operation.Responses.Status(code) == nil && route.Operation.Responses.Status(http.StatusBadRequest) != nil { + code = http.StatusBadRequest + } + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + status, body := s.generate(route.Operation, r) + if route.Operation.Deprecated { + w.Header().Set("Deprecation", "true") + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body != nil { + _ = json.NewEncoder(w).Encode(body) + } +} + +func (s *Server) generate(op *openapi3.Operation, r *http.Request) (int, any) { + prefer := parsePrefer(r.Header.Get("Prefer")) + + dynamic := s.opts.Dynamic + if val, ok := prefer["dynamic"]; ok { + dynamic = val == "true" + } + + code := 200 + if val, ok := prefer["code"]; ok { + if c, err := strconv.Atoi(val); err == nil { + code = c + } + } + + var resp *openapi3.Response + if res := op.Responses.Status(code); res != nil { + resp = res.Value + } else { + for k, v := range op.Responses.Map() { + if i, err := strconv.Atoi(k); err == nil { + code = i + } + resp = v.Value + break + } + } + if resp == nil { + return http.StatusNotImplemented, nil + } + + mt := negotiate(resp.Content, r.Header.Get("Accept")) + if mt == nil { + return code, nil + } + + // choose example + if !dynamic && !s.opts.IgnoreExamples { + if key, ok := prefer["example"]; ok { + if ex, ok := mt.Examples[key]; ok { + return code, ex.Value.Value + } + } + if mt.Example != nil { + return code, mt.Example + } + for _, ex := range mt.Examples { + return code, ex.Value.Value + } + } + + return code, generateFromSchema(mt.Schema, dynamic, s.opts.FillProperties) +} + +func negotiate(content openapi3.Content, accept string) *openapi3.MediaType { + if mt, ok := content[accept]; ok { + return mt + } + if mt, ok := content["application/json"]; ok { + return mt + } + for _, mt := range content { + return mt + } + return nil +} + +type schemaOpts struct { + dynamic bool + fill bool +} + +func generateFromSchema(ref *openapi3.SchemaRef, dynamic bool, fill bool) any { + if ref == nil || ref.Value == nil { + return nil + } + s := ref.Value + + if dynamic { + if faker, ok := s.Extensions["x-faker"]; ok { + switch v := faker.(type) { + case string: + if val, err := callFaker(v, nil); err == nil { + return val + } + case map[string]any: + for name, arg := range v { + if val, err := callFaker(name, arg); err == nil { + return val + } + } + } + } + } + + if !dynamic && s.Example != nil { + return s.Example + } + if !dynamic && s.Default != nil { + return s.Default + } + + var t string + if s.Type != nil && len(*s.Type) > 0 { + t = (*s.Type)[0] + } + + switch t { + case openapi3.TypeString: + switch s.Format { + case "email": + return gofakeit.Email() + case "uuid": + return gofakeit.UUID() + default: + return gofakeit.Word() + } + case openapi3.TypeInteger: + return gofakeit.Int64() + case openapi3.TypeNumber: + return gofakeit.Float64() + case openapi3.TypeBoolean: + return gofakeit.Bool() + case openapi3.TypeArray: + if s.Items != nil { + return []any{generateFromSchema(s.Items, dynamic, fill)} + } + return []any{} + case openapi3.TypeObject: + obj := map[string]any{} + for name, prop := range s.Properties { + required := false + for _, r := range s.Required { + if r == name { + required = true + break + } + } + if !fill && !required { + continue + } + obj[name] = generateFromSchema(prop, dynamic, fill) + } + return obj + default: + return nil + } +} + +func callFaker(name string, arg any) (any, error) { + lookup := strings.ReplaceAll(strings.ToLower(name), ".", "") + info := gofakeit.GetFuncLookup(lookup) + if info == nil { + return nil, errors.New("unknown faker") + } + params := gofakeit.MapParams{} + switch v := arg.(type) { + case map[string]any: + for k, val := range v { + params.Add(k, fmt.Sprintf("%v", val)) + } + case []any: + for i, val := range v { + params.Add(fmt.Sprintf("%d", i), fmt.Sprintf("%v", val)) + } + case string: + params.Add("0", v) + case nil: + default: + params.Add("0", fmt.Sprintf("%v", v)) + } + faker := gofakeit.New(0) + val, err := info.Generate(faker.Rand, ¶ms, info) + if err != nil { + return nil, err + } + return val, nil +} + +func parsePrefer(header string) map[string]string { + res := map[string]string{} + parts := strings.Split(header, ",") + for _, p := range parts { + kv := strings.SplitN(strings.TrimSpace(p), "=", 2) + if len(kv) == 2 { + res[strings.ToLower(kv[0])] = strings.Trim(kv[1], "\"") + } + } + return res +}