From 0319045a4ec7c747ca4ee2f75687584b4b811d9c Mon Sep 17 00:00:00 2001 From: limpo1989 Date: Tue, 5 Dec 2023 17:29:36 +0800 Subject: [PATCH 1/2] Add web package for web applications support --- CNAME | 1 - conf/validate.go | 54 ++- conf/validate_test.go | 27 +- go.mod | 5 +- go.sum | 14 +- gs/cond/cond.go | 2 +- web/bind.go | 188 +++++++++++ web/binding/binding.go | 193 +++++++++++ web/binding/binding_test.go | 117 +++++++ web/binding/form.go | 161 +++++++++ web/binding/form_test.go | 62 ++++ web/binding/json.go | 26 ++ web/binding/json_test.go | 68 ++++ web/binding/xml.go | 26 ++ web/binding/xml_test.go | 68 ++++ web/context.go | 307 ++++++++++++++++++ web/error.go | 24 ++ web/examples/go.mod | 17 + web/examples/go.sum | 57 ++++ web/examples/greeting/config/application.yaml | 43 +++ web/examples/greeting/main.go | 72 ++++ web/options.go | 76 +++++ web/render/binary.go | 22 ++ web/render/binary_test.go | 25 ++ web/render/html.go | 22 ++ web/render/html_test.go | 22 ++ web/render/json.go | 23 ++ web/render/json_test.go | 23 ++ web/render/redirect.go | 20 ++ web/render/redirect_test.go | 46 +++ web/render/renderer.go | 8 + web/render/text.go | 21 ++ web/render/text_test.go | 21 ++ web/render/xml.go | 24 ++ web/render/xml_test.go | 46 +++ web/router.go | 91 ++++++ web/server.go | 307 ++++++++++++++++++ web/starter/configuration.go | 64 ++++ 38 files changed, 2378 insertions(+), 15 deletions(-) delete mode 100644 CNAME create mode 100644 web/bind.go create mode 100644 web/binding/binding.go create mode 100644 web/binding/binding_test.go create mode 100644 web/binding/form.go create mode 100644 web/binding/form_test.go create mode 100644 web/binding/json.go create mode 100644 web/binding/json_test.go create mode 100644 web/binding/xml.go create mode 100644 web/binding/xml_test.go create mode 100644 web/context.go create mode 100644 web/error.go create mode 100644 web/examples/go.mod create mode 100644 web/examples/go.sum create mode 100644 web/examples/greeting/config/application.yaml create mode 100644 web/examples/greeting/main.go create mode 100644 web/options.go create mode 100644 web/render/binary.go create mode 100644 web/render/binary_test.go create mode 100644 web/render/html.go create mode 100644 web/render/html_test.go create mode 100644 web/render/json.go create mode 100644 web/render/json_test.go create mode 100644 web/render/redirect.go create mode 100644 web/render/redirect_test.go create mode 100644 web/render/renderer.go create mode 100644 web/render/text.go create mode 100644 web/render/text_test.go create mode 100644 web/render/xml.go create mode 100644 web/render/xml_test.go create mode 100644 web/router.go create mode 100644 web/server.go create mode 100644 web/starter/configuration.go diff --git a/CNAME b/CNAME deleted file mode 100644 index aa73e9a1..00000000 --- a/CNAME +++ /dev/null @@ -1 +0,0 @@ -go-spring.dev \ No newline at end of file diff --git a/conf/validate.go b/conf/validate.go index 5bee74a3..a1c85983 100644 --- a/conf/validate.go +++ b/conf/validate.go @@ -20,7 +20,7 @@ import ( "fmt" "reflect" - "github.com/antonmedv/expr" + "github.com/expr-lang/expr" ) var validators = map[string]Validator{ @@ -39,13 +39,59 @@ func Register(name string, v Validator) { // Validate validates a single variable. func Validate(tag reflect.StructTag, i interface{}) error { - for name, v := range validators { - if s, ok := tag.Lookup(name); ok { - if err := v.Field(s, i); err != nil { + if len(tag) > 0 { + for name, v := range validators { + if s, ok := tag.Lookup(name); ok && s != "-" { + if err := v.Field(s, i); err != nil { + return err + } + } + } + } + return nil +} + +// ValidateStruct validates a single struct. +func ValidateStruct(s interface{}) error { + sValue := reflect.ValueOf(s) + if reflect.Ptr == sValue.Type().Kind() { + // ignore validate nil value + if sValue.IsNil() { + return nil + } + sValue = sValue.Elem() + } + return validStruct(sValue) +} + +func validStruct(v reflect.Value) error { + vType := v.Type() + if reflect.Struct != vType.Kind() { + return fmt.Errorf("%s: is not a struct", vType.String()) + } + + for i := 0; i < v.NumField(); i++ { + fieldVal := v.Field(i) + fieldType := vType.Field(i) + if !fieldType.IsExported() { + continue + } + + if err := Validate(fieldType.Tag, fieldVal.Interface()); nil != err { + return err + } + + if reflect.Struct == fieldType.Type.Kind() { + if err := validStruct(fieldVal); nil != err { + return err + } + } else if reflect.Ptr == fieldType.Type.Kind() && reflect.Struct == fieldType.Type.Elem().Kind() && !fieldVal.IsNil() { + if err := validStruct(fieldVal.Elem()); nil != err { return err } } } + return nil } diff --git a/conf/validate_test.go b/conf/validate_test.go index 39b42830..64d6881f 100644 --- a/conf/validate_test.go +++ b/conf/validate_test.go @@ -41,7 +41,7 @@ func (d *emptyValidator) Field(tag string, i interface{}) error { return nil } -func TestField(t *testing.T) { +func TestValidateField(t *testing.T) { i := 6 err := Validate("empty:\"\"", i) @@ -57,3 +57,28 @@ func TestField(t *testing.T) { err = Validate("expr:\"$<3\"", "abc") assert.Error(t, err, "invalid operation\\: string \\< int \\(1:2\\)") } + +func TestValidateStruct(t *testing.T) { + type testForm struct { + Age int `expr:"$>=18"` + Summary struct { + Weight int `expr:"$>100"` + } + Skip *struct{} + unexported struct{} + } + + tf1 := testForm{Age: 18} + tf1.Summary.Weight = 101 + err := ValidateStruct(tf1) + assert.Nil(t, err) + + tf2 := testForm{Age: 17} + err = ValidateStruct(tf2) + assert.Error(t, err, "validate failed on \"\\$>=18\" for value 17") + + tf3 := testForm{Age: 18} + err = ValidateStruct(tf3) + assert.Error(t, err, "validate failed on \"\\$>100\" for value 0") + +} diff --git a/go.mod b/go.mod index 5684161a..99e26ff9 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,11 @@ module github.com/go-spring-projects/go-spring go 1.21 require ( - github.com/antonmedv/expr v1.15.3 + github.com/expr-lang/expr v1.15.6 github.com/golang/mock v1.6.0 + github.com/gorilla/mux v1.8.1 github.com/magiconair/properties v1.8.7 github.com/pelletier/go-toml v1.9.5 - github.com/spf13/cast v1.5.1 + github.com/spf13/cast v1.6.0 gopkg.in/yaml.v2 v2.4.0 ) diff --git a/go.sum b/go.sum index 57f115ea..fbf30920 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,15 @@ -github.com/antonmedv/expr v1.15.3 h1:q3hOJZNvLvhqE8OHBs1cFRdbXFNKuA+bHmRaI+AmRmI= -github.com/antonmedv/expr v1.15.3/go.mod h1:0E/6TxnOlRNp81GMzX9QfDPAmHo2Phg00y4JUv1ihsE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= -github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/expr-lang/expr v1.15.6 h1:dQFgzj5DBu3wnUz8+PGLZdPMpefAvxaCFTNM3iSjkGA= +github.com/expr-lang/expr v1.15.6/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -20,8 +22,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= -github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= diff --git a/gs/cond/cond.go b/gs/cond/cond.go index c922954d..82f7ee5a 100644 --- a/gs/cond/cond.go +++ b/gs/cond/cond.go @@ -25,7 +25,7 @@ import ( "strconv" "strings" - "github.com/antonmedv/expr" + "github.com/expr-lang/expr" "github.com/go-spring-projects/go-spring/conf" "github.com/go-spring-projects/go-spring/internal/utils" ) diff --git a/web/bind.go b/web/bind.go new file mode 100644 index 00000000..f6f025a2 --- /dev/null +++ b/web/bind.go @@ -0,0 +1,188 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package web + +import ( + "context" + "fmt" + "net/http" + "reflect" + + "github.com/go-spring-projects/go-spring/internal/utils" + "github.com/go-spring-projects/go-spring/web/binding" + "github.com/go-spring-projects/go-spring/web/render" +) + +type Renderer interface { + Render(ctx context.Context, err error, result interface{}) render.Renderer +} + +type RendererFunc func(ctx context.Context, err error, result interface{}) render.Renderer + +func (fn RendererFunc) Render(ctx context.Context, err error, result interface{}) render.Renderer { + return fn(ctx, err, result) +} + +// Bind convert fn to HandlerFunc. +// +// func(ctx context.Context) +// +// func(ctx context.Context) R +// +// func(ctx context.Context, req T) R +// +// func(ctx context.Context, req T) (R, error) +// +// func(writer http.ResponseWriter, request *http.Request) +func Bind(fn interface{}, render Renderer) http.HandlerFunc { + + fnValue := reflect.ValueOf(fn) + fnType := fnValue.Type() + + switch h := fn.(type) { + case http.HandlerFunc: + return h + case http.Handler: + return h.ServeHTTP + case func(http.ResponseWriter, *http.Request): + return h + default: + // valid func + if err := validMappingFunc(fnType); nil != err { + panic(err) + } + } + + return func(writer http.ResponseWriter, request *http.Request) { + + // param of context + webCtx := &Context{Writer: writer, Request: request} + ctx := WithContext(request.Context(), webCtx) + ctxValue := reflect.ValueOf(ctx) + + defer func() { + if nil != request.MultipartForm { + request.MultipartForm.RemoveAll() + } + request.Body.Close() + }() + + var returnValues []reflect.Value + var err error + + defer func() { + if r := recover(); nil != r { + if e, ok := r.(error); ok { + err = fmt.Errorf("%s: %w", request.URL.Path, e) + } else { + err = fmt.Errorf("%s: %v", request.URL.Path, r) + } + + // render error response + render.Render(ctx, err, nil).Render(writer) + } + }() + + switch fnType.NumIn() { + case 1: + returnValues = fnValue.Call([]reflect.Value{ctxValue}) + case 2: + paramType := fnType.In(1) + pointer := false + if reflect.Ptr == paramType.Kind() { + paramType = paramType.Elem() + pointer = true + } + + // new param instance with paramType. + paramValue := reflect.New(paramType) + // bind paramValue with request + if err = binding.Bind(paramValue.Interface(), webCtx); nil != err { + break + } + if !pointer { + paramValue = paramValue.Elem() + } + returnValues = fnValue.Call([]reflect.Value{ctxValue, paramValue}) + default: + panic("unreachable here") + } + + var result interface{} + + if nil == err { + switch len(returnValues) { + case 0: + // nothing + return + case 1: + // write response + result = returnValues[0].Interface() + case 2: + // check error + result = returnValues[0].Interface() + if e, ok := returnValues[1].Interface().(error); ok && nil != e { + err = e + } + default: + panic("unreachable here") + } + } + + // render response + render.Render(ctx, err, result).Render(writer) + } +} + +func validMappingFunc(fnType reflect.Type) error { + // func(ctx context.Context) + // func(ctx context.Context) R + // func(ctx context.Context, req T) R + // func(ctx context.Context, req T) (R, error) + if !utils.IsFuncType(fnType) { + return fmt.Errorf("%s: not a func", fnType.String()) + } + + if fnType.NumIn() < 1 || fnType.NumIn() > 2 { + return fmt.Errorf("%s: invalid input parameter count", fnType.String()) + } + + if fnType.NumOut() > 2 { + return fmt.Errorf("%s: invalid output parameter count", fnType.String()) + } + + if !utils.IsContextType(fnType.In(0)) { + return fmt.Errorf("%s: first input param type (%s) must be context", fnType.String(), fnType.In(0).String()) + } + + if fnType.NumIn() > 1 { + argType := fnType.In(1) + if !(reflect.Struct == argType.Kind() || (reflect.Ptr == argType.Kind() && reflect.Struct == argType.Elem().Kind())) { + return fmt.Errorf("%s: second input param type (%s) must be struct/*struct", fnType.String(), argType.String()) + } + } + + if 0 < fnType.NumOut() && utils.IsErrorType(fnType.Out(0)) { + return fmt.Errorf("%s: first output param type not be error", fnType.String()) + } + + if 1 < fnType.NumOut() && !utils.IsErrorType(fnType.Out(1)) { + return fmt.Errorf("%s: second output type (%s) must a error", fnType.String(), fnType.Out(1).String()) + } + + return nil +} diff --git a/web/binding/binding.go b/web/binding/binding.go new file mode 100644 index 00000000..2ece4c70 --- /dev/null +++ b/web/binding/binding.go @@ -0,0 +1,193 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package binding ... +package binding + +import ( + "fmt" + "io" + "mime" + "mime/multipart" + "net/url" + "reflect" + "strconv" + "strings" + + "github.com/go-spring-projects/go-spring/conf" +) + +const ( + MIMEApplicationJSON = "application/json" + MIMEApplicationXML = "application/xml" + MIMETextXML = "text/xml" + MIMEApplicationForm = "application/x-www-form-urlencoded" + MIMEMultipartForm = "multipart/form-data" +) + +type Request interface { + ContentType() string + Header(key string) string + Cookie(name string) string + PathParam(name string) string + QueryParam(name string) string + FormParams() (url.Values, error) + MultipartParams(maxMemory int64) (*multipart.Form, error) + RequestBody() io.Reader +} + +type BindScope int + +const ( + BindScopeURI BindScope = iota + BindScopeQuery + BindScopeHeader + BindScopeCookie + BindScopeBody +) + +var scopeTags = map[BindScope]string{ + BindScopeURI: "path", + BindScopeQuery: "query", + BindScopeHeader: "header", + BindScopeCookie: "cookie", +} + +var scopeGetters = map[BindScope]func(r Request, name string) string{ + BindScopeURI: Request.PathParam, + BindScopeQuery: Request.QueryParam, + BindScopeHeader: Request.Header, + BindScopeCookie: Request.Cookie, +} + +type BodyBinder func(i interface{}, r Request) error + +var bodyBinders = map[string]BodyBinder{ + MIMEApplicationForm: BindForm, + MIMEMultipartForm: BindMultipartForm, + MIMEApplicationJSON: BindJSON, + MIMEApplicationXML: BindXML, + MIMETextXML: BindXML, +} + +func RegisterScopeTag(scope BindScope, tag string) { + scopeTags[scope] = tag +} + +func RegisterBodyBinder(mime string, binder BodyBinder) { + bodyBinders[mime] = binder +} + +func Bind(i interface{}, r Request) error { + if err := bindScope(i, r); err != nil { + return err + } + if err := bindBody(i, r); err != nil { + return err + } + return conf.ValidateStruct(i) +} + +func bindBody(i interface{}, r Request) error { + mediaType, _, err := mime.ParseMediaType(r.ContentType()) + if nil != err && !strings.Contains(err.Error(), "mime: no media type") { + return err + } + binder, ok := bodyBinders[mediaType] + if !ok { + binder = bodyBinders[MIMEApplicationForm] + } + return binder(i, r) +} + +func bindScope(i interface{}, r Request) error { + t := reflect.TypeOf(i) + if t.Kind() != reflect.Ptr { + return fmt.Errorf("%s: is not pointer", t.String()) + } + + et := t.Elem() + if et.Kind() != reflect.Struct { + return fmt.Errorf("%s: is not a struct pointer", t.String()) + } + + ev := reflect.ValueOf(i).Elem() + for j := 0; j < ev.NumField(); j++ { + fv := ev.Field(j) + ft := et.Field(j) + for scope := BindScopeURI; scope < BindScopeBody; scope++ { + err := bindScopeField(scope, fv, ft, r) + if err != nil { + return err + } + } + } + return nil +} + +func bindScopeField(scope BindScope, v reflect.Value, field reflect.StructField, r Request) error { + if tag, loaded := scopeTags[scope]; loaded { + if name, ok := field.Tag.Lookup(tag); ok { + if name == "-" { + return nil // ignore bind + } + val := scopeGetters[scope](r, name) + err := bindData(v, val) + if err != nil { + return err + } + } + } + return nil +} + +func bindData(v reflect.Value, val string) error { + switch v.Kind() { + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + u, err := strconv.ParseUint(val, 0, 0) + if err != nil { + return err + } + v.SetUint(u) + return nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + i, err := strconv.ParseInt(val, 0, 0) + if err != nil { + return err + } + v.SetInt(i) + return nil + case reflect.Float32, reflect.Float64: + f, err := strconv.ParseFloat(val, 64) + if err != nil { + return err + } + v.SetFloat(f) + return nil + case reflect.Bool: + b, err := strconv.ParseBool(val) + if err != nil { + return err + } + v.SetBool(b) + return nil + case reflect.String: + v.SetString(val) + return nil + default: + return fmt.Errorf("unsupported binding type %q", v.Type().String()) + } +} diff --git a/web/binding/binding_test.go b/web/binding/binding_test.go new file mode 100644 index 00000000..df07d23f --- /dev/null +++ b/web/binding/binding_test.go @@ -0,0 +1,117 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package binding_test + +import ( + "fmt" + "io" + "mime/multipart" + "net/url" + "strings" + "testing" + + "github.com/go-spring-projects/go-spring/internal/utils/assert" + "github.com/go-spring-projects/go-spring/web/binding" +) + +type MockRequest struct { + contentType string + headers map[string]string + queryParams map[string]string + pathParams map[string]string + cookies map[string]string + formParams url.Values + requestBody string +} + +var _ binding.Request = &MockRequest{} + +func (r *MockRequest) ContentType() string { + return r.contentType +} + +func (r *MockRequest) Header(key string) string { + return r.headers[key] +} + +func (r *MockRequest) Cookie(name string) string { + return r.cookies[name] +} + +func (r *MockRequest) QueryParam(name string) string { + return r.queryParams[name] +} + +func (r *MockRequest) PathParam(name string) string { + return r.pathParams[name] +} + +func (r *MockRequest) FormParams() (url.Values, error) { + return r.formParams, nil +} + +func (r *MockRequest) MultipartParams(maxMemory int64) (*multipart.Form, error) { + return nil, fmt.Errorf("not impl") +} + +func (r *MockRequest) RequestBody() io.Reader { + return strings.NewReader(r.requestBody) +} + +type ScopeBindParam struct { + A string `path:"a"` + B string `path:"b"` + C string `path:"c" query:"c"` + D string `query:"d"` + E string `query:"e" header:"e"` + F string `cookie:"f"` +} + +func TestScopeBind(t *testing.T) { + + ctx := &MockRequest{ + headers: map[string]string{ + "e": "6", + }, + queryParams: map[string]string{ + "c": "3", + "d": "4", + "e": "5", + }, + pathParams: map[string]string{ + "a": "1", + "b": "2", + }, + cookies: map[string]string{ + "f": "7", + }, + } + + expect := ScopeBindParam{ + A: "1", + B: "2", + C: "3", + D: "4", + E: "6", + F: "7", + } + + var p ScopeBindParam + err := binding.Bind(&p, ctx) + assert.Nil(t, err) + assert.Equal(t, p, expect) +} diff --git a/web/binding/form.go b/web/binding/form.go new file mode 100644 index 00000000..ead7be2c --- /dev/null +++ b/web/binding/form.go @@ -0,0 +1,161 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package binding + +import ( + "mime/multipart" + "net/url" + "reflect" +) + +var fileHeaderType = reflect.TypeOf((*multipart.FileHeader)(nil)) + +func BindForm(i interface{}, r Request) error { + params, err := r.FormParams() + if err != nil { + return err + } + t := reflect.TypeOf(i) + if t.Kind() != reflect.Ptr { + return nil + } + et := t.Elem() + if et.Kind() != reflect.Struct { + return nil + } + ev := reflect.ValueOf(i).Elem() + return bindFormStruct(ev, et, params) +} + +func bindFormStruct(v reflect.Value, t reflect.Type, params url.Values) error { + for j := 0; j < t.NumField(); j++ { + ft := t.Field(j) + fv := v.Field(j) + if ft.Anonymous { + if ft.Type.Kind() != reflect.Struct { + continue + } + if err := bindFormStruct(fv, ft.Type, params); nil != err { + return err + } + continue + } + name, ok := ft.Tag.Lookup("form") + if !ok || !fv.CanInterface() { + continue + } + values := params[name] + if len(values) == 0 { + continue + } + err := bindFormField(fv, ft.Type, values) + if err != nil { + return err + } + } + return nil +} + +func bindFormField(v reflect.Value, t reflect.Type, values []string) error { + if v.Kind() == reflect.Slice { + slice := reflect.MakeSlice(t, 0, len(values)) + defer func() { v.Set(slice) }() + et := t.Elem() + for _, value := range values { + ev := reflect.New(et).Elem() + if err := bindData(ev, value); nil != err { + return err + } + slice = reflect.Append(slice, ev) + } + return nil + } + return bindData(v, values[0]) +} + +func BindMultipartForm(i interface{}, r Request) error { + const defaultMaxMemory = 32 << 20 // 32 MB + form, err := r.MultipartParams(defaultMaxMemory) + if nil != err { + return err + } + + t := reflect.TypeOf(i) + if t.Kind() != reflect.Ptr { + return nil + } + et := t.Elem() + if et.Kind() != reflect.Struct { + return nil + } + ev := reflect.ValueOf(i).Elem() + return bindMultipartFormStruct(ev, et, form) +} + +func bindMultipartFormStruct(v reflect.Value, t reflect.Type, form *multipart.Form) error { + for j := 0; j < t.NumField(); j++ { + ft := t.Field(j) + fv := v.Field(j) + if ft.Anonymous { + if ft.Type.Kind() != reflect.Struct { + continue + } + if err := bindMultipartFormStruct(fv, ft.Type, form); nil != err { + return err + } + continue + } + name, ok := ft.Tag.Lookup("form") + if !ok || !fv.CanInterface() { + continue + } + + if ft.Type == fileHeaderType || (reflect.Slice == ft.Type.Kind() && ft.Type.Elem() == fileHeaderType) { + files := form.File[name] + if len(files) == 0 { + continue + } + if err := bindMultipartFormFiles(fv, ft.Type, files); nil != err { + return err + } + } else { + values := form.Value[name] + if len(values) == 0 { + continue + } + if err := bindFormField(fv, ft.Type, values); nil != err { + return err + } + } + + } + return nil +} + +func bindMultipartFormFiles(v reflect.Value, t reflect.Type, files []*multipart.FileHeader) error { + if v.Kind() == reflect.Slice { + slice := reflect.MakeSlice(t, 0, len(files)) + defer func() { v.Set(slice) }() + for _, file := range files { + slice = reflect.Append(slice, reflect.ValueOf(file)) + } + return nil + } + + v.Set(reflect.ValueOf(files[0])) + return nil +} diff --git a/web/binding/form_test.go b/web/binding/form_test.go new file mode 100644 index 00000000..4931c2f9 --- /dev/null +++ b/web/binding/form_test.go @@ -0,0 +1,62 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package binding_test + +import ( + "net/url" + "testing" + + "github.com/go-spring-projects/go-spring/internal/utils/assert" + "github.com/go-spring-projects/go-spring/web/binding" +) + +type FormBindParamCommon struct { + A string `form:"a"` + B []string `form:"b"` +} + +type FormBindParam struct { + FormBindParamCommon + C int `form:"c"` + D []int `form:"d"` +} + +func TestBindForm(t *testing.T) { + + ctx := &MockRequest{ + formParams: url.Values{ + "a": {"1"}, + "b": {"2", "3"}, + "c": {"4"}, + "d": {"5", "6"}, + }, + } + + expect := FormBindParam{ + FormBindParamCommon: FormBindParamCommon{ + A: "1", + B: []string{"2", "3"}, + }, + C: 4, + D: []int{5, 6}, + } + + var p FormBindParam + err := binding.Bind(&p, ctx) + assert.Nil(t, err) + assert.Equal(t, p, expect) +} diff --git a/web/binding/json.go b/web/binding/json.go new file mode 100644 index 00000000..1d13f029 --- /dev/null +++ b/web/binding/json.go @@ -0,0 +1,26 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package binding + +import ( + "encoding/json" +) + +func BindJSON(i interface{}, r Request) error { + decoder := json.NewDecoder(r.RequestBody()) + return decoder.Decode(i) +} diff --git a/web/binding/json_test.go b/web/binding/json_test.go new file mode 100644 index 00000000..33ea00de --- /dev/null +++ b/web/binding/json_test.go @@ -0,0 +1,68 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package binding_test + +import ( + "encoding/json" + "testing" + + "github.com/go-spring-projects/go-spring/internal/utils/assert" + "github.com/go-spring-projects/go-spring/web/binding" +) + +type JSONBindParamCommon struct { + A string `json:"a"` + B []string `json:"b"` +} + +type JSONBindParam struct { + JSONBindParamCommon + C int `json:"c"` + D []int `json:"d"` +} + +func TestBindJSON(t *testing.T) { + + data, err := json.Marshal(map[string]interface{}{ + "a": "1", + "b": []string{"2", "3"}, + "c": 4, + "d": []int64{5, 6}, + }) + if err != nil { + t.Fatal(err) + } + + ctx := &MockRequest{ + contentType: binding.MIMEApplicationJSON, + requestBody: string(data), + } + + expect := JSONBindParam{ + JSONBindParamCommon: JSONBindParamCommon{ + A: "1", + B: []string{"2", "3"}, + }, + C: 4, + D: []int{5, 6}, + } + + var p JSONBindParam + err = binding.Bind(&p, ctx) + assert.Nil(t, err) + assert.Equal(t, p, expect) +} diff --git a/web/binding/xml.go b/web/binding/xml.go new file mode 100644 index 00000000..0100d3da --- /dev/null +++ b/web/binding/xml.go @@ -0,0 +1,26 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package binding + +import ( + "encoding/xml" +) + +func BindXML(i interface{}, r Request) error { + decoder := xml.NewDecoder(r.RequestBody()) + return decoder.Decode(i) +} diff --git a/web/binding/xml_test.go b/web/binding/xml_test.go new file mode 100644 index 00000000..ecbdb406 --- /dev/null +++ b/web/binding/xml_test.go @@ -0,0 +1,68 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package binding_test + +import ( + "encoding/xml" + "testing" + + "github.com/go-spring-projects/go-spring/internal/utils/assert" + "github.com/go-spring-projects/go-spring/web/binding" +) + +type XMLBindParamCommon struct { + A string `xml:"a"` + B []string `xml:"b"` +} + +type XMLBindParam struct { + XMLBindParamCommon + C int `xml:"c"` + D []int `xml:"d"` +} + +func TestBindXML(t *testing.T) { + + data, err := xml.Marshal(&XMLBindParam{ + XMLBindParamCommon: XMLBindParamCommon{ + A: "1", + B: []string{"2", "3"}, + }, + C: 4, + D: []int{5, 6}, + }) + assert.Nil(t, err) + + r := &MockRequest{ + contentType: binding.MIMEApplicationXML, + requestBody: string(data), + } + + expect := XMLBindParam{ + XMLBindParamCommon: XMLBindParamCommon{ + A: "1", + B: []string{"2", "3"}, + }, + C: 4, + D: []int{5, 6}, + } + + var p XMLBindParam + err = binding.Bind(&p, r) + assert.Nil(t, err) + assert.Equal(t, p, expect) +} diff --git a/web/context.go b/web/context.go new file mode 100644 index 00000000..c1f46de7 --- /dev/null +++ b/web/context.go @@ -0,0 +1,307 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package web + +import ( + "context" + "fmt" + "io" + "mime/multipart" + "net" + "net/http" + "net/url" + "strings" + "unicode" + + "github.com/go-spring-projects/go-spring/web/binding" + "github.com/go-spring-projects/go-spring/web/render" + "github.com/gorilla/mux" +) + +type contextKey struct{} + +func WithContext(parent context.Context, ctx *Context) context.Context { + return context.WithValue(parent, contextKey{}, ctx) +} + +func FromContext(ctx context.Context) *Context { + if v := ctx.Value(contextKey{}); v != nil { + return v.(*Context) + } + return nil +} + +type Context struct { + // A ResponseWriter interface is used by an HTTP handler to + // construct an HTTP response. + Writer http.ResponseWriter + + // A Request represents an HTTP request received by a server + // or to be sent by a client. + Request *http.Request + + // SameSite allows a server to define a cookie attribute making it impossible for + // the browser to send this cookie along with cross-site requests. + sameSite http.SameSite +} + +// Context returns the request's context. +func (c *Context) Context() context.Context { + return c.Request.Context() +} + +// ContentType returns the request header `Content-Type`. +func (c *Context) ContentType() string { + contentType := c.Request.Header.Get("Content-Type") + return contentType +} + +// Header returns the named header in the request. +func (c *Context) Header(key string) string { + return c.Request.Header.Get(key) +} + +// Cookie returns the named cookie provided in the request. +func (c *Context) Cookie(name string) string { + cookie, err := c.Request.Cookie(name) + if err != nil { + return "" + } + val, _ := url.QueryUnescape(cookie.Value) + return val +} + +// PathParam returns the named variables in the request. +func (c *Context) PathParam(name string) string { + if params := mux.Vars(c.Request); nil != params { + if value, ok := params[name]; ok { + return value + } + } + return "" +} + +// QueryParam returns the named query in the request. +func (c *Context) QueryParam(name string) string { + if values := c.Request.URL.Query(); nil != values { + if value, ok := values[name]; ok && len(value) > 0 { + return value[0] + } + } + return "" +} + +// FormParams returns the form in the request. +func (c *Context) FormParams() (url.Values, error) { + if err := c.Request.ParseForm(); nil != err { + return nil, err + } + return c.Request.Form, nil +} + +// MultipartParams returns a request body as multipart/form-data. +// The whole request body is parsed and up to a total of maxMemory bytes of its file parts are stored in memory, with the remainder stored on disk in temporary files. +func (c *Context) MultipartParams(maxMemory int64) (*multipart.Form, error) { + if !strings.Contains(c.ContentType(), binding.MIMEMultipartForm) { + return nil, fmt.Errorf("require `multipart/form-data` request") + } + + if nil == c.Request.MultipartForm { + if err := c.Request.ParseMultipartForm(maxMemory); nil != err { + return nil, err + } + } + return c.Request.MultipartForm, nil +} + +// RequestBody returns the request body. +func (c *Context) RequestBody() io.Reader { + return c.Request.Body +} + +// IsWebsocket returns true if the request headers indicate that a websocket +// handshake is being initiated by the client. +func (c *Context) IsWebsocket() bool { + if strings.Contains(strings.ToLower(c.Request.Header.Get("Connection")), "upgrade") && + strings.EqualFold(c.Request.Header.Get("Upgrade"), "websocket") { + return true + } + return false +} + +// SetSameSite with cookie +func (c *Context) SetSameSite(samesite http.SameSite) { + c.sameSite = samesite +} + +// Status sets the HTTP response code. +func (c *Context) Status(code int) { + c.Writer.WriteHeader(code) +} + +// SetHeader is an intelligent shortcut for c.Writer.Header().Set(key, value). +// It writes a header in the response. +// If value == "", this method removes the header `c.Writer.Header().Del(key)` +func (c *Context) SetHeader(key, value string) { + if value == "" { + c.Writer.Header().Del(key) + return + } + c.Writer.Header().Set(key, value) +} + +// SetCookie adds a Set-Cookie header to the ResponseWriter's headers. +// The provided cookie must have a valid Name. Invalid cookies may be +// silently dropped. +func (c *Context) SetCookie(name, value string, maxAge int, path, domain string, secure, httpOnly bool) { + if path == "" { + path = "/" + } + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, + Value: url.QueryEscape(value), + MaxAge: maxAge, + Path: path, + Domain: domain, + SameSite: c.sameSite, + Secure: secure, + HttpOnly: httpOnly, + }) +} + +// Render writes the response headers and calls render.Render to render data. +func (c *Context) Render(code int, render render.Renderer) error { + if code > 0 { + c.Writer.WriteHeader(code) + } + return render.Render(c.Writer) +} + +// Redirect returns an HTTP redirect to the specific location. +func (c *Context) Redirect(code int, location string) error { + return c.Render(-1, render.RedirectRenderer{Code: code, Request: c.Request, Location: location}) +} + +// String writes the given string into the response body. +func (c *Context) String(code int, format string, args ...interface{}) error { + return c.Render(code, render.TextRenderer{Format: format, Args: args}) +} + +// Data writes some data into the body stream and updates the HTTP code. +func (c *Context) Data(code int, contentType string, data []byte) error { + return c.Render(code, render.BinaryRenderer{ContentType: contentType, Data: data}) +} + +// JSON serializes the given struct as JSON into the response body. +// It also sets the Content-Type as "application/json". +func (c *Context) JSON(code int, obj interface{}) error { + return c.Render(code, render.JsonRenderer{Data: obj}) +} + +// IndentedJSON serializes the given struct as pretty JSON (indented + endlines) into the response body. +// It also sets the Content-Type as "application/json". +func (c *Context) IndentedJSON(code int, obj interface{}) error { + return c.Render(code, render.JsonRenderer{Data: obj, Indent: " "}) +} + +// XML serializes the given struct as XML into the response body. +// It also sets the Content-Type as "application/xml". +func (c *Context) XML(code int, obj interface{}) error { + return c.Render(code, render.XmlRenderer{Data: obj}) +} + +// IndentedXML serializes the given struct as pretty XML (indented + endlines) into the response body. +// It also sets the Content-Type as "application/xml". +func (c *Context) IndentedXML(code int, obj interface{}) error { + return c.Render(code, render.XmlRenderer{Data: obj, Indent: " "}) +} + +// File writes the specified file into the body stream in an efficient way. +func (c *Context) File(filepath string) { + http.ServeFile(c.Writer, c.Request, filepath) +} + +// FileAttachment writes the specified file into the body stream in an efficient way +// On the client side, the file will typically be downloaded with the given filename +func (c *Context) FileAttachment(filepath, filename string) { + if isASCII(filename) { + c.Writer.Header().Set("Content-Disposition", `attachment; filename="`+escapeQuotes(filename)+`"`) + } else { + c.Writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''`+url.QueryEscape(filename)) + } + http.ServeFile(c.Writer, c.Request, filepath) +} + +// RemoteIP parses the IP from Request.RemoteAddr, normalizes and returns the IP (without the port). +func (c *Context) RemoteIP() string { + ip, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr)) + if err != nil { + return "" + } + return ip +} + +// ClientIP implements one best effort algorithm to return the real client IP. +// It calls c.RemoteIP() under the hood, to check if the remote IP is a trusted proxy or not. +// If it is it will then try to parse the headers defined in RemoteIPHeaders (defaulting to [X-Forwarded-For, X-Real-Ip]). +// If the headers are not syntactically valid OR the remote IP does not correspond to a trusted proxy, +// the remote IP (coming from Request.RemoteAddr) is returned. +func (c *Context) ClientIP() string { + // It also checks if the remoteIP is a trusted proxy or not. + // In order to perform this validation, it will see if the IP is contained within at least one of the CIDR blocks + // defined by Engine.SetTrustedProxies() + remoteIP := net.ParseIP(c.RemoteIP()) + if remoteIP == nil { + return "" + } + + for _, headerName := range []string{"X-Forwarded-For", "X-Real-Ip"} { + if ns := strings.Split(c.Request.Header.Get(headerName), ","); len(ns) > 0 && len(ns[0]) > 0 { + return ns[0] + } + } + return remoteIP.String() +} + +// https://stackoverflow.com/questions/53069040/checking-a-string-contains-only-ascii-characters +func isASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] > unicode.MaxASCII { + return false + } + } + return true +} + +var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + +func escapeQuotes(s string) string { + return quoteEscaper.Replace(s) +} + +// bodyAllowedForStatus is a copy of http.bodyAllowedForStatus non-exported function. +func bodyAllowedForStatus(status int) bool { + switch { + case status >= 100 && status <= 199: + return false + case status == http.StatusNoContent: + return false + case status == http.StatusNotModified: + return false + } + return true +} diff --git a/web/error.go b/web/error.go new file mode 100644 index 00000000..cef1be2a --- /dev/null +++ b/web/error.go @@ -0,0 +1,24 @@ +package web + +import ( + "fmt" + "net/http" + "strings" +) + +type HttpError struct { + Code int + Message string +} + +func (e HttpError) Error() string { + return fmt.Sprintf("%d: %s", e.Code, e.Message) +} + +func Error(code int, msg ...string) HttpError { + var message = http.StatusText(code) + if len(msg) > 0 { + message = strings.Join(msg, ",") + } + return HttpError{Code: code, Message: message} +} diff --git a/web/examples/go.mod b/web/examples/go.mod new file mode 100644 index 00000000..8ebfd4a8 --- /dev/null +++ b/web/examples/go.mod @@ -0,0 +1,17 @@ +module examples + +go 1.21.1 + +replace github.com/go-spring-projects/go-spring => ../../ + +require github.com/go-spring-projects/go-spring v0.0.0-00010101000000-000000000000 + +require ( + github.com/expr-lang/expr v1.15.6 // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/spf13/cast v1.6.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/web/examples/go.sum b/web/examples/go.sum new file mode 100644 index 00000000..fbf30920 --- /dev/null +++ b/web/examples/go.sum @@ -0,0 +1,57 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/expr-lang/expr v1.15.6 h1:dQFgzj5DBu3wnUz8+PGLZdPMpefAvxaCFTNM3iSjkGA= +github.com/expr-lang/expr v1.15.6/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +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/web/examples/greeting/config/application.yaml b/web/examples/greeting/config/application.yaml new file mode 100644 index 00000000..349ff012 --- /dev/null +++ b/web/examples/greeting/config/application.yaml @@ -0,0 +1,43 @@ + +# http server +http: + # Addr specifies the TCP address for the server to listen on, in the form "host:port". + addr: ":8080" + + # ReadTimeout is the maximum duration for reading the entire + # request, including the body. A zero or negative value means + # there will be no timeout. + # + # Because ReadTimeout does not let Handlers make per-request + # decisions on each request body's acceptable deadline or + # upload rate, most users will prefer to use + # ReadHeaderTimeout. It is valid to use them both. + read-timeout: 0s + + # ReadHeaderTimeout is the amount of time allowed to read + # request headers. The connection's read deadline is reset + # after reading the headers and the Handler can decide what + # is considered too slow for the body. If ReadHeaderTimeout + # is zero, the value of ReadTimeout is used. If both are + # zero, there is no timeout. + read-header-timeout: 0s + + # WriteTimeout is the maximum duration before timing out + # writes of the response. It is reset whenever a new + # request's header is read. Like ReadTimeout, it does not + # let Handlers make decisions on a per-request basis. + # A zero or negative value means there will be no timeout. + write-timeout: 0s + + # IdleTimeout is the maximum amount of time to wait for the + # next request when keep-alives are enabled. If IdleTimeout + # is zero, the value of ReadTimeout is used. If both are + # zero, there is no timeout. + idle-timeout: 0s + + # MaxHeaderBytes controls the maximum number of bytes the + # server will read parsing the request header's keys and + # values, including the request line. It does not limit the + # size of the request body. + # If zero, DefaultMaxHeaderBytes is used. + max-header-bytes: 0 \ No newline at end of file diff --git a/web/examples/greeting/main.go b/web/examples/greeting/main.go new file mode 100644 index 00000000..c51c4633 --- /dev/null +++ b/web/examples/greeting/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "math/rand" + "mime/multipart" + "time" + + "github.com/go-spring-projects/go-spring/gs" + "github.com/go-spring-projects/go-spring/web" + _ "github.com/go-spring-projects/go-spring/web/starter" +) + +type Greeting struct { + Logger *slog.Logger `logger:""` + Server *web.Server `autowire:""` +} + +func (g *Greeting) OnInit(ctx context.Context) error { + g.Server.Bind("/greeting", g.Greeting) + g.Server.Bind("/health", g.Health) + g.Server.Bind("/user/register/{username}/{password}", g.Register) + return nil +} + +func (g *Greeting) Greeting(ctx context.Context) string { + web.FromContext(ctx). + SetCookie("token", "1234567890", 500, "", "", false, false) + return "greeting!!!" +} + +func (g *Greeting) Health(ctx context.Context) (string, error) { + if 0 == rand.Int()%2 { + return "", fmt.Errorf("health check failed") + } + return time.Now().String(), nil +} + +func (g *Greeting) Register( + ctx context.Context, + req struct { + Username string `path:"username"` // 用户名 + Password string `path:"password"` // 密码 + HeadImg *multipart.FileHeader `form:"headImg"` // 上传头像 + Captcha string `form:"captcha"` // 验证码 + UserAgent string `header:"User-Agent"` // 用户代理 + Ad string `query:"ad"` // 推广ID + Token string `cookie:"token"` // cookie参数 + }, +) string { + g.Logger.Info("register user", + slog.String("username", req.Username), + slog.String("password", req.Password), + slog.String("userAgent", req.UserAgent), + slog.String("headImg", req.HeadImg.Filename), + slog.String("captcha", req.Captcha), + slog.String("userAgent", req.UserAgent), + slog.String("ad", req.Ad), + slog.String("token", req.Token), + ) + return "ok" +} + +func main() { + gs.Object(new(Greeting)) + + if err := gs.Run(); nil != err { + panic(err) + } +} diff --git a/web/options.go b/web/options.go new file mode 100644 index 00000000..9a1fc2cd --- /dev/null +++ b/web/options.go @@ -0,0 +1,76 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package web + +import "time" + +type Options struct { + // Addr optionally specifies the TCP address for the server to listen on, + // in the form "host:port". If empty, ":http" (port 8080) is used. + // The service names are defined in RFC 6335 and assigned by IANA. + // See net.Dial for details of the address format. + Addr string `value:"${addr:=}"` + + // CertFile containing a certificate and matching private key for the + // server must be provided if neither the Server's + // TLSConfig.Certificates nor TLSConfig.GetCertificate are populated. + // If the certificate is signed by a certificate authority, the + // certFile should be the concatenation of the server's certificate, + // any intermediates, and the CA's certificate. + CertFile string `value:"${cert-file:=}"` + + // KeyFile containing a private key file. + KeyFile string `value:"${key-file:=}"` + + // ReadTimeout is the maximum duration for reading the entire + // request, including the body. A zero or negative value means + // there will be no timeout. + // + // Because ReadTimeout does not let Handlers make per-request + // decisions on each request body's acceptable deadline or + // upload rate, most users will prefer to use + // ReadHeaderTimeout. It is valid to use them both. + ReadTimeout time.Duration `value:"${read-timeout:=0s}"` + + // ReadHeaderTimeout is the amount of time allowed to read + // request headers. The connection's read deadline is reset + // after reading the headers and the Handler can decide what + // is considered too slow for the body. If ReadHeaderTimeout + // is zero, the value of ReadTimeout is used. If both are + // zero, there is no timeout. + ReadHeaderTimeout time.Duration `value:"${read-header-timeout:=0s}"` + + // WriteTimeout is the maximum duration before timing out + // writes of the response. It is reset whenever a new + // request's header is read. Like ReadTimeout, it does not + // let Handlers make decisions on a per-request basis. + // A zero or negative value means there will be no timeout. + WriteTimeout time.Duration `value:"${write-timeout:=0s}"` + + // IdleTimeout is the maximum amount of time to wait for the + // next request when keep-alives are enabled. If IdleTimeout + // is zero, the value of ReadTimeout is used. If both are + // zero, there is no timeout. + IdleTimeout time.Duration `value:"${idle-timeout:=0s}"` + + // MaxHeaderBytes controls the maximum number of bytes the + // server will read parsing the request header's keys and + // values, including the request line. It does not limit the + // size of the request body. + // If zero, DefaultMaxHeaderBytes is used. + MaxHeaderBytes int `value:"${max-header-bytes:=0}"` +} diff --git a/web/render/binary.go b/web/render/binary.go new file mode 100644 index 00000000..7afa6123 --- /dev/null +++ b/web/render/binary.go @@ -0,0 +1,22 @@ +package render + +import ( + "net/http" +) + +type BinaryRenderer struct { + ContentType string + Data []byte +} + +func (b BinaryRenderer) Render(writer http.ResponseWriter) error { + if header := writer.Header(); len(header.Get("Content-Type")) == 0 { + contentType := "application/octet-stream" + if len(b.ContentType) > 0 { + contentType = b.ContentType + } + header.Set("Content-Type", contentType) + } + _, err := writer.Write(b.Data) + return err +} diff --git a/web/render/binary_test.go b/web/render/binary_test.go new file mode 100644 index 00000000..d8de415a --- /dev/null +++ b/web/render/binary_test.go @@ -0,0 +1,25 @@ +package render + +import ( + "crypto/rand" + "net/http/httptest" + "testing" + + "github.com/go-spring-projects/go-spring/internal/utils/assert" +) + +func TestBinaryRenderer(t *testing.T) { + + data := make([]byte, 1024) + if _, err := rand.Reader.Read(data); nil != err { + panic(err) + } + + w := httptest.NewRecorder() + + err := BinaryRenderer{ContentType: "application/octet-stream", Data: data}.Render(w) + assert.Nil(t, err) + + assert.Equal(t, w.Header().Get("Content-Type"), "application/octet-stream") + assert.Equal(t, w.Body.Bytes(), data) +} diff --git a/web/render/html.go b/web/render/html.go new file mode 100644 index 00000000..284d49ad --- /dev/null +++ b/web/render/html.go @@ -0,0 +1,22 @@ +package render + +import ( + "html/template" + "net/http" +) + +type HTMLRenderer struct { + Template *template.Template + Name string + Data interface{} +} + +func (h HTMLRenderer) Render(writer http.ResponseWriter) error { + if header := writer.Header(); len(header.Get("Content-Type")) == 0 { + header.Set("Content-Type", "text/html; charset=utf-8") + } + if len(h.Name) > 0 { + return h.Template.ExecuteTemplate(writer, h.Name, h.Data) + } + return h.Template.Execute(writer, h.Data) +} diff --git a/web/render/html_test.go b/web/render/html_test.go new file mode 100644 index 00000000..d6d6e724 --- /dev/null +++ b/web/render/html_test.go @@ -0,0 +1,22 @@ +package render + +import ( + "html/template" + "net/http/httptest" + "testing" + + "github.com/go-spring-projects/go-spring/internal/utils/assert" +) + +func TestHTMLRenderer(t *testing.T) { + + w := httptest.NewRecorder() + templ := template.Must(template.New("t").Parse(`Hello {{.name}}`)) + + htmlRender := HTMLRenderer{Template: templ, Name: "t", Data: map[string]interface{}{"name": "asdklajhdasdd"}} + err := htmlRender.Render(w) + + assert.Nil(t, err) + assert.Equal(t, w.Header().Get("Content-Type"), "text/html; charset=utf-8") + assert.Equal(t, w.Body.String(), "Hello asdklajhdasdd") +} diff --git a/web/render/json.go b/web/render/json.go new file mode 100644 index 00000000..13b3ef76 --- /dev/null +++ b/web/render/json.go @@ -0,0 +1,23 @@ +package render + +import ( + "encoding/json" + "net/http" +) + +type JsonRenderer struct { + Prefix string + Indent string + Data interface{} +} + +func (j JsonRenderer) Render(writer http.ResponseWriter) error { + if header := writer.Header(); len(header.Get("Content-Type")) == 0 { + header.Set("Content-Type", "application/json; charset=utf-8") + } + encoder := json.NewEncoder(writer) + if len(j.Prefix) > 0 || len(j.Indent) > 0 { + encoder.SetIndent(j.Prefix, j.Indent) + } + return encoder.Encode(j.Data) +} diff --git a/web/render/json_test.go b/web/render/json_test.go new file mode 100644 index 00000000..a77bb668 --- /dev/null +++ b/web/render/json_test.go @@ -0,0 +1,23 @@ +package render + +import ( + "net/http/httptest" + "testing" + + "github.com/go-spring-projects/go-spring/internal/utils/assert" +) + +func TestJSONRenderer(t *testing.T) { + data := map[string]any{ + "foo": "bar", + "html": "", + } + + w := httptest.NewRecorder() + + err := JsonRenderer{Data: data}.Render(w) + assert.Nil(t, err) + + assert.Equal(t, w.Header().Get("Content-Type"), "application/json; charset=utf-8") + assert.Equal(t, w.Body.String(), "{\"foo\":\"bar\",\"html\":\"\\u003cb\\u003e\"}\n") +} diff --git a/web/render/redirect.go b/web/render/redirect.go new file mode 100644 index 00000000..95f76840 --- /dev/null +++ b/web/render/redirect.go @@ -0,0 +1,20 @@ +package render + +import ( + "fmt" + "net/http" +) + +type RedirectRenderer struct { + Code int + Request *http.Request + Location string +} + +func (r RedirectRenderer) Render(writer http.ResponseWriter) error { + if (r.Code < http.StatusMultipleChoices || r.Code > http.StatusPermanentRedirect) && r.Code != http.StatusCreated { + panic(fmt.Sprintf("Cannot redirect with status code %d", r.Code)) + } + http.Redirect(writer, r.Request, r.Location, r.Code) + return nil +} diff --git a/web/render/redirect_test.go b/web/render/redirect_test.go new file mode 100644 index 00000000..1cbf0b7c --- /dev/null +++ b/web/render/redirect_test.go @@ -0,0 +1,46 @@ +package render + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-spring-projects/go-spring/internal/utils/assert" +) + +func TestRedirectRenderer(t *testing.T) { + req, err := http.NewRequest("GET", "/test-redirect", nil) + assert.Nil(t, err) + + data1 := RedirectRenderer{ + Code: http.StatusMovedPermanently, + Request: req, + Location: "/new/location", + } + + w := httptest.NewRecorder() + err = data1.Render(w) + assert.Nil(t, err) + + data2 := RedirectRenderer{ + Code: http.StatusOK, + Request: req, + Location: "/new/location", + } + + w = httptest.NewRecorder() + assert.Panic(t, func() { + err := data2.Render(w) + assert.Nil(t, err) + }, "Cannot redirect with status code 200") + + data3 := RedirectRenderer{ + Code: http.StatusCreated, + Request: req, + Location: "/new/location", + } + + w = httptest.NewRecorder() + err = data3.Render(w) + assert.Nil(t, err) +} diff --git a/web/render/renderer.go b/web/render/renderer.go new file mode 100644 index 00000000..8693ad53 --- /dev/null +++ b/web/render/renderer.go @@ -0,0 +1,8 @@ +package render + +import "net/http" + +// Renderer writes data with custom ContentType and headers. +type Renderer interface { + Render(writer http.ResponseWriter) error +} diff --git a/web/render/text.go b/web/render/text.go new file mode 100644 index 00000000..dcb5de49 --- /dev/null +++ b/web/render/text.go @@ -0,0 +1,21 @@ +package render + +import ( + "fmt" + "io" + "net/http" + "strings" +) + +type TextRenderer struct { + Format string + Args []interface{} +} + +func (t TextRenderer) Render(writer http.ResponseWriter) error { + if header := writer.Header(); len(header.Get("Content-Type")) == 0 { + header.Set("Content-Type", "text/plain; charset=utf-8") + } + _, err := io.Copy(writer, strings.NewReader(fmt.Sprintf(t.Format, t.Args...))) + return err +} diff --git a/web/render/text_test.go b/web/render/text_test.go new file mode 100644 index 00000000..b8518981 --- /dev/null +++ b/web/render/text_test.go @@ -0,0 +1,21 @@ +package render + +import ( + "net/http/httptest" + "testing" + + "github.com/go-spring-projects/go-spring/internal/utils/assert" +) + +func TestTextRenderer(t *testing.T) { + w := httptest.NewRecorder() + + err := (TextRenderer{ + Format: "hello %s %d", + Args: []any{"bob", 2}, + }).Render(w) + + assert.Nil(t, err) + assert.Equal(t, w.Header().Get("Content-Type"), "text/plain; charset=utf-8") + assert.Equal(t, w.Body.String(), "hello bob 2") +} diff --git a/web/render/xml.go b/web/render/xml.go new file mode 100644 index 00000000..c6662460 --- /dev/null +++ b/web/render/xml.go @@ -0,0 +1,24 @@ +package render + +import ( + "encoding/xml" + "net/http" +) + +type XmlRenderer struct { + Prefix string + Indent string + Data interface{} +} + +func (x XmlRenderer) Render(writer http.ResponseWriter) error { + if header := writer.Header(); len(header.Get("Content-Type")) == 0 { + header.Set("Content-Type", "application/xml; charset=utf-8") + } + + encoder := xml.NewEncoder(writer) + if len(x.Prefix) > 0 || len(x.Indent) > 0 { + encoder.Indent(x.Prefix, x.Indent) + } + return encoder.Encode(x.Data) +} diff --git a/web/render/xml_test.go b/web/render/xml_test.go new file mode 100644 index 00000000..5dd26468 --- /dev/null +++ b/web/render/xml_test.go @@ -0,0 +1,46 @@ +package render + +import ( + "encoding/xml" + "net/http/httptest" + "testing" + + "github.com/go-spring-projects/go-spring/internal/utils/assert" +) + +type xmlmap map[string]any + +// Allows type H to be used with xml.Marshal +func (h xmlmap) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + start.Name = xml.Name{ + Space: "", + Local: "map", + } + if err := e.EncodeToken(start); err != nil { + return err + } + for key, value := range h { + elem := xml.StartElement{ + Name: xml.Name{Space: "", Local: key}, + Attr: []xml.Attr{}, + } + if err := e.EncodeElement(value, elem); err != nil { + return err + } + } + + return e.EncodeToken(xml.EndElement{Name: start.Name}) +} + +func TestXmlRenderer(t *testing.T) { + w := httptest.NewRecorder() + data := xmlmap{ + "foo": "bar", + } + + err := (XmlRenderer{Data: data}).Render(w) + + assert.Nil(t, err) + assert.Equal(t, w.Header().Get("Content-Type"), "application/xml; charset=utf-8") + assert.Equal(t, w.Body.String(), "bar") +} diff --git a/web/router.go b/web/router.go new file mode 100644 index 00000000..b374bea7 --- /dev/null +++ b/web/router.go @@ -0,0 +1,91 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package web + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +var ( + // ErrMethodMismatch is returned when the method in the request does not match + // the method defined against the route. + ErrMethodMismatch = mux.ErrMethodMismatch + // ErrNotFound is returned when no route match is found. + ErrNotFound = mux.ErrNotFound + // SkipRouter is used as a return value from WalkFuncs to indicate that the + // router that walk is about to descend down to should be skipped. + SkipRouter = mux.SkipRouter +) + +type ( + // Router registers routes to be matched and dispatches a handler. + // + // It implements the http.Handler interface, so it can be registered to serve + // requests: + // + // var router = mux.NewRouter() + // + // func main() { + // http.Handle("/", router) + // } + // + // Or, for Google App Engine, register it in a init() function: + // + // func init() { + // http.Handle("/", router) + // } + // + // This will send all incoming requests to the router. + Router = mux.Router + // Route stores information to match a request and build URLs. + Route = mux.Route + // RouteMatch stores information about a matched route. + RouteMatch = mux.RouteMatch + // BuildVarsFunc is the function signature used by custom build variable + // functions (which can modify route variables before a route's URL is built). + BuildVarsFunc = mux.BuildVarsFunc + // MatcherFunc is the function signature used by custom matchers. + MatcherFunc = mux.MatcherFunc + // WalkFunc is the type of the function called for each route visited by Walk. + // At every invocation, it is given the current route, and the current router, + // and a list of ancestor routes that lead to the current route. + WalkFunc = mux.WalkFunc + // MiddlewareFunc is a function which receives an http.Handler and returns another http.Handler. + // Typically, the returned handler is a closure which does something with the http.ResponseWriter and http.Request passed + // to it, and then calls the handler passed as parameter to the MiddlewareFunc. + MiddlewareFunc = mux.MiddlewareFunc +) + +// NewRouter returns a new router instance. +func NewRouter() *Router { + return mux.NewRouter() +} + +// Vars returns the route variables for the current request, if any. +func Vars(r *http.Request) map[string]string { + return mux.Vars(r) +} + +// CurrentRoute returns the matched route for the current request, if any. +// This only works when called inside the handler of the matched route +// because the matched route is stored in the request context which is cleared +// after the handler returns. +func CurrentRoute(r *http.Request) *Route { + return mux.CurrentRoute(r) +} diff --git a/web/server.go b/web/server.go new file mode 100644 index 00000000..f9ccbc91 --- /dev/null +++ b/web/server.go @@ -0,0 +1,307 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package web + +import ( + "context" + "crypto/tls" + "errors" + "net/http" + + "github.com/go-spring-projects/go-spring/web/render" +) + +// A Server defines parameters for running an HTTP server. +type Server struct { + options Options + router *Router + renderer Renderer + httpSvr *http.Server +} + +// NewServer returns a new server instance. +func NewServer(router *Router, options Options) *Server { + + var addr = options.Addr + if 0 == len(addr) { + addr = ":8080" // default port: 8080 + } + + var tlsConfig *tls.Config + if len(options.CertFile) > 0 && len(options.KeyFile) > 0 { + tlsConfig = &tls.Config{ + GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { + cert, err := tls.LoadX509KeyPair(options.CertFile, options.KeyFile) + if err != nil { + return nil, err + } + return &cert, nil + }, + } + } + + var jsonRenderer = func(ctx context.Context, err error, result interface{}) render.Renderer { + + type jsonResponse struct { + Code int `json:"code"` + Message string `json:"message,omitempty"` + Data interface{} `json:"data"` + } + + var code = 0 + var message = "" + if nil != err { + var e HttpError + if errors.As(err, &e) { + code = e.Code + message = e.Message + } else { + code = http.StatusInternalServerError + message = err.Error() + } + } + + return render.JsonRenderer{Data: jsonResponse{Code: code, Message: message, Data: result}} + } + + return &Server{ + options: options, + router: router, + renderer: RendererFunc(jsonRenderer), + httpSvr: &http.Server{ + Addr: addr, + Handler: router, + TLSConfig: tlsConfig, + ReadTimeout: options.ReadTimeout, + ReadHeaderTimeout: options.ReadHeaderTimeout, + WriteTimeout: options.WriteTimeout, + IdleTimeout: options.IdleTimeout, + MaxHeaderBytes: options.MaxHeaderBytes, + }, + } +} + +// Addr returns the server listen address. +func (s *Server) Addr() string { + return s.httpSvr.Addr +} + +// Run listens on the TCP network address Addr and then +// calls Serve to handle requests on incoming connections. +// Accepted connections are configured to enable TCP keep-alives. +func (s *Server) Run() error { + if nil != s.httpSvr.TLSConfig { + return s.httpSvr.ListenAndServeTLS(s.options.CertFile, s.options.KeyFile) + } + return s.httpSvr.ListenAndServe() +} + +// Shutdown gracefully shuts down the server without interrupting any +// active connections. Shutdown works by first closing all open +// listeners, then closing all idle connections, and then waiting +// indefinitely for connections to return to idle and then shut down. +// If the provided context expires before the shutdown is complete, +// Shutdown returns the context's error, otherwise it returns any +// error returned from closing the Server's underlying Listener(s). +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpSvr.Shutdown(ctx) +} + +// NotFound to be used when no route matches. +// This can be used to render your own 404 Not Found errors. +func (s *Server) NotFound(handler http.Handler) { + s.router.NotFoundHandler = handler +} + +// MethodNotAllowed to be used when the request method does not match the route. +// This can be used to render your own 405 Method Not Allowed errors. +func (s *Server) MethodNotAllowed(handler http.Handler) { + s.router.MethodNotAllowedHandler = handler +} + +// Renderer to be used Response renderer in default. +func (s *Server) Renderer(renderer Renderer) { + s.renderer = renderer +} + +// Match attempts to match the given request against the router's registered routes. +// +// If the request matches a route of this router or one of its subrouters the Route, +// Handler, and Vars fields of the the match argument are filled and this function +// returns true. +// +// If the request does not match any of this router's or its subrouters' routes +// then this function returns false. If available, a reason for the match failure +// will be filled in the match argument's MatchErr field. If the match failure type +// (eg: not found) has a registered handler, the handler is assigned to the Handler +// field of the match argument. +func (s *Server) Match(req *http.Request, match *RouteMatch) bool { + return s.router.Match(req, match) +} + +// ServeHTTP dispatches the handler registered in the matched route. +// +// When there is a match, the route variables can be retrieved calling +// Vars(request). +func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { + s.router.ServeHTTP(w, req) +} + +// Get returns a route registered with the given name. +func (s *Server) Get(name string) *Route { + return s.router.Get(name) +} + +// StrictSlash defines the trailing slash behavior for new routes. The initial +// value is false. +// +// When true, if the route path is "/path/", accessing "/path" will perform a redirect +// to the former and vice versa. In other words, your application will always +// see the path as specified in the route. +// +// When false, if the route path is "/path", accessing "/path/" will not match +// this route and vice versa. +// +// The re-direct is a HTTP 301 (Moved Permanently). Note that when this is set for +// routes with a non-idempotent method (e.g. POST, PUT), the subsequent re-directed +// request will be made as a GET by most clients. Use middleware or client settings +// to modify this behaviour as needed. +// +// Special case: when a route sets a path prefix using the PathPrefix() method, +// strict slash is ignored for that route because the redirect behavior can't +// be determined from a prefix alone. However, any subrouters created from that +// route inherit the original StrictSlash setting. +func (s *Server) StrictSlash(value bool) { + s.router.StrictSlash(value) +} + +// SkipClean defines the path cleaning behaviour for new routes. The initial +// value is false. Users should be careful about which routes are not cleaned +// +// When true, if the route path is "/path//to", it will remain with the double +// slash. This is helpful if you have a route like: /fetch/http://xkcd.com/534/ +// +// When false, the path will be cleaned, so /fetch/http://xkcd.com/534/ will +// become /fetch/http/xkcd.com/534 +func (s *Server) SkipClean(value bool) { + s.router.SkipClean(value) +} + +// UseEncodedPath tells the router to match the encoded original path +// to the routes. +// For eg. "/path/foo%2Fbar/to" will match the path "/path/{var}/to". +// +// If not called, the router will match the unencoded path to the routes. +// For eg. "/path/foo%2Fbar/to" will match the path "/path/foo/bar/to" +func (s *Server) UseEncodedPath() { + s.router.UseEncodedPath() +} + +// NewRoute registers an empty route. +func (s *Server) NewRoute() *Route { + return s.router.NewRoute() +} + +// Name registers a new route with a name. +// See Route.Name(). +func (s *Server) Name(name string) *Route { + return s.router.Name(name) +} + +// Handle registers a new route with a matcher for the URL path. +// See Route.Path() and Route.Handler(). +func (s *Server) Handle(path string, handler http.Handler) *Route { + return s.router.Handle(path, handler) +} + +// HandleFunc registers a new route with a matcher for the URL path. +// See Route.Path() and Route.HandlerFunc(). +func (s *Server) HandleFunc(path string, f func(http.ResponseWriter, *http.Request)) *Route { + return s.router.HandleFunc(path, f) +} + +// Bind registers a new route with a matcher for the URL path. +// +// func(ctx context.Context) +// +// func(ctx context.Context) R +// +// func(ctx context.Context, req T) R +// +// func(ctx context.Context, req T) (R, error) +func (s *Server) Bind(path string, f interface{}, r ...Renderer) *Route { + var renderer = s.renderer + if len(r) > 0 { + renderer = r[0] + } + return s.Handle(path, Bind(f, renderer)) +} + +// Headers registers a new route with a matcher for request header values. +// See Route.Headers(). +func (s *Server) Headers(pairs ...string) *Route { + return s.router.Headers(pairs...) +} + +// MatcherFunc registers a new route with a custom matcher function. +// See Route.MatcherFunc(). +func (s *Server) MatcherFunc(f MatcherFunc) *Route { + return s.router.MatcherFunc(f) +} + +// Methods registers a new route with a matcher for HTTP methods. +// See Route.Methods(). +func (s *Server) Methods(methods ...string) *Route { + return s.router.Methods(methods...) +} + +// Path registers a new route with a matcher for the URL path. +// See Route.Path(). +func (s *Server) Path(tpl string) *Route { + return s.router.Path(tpl) +} + +// PathPrefix registers a new route with a matcher for the URL path prefix. +// See Route.PathPrefix(). +func (s *Server) PathPrefix(tpl string) *Route { + return s.router.PathPrefix(tpl) +} + +// Queries registers a new route with a matcher for URL query values. +// See Route.Queries(). +func (s *Server) Queries(pairs ...string) *Route { + return s.router.Queries(pairs...) +} + +// Schemes registers a new route with a matcher for URL schemes. +// See Route.Schemes(). +func (s *Server) Schemes(schemes ...string) *Route { + return s.router.Schemes(schemes...) +} + +// BuildVarsFunc registers a new route with a custom function for modifying +// route variables before building a URL. +func (s *Server) BuildVarsFunc(f BuildVarsFunc) *Route { + return s.router.BuildVarsFunc(f) +} + +// Walk walks the router and all its sub-routers, calling walkFn for each route +// in the tree. The routes are walked in the order they were added. Sub-routers +// are explored depth-first. +func (s *Server) Walk(walkFn WalkFunc) error { + return s.router.Walk(walkFn) +} diff --git a/web/starter/configuration.go b/web/starter/configuration.go new file mode 100644 index 00000000..03c1cfe1 --- /dev/null +++ b/web/starter/configuration.go @@ -0,0 +1,64 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package starter + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/go-spring-projects/go-spring/gs" + "github.com/go-spring-projects/go-spring/gs/arg" + "github.com/go-spring-projects/go-spring/gs/cond" + "github.com/go-spring-projects/go-spring/web" +) + +func init() { + gs.Configuration(new(serverConfiguration)). + On(cond.OnProperty("http.addr")) +} + +type serverConfiguration struct { + Logger *slog.Logger `logger:""` + Server *web.Server `autowire:""` +} + +func (sc *serverConfiguration) OnAppStart(ctx context.Context) { + sc.Logger.Info("starting http server", slog.String("addr", sc.Server.Addr())) + + go func() { + if err := sc.Server.Run(); nil != err && !errors.Is(err, http.ErrServerClosed) { + panic(fmt.Errorf("failed to start http server `%s`: %w", sc.Server.Addr(), err)) + } + }() +} + +func (sc *serverConfiguration) OnAppStop(ctx context.Context) { + sc.Logger.Info("stopping http server", slog.String("addr", sc.Server.Addr())) + + if err := sc.Server.Shutdown(ctx); nil != err { + sc.Logger.Error("http server shutdown failed", slog.String("addr", sc.Server.Addr()), slog.Any("err", err)) + } else { + sc.Logger.Info("http server shutdown successfully", slog.String("addr", sc.Server.Addr())) + } +} + +func (sc *serverConfiguration) NewServer() *gs.BeanDefinition { + return gs.NewBean(web.NewServer, arg.Value(web.NewRouter()), "${http}").Primary() +} From 2012f1130c5e800b28fce732ce922078026fd290 Mon Sep 17 00:00:00 2001 From: limpo1989 Date: Tue, 5 Dec 2023 17:33:02 +0800 Subject: [PATCH 2/2] Add web package for web applications support --- web/error.go | 16 ++++++++++++++++ web/examples/greeting/main.go | 16 ++++++++++++++++ web/render/binary.go | 16 ++++++++++++++++ web/render/binary_test.go | 16 ++++++++++++++++ web/render/html.go | 16 ++++++++++++++++ web/render/html_test.go | 16 ++++++++++++++++ web/render/json.go | 16 ++++++++++++++++ web/render/json_test.go | 16 ++++++++++++++++ web/render/redirect.go | 16 ++++++++++++++++ web/render/redirect_test.go | 16 ++++++++++++++++ web/render/renderer.go | 16 ++++++++++++++++ web/render/text.go | 16 ++++++++++++++++ web/render/text_test.go | 16 ++++++++++++++++ web/render/xml.go | 16 ++++++++++++++++ web/render/xml_test.go | 16 ++++++++++++++++ 15 files changed, 240 insertions(+) diff --git a/web/error.go b/web/error.go index cef1be2a..9f2944a3 100644 --- a/web/error.go +++ b/web/error.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package web import ( diff --git a/web/examples/greeting/main.go b/web/examples/greeting/main.go index c51c4633..17e3ac27 100644 --- a/web/examples/greeting/main.go +++ b/web/examples/greeting/main.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package main import ( diff --git a/web/render/binary.go b/web/render/binary.go index 7afa6123..f25dc0f2 100644 --- a/web/render/binary.go +++ b/web/render/binary.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package render import ( diff --git a/web/render/binary_test.go b/web/render/binary_test.go index d8de415a..444ff024 100644 --- a/web/render/binary_test.go +++ b/web/render/binary_test.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package render import ( diff --git a/web/render/html.go b/web/render/html.go index 284d49ad..603a1aa1 100644 --- a/web/render/html.go +++ b/web/render/html.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package render import ( diff --git a/web/render/html_test.go b/web/render/html_test.go index d6d6e724..d16e9e89 100644 --- a/web/render/html_test.go +++ b/web/render/html_test.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package render import ( diff --git a/web/render/json.go b/web/render/json.go index 13b3ef76..a455c5d0 100644 --- a/web/render/json.go +++ b/web/render/json.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package render import ( diff --git a/web/render/json_test.go b/web/render/json_test.go index a77bb668..e4dc05a3 100644 --- a/web/render/json_test.go +++ b/web/render/json_test.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package render import ( diff --git a/web/render/redirect.go b/web/render/redirect.go index 95f76840..02521516 100644 --- a/web/render/redirect.go +++ b/web/render/redirect.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package render import ( diff --git a/web/render/redirect_test.go b/web/render/redirect_test.go index 1cbf0b7c..c02d543a 100644 --- a/web/render/redirect_test.go +++ b/web/render/redirect_test.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package render import ( diff --git a/web/render/renderer.go b/web/render/renderer.go index 8693ad53..7d228ea9 100644 --- a/web/render/renderer.go +++ b/web/render/renderer.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package render import "net/http" diff --git a/web/render/text.go b/web/render/text.go index dcb5de49..a7cff06b 100644 --- a/web/render/text.go +++ b/web/render/text.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package render import ( diff --git a/web/render/text_test.go b/web/render/text_test.go index b8518981..f30d3424 100644 --- a/web/render/text_test.go +++ b/web/render/text_test.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package render import ( diff --git a/web/render/xml.go b/web/render/xml.go index c6662460..bcea5d6d 100644 --- a/web/render/xml.go +++ b/web/render/xml.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package render import ( diff --git a/web/render/xml_test.go b/web/render/xml_test.go index 5dd26468..1e46c93f 100644 --- a/web/render/xml_test.go +++ b/web/render/xml_test.go @@ -1,3 +1,19 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package render import (