1 package main 2 3 import ( 4 "bytes" 5 "encoding/json" 6 "fmt" 7 "io" 8 "log" 9 "net/http" 10 "os" 11 "path/filepath" 12 "strings" 13 "sync" 14 15 jsonschema "github.com/santhosh-tekuri/jsonschema/v5" 16 ) 17 18 const ( 19 optionsPost = "OPTIONS, POST" 20 optionsGetHead = "OPTIONS, GET, HEAD" 21 ) 22 23 func validID(id string) bool { 24 for _, c := range id { 25 if (c < '0' || c > '9') && (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && c != '_' && c != '-' { 26 return false 27 } 28 } 29 return true 30 } 31 32 func respond(w http.ResponseWriter, code int, format string, a ...interface{}) { 33 w.Header().Set("Content-Type", "application/json") 34 w.WriteHeader(code) 35 fmt.Fprintf(w, format, a...) 36 } 37 38 type Schema struct { 39 Compiler *jsonschema.Compiler 40 Dir string 41 42 mu sync.RWMutex 43 Schema map[string]*jsonschema.Schema 44 } 45 46 func NewSchema(dir string) (*Schema, error) { 47 if err := os.MkdirAll(dir, 0o755); err != nil { 48 return nil, fmt.Errorf("error creating schema directory: %w", err) 49 } 50 schemaDir, err := os.ReadDir(dir) 51 if err != nil { 52 return nil, fmt.Errorf("error reading schema directory: %w", err) 53 } 54 c := jsonschema.NewCompiler() 55 m := make(map[string]*jsonschema.Schema) 56 for _, file := range schemaDir { 57 name := file.Name() 58 schemapath := filepath.Join(dir, name) 59 f, err := os.Open(schemapath) 60 if err != nil { 61 return nil, fmt.Errorf("error reading schema file (%s): %w", schemapath, err) 62 } 63 url := "schema:///" + name 64 if err := c.AddResource(url, f); err != nil { 65 return nil, fmt.Errorf("error adding scheme as resource: %w", err) 66 } 67 s, err := c.Compile(url) 68 if err != nil { 69 return nil, fmt.Errorf("error compiling schema: %w", err) 70 } 71 f.Close() 72 m[name] = s 73 } 74 return &Schema{ 75 Compiler: c, 76 Dir: dir, 77 Schema: m, 78 }, nil 79 } 80 81 func (s *Schema) hasID(id string) bool { 82 s.mu.RLock() 83 _, ok := s.Schema[id] 84 s.mu.RUnlock() 85 return ok 86 } 87 88 func (s *Schema) ServeHTTP(w http.ResponseWriter, r *http.Request) { 89 if strings.HasPrefix(r.URL.Path, "/schema/") { 90 s.handleSchema(w, r) 91 } else if strings.HasPrefix(r.URL.Path, "/validate/") { 92 s.handleValidate(w, r) 93 } else { 94 respond(w, http.StatusNotFound, `{"status": "error", "message": Unknown Endpoint" }`) 95 } 96 } 97 98 func (s *Schema) handleSchema(w http.ResponseWriter, r *http.Request) { 99 id := strings.TrimPrefix(r.URL.Path, "/schema/") 100 if !validID(id) { 101 respond(w, http.StatusBadRequest, `{"action": "uploadSchema", "id": %q, "status": "error", "message": "Invalid ID"}`, id) 102 return 103 } 104 switch r.Method { 105 case http.MethodGet, http.MethodHead: 106 s.serveSchema(w, r, id) 107 case http.MethodPost: 108 s.uploadSchema(w, r, id) 109 default: 110 if s.hasID(id) { 111 w.Header().Add("Allow", optionsGetHead) 112 } else { 113 w.Header().Add("Allow", optionsPost) 114 } 115 if r.Method == http.MethodOptions { 116 w.WriteHeader(http.StatusNoContent) 117 } else { 118 respond(w, http.StatusMethodNotAllowed, `{"action": "uploadSchema", "id": %q, "status": "error", "message": "Method Not Allowed"}`, id) 119 } 120 } 121 } 122 123 func (s *Schema) handleValidate(w http.ResponseWriter, r *http.Request) { 124 id := strings.TrimPrefix(r.URL.Path, "/validate/") 125 if !validID(id) { 126 respond(w, http.StatusBadRequest, `{"action": "validateDocument", "id": %q, "status": "error", "message": "Invalid ID"}`, id) 127 return 128 } 129 if s.hasID(id) { 130 switch r.Method { 131 case http.MethodPost: 132 s.validateJSON(w, r, id) 133 case http.MethodOptions: 134 w.Header().Add("Allow", optionsPost) 135 w.WriteHeader(http.StatusNoContent) 136 default: 137 w.Header().Add("Allow", optionsPost) 138 respond(w, http.StatusMethodNotAllowed, `{"action": "validateDocument", "id": %q, "status": "error", "message": "Method Not Allowed"}`, id) 139 } 140 } else { 141 respond(w, http.StatusNotFound, `{"action": "validateDocument", "id": %q, "status": "error", "message": "Unknown ID"}`, id) 142 } 143 } 144 145 func (s *Schema) serveSchema(w http.ResponseWriter, r *http.Request, id string) { 146 if s.hasID(id) { 147 w.Header().Set("Content-Type", "application/schema+json") 148 http.ServeFile(w, r, filepath.Join(s.Dir, id)) 149 } else { 150 respond(w, http.StatusMethodNotAllowed, `{"action": "uploadSchema", "id": %q, "status": "error", "message": "Method Not Allowed"}`, id) 151 } 152 } 153 154 func (s *Schema) uploadSchema(w http.ResponseWriter, r *http.Request, id string) { 155 s.mu.Lock() 156 defer s.mu.Unlock() 157 if _, ok := s.Schema[id]; ok { 158 w.Header().Add("Allow", optionsGetHead) 159 respond(w, http.StatusMethodNotAllowed, `{"action": "uploadSchema", "id": %q, "status": "error", "message": "Method Not Allowed"}`, id) 160 return 161 } 162 var b bytes.Buffer 163 io.Copy(&b, r.Body) 164 data := b.Bytes() 165 url := "schema:///" + id 166 if err := s.Compiler.AddResource(url, &b); err != nil { 167 respond(w, http.StatusBadRequest, `{"action": "uploadSchema", "id": %q, "status": "error", "message": "Invalid JSON"}`, id) 168 return 169 } 170 cs, err := s.Compiler.Compile(url) 171 if err != nil { 172 respond(w, http.StatusBadRequest, `{"action": "uploadSchema", "id": %q, "status": "error", "message": %q}`, id, err) 173 return 174 } 175 f, err := os.Create(filepath.Join(s.Dir, id)) 176 if err == nil { 177 if _, err = f.Write(data); err == nil { 178 if err = f.Close(); err == nil { 179 s.Schema[id] = cs 180 respond(w, http.StatusCreated, `{"action": "uploadSchema", "id": %q, "status": "success"}`, id) 181 return 182 } 183 } 184 } 185 respond(w, http.StatusInternalServerError, `{"action": "uploadSchema", "id": %q, "status": "error", "message": "Unexpected Error"}`, id) 186 log.Printf("error saving schema: %s", err) 187 } 188 189 func (s *Schema) validateJSON(w http.ResponseWriter, r *http.Request, id string) { 190 s.mu.RLock() 191 schema := s.Schema[id] 192 s.mu.RUnlock() 193 dec := json.NewDecoder(r.Body) 194 dec.UseNumber() 195 var v interface{} 196 if err := dec.Decode(&v); err != nil { 197 respond(w, http.StatusBadRequest, `{"action": "validateDocument", "id": %q, "status": "error", "message": "Invalid JSON"}`, id) 198 return 199 } 200 removeNulls(v) 201 if err := schema.Validate(v); err != nil { 202 respond(w, http.StatusOK, `{"action": "validateDocument", "id": %q, "status": "error", "message": %q}`, id, err) 203 } else { 204 respond(w, http.StatusOK, `{"action": "validateDocument", "id": %q, "status": "success"}`, id) 205 } 206 } 207 208 func removeNulls(v interface{}) { 209 if obj, ok := v.(map[string]interface{}); ok { 210 for key, value := range obj { 211 if value == nil { 212 delete(obj, key) 213 } else { 214 removeNulls(value) 215 } 216 } 217 } 218 } 219