github.com/gocaveman/caveman@v0.0.0-20191211162744-0ddf99dbdf6e/gen/ctrl-api-crud.go (about) 1 package gen 2 3 import ( 4 "fmt" 5 "path/filepath" 6 "strings" 7 8 "github.com/spf13/pflag" 9 ) 10 11 // basic crud 12 13 // advanced find methods for specific fields (do we do this by 14 // calling store.FindByFirstName or store.Find("first_name", ...)) 15 // should do something with LIKE vs exact field match 16 17 // paging 18 19 // basic detail view page load - so when we generate the view it doesn't 20 // need a separate handler to loads its data - the common case of a single 21 // record by ID is already handled here 22 23 // update mulitple records with one PATCH/PUT call, so you can e.g. update a bunch of sequence numbers at once 24 // See httpapi and fill stuff to properly implement PATCH 25 26 // i18n for validation (and other?) error messages 27 28 // callback methods, with interface and New method checks to see if we implement it - 29 // looks like this is changing to having a ...Router and a ...Controller and a Default...Controller 30 31 // handlerregistry integration 32 33 // optional (but default) permissions code (crud perms at top, registered, and checks in code) 34 35 // prefix for api calls 36 37 // prefix/path for page data (will need the renderer stuff for this to work as expected) 38 // what about login check for other pages (listing page), even though they don't necessarily 39 // require data loading... 40 41 func init() { 42 globalMapGenerator["ctrl-api-crud"] = GeneratorFunc(func(s *Settings, name string, args ...string) error { 43 44 fset := pflag.NewFlagSet("gen", pflag.ContinueOnError) 45 storeType := fset.String("store", "", "The type of the store to use for data access (defaults to '*store.Store' or '*Store', depending on package).") 46 modelName := fset.String("model", "", "The model object name, if not specified default will be deduced from file name.") 47 // TODO: responder code should be an option - it's a fair amount of cruft and people shouldn't 48 // be forced to have it if they don't need it; default to off 49 // TODO: option for permission stuff - default to on 50 renderer := fset.Bool("renderer", true, "Output renderer integration for view/edit page.") 51 tests := fset.Bool("tests", true, "Create test file with test(s) for this controller.") 52 targetFile, data, err := ParsePFlagsAndOneFile(s, fset, args) 53 if err != nil { 54 return err 55 } 56 57 _, targetFileName := filepath.Split(targetFile) 58 59 if *modelName == "" { 60 *modelName = NameSnakeToCamel(targetFileName, []string{"ctrl-"}, []string{"-api.go", ".go"}) 61 } 62 data["ModelName"] = *modelName 63 // FIXME: this breaks on JSONThing -> jSONThing 64 data["ModelNameL"] = strings.ToLower((*modelName)[:1]) + (*modelName)[1:] 65 66 data["ModelPathPart"] = strings.TrimPrefix( 67 strings.TrimSuffix(strings.TrimSuffix(targetFileName, ".go"), "-api"), 68 "ctrl-") 69 70 if *storeType == "" { 71 if data["PackageName"].(string) == "main" { 72 *storeType = "*Store" 73 data["ModelTypeName"] = *modelName 74 } else { 75 *storeType = "*store.Store" 76 data["ModelTypeName"] = "store." + *modelName 77 } 78 } 79 data["StoreType"] = *storeType 80 81 data["Renderer"] = *renderer 82 data["Tests"] = *tests 83 84 err = OutputGoSrcTemplate(s, data, targetFile, ` 85 package {{.PackageName}} 86 87 import ( 88 "net/http" 89 ) 90 91 const ( 92 {{.ModelName}}CreatePerm = "{{.ModelName}}.Create" 93 {{.ModelName}}FetchPerm = "{{.ModelName}}.Fetch" 94 {{.ModelName}}SearchPerm = "{{.ModelName}}.Search" 95 {{.ModelName}}UpdatePerm = "{{.ModelName}}.Update" 96 {{.ModelName}}DeletePerm = "{{.ModelName}}.Delete" 97 98 {{/* TODO: figure out if we should provide some variations as options 99 {{.ModelName}}CreateAnyPerm = "{{.ModelName}}.CreateAny" 100 {{.ModelName}}FetchAnyPerm = "{{.ModelName}}.FetchAny" 101 {{.ModelName}}SearchAnyPerm = "{{.ModelName}}.SearchAny" 102 {{.ModelName}}UpdateAnyPerm = "{{.ModelName}}.UpdateAny" 103 {{.ModelName}}DeleteAnyPerm = "{{.ModelName}}.DeleteAny" 104 */}} 105 ) 106 107 func init() { 108 permregistry.MustAddPerm("admin", {{.ModelName}}CreatePerm) 109 permregistry.MustAddPerm("admin", {{.ModelName}}FetchPerm) 110 permregistry.MustAddPerm("admin", {{.ModelName}}SearchPerm) 111 permregistry.MustAddPerm("admin", {{.ModelName}}UpdatePerm) 112 permregistry.MustAddPerm("admin", {{.ModelName}}DeletePerm) 113 } 114 115 type {{.ModelName}}APIRouter struct { 116 APIPrefix string {{bq "autowire:\"api_prefix,optional\""}} // default: "/api" {{/* TODO: naming convention? */}} 117 ModelPrefix string // default: "/{{.ModelPathPart}}" 118 Controller *{{.ModelName}}APIController 119 120 {{/* 121 TODO: Renderer and path for data loading on detail page. 122 Hm, need to figure out if the data loading for detail page, 123 etc should be done here or in a different handler. Or do we 124 just assume people won't need to do this... not sure, different 125 schools of thought on that 126 */}} 127 } 128 129 type {{.ModelName}}APIController struct { 130 Store {{.StoreType}} {{bq "autowire:\"\""}} 131 } 132 133 func (h *{{.ModelName}}APIRouter) AfterWire() error { 134 if h.APIPrefix == "" { 135 h.APIPrefix = "/api" 136 } 137 if h.ModelPrefix == "" { 138 h.ModelPrefix = "/{{.ModelPathPart}}" 139 } 140 return nil 141 } 142 143 // search{{.ModelName}}Params is the criteria for a search, 144 // corresponding to URL parameters 145 type search{{.ModelName}}Params struct { 146 Criteria tmetautil.Criteria {{bq "json:\"criteria\""}} 147 OrderBy tmetautil.OrderByList {{bq "json:\"order_by\""}} 148 Limit int64 {{bq "json:\"limit\""}} 149 Offset int64 {{bq "json:\"offset\""}} 150 Related []string {{bq "json:\"related\""}} 151 ReturnCount bool {{bq "json:\"return_count\""}} 152 } 153 154 func (h *{{.ModelName}}APIRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { 155 156 var {{.ModelNameL}} {{.ModelTypeName}} 157 var {{.ModelNameL}}ID string 158 var mapData map[string]interface{} 159 160 ar := httpapi.NewRequest(r) 161 162 searchParams := search{{.ModelName}}Params { 163 Limit: 100, 164 } 165 var err error 166 167 switch { 168 169 /** 170 * @api {post} /api/{{.ModelPathPart}} Create {{.ModelName}} 171 * @apiGroup {{.ModelName}} 172 * @apiName create-{{.ModelPathPart}} 173 * @apiDescription Create a {{.ModelName}} 174 * 175 * @apiSuccessExample {json} Success-Response: 176 * HTTP/1.1 200 OK 177 * Content-Type: application/json 178 * Location: /api/{{.ModelPathPart}}/6cSAs4i2P3PsHq2s6PZi6V 179 * X-Id: 6cSAs4i2P3PsHq2s6PZi6V 180 * 181 * [{ 182 * // {{.ModelName}} 183 * }] 184 */ 185 case ar.ParseRESTObj("POST", &{{.ModelNameL}}, h.APIPrefix+h.ModelPrefix): 186 err = h.Controller.Create(w, r, ar, &{{.ModelNameL}}) 187 188 /** 189 * @api {get} /api/{{.ModelPathPart}} Search {{plural .ModelName}} 190 * @apiGroup {{.ModelName}} 191 * @apiName search-{{.ModelPathPart}} 192 * @apiDescription List {{plural .ModelName}} 193 * 194 * @apiSuccessExample {json} Success-Response: 195 * HTTP/1.1 200 OK 196 * Content-Type: application/json 197 * 198 * [{ 199 * // {{.ModelName}} 200 * }] 201 */ 202 case ar.ParseRESTPath("GET", h.APIPrefix+h.ModelPrefix): 203 err = httpapi.FormUnmarshal(r.URL.Query(), &searchParams) 204 if err != nil { 205 break 206 } 207 err = h.Controller.Search(w, r, ar, searchParams) 208 209 /** 210 * @api {get} /api/{{.ModelPathPart}}/:id Fetch {{.ModelName}} 211 * @apiGroup {{.ModelName}} 212 * @apiName fetch-{{.ModelPathPart}} 213 * @apiDescription Get a {{.ModelName}} with the specified ID. Will return an error 214 * if the object cannot be found. 215 * 216 * @apiParam {String} id ID of the {{.ModelName}} to return 217 * 218 * @apiSuccessExample {json} Success-Response: 219 * HTTP/1.1 200 OK 220 * Content-Type: application/json 221 * 222 * [{ 223 * // {{.ModelName}} 224 * }] 225 */ 226 case ar.ParseRESTPath("GET", h.APIPrefix+h.ModelPrefix+"/%s", &{{.ModelNameL}}ID): 227 err = httpapi.FormUnmarshal(r.URL.Query(), &searchParams) 228 if err != nil { 229 break 230 } 231 err = h.Controller.Fetch(w, r, ar, {{.ModelNameL}}ID, searchParams.Related...) 232 233 /** 234 * @api {patch} /api/{{.ModelPathPart}}/:id Update {{.ModelName}} 235 * @apiGroup {{.ModelName}} 236 * @apiName update-{{.ModelPathPart}} 237 * @apiDescription Update a {{.ModelName}} by ID. Will return an error 238 * if the object cannot be found or validation error occurs. The updated 239 * object will be returned. 240 * 241 * @apiParam {String} id ID of the {{.ModelName}} to update 242 * 243 * @apiSuccessExample {json} Success-Response: 244 * HTTP/1.1 200 OK 245 * Content-Type: application/json 246 * 247 * [{ 248 * // {{.ModelName}} 249 * }] 250 */ 251 case ar.ParseRESTObjPath("PUT", &mapData, h.APIPrefix+h.ModelPrefix+"/%s", &{{.ModelNameL}}ID) || 252 ar.ParseRESTObjPath("PATCH", &mapData, h.APIPrefix+h.ModelPrefix+"/%s", &{{.ModelNameL}}ID): 253 {{.ModelNameL}}.{{.ModelName}}ID = {{.ModelNameL}}ID 254 err = h.Controller.Update(w, r, ar, {{.ModelNameL}}ID, mapData) 255 256 /** 257 * @api {delete} /api/{{.ModelPathPart}}/:id Delete {{.ModelName}} 258 * @apiGroup {{.ModelName}} 259 * @apiName delete-{{.ModelPathPart}} 260 * @apiDescription Delete a {{.ModelName}} by ID. 261 * 262 * @apiParam {String} id ID of the {{.ModelName}} to delete 263 * 264 * @apiSuccessExample {json} Success-Response: 265 * HTTP/1.1 200 OK 266 * Content-Type: application/json 267 * 268 * {"result":true} 269 */ 270 case ar.ParseRESTPath("DELETE", h.APIPrefix+h.ModelPrefix+"/%s", &{{.ModelNameL}}ID): 271 err = h.Controller.Delete(w, r, ar, {{.ModelNameL}}ID) 272 273 } 274 275 if err != nil { 276 ar.WriteErr(w, err) 277 return 278 } 279 280 } 281 282 func (h *{{.ModelName}}APIController) Create(w http.ResponseWriter, r *http.Request, ar *httpapi.APIRequest, {{.ModelNameL}} *{{.ModelTypeName}}) error { 283 284 if !userctrl.ReqUserHasPerm(r, {{.ModelName}}CreatePerm) { 285 return &httpapi.ErrorDetail{Code: 403, Message: "access denied"} 286 } 287 288 // FIXME: we should probably be using httpapi.Fill() here to ensure 289 // consistent behavior with Update() 290 291 err := h.Store.Create{{.ModelName}}(r.Context(), {{.ModelNameL}}) 292 if err != nil { 293 return err 294 } 295 296 w.Header().Set("Location", r.URL.Path + "/"+{{.ModelNameL}}.{{.ModelName}}ID) 297 w.Header().Set("X-Id", {{.ModelNameL}}.{{.ModelName}}ID) 298 ar.WriteResult(w, 201, {{.ModelNameL}}) 299 300 return nil 301 } 302 303 // TODO: we need our fancy find, it should have: 304 // * List of relations to return 305 // * Make sure to set the deadline, and a default and a max limit on the records, offset for paging 306 // * Where conditions expressed as nested struct (probably something that should go in dbutil pkg). 307 // There can also be something on that where struct that allows the Store to easily say 308 // "I need an exact match (optionally or LIKE prefix% with at least N chars) on 309 // at least one of these fields", to enforce that an index is being 310 // used (if desired - which it should be by default). 311 // * What about rate limiting for security purposes, and API usage? (related but not necessarily the same) 312 313 func (h *{{.ModelName}}APIController) Search(w http.ResponseWriter, r *http.Request, ar *httpapi.APIRequest, params search{{.ModelName}}Params) error { 314 315 if !userctrl.ReqUserHasPerm(r, {{.ModelName}}SearchPerm) { 316 return &httpapi.ErrorDetail{Code: 403, Message: "access denied"} 317 } 318 319 // make a separate context with timeout so search doesn't run too long 320 queryCtx, cancel := context.WithTimeout(context.Background(), time.Second*5) 321 defer cancel() 322 323 maxLimit := int64(5000) // never allow more than this many records 324 if params.Limit + params.Offset > maxLimit { 325 return fmt.Errorf("limit plus offset exceeded maximum value") 326 } 327 328 // check all of the field and relation names to prevent SQL injection or other unexpected behavior 329 err := params.Criteria.CheckFieldNames(h.Store.Meta.For({{.ModelTypeName}}{}).SQLFields(true)...) 330 if err != nil { 331 return err 332 } 333 err = params.OrderBy.CheckFieldNames(h.Store.Meta.For({{.ModelTypeName}}{}).SQLFields(true)...) 334 if err != nil { 335 return err 336 } 337 err = h.Store.Meta.For({{.ModelTypeName}}{}).CheckRelationNames(params.Related...) 338 if err != nil { 339 return err 340 } 341 342 ret := make(map[string]interface{}, 3) 343 344 if params.ReturnCount { 345 count, err := h.Store.Search{{.ModelName}}Count(queryCtx, 346 params.Criteria, params.OrderBy, 347 maxLimit) 348 if err != nil { 349 return err 350 } 351 ret["count"] = count 352 } 353 354 resultList, err := h.Store.Search{{.ModelName}}(queryCtx, 355 params.Criteria, params.OrderBy, 356 params.Limit, params.Offset, 357 params.Related...) 358 if err != nil { 359 return err 360 } 361 ret["result_list"] = resultList 362 ret["result_length"] = len(resultList) 363 364 {{/* 365 // TODO: It would be nice to sanely implement paging. I think using 366 // headers to convey paging is a crappy way to go - harder for clients 367 // expecting JSON to handle. The idea of "caller needs 10 on a page 368 // so ask for 11 and if you get it you know there's another page" should 369 // work fine but should be internalized here so that complexity isn't 370 // pushed back to the caller. So maybe it's just limit, offset that gets 371 // passed in and the result {result_list:...:has_more:true} or something. 372 // Do more looking at other APIs and find someone doing it well. 373 // https://www.moesif.com/blog/technical/api-design/REST-API-Design-Filtering-Sorting-and-Pagination/ 374 375 // We also should support pagination based on keys (i.e. "10 next records after X") 376 // so it doesn't choke on large datasets - I can see use cases for both approaches 377 */}} 378 379 {{/* FIXME: so do we return this error or not? if we return it 380 then the caller will double-output the error, if not we just 381 hid the error - maybe we need a log statement specifically 382 for this case, think it through and drop it in */}} 383 384 ar.WriteResult(w, 200, ret) 385 386 return nil 387 } 388 389 func (h *{{.ModelName}}APIController) Fetch(w http.ResponseWriter, r *http.Request, ar *httpapi.APIRequest, {{.ModelNameL}}ID string, related ...string) error { 390 391 if !userctrl.ReqUserHasPerm(r, {{.ModelName}}FetchPerm) { 392 return &httpapi.ErrorDetail{Code: 403, Message: "access denied"} 393 } 394 395 var {{.ModelNameL}} {{.ModelTypeName}} 396 err := h.Store.Fetch{{.ModelName}}(r.Context(), &{{.ModelNameL}}, {{.ModelNameL}}ID, related...) 397 if webutil.IsNotFound(err) { 398 return &httpapi.ErrorDetail{Code: 404, Message: "not found"} 399 } 400 if err != nil { 401 return err 402 } 403 404 // TODO: sometimes return values need to be vetted/scrubbed, both 405 // here and in Search, before being returned, where should this 406 // call go? Might be better actually to ensure at the store 407 // layer we can just avoid loading the fields we don't need... 408 // Although that might be a specific case for certain data types that we 409 // don't want to have in everything by default. 410 411 ar.WriteResult(w, 200, {{.ModelNameL}}) 412 413 return nil 414 } 415 416 func (h *{{.ModelName}}APIController) Update(w http.ResponseWriter, r *http.Request, ar *httpapi.APIRequest, {{.ModelNameL}}ID string, mapData map[string]interface{}) error { 417 418 if !userctrl.ReqUserHasPerm(r, {{.ModelName}}UpdatePerm) { 419 return &httpapi.ErrorDetail{Code: 403, Message: "access denied"} 420 } 421 422 {{/* 423 // FIXME: THIS IS STILL NEEDED - ESPECIALLY THE ERRORS THAT ROLL UP TO THE UI 424 // TODO: Validation! 425 // Tags in model 426 // Check in store calls 427 // Additional validation in controller 428 // Errors that roll all the way up to the UI - look at responses codes, how to indicate what validation went wrong etc 429 // Translatable 430 */}} 431 432 // TODO: add version number check, so we can do optimistic locking all the 433 // way up to the form/UI 434 var {{.ModelNameL}} {{.ModelTypeName}} 435 err := h.Store.Fetch{{.ModelName}}(r.Context(), &{{.ModelNameL}}, {{.ModelNameL}}ID) 436 if err != nil { 437 return err 438 } 439 440 // patch the fillable fields 441 err = httpapi.Fill(&{{.ModelNameL}}, mapData) 442 if err != nil { 443 return err 444 } 445 446 err = h.Store.Update{{.ModelName}}(r.Context(), &{{.ModelNameL}}) 447 if webutil.IsNotFound(err) { 448 return &httpapi.ErrorDetail{Code: 404, Message: "not found"} 449 } 450 if err != nil { 451 return err 452 } 453 454 ar.WriteResult(w, 200, {{.ModelNameL}}) 455 456 return nil 457 } 458 459 func (h *{{.ModelName}}APIController) Delete(w http.ResponseWriter, r *http.Request, ar *httpapi.APIRequest, {{.ModelNameL}}ID string) error { 460 461 if !userctrl.ReqUserHasPerm(r, {{.ModelName}}DeletePerm) { 462 return &httpapi.ErrorDetail{Code: 403, Message: "access denied"} 463 } 464 465 err := h.Store.Delete{{.ModelName}}(r.Context(), &{{.ModelTypeName}}{ {{.ModelName}}ID: {{.ModelNameL}}ID } ) 466 if webutil.IsNotFound(err) { 467 return &httpapi.ErrorDetail{Code: 404, Message: "not found"} 468 } 469 if err != nil { 470 return err 471 } 472 473 ar.WriteResult(w, 200, true) 474 475 return nil 476 } 477 478 `, false) 479 480 if err != nil { 481 return err 482 } 483 484 if *tests { 485 486 testsTargetFile := strings.Replace(targetFile, ".go", "_test.go", 1) 487 if testsTargetFile == targetFile { 488 return fmt.Errorf("unable to determine test file name for %q", targetFile) 489 } 490 491 err = OutputGoSrcTemplate(s, data, testsTargetFile, ` 492 package {{.PackageName}} 493 494 // TODO: write tests 495 496 {{/* 497 func Test{{.ModelName}}CRUD(t *testing.T) { 498 499 assert := assert.New(t) 500 _ = assert 501 502 } 503 */}} 504 505 `, false) 506 if err != nil { 507 return err 508 } 509 510 } 511 512 return nil 513 514 }) 515 }