├── card-view.jpg ├── struc2frm.jpg ├── .gitignore ├── dev-server ├── static │ └── favicon.ico └── main.go ├── coverage.bat ├── go.mod ├── validator-interface.go ├── go.sum ├── pre-commit ├── .github └── workflows │ └── codecov.yml ├── tpl-main.html ├── .travis.yml ├── LICENSE ├── handler-card.go ├── handler-file-upload.go ├── form-token.go ├── funcs_test.go ├── csv.go ├── handler-file-upload_test.go ├── card_test.go ├── card.go ├── default.css ├── static.go ├── handler-form_test.go ├── handler-form.go ├── README.md └── struc2frm.go /card-view.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbberlin/struc2frm/HEAD/card-view.jpg -------------------------------------------------------------------------------- /struc2frm.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbberlin/struc2frm/HEAD/struc2frm.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | 5 | # Test files 6 | tmp* -------------------------------------------------------------------------------- /dev-server/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbberlin/struc2frm/HEAD/dev-server/static/favicon.ico -------------------------------------------------------------------------------- /coverage.bat: -------------------------------------------------------------------------------- 1 | REM go get golang.org/x/tools/cmd/cover 2 | go test -coverprofile tmp-coverage.out github.com/pbberlin/struc2frm 3 | go tool cover -html=tmp-coverage.out -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pbberlin/struc2frm 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/go-playground/form v3.1.4+incompatible 7 | github.com/pkg/errors v0.9.1 8 | gopkg.in/go-playground/assert.v1 v1.2.1 // indirect 9 | ) 10 | -------------------------------------------------------------------------------- /validator-interface.go: -------------------------------------------------------------------------------- 1 | package struc2frm 2 | 3 | // Validator interface is non mandatory helper interface for form structs; 4 | // it returns error messages suitable for s2f.AddErrors; 5 | // a valid form struct enables further processing; 6 | type Validator interface { 7 | Validate() (map[string]string, bool) 8 | } 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-playground/form v3.1.4+incompatible h1:lvKiHVxE2WvzDIoyMnWcjyiBxKt2+uFJyZcPYWsLnjI= 2 | github.com/go-playground/form v3.1.4+incompatible/go.mod h1:lhcKXfTuhRtIZCIKUeJ0b5F207aeQCPbZU09ScKjwWg= 3 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 4 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 5 | gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= 6 | gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= 7 | -------------------------------------------------------------------------------- /pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # since appengine does not find static files 4 | # in packages, we create this backup upon each git commit 5 | 6 | echo "package struc2frm" > static.go 7 | echo '' >> static.go 8 | 9 | 10 | echo -n "const staticTplMainHTML = \`" >> static.go 11 | cat tpl-main.html >> static.go 12 | echo "\`" >> static.go 13 | echo '' >> static.go 14 | 15 | 16 | echo -n "const staticDefaultCSS = \`" >> static.go 17 | cat default.css >> static.go 18 | echo "\`" >> static.go 19 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: Test and coverage 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 2 17 | 18 | - uses: actions/setup-go@v2 19 | with: 20 | go-version: 1.17 21 | 22 | - name: Run coverage 23 | run: go test -race -coverprofile=coverage.out -covermode=atomic ./... 24 | 25 | - name: Upload coverage to Codecov 26 | run: bash <(curl -s https://codecov.io/bash) 27 | -------------------------------------------------------------------------------- /tpl-main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 |%v", err, indentedDump(req.Form))) 35 | log.Printf("cannot decode multipart form: %v
%v", err, indentedDump(req.Form)) 36 | } 37 | 38 | bts, excelFileName, err := ExtractUploadedFile(req) 39 | if err != nil { 40 | s2f.AddError("global", fmt.Sprintf("Cannot extract file from POST form: %v
%v", err, indentedDump(req.Form))) 100 | log.Printf("cannot decode form: %v
%v", err, indentedDump(req.Form)) 101 | } 102 | 103 | // init values - multiple 104 | if !populated { 105 | if len(frm.Items2) == 0 { 106 | frm.Items2 = []string{"berta", "dora"} 107 | } 108 | } 109 | 110 | if req.Form.Get("debug") != "" { 111 | fmt.Fprintf(w, "
%v", indentedDump(req.Form)) 112 | fmt.Fprintf(w, "
%v", indentedDump(frm)) 113 | } 114 | 115 | dept := req.FormValue("department") 116 | if dept == "" { 117 | dept = s2f.DefaultOptionKey("department") 118 | } 119 | frm.Items = strings.Join(itemGroups[dept], "\n") 120 | 121 | errs, valid := frm.Validate() 122 | 123 | // 124 | // business logic: reshuffling... 125 | bins := [][]string{} 126 | binsF := "" // formatted as html 127 | 128 | if populated { 129 | 130 | if !valid { 131 | s2f.AddErrors(errs) // add errors only for a populated form 132 | } else { 133 | // further processing 134 | // see below 135 | } 136 | 137 | salt1 := req.FormValue("hashkey") 138 | salt2 := "dudoedeldu" 139 | 140 | num, _ := strconv.Atoi(req.FormValue("groups")) 141 | items := strings.Split(req.FormValue("items"), "\n") 142 | for i := 0; i < len(items); i++ { 143 | items[i] = strings.TrimSpace(items[i]) 144 | } 145 | 146 | itemMp := map[string]string{} 147 | keys := []string{} 148 | 149 | hasher := crypto.MD5.New() 150 | for _, item := range items { 151 | hasher.Write([]byte(item + salt1 + salt2)) 152 | key := string(hasher.Sum(nil)) 153 | itemMp[key] = item 154 | keys = append(keys, key) 155 | } 156 | sort.Strings(keys) 157 | items = make([]string, 0, len(items)) 158 | for _, key := range keys { 159 | items = append(items, itemMp[key]) 160 | } 161 | 162 | bins = make([][]string, num) 163 | 164 | for itemCounter, item := range items { 165 | binID := itemCounter % num 166 | bins[binID] = append(bins[binID], item) 167 | } 168 | 169 | for i := 0; i < len(bins); i++ { 170 | binsF += "
29 |
30 | * Fully functional example-webserver in directory `systemtest`;
31 | compile and run, then
32 | [Main example](http://localhost:8085/)
33 | [File upload example](http://localhost:8085/file-upload)
34 |
35 | ## Example use
36 |
37 | ```golang
38 | type entryForm struct {
39 | Department string `json:"department,omitempty" form:"subtype='select',accesskey='p',onchange='true',label='Department/Abteilung',title='loading items'"`
40 | Separator01 string `json:"separator01,omitempty" form:"subtype='separator'"`
41 | HashKey string `json:"hashkey,omitempty" form:"maxlength='16',size='16',autocapitalize='off',suffix='salt, changes randomness'"` // the , instead of , prevents wrong parsing
42 | Groups int `json:"groups,omitempty" form:"min=1,max='100',maxlength='3',size='3'"`
43 | Items string `json:"items,omitempty" form:"subtype='textarea',cols='22',rows='4',maxlength='4000',label='Textarea of%v", err, indentedDump(r.Form))) 94 | log.Printf("cannot decode form: %v
%v", err, indentedDump(r.Form)) 95 | } 96 | 97 | // init values - multiple 98 | if !populated { 99 | if len(frm.Items2) == 0 { 100 | frm.Items2 = []string{"berta", "dora"} 101 | } 102 | } 103 | 104 | errs, valid := frm.Validate() 105 | 106 | if populated { 107 | if !valid { 108 | s2f.AddErrors(errs) // add errors only for a populated form 109 | } else { 110 | // further processing with valid form data 111 | } 112 | } 113 | 114 | if !valid { 115 | // render to HTML for user input / error correction 116 | fmt.Fprint(w, s2f.Form(frm)) 117 | } 118 | 119 | 120 | ``` 121 | 122 | ## Global options 123 | 124 | * `ShowHeadline` - show a headline derived from struct name; default `false`. 125 | 126 | * `FormTag` - suppress the surrounding `` if you want to compose a form from multiple structs. 127 | 128 | * `Name` - form name attribute; default `frmMain` 129 | 130 | * `Action` - HTML form action URL; default is empty string 131 | 132 | * `Method` - GET or POST; default `POST` 133 | 134 | * `Salt` and `FormTimeout` - parameters to generate CSRF token 135 | 136 | * `FocusFirstError` - focus on inputs with errors; default `true` 137 | 138 | * `ForceSubmit` - show submit button despite `onchange=form.submit()`; default `false` 139 | 140 | * `Indent`, `IndentAddenum`, `VerticalSpacer` - change indentation in `px`; vertical spacing in `rem` 141 | 142 | * `CSS` - default CSS classes for reasonable appearance. 143 | Incorporate similar rules into your application style sheet, 144 | and set to empty string. 145 | 146 | ## Attributes for field types 147 | 148 | * Use `float64` or `int` to create number inputs - with attributes `min=1,max=100,step=2`. 149 | Notice that `step=2` defines maximum precision; uneven numbers become invalid. 150 | This is an [HTML5 restriction](https://stackoverflow.com/questions/14365348/). 151 | 152 | * `string` supports attribute `placeholder='2006/01/02 15:04'` to show a pattern to the user (placeholder). 153 | 154 | * `string` supports attribute `pattern='[0-9\\.\\-/]{10}'` to restrict the entry to a regular expression. 155 | 156 | * Use attributes `maxlength='16'` and `size='16'` 157 | determine width and maximum content length respectively for `input` and `textarea`. 158 | Attribute `size` determines height for select/dropdown elements. 159 | 160 | * Use `string` field with subtype `textarea` and attributes `cols='32',rows='22'` 161 | 162 | * Use `string` field with subtype `date` and attributes `min='1989-10-29'` or `max=...` 163 | 164 | * Use `string` field with subtype `time` 165 | 166 | * Use `bool` to create a checkbox 167 | 168 | ### Separator and fieldset 169 | 170 | These are `dummmy` fields for formatting only 171 | 172 | * Every `string` field with subtype `separator` is rendered into a horizontal line 173 | * If the struct tag `form` has as `label`, then its contents are rendered. 174 | Serving as static text paragraph. 175 | 176 | * Every `string` field with subtype `fieldset` is rendered into grouping box with label 177 | 178 | ### Select / dropdown inputs 179 | 180 | * Use `string | int | float64 | bool` field with subtype `select` 181 | 182 | * Use `size=1` or `size=5` to determine the height 183 | 184 | * Use `SetOptions()` to fill input[select] elements 185 | 186 | * Use `DefaultOptionKey()` to read the pre-selected option on clean forms 187 | 188 | * Use `onchange='true'` for onchange submit 189 | 190 | ### Radiogroup 191 | 192 | Like [select / dropdown](#select--dropdown-inputs), 193 | but rendered as radio inputs. 194 | 195 | ### Select multiple 196 | 197 | * Use subtype `select` with `multiple='true'` to enable the selection of __multiple items__ 198 | in conjunction with struct field type `[]string | []int | []float64 | []bool` 199 | 200 | * Use `wildcardselect='true'` to show an additional input after the select, 201 | accepting wildcard expressions with `*` for selecting options from the select. 202 | * Wildcard expressions are case sensitive. 203 | * Multiple wildcard expressions can be chained using `;`. 204 | * Multiple expressions are applied successively additively. 205 | * Any wildcard expression can be negated by `!` prefixing, resulting in _unselect_. 206 | * Example `Car*;Bike*;!Carsharing`. 207 | * To debug, open the Javascript console of your browser and type `wildcardselectDebug = true;` 208 | 209 | * Parsing of HTTP request into form struct for `multiple` fields 210 | is __additive__. 211 | => Init values should not be set before parsing but afterwards. 212 | 213 | ```golang 214 | // ... 215 | populated, err := Decode(req, &frm) 216 | // ... 217 | 218 | if len(frm.Items2) == 0 { 219 | frm.Items2 = []string{"berta", "dora"} // setting defaults if request parsing did not yield any user input 220 | } 221 | ``` 222 | 223 | ## Submit button 224 | 225 | If your form only has `select` inputs with `onchange='this.form.submit()'` 226 | then no submit button is shown. 227 | 228 | This can be overridden by setting `struc2frm.New().ShowSubmit` to true. 229 | 230 | ## General field attributes 231 | 232 | * Use `form:"-"` to exclude fields from being rendered 233 | neither in form view nor in card view 234 | 235 | * Every field can have an attribute `label=...`, 236 | appearing before the input element, 237 | if not specified, json:"[name]..." is labelized and used 238 | * `label-style` can specify an individual CSS styles for the label tag 239 | 240 | * Every field can have an attribute `suffix=...`, 241 | appearing after the input element 242 | 243 | * Every field can have an attribute `title=...` 244 | for mouse-over tooltips 245 | 246 | * Values inside of `label='...'`, `suffix='...'`, `title='...'`, `placeholder='...'`, `pattern='...'` 247 | need `,` instead of `,` 248 | 249 | * Every field can have an attribute `accesskey='[a-z]'` 250 | Accesskeys are not put into the label, but into the input tag 251 | 252 | * Every field can have an attribute `nobreak='true'` 253 | so that the next input remains on the same line 254 | 255 | * Every field can have an attribute `autofocus='true'` 256 | setting the keyboard focus to this input. 257 | Use this only once per form. 258 | `autofocus='true'` is overwritten by `FocusFirstError==true`; see below. 259 | 260 | ### Field attributes for mobile phones 261 | 262 | * `inputmode="numeric"` opens the numbers keyboard on mobile phones 263 | 264 | * `autocapitalize=off` switches off first letter upper casing 265 | 266 | ## Validation and errors 267 | 268 | The `Validator` interface is non mandatory helper interface for form structs. 269 | 270 | ```golang 271 | type Validator interface { 272 | Validate() (map[string]string, bool) 273 | } 274 | ``` 275 | 276 | It returns error messages suitable for `s2f.AddErrors()`. 277 | 278 | ```golang 279 | if populated { 280 | errs, valid := frm.Validate() 281 | if !valid { 282 | s2f.AddErrors(errs) // add errors only for a populated form 283 | // render to HTML for user input / error correction 284 | fmt.Fprint(w, s2f.Form(frm)) 285 | ``` 286 | 287 | A _valid_ form struct enables further processing. 288 | 289 | ```golang 290 | } else { 291 | // further processing 292 | } 293 | ``` 294 | 295 | * Keep `FocusFirstError=true` to focus the first input having an error message. 296 | 297 | * This overrides `autofocus='true'`. 298 | 299 | ## File upload 300 | 301 | * input[file] must have golang type `[]byte` 302 | 303 | * input[file] should be named `upload` 304 | and _requires_ `ParseMultipartForm()` instead of `ParseForm()` 305 | 306 | * `DecodeMultipartForm()` and `ExtractUploadedFile()` are helper funcs 307 | to extract file upload data 308 | 309 | Example 310 | 311 | ```golang 312 | 313 | type entryForm struct { 314 | TextField string `json:"text_field,omitempty" form:"maxlength='16',size='16'"` 315 | // Requires distinct way of form parsing 316 | Upload []byte `json:"upload,omitempty" form:"accesskey='u',accept='.txt',suffix='*.txt files'"` 317 | } 318 | 319 | s2f := struc2frm.New() // or clone existing one 320 | s2f.ShowHeadline = true // set options 321 | s2f.Indent = 80 322 | 323 | 324 | // init values 325 | frm := entryForm{ 326 | TextField: "some-init-text", 327 | } 328 | 329 | populated, err := DecodeMultipartForm(req, &frm) 330 | if populated && err != nil { 331 | s2f.AddError("global", fmt.Sprintf("cannot decode multipart form: %v
%v", err, indentedDump(req.Form))) 332 | log.Printf("cannot decode multipart form: %v
%v", err, indentedDump(req.Form)) 333 | } 334 | 335 | bts, excelFileName, err := ExtractUploadedFile(req) 336 | if err != nil { 337 | fmt.Fprintf(w, "Cannot extract file from POST form: %v
%v", err, indentedDump(r.Form)) 1081 | } 1082 | return decode(r, ptr2Struct) 1083 | } 1084 | 1085 | // DecodeMultipartForm decodes the form into an instance of struct 1086 | // and checks the token against CSRF attacks (https://en.wikipedia.org/wiki/Cross-site_request_forgery) 1087 | func DecodeMultipartForm(r *http.Request, ptr2Struct interface{}) (populated bool, err error) { 1088 | err = ParseMultipartForm(r) 1089 | if err != nil { 1090 | return false, errors.Wrapf(err, "cannot parse multi part form: %v
%v", err, indentedDump(r.Form)) 1091 | } 1092 | return decode(r, ptr2Struct) 1093 | } 1094 | 1095 | func decode(r *http.Request, ptr2Struct interface{}) (populated bool, err error) { 1096 | 1097 | // 1098 | // check for empty requests 1099 | _, hasToken := r.Form["token"] // missing validation token 1100 | ln := len(r.Form) // request form is empty 1101 | // sm := r.FormValue("btnSubmit") != "" // submit btn would not be present in single dropdown forms with onclick 1102 | if ln > 0 && !hasToken { 1103 | log.Printf("warning: request params ignored, due to missing validation token") 1104 | } 1105 | if ln < 1 || !hasToken { 1106 | return false, nil 1107 | } 1108 | 1109 | err = New().ValidateFormToken(r.Form.Get("token")) 1110 | if err != nil { 1111 | return true, errors.Wrap(err, "form token exists; but invalid") 1112 | } 1113 | 1114 | dec := form.NewDecoder() 1115 | dec.SetTagName("json") 1116 | err = dec.Decode(ptr2Struct, r.Form) 1117 | if err != nil { 1118 | return true, errors.Wrapf(err, "cannot decode form: %v
%v", err, indentedDump(r.Form)) 1119 | } 1120 | 1121 | // this belongs outside of the library into application side 1122 | if false { 1123 | if vldr, ok := ptr2Struct.(Validator); ok { 1124 | _, valid := vldr.Validate() 1125 | if !valid { 1126 | return false, nil 1127 | } 1128 | } 1129 | } 1130 | 1131 | return true, nil 1132 | 1133 | } 1134 | --------------------------------------------------------------------------------