github.com/labstack/echo/v4
Advanced tools
| package middleware | ||
| import ( | ||
| "bytes" | ||
| "testing" | ||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
| func TestWriteJSONSafeString(t *testing.T) { | ||
| testCases := []struct { | ||
| name string | ||
| whenInput string | ||
| expect string | ||
| expectN int | ||
| }{ | ||
| // Basic cases | ||
| { | ||
| name: "empty string", | ||
| whenInput: "", | ||
| expect: "", | ||
| expectN: 0, | ||
| }, | ||
| { | ||
| name: "simple ASCII without special chars", | ||
| whenInput: "hello", | ||
| expect: "hello", | ||
| expectN: 5, | ||
| }, | ||
| { | ||
| name: "single character", | ||
| whenInput: "a", | ||
| expect: "a", | ||
| expectN: 1, | ||
| }, | ||
| { | ||
| name: "alphanumeric", | ||
| whenInput: "Hello123World", | ||
| expect: "Hello123World", | ||
| expectN: 13, | ||
| }, | ||
| // Special character escaping | ||
| { | ||
| name: "backslash", | ||
| whenInput: `path\to\file`, | ||
| expect: `path\\to\\file`, | ||
| expectN: 14, | ||
| }, | ||
| { | ||
| name: "double quote", | ||
| whenInput: `say "hello"`, | ||
| expect: `say \"hello\"`, | ||
| expectN: 13, | ||
| }, | ||
| { | ||
| name: "backslash and quote combined", | ||
| whenInput: `a\b"c`, | ||
| expect: `a\\b\"c`, | ||
| expectN: 7, | ||
| }, | ||
| { | ||
| name: "single backslash", | ||
| whenInput: `\`, | ||
| expect: `\\`, | ||
| expectN: 2, | ||
| }, | ||
| { | ||
| name: "single quote", | ||
| whenInput: `"`, | ||
| expect: `\"`, | ||
| expectN: 2, | ||
| }, | ||
| // Control character escaping | ||
| { | ||
| name: "backspace", | ||
| whenInput: "hello\bworld", | ||
| expect: `hello\bworld`, | ||
| expectN: 12, | ||
| }, | ||
| { | ||
| name: "form feed", | ||
| whenInput: "hello\fworld", | ||
| expect: `hello\fworld`, | ||
| expectN: 12, | ||
| }, | ||
| { | ||
| name: "newline", | ||
| whenInput: "hello\nworld", | ||
| expect: `hello\nworld`, | ||
| expectN: 12, | ||
| }, | ||
| { | ||
| name: "carriage return", | ||
| whenInput: "hello\rworld", | ||
| expect: `hello\rworld`, | ||
| expectN: 12, | ||
| }, | ||
| { | ||
| name: "tab", | ||
| whenInput: "hello\tworld", | ||
| expect: `hello\tworld`, | ||
| expectN: 12, | ||
| }, | ||
| { | ||
| name: "multiple newlines", | ||
| whenInput: "line1\nline2\nline3", | ||
| expect: `line1\nline2\nline3`, | ||
| expectN: 19, | ||
| }, | ||
| // Low control characters (< 0x20) | ||
| { | ||
| name: "null byte", | ||
| whenInput: "hello\x00world", | ||
| expect: `hello\u0000world`, | ||
| expectN: 16, | ||
| }, | ||
| { | ||
| name: "control character 0x01", | ||
| whenInput: "test\x01value", | ||
| expect: `test\u0001value`, | ||
| expectN: 15, | ||
| }, | ||
| { | ||
| name: "control character 0x0e", | ||
| whenInput: "test\x0evalue", | ||
| expect: `test\u000evalue`, | ||
| expectN: 15, | ||
| }, | ||
| { | ||
| name: "control character 0x1f", | ||
| whenInput: "test\x1fvalue", | ||
| expect: `test\u001fvalue`, | ||
| expectN: 15, | ||
| }, | ||
| { | ||
| name: "multiple control characters", | ||
| whenInput: "\x00\x01\x02", | ||
| expect: `\u0000\u0001\u0002`, | ||
| expectN: 18, | ||
| }, | ||
| // UTF-8 handling | ||
| { | ||
| name: "valid UTF-8 Chinese", | ||
| whenInput: "hello 世界", | ||
| expect: "hello 世界", | ||
| expectN: 12, | ||
| }, | ||
| { | ||
| name: "valid UTF-8 emoji", | ||
| whenInput: "party 🎉 time", | ||
| expect: "party 🎉 time", | ||
| expectN: 15, | ||
| }, | ||
| { | ||
| name: "mixed ASCII and UTF-8", | ||
| whenInput: "Hello世界123", | ||
| expect: "Hello世界123", | ||
| expectN: 14, | ||
| }, | ||
| { | ||
| name: "UTF-8 with special chars", | ||
| whenInput: "世界\n\"test\"", | ||
| expect: `世界\n\"test\"`, | ||
| expectN: 16, | ||
| }, | ||
| // Invalid UTF-8 | ||
| { | ||
| name: "invalid UTF-8 sequence", | ||
| whenInput: "hello\xff\xfeworld", | ||
| expect: `hello\ufffd\ufffdworld`, | ||
| expectN: 22, | ||
| }, | ||
| { | ||
| name: "incomplete UTF-8 sequence", | ||
| whenInput: "test\xc3value", | ||
| expect: `test\ufffdvalue`, | ||
| expectN: 15, | ||
| }, | ||
| // Complex mixed cases | ||
| { | ||
| name: "all common escapes", | ||
| whenInput: "tab\there\nquote\"backslash\\", | ||
| expect: `tab\there\nquote\"backslash\\`, | ||
| expectN: 29, | ||
| }, | ||
| { | ||
| name: "mixed controls and UTF-8", | ||
| whenInput: "hello\t世界\ntest\"", | ||
| expect: `hello\t世界\ntest\"`, | ||
| expectN: 21, | ||
| }, | ||
| { | ||
| name: "all control characters", | ||
| whenInput: "\b\f\n\r\t", | ||
| expect: `\b\f\n\r\t`, | ||
| expectN: 10, | ||
| }, | ||
| { | ||
| name: "control and low ASCII", | ||
| whenInput: "a\nb\x00c", | ||
| expect: `a\nb\u0000c`, | ||
| expectN: 11, | ||
| }, | ||
| // Edge cases | ||
| { | ||
| name: "starts with special char", | ||
| whenInput: "\\start", | ||
| expect: `\\start`, | ||
| expectN: 7, | ||
| }, | ||
| { | ||
| name: "ends with special char", | ||
| whenInput: "end\"", | ||
| expect: `end\"`, | ||
| expectN: 5, | ||
| }, | ||
| { | ||
| name: "consecutive special chars", | ||
| whenInput: "\\\\\"\"", | ||
| expect: `\\\\\"\"`, | ||
| expectN: 8, | ||
| }, | ||
| { | ||
| name: "only special characters", | ||
| whenInput: "\"\\\n\t", | ||
| expect: `\"\\\n\t`, | ||
| expectN: 8, | ||
| }, | ||
| { | ||
| name: "spaces and punctuation", | ||
| whenInput: "Hello, World! How are you?", | ||
| expect: "Hello, World! How are you?", | ||
| expectN: 26, | ||
| }, | ||
| { | ||
| name: "JSON-like string", | ||
| whenInput: "{\"key\":\"value\"}", | ||
| expect: `{\"key\":\"value\"}`, | ||
| expectN: 19, | ||
| }, | ||
| } | ||
| for _, tt := range testCases { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| buf := &bytes.Buffer{} | ||
| n, err := writeJSONSafeString(buf, tt.whenInput) | ||
| assert.NoError(t, err) | ||
| assert.Equal(t, tt.expect, buf.String()) | ||
| assert.Equal(t, tt.expectN, n) | ||
| }) | ||
| } | ||
| } | ||
| func BenchmarkWriteJSONSafeString(b *testing.B) { | ||
| testCases := []struct { | ||
| name string | ||
| input string | ||
| }{ | ||
| {"simple", "hello world"}, | ||
| {"with escapes", "tab\there\nquote\"backslash\\"}, | ||
| {"utf8", "hello 世界 🎉"}, | ||
| {"mixed", "Hello\t世界\ntest\"value\\path"}, | ||
| {"long simple", "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789"}, | ||
| {"long complex", "line1\nline2\tline3\"quote\\slash\x00null世界🎉"}, | ||
| } | ||
| for _, tc := range testCases { | ||
| b.Run(tc.name, func(b *testing.B) { | ||
| buf := &bytes.Buffer{} | ||
| b.ResetTimer() | ||
| for i := 0; i < b.N; i++ { | ||
| buf.Reset() | ||
| writeJSONSafeString(buf, tc.input) | ||
| } | ||
| }) | ||
| } | ||
| } |
| // SPDX-License-Identifier: BSD-3-Clause | ||
| // SPDX-FileCopyrightText: Copyright 2010 The Go Authors | ||
| // | ||
| // Copyright 2010 The Go Authors. All rights reserved. | ||
| // Use of this source code is governed by a BSD-style | ||
| // license that can be found in the LICENSE file. | ||
| // | ||
| // | ||
| // Go LICENSE https://raw.githubusercontent.com/golang/go/36bca3166e18db52687a4d91ead3f98ffe6d00b8/LICENSE | ||
| /** | ||
| Copyright 2009 The Go Authors. | ||
| Redistribution and use in source and binary forms, with or without | ||
| modification, are permitted provided that the following conditions are | ||
| met: | ||
| * Redistributions of source code must retain the above copyright | ||
| notice, this list of conditions and the following disclaimer. | ||
| * Redistributions in binary form must reproduce the above | ||
| copyright notice, this list of conditions and the following disclaimer | ||
| in the documentation and/or other materials provided with the | ||
| distribution. | ||
| * Neither the name of Google LLC nor the names of its | ||
| contributors may be used to endorse or promote products derived from | ||
| this software without specific prior written permission. | ||
| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | ||
| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | ||
| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | ||
| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | ||
| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | ||
| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | ||
| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | ||
| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | ||
| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | ||
| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||
| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||
| */ | ||
| package middleware | ||
| import ( | ||
| "bytes" | ||
| "unicode/utf8" | ||
| ) | ||
| // This function is modified copy from Go standard library encoding/json/encode.go `appendString` function | ||
| // Source: https://github.com/golang/go/blob/36bca3166e18db52687a4d91ead3f98ffe6d00b8/src/encoding/json/encode.go#L999 | ||
| func writeJSONSafeString(buf *bytes.Buffer, src string) (int, error) { | ||
| const hex = "0123456789abcdef" | ||
| written := 0 | ||
| start := 0 | ||
| for i := 0; i < len(src); { | ||
| if b := src[i]; b < utf8.RuneSelf { | ||
| if safeSet[b] { | ||
| i++ | ||
| continue | ||
| } | ||
| n, err := buf.Write([]byte(src[start:i])) | ||
| written += n | ||
| if err != nil { | ||
| return written, err | ||
| } | ||
| switch b { | ||
| case '\\', '"': | ||
| n, err := buf.Write([]byte{'\\', b}) | ||
| written += n | ||
| if err != nil { | ||
| return written, err | ||
| } | ||
| case '\b': | ||
| n, err := buf.Write([]byte{'\\', 'b'}) | ||
| written += n | ||
| if err != nil { | ||
| return n, err | ||
| } | ||
| case '\f': | ||
| n, err := buf.Write([]byte{'\\', 'f'}) | ||
| written += n | ||
| if err != nil { | ||
| return written, err | ||
| } | ||
| case '\n': | ||
| n, err := buf.Write([]byte{'\\', 'n'}) | ||
| written += n | ||
| if err != nil { | ||
| return written, err | ||
| } | ||
| case '\r': | ||
| n, err := buf.Write([]byte{'\\', 'r'}) | ||
| written += n | ||
| if err != nil { | ||
| return written, err | ||
| } | ||
| case '\t': | ||
| n, err := buf.Write([]byte{'\\', 't'}) | ||
| written += n | ||
| if err != nil { | ||
| return written, err | ||
| } | ||
| default: | ||
| // This encodes bytes < 0x20 except for \b, \f, \n, \r and \t. | ||
| n, err := buf.Write([]byte{'\\', 'u', '0', '0', hex[b>>4], hex[b&0xF]}) | ||
| written += n | ||
| if err != nil { | ||
| return written, err | ||
| } | ||
| } | ||
| i++ | ||
| start = i | ||
| continue | ||
| } | ||
| srcN := min(len(src)-i, utf8.UTFMax) | ||
| c, size := utf8.DecodeRuneInString(src[i : i+srcN]) | ||
| if c == utf8.RuneError && size == 1 { | ||
| n, err := buf.Write([]byte(src[start:i])) | ||
| written += n | ||
| if err != nil { | ||
| return written, err | ||
| } | ||
| n, err = buf.Write([]byte(`\ufffd`)) | ||
| written += n | ||
| if err != nil { | ||
| return written, err | ||
| } | ||
| i += size | ||
| start = i | ||
| continue | ||
| } | ||
| i += size | ||
| } | ||
| n, err := buf.Write([]byte(src[start:])) | ||
| written += n | ||
| return written, err | ||
| } | ||
| // safeSet holds the value true if the ASCII character with the given array | ||
| // position can be represented inside a JSON string without any further | ||
| // escaping. | ||
| // | ||
| // All values are true except for the ASCII control characters (0-31), the | ||
| // double quote ("), and the backslash character ("\"). | ||
| var safeSet = [utf8.RuneSelf]bool{ | ||
| ' ': true, | ||
| '!': true, | ||
| '"': false, | ||
| '#': true, | ||
| '$': true, | ||
| '%': true, | ||
| '&': true, | ||
| '\'': true, | ||
| '(': true, | ||
| ')': true, | ||
| '*': true, | ||
| '+': true, | ||
| ',': true, | ||
| '-': true, | ||
| '.': true, | ||
| '/': true, | ||
| '0': true, | ||
| '1': true, | ||
| '2': true, | ||
| '3': true, | ||
| '4': true, | ||
| '5': true, | ||
| '6': true, | ||
| '7': true, | ||
| '8': true, | ||
| '9': true, | ||
| ':': true, | ||
| ';': true, | ||
| '<': true, | ||
| '=': true, | ||
| '>': true, | ||
| '?': true, | ||
| '@': true, | ||
| 'A': true, | ||
| 'B': true, | ||
| 'C': true, | ||
| 'D': true, | ||
| 'E': true, | ||
| 'F': true, | ||
| 'G': true, | ||
| 'H': true, | ||
| 'I': true, | ||
| 'J': true, | ||
| 'K': true, | ||
| 'L': true, | ||
| 'M': true, | ||
| 'N': true, | ||
| 'O': true, | ||
| 'P': true, | ||
| 'Q': true, | ||
| 'R': true, | ||
| 'S': true, | ||
| 'T': true, | ||
| 'U': true, | ||
| 'V': true, | ||
| 'W': true, | ||
| 'X': true, | ||
| 'Y': true, | ||
| 'Z': true, | ||
| '[': true, | ||
| '\\': false, | ||
| ']': true, | ||
| '^': true, | ||
| '_': true, | ||
| '`': true, | ||
| 'a': true, | ||
| 'b': true, | ||
| 'c': true, | ||
| 'd': true, | ||
| 'e': true, | ||
| 'f': true, | ||
| 'g': true, | ||
| 'h': true, | ||
| 'i': true, | ||
| 'j': true, | ||
| 'k': true, | ||
| 'l': true, | ||
| 'm': true, | ||
| 'n': true, | ||
| 'o': true, | ||
| 'p': true, | ||
| 'q': true, | ||
| 'r': true, | ||
| 's': true, | ||
| 't': true, | ||
| 'u': true, | ||
| 'v': true, | ||
| 'w': true, | ||
| 'x': true, | ||
| 'y': true, | ||
| 'z': true, | ||
| '{': true, | ||
| '|': true, | ||
| '}': true, | ||
| '~': true, | ||
| '\u007f': true, | ||
| } |
+39
-0
| # Changelog | ||
| ## v4.14.0 - 2025-12-11 | ||
| `middleware.Logger` has been deprecated. For request logging, use `middleware.RequestLogger` or | ||
| `middleware.RequestLoggerWithConfig`. | ||
| `middleware.RequestLogger` replaces `middleware.Logger`, offering comparable configuration while relying on the | ||
| Go standard library’s new `slog` logger. | ||
| The previous default output format was JSON. The new default follows the standard `slog` logger settings. | ||
| To continue emitting request logs in JSON, configure `slog` accordingly: | ||
| ```go | ||
| slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) | ||
| e.Use(middleware.RequestLogger()) | ||
| ``` | ||
| **Security** | ||
| * Logger middleware json string escaping and deprecation by @aldas in https://github.com/labstack/echo/pull/2849 | ||
| **Enhancements** | ||
| * Update deps by @aldas in https://github.com/labstack/echo/pull/2807 | ||
| * refactor to use reflect.TypeFor by @cuiweixie in https://github.com/labstack/echo/pull/2812 | ||
| * Use Go 1.25 in CI by @aldas in https://github.com/labstack/echo/pull/2810 | ||
| * Modernize context.go by replacing interface{} with any by @vishr in https://github.com/labstack/echo/pull/2822 | ||
| * Fix typo in SetParamValues comment by @vishr in https://github.com/labstack/echo/pull/2828 | ||
| * Fix typo in ContextTimeout middleware comment by @vishr in https://github.com/labstack/echo/pull/2827 | ||
| * Improve BasicAuth middleware: use strings.Cut and RFC compliance by @vishr in https://github.com/labstack/echo/pull/2825 | ||
| * Fix duplicate plus operator in router backtracking logic by @yuya-morimoto in https://github.com/labstack/echo/pull/2832 | ||
| * Replace custom private IP range check with built-in net.IP.IsPrivate by @kumapower17 in https://github.com/labstack/echo/pull/2835 | ||
| * Ensure proxy connection is closed in proxyRaw function(#2837) by @kumapower17 in https://github.com/labstack/echo/pull/2838 | ||
| * Update deps by @aldas in https://github.com/labstack/echo/pull/2843 | ||
| * Update golang.org/x/* deps by @aldas in https://github.com/labstack/echo/pull/2850 | ||
| ## v4.13.4 - 2025-05-22 | ||
@@ -4,0 +43,0 @@ |
+1
-1
@@ -262,3 +262,3 @@ // SPDX-License-Identifier: MIT | ||
| // Version of Echo | ||
| Version = "4.13.4" | ||
| Version = "4.14.0" | ||
| website = "https://echo.labstack.com" | ||
@@ -265,0 +265,0 @@ // http://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Echo |
+4
-4
@@ -9,4 +9,4 @@ module github.com/labstack/echo/v4 | ||
| github.com/valyala/fasttemplate v1.2.2 | ||
| golang.org/x/crypto v0.45.0 | ||
| golang.org/x/net v0.47.0 | ||
| golang.org/x/crypto v0.46.0 | ||
| golang.org/x/net v0.48.0 | ||
| golang.org/x/time v0.14.0 | ||
@@ -21,5 +21,5 @@ ) | ||
| github.com/valyala/bytebufferpool v1.0.0 // indirect | ||
| golang.org/x/sys v0.38.0 // indirect | ||
| golang.org/x/text v0.31.0 // indirect | ||
| golang.org/x/sys v0.39.0 // indirect | ||
| golang.org/x/text v0.32.0 // indirect | ||
| gopkg.in/yaml.v3 v3.0.1 // indirect | ||
| ) |
+8
-8
@@ -17,11 +17,11 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||
| github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= | ||
| golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= | ||
| golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= | ||
| golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= | ||
| golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= | ||
| golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= | ||
| golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= | ||
| golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= | ||
| golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= | ||
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= | ||
| golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | ||
| golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= | ||
| golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= | ||
| golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= | ||
| golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | ||
| golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= | ||
| golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= | ||
| golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= | ||
@@ -28,0 +28,0 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= |
+310
-104
@@ -8,2 +8,3 @@ // SPDX-License-Identifier: MIT | ||
| "bytes" | ||
| "cmp" | ||
| "encoding/json" | ||
@@ -14,3 +15,3 @@ "errors" | ||
| "net/url" | ||
| "strconv" | ||
| "regexp" | ||
| "strings" | ||
@@ -25,68 +26,319 @@ "testing" | ||
| func TestLogger(t *testing.T) { | ||
| // Note: Just for the test coverage, not a real test. | ||
| e := echo.New() | ||
| req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
| rec := httptest.NewRecorder() | ||
| c := e.NewContext(req, rec) | ||
| h := Logger()(func(c echo.Context) error { | ||
| return c.String(http.StatusOK, "test") | ||
| }) | ||
| func TestLoggerDefaultMW(t *testing.T) { | ||
| var testCases = []struct { | ||
| name string | ||
| whenHeader map[string]string | ||
| whenStatusCode int | ||
| whenResponse string | ||
| whenError error | ||
| expect string | ||
| }{ | ||
| { | ||
| name: "ok, status 200", | ||
| whenStatusCode: http.StatusOK, | ||
| whenResponse: "test", | ||
| expect: `{"time":"2020-04-28T01:26:40Z","id":"","remote_ip":"192.0.2.1","host":"example.com","method":"GET","uri":"/","user_agent":"","status":200,"error":"","latency":1,"latency_human":"1µs","bytes_in":0,"bytes_out":4}` + "\n", | ||
| }, | ||
| { | ||
| name: "ok, status 300", | ||
| whenStatusCode: http.StatusTemporaryRedirect, | ||
| whenResponse: "test", | ||
| expect: `{"time":"2020-04-28T01:26:40Z","id":"","remote_ip":"192.0.2.1","host":"example.com","method":"GET","uri":"/","user_agent":"","status":307,"error":"","latency":1,"latency_human":"1µs","bytes_in":0,"bytes_out":4}` + "\n", | ||
| }, | ||
| { | ||
| name: "ok, handler error = status 500", | ||
| whenError: errors.New("error"), | ||
| expect: `{"time":"2020-04-28T01:26:40Z","id":"","remote_ip":"192.0.2.1","host":"example.com","method":"GET","uri":"/","user_agent":"","status":500,"error":"error","latency":1,"latency_human":"1µs","bytes_in":0,"bytes_out":36}` + "\n", | ||
| }, | ||
| { | ||
| name: "ok, remote_ip from X-Real-Ip header", | ||
| whenHeader: map[string]string{echo.HeaderXRealIP: "127.0.0.1"}, | ||
| whenStatusCode: http.StatusOK, | ||
| whenResponse: "test", | ||
| expect: `{"time":"2020-04-28T01:26:40Z","id":"","remote_ip":"127.0.0.1","host":"example.com","method":"GET","uri":"/","user_agent":"","status":200,"error":"","latency":1,"latency_human":"1µs","bytes_in":0,"bytes_out":4}` + "\n", | ||
| }, | ||
| { | ||
| name: "ok, remote_ip from X-Forwarded-For header", | ||
| whenHeader: map[string]string{echo.HeaderXForwardedFor: "127.0.0.1"}, | ||
| whenStatusCode: http.StatusOK, | ||
| whenResponse: "test", | ||
| expect: `{"time":"2020-04-28T01:26:40Z","id":"","remote_ip":"127.0.0.1","host":"example.com","method":"GET","uri":"/","user_agent":"","status":200,"error":"","latency":1,"latency_human":"1µs","bytes_in":0,"bytes_out":4}` + "\n", | ||
| }, | ||
| } | ||
| // Status 2xx | ||
| h(c) | ||
| for _, tc := range testCases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| e := echo.New() | ||
| req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
| if len(tc.whenHeader) > 0 { | ||
| for k, v := range tc.whenHeader { | ||
| req.Header.Add(k, v) | ||
| } | ||
| } | ||
| // Status 3xx | ||
| rec = httptest.NewRecorder() | ||
| c = e.NewContext(req, rec) | ||
| h = Logger()(func(c echo.Context) error { | ||
| return c.String(http.StatusTemporaryRedirect, "test") | ||
| }) | ||
| h(c) | ||
| rec := httptest.NewRecorder() | ||
| c := e.NewContext(req, rec) | ||
| // Status 4xx | ||
| rec = httptest.NewRecorder() | ||
| c = e.NewContext(req, rec) | ||
| h = Logger()(func(c echo.Context) error { | ||
| return c.String(http.StatusNotFound, "test") | ||
| }) | ||
| h(c) | ||
| DefaultLoggerConfig.timeNow = func() time.Time { return time.Unix(1588037200, 0).UTC() } | ||
| h := Logger()(func(c echo.Context) error { | ||
| if tc.whenError != nil { | ||
| return tc.whenError | ||
| } | ||
| return c.String(tc.whenStatusCode, tc.whenResponse) | ||
| }) | ||
| buf := new(bytes.Buffer) | ||
| e.Logger.SetOutput(buf) | ||
| // Status 5xx with empty path | ||
| req = httptest.NewRequest(http.MethodGet, "/", nil) | ||
| rec = httptest.NewRecorder() | ||
| c = e.NewContext(req, rec) | ||
| h = Logger()(func(c echo.Context) error { | ||
| return errors.New("error") | ||
| }) | ||
| h(c) | ||
| err := h(c) | ||
| assert.NoError(t, err) | ||
| result := buf.String() | ||
| // handle everchanging latency numbers | ||
| result = regexp.MustCompile(`"latency":\d+,`).ReplaceAllString(result, `"latency":1,`) | ||
| result = regexp.MustCompile(`"latency_human":"[^"]+"`).ReplaceAllString(result, `"latency_human":"1µs"`) | ||
| assert.Equal(t, tc.expect, result) | ||
| }) | ||
| } | ||
| } | ||
| func TestLoggerIPAddress(t *testing.T) { | ||
| e := echo.New() | ||
| req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
| rec := httptest.NewRecorder() | ||
| c := e.NewContext(req, rec) | ||
| buf := new(bytes.Buffer) | ||
| e.Logger.SetOutput(buf) | ||
| ip := "127.0.0.1" | ||
| h := Logger()(func(c echo.Context) error { | ||
| return c.String(http.StatusOK, "test") | ||
| }) | ||
| func TestLoggerWithLoggerConfig(t *testing.T) { | ||
| // to handle everchanging latency numbers | ||
| jsonLatency := map[string]*regexp.Regexp{ | ||
| `"latency":1,`: regexp.MustCompile(`"latency":\d+,`), | ||
| `"latency_human":"1µs"`: regexp.MustCompile(`"latency_human":"[^"]+"`), | ||
| } | ||
| // With X-Real-IP | ||
| req.Header.Add(echo.HeaderXRealIP, ip) | ||
| h(c) | ||
| assert.Contains(t, buf.String(), ip) | ||
| form := make(url.Values) | ||
| form.Set("csrf", "token") | ||
| form.Add("multiple", "1") | ||
| form.Add("multiple", "2") | ||
| // With X-Forwarded-For | ||
| buf.Reset() | ||
| req.Header.Del(echo.HeaderXRealIP) | ||
| req.Header.Add(echo.HeaderXForwardedFor, ip) | ||
| h(c) | ||
| assert.Contains(t, buf.String(), ip) | ||
| var testCases = []struct { | ||
| name string | ||
| givenConfig LoggerConfig | ||
| whenURI string | ||
| whenMethod string | ||
| whenHost string | ||
| whenPath string | ||
| whenRoute string | ||
| whenProto string | ||
| whenRequestURI string | ||
| whenHeader map[string]string | ||
| whenFormValues url.Values | ||
| whenStatusCode int | ||
| whenResponse string | ||
| whenError error | ||
| whenReplacers map[string]*regexp.Regexp | ||
| expect string | ||
| }{ | ||
| { | ||
| name: "ok, skipper", | ||
| givenConfig: LoggerConfig{ | ||
| Skipper: func(c echo.Context) bool { return true }, | ||
| }, | ||
| expect: ``, | ||
| }, | ||
| { // this is an example how format that does not seem to be JSON is not currently escaped | ||
| name: "ok, NON json string is not escaped: method", | ||
| givenConfig: LoggerConfig{Format: `method:"${method}"`}, | ||
| whenMethod: `","method":":D"`, | ||
| expect: `method:"","method":":D""`, | ||
| }, | ||
| { | ||
| name: "ok, json string escape: method", | ||
| givenConfig: LoggerConfig{Format: `{"method":"${method}"}`}, | ||
| whenMethod: `","method":":D"`, | ||
| expect: `{"method":"\",\"method\":\":D\""}`, | ||
| }, | ||
| { | ||
| name: "ok, json string escape: id", | ||
| givenConfig: LoggerConfig{Format: `{"id":"${id}"}`}, | ||
| whenHeader: map[string]string{echo.HeaderXRequestID: `\"127.0.0.1\"`}, | ||
| expect: `{"id":"\\\"127.0.0.1\\\""}`, | ||
| }, | ||
| { | ||
| name: "ok, json string escape: remote_ip", | ||
| givenConfig: LoggerConfig{Format: `{"remote_ip":"${remote_ip}"}`}, | ||
| whenHeader: map[string]string{echo.HeaderXForwardedFor: `\"127.0.0.1\"`}, | ||
| expect: `{"remote_ip":"\\\"127.0.0.1\\\""}`, | ||
| }, | ||
| { | ||
| name: "ok, json string escape: host", | ||
| givenConfig: LoggerConfig{Format: `{"host":"${host}"}`}, | ||
| whenHost: `\"127.0.0.1\"`, | ||
| expect: `{"host":"\\\"127.0.0.1\\\""}`, | ||
| }, | ||
| { | ||
| name: "ok, json string escape: path", | ||
| givenConfig: LoggerConfig{Format: `{"path":"${path}"}`}, | ||
| whenPath: `\","` + "\n", | ||
| expect: `{"path":"\\\",\"\n"}`, | ||
| }, | ||
| { | ||
| name: "ok, json string escape: route", | ||
| givenConfig: LoggerConfig{Format: `{"route":"${route}"}`}, | ||
| whenRoute: `\","` + "\n", | ||
| expect: `{"route":"\\\",\"\n"}`, | ||
| }, | ||
| { | ||
| name: "ok, json string escape: proto", | ||
| givenConfig: LoggerConfig{Format: `{"protocol":"${protocol}"}`}, | ||
| whenProto: `\","` + "\n", | ||
| expect: `{"protocol":"\\\",\"\n"}`, | ||
| }, | ||
| { | ||
| name: "ok, json string escape: referer", | ||
| givenConfig: LoggerConfig{Format: `{"referer":"${referer}"}`}, | ||
| whenHeader: map[string]string{"Referer": `\","` + "\n"}, | ||
| expect: `{"referer":"\\\",\"\n"}`, | ||
| }, | ||
| { | ||
| name: "ok, json string escape: user_agent", | ||
| givenConfig: LoggerConfig{Format: `{"user_agent":"${user_agent}"}`}, | ||
| whenHeader: map[string]string{"User-Agent": `\","` + "\n"}, | ||
| expect: `{"user_agent":"\\\",\"\n"}`, | ||
| }, | ||
| { | ||
| name: "ok, json string escape: bytes_in", | ||
| givenConfig: LoggerConfig{Format: `{"bytes_in":"${bytes_in}"}`}, | ||
| whenHeader: map[string]string{echo.HeaderContentLength: `\","` + "\n"}, | ||
| expect: `{"bytes_in":"\\\",\"\n"}`, | ||
| }, | ||
| { | ||
| name: "ok, json string escape: query param", | ||
| givenConfig: LoggerConfig{Format: `{"query":"${query:test}"}`}, | ||
| whenURI: `/?test=1","`, | ||
| expect: `{"query":"1\",\""}`, | ||
| }, | ||
| { | ||
| name: "ok, json string escape: header", | ||
| givenConfig: LoggerConfig{Format: `{"header":"${header:referer}"}`}, | ||
| whenHeader: map[string]string{"referer": `\","` + "\n"}, | ||
| expect: `{"header":"\\\",\"\n"}`, | ||
| }, | ||
| { | ||
| name: "ok, json string escape: form", | ||
| givenConfig: LoggerConfig{Format: `{"csrf":"${form:csrf}"}`}, | ||
| whenMethod: http.MethodPost, | ||
| whenFormValues: url.Values{"csrf": {`token","`}}, | ||
| expect: `{"csrf":"token\",\""}`, | ||
| }, | ||
| { | ||
| name: "nok, json string escape: cookie - will not accept invalid chars", | ||
| // net/cookie.go: validCookieValueByte function allows these byte in cookie value | ||
| // only `0x20 <= b && b < 0x7f && b != '"' && b != ';' && b != '\\'` | ||
| givenConfig: LoggerConfig{Format: `{"cookie":"${cookie:session}"}`}, | ||
| whenHeader: map[string]string{"Cookie": `_ga=GA1.2.000000000.0000000000; session=test\n`}, | ||
| expect: `{"cookie":""}`, | ||
| }, | ||
| { | ||
| name: "ok, format time_unix", | ||
| givenConfig: LoggerConfig{Format: `${time_unix}`}, | ||
| whenStatusCode: http.StatusOK, | ||
| whenResponse: "test", | ||
| expect: `1588037200`, | ||
| }, | ||
| { | ||
| name: "ok, format time_unix_milli", | ||
| givenConfig: LoggerConfig{Format: `${time_unix_milli}`}, | ||
| whenStatusCode: http.StatusOK, | ||
| whenResponse: "test", | ||
| expect: `1588037200000`, | ||
| }, | ||
| { | ||
| name: "ok, format time_unix_micro", | ||
| givenConfig: LoggerConfig{Format: `${time_unix_micro}`}, | ||
| whenStatusCode: http.StatusOK, | ||
| whenResponse: "test", | ||
| expect: `1588037200000000`, | ||
| }, | ||
| { | ||
| name: "ok, format time_unix_nano", | ||
| givenConfig: LoggerConfig{Format: `${time_unix_nano}`}, | ||
| whenStatusCode: http.StatusOK, | ||
| whenResponse: "test", | ||
| expect: `1588037200000000000`, | ||
| }, | ||
| { | ||
| name: "ok, format time_rfc3339", | ||
| givenConfig: LoggerConfig{Format: `${time_rfc3339}`}, | ||
| whenStatusCode: http.StatusOK, | ||
| whenResponse: "test", | ||
| expect: `2020-04-28T01:26:40Z`, | ||
| }, | ||
| { | ||
| name: "ok, status 200", | ||
| whenStatusCode: http.StatusOK, | ||
| whenResponse: "test", | ||
| whenReplacers: jsonLatency, | ||
| expect: `{"time":"2020-04-28T01:26:40Z","id":"","remote_ip":"192.0.2.1","host":"example.com","method":"GET","uri":"/","user_agent":"","status":200,"error":"","latency":1,"latency_human":"1µs","bytes_in":0,"bytes_out":4}` + "\n", | ||
| }, | ||
| } | ||
| buf.Reset() | ||
| h(c) | ||
| assert.Contains(t, buf.String(), ip) | ||
| for _, tc := range testCases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| e := echo.New() | ||
| req := httptest.NewRequest(http.MethodGet, cmp.Or(tc.whenURI, "/"), nil) | ||
| if tc.whenFormValues != nil { | ||
| req = httptest.NewRequest(http.MethodGet, cmp.Or(tc.whenURI, "/"), strings.NewReader(tc.whenFormValues.Encode())) | ||
| req.Header.Add(echo.HeaderContentType, echo.MIMEApplicationForm) | ||
| } | ||
| for k, v := range tc.whenHeader { | ||
| req.Header.Add(k, v) | ||
| } | ||
| if tc.whenHost != "" { | ||
| req.Host = tc.whenHost | ||
| } | ||
| if tc.whenMethod != "" { | ||
| req.Method = tc.whenMethod | ||
| } | ||
| if tc.whenProto != "" { | ||
| req.Proto = tc.whenProto | ||
| } | ||
| if tc.whenRequestURI != "" { | ||
| req.RequestURI = tc.whenRequestURI | ||
| } | ||
| if tc.whenPath != "" { | ||
| req.URL.Path = tc.whenPath | ||
| } | ||
| rec := httptest.NewRecorder() | ||
| c := e.NewContext(req, rec) | ||
| if tc.whenFormValues != nil { | ||
| c.FormValue("to trigger form parsing") | ||
| } | ||
| if tc.whenRoute != "" { | ||
| c.SetPath(tc.whenRoute) | ||
| } | ||
| config := tc.givenConfig | ||
| if config.timeNow == nil { | ||
| config.timeNow = func() time.Time { return time.Unix(1588037200, 0).UTC() } | ||
| } | ||
| buf := new(bytes.Buffer) | ||
| if config.Output == nil { | ||
| e.Logger.SetOutput(buf) | ||
| } | ||
| h := LoggerWithConfig(config)(func(c echo.Context) error { | ||
| if tc.whenError != nil { | ||
| return tc.whenError | ||
| } | ||
| return c.String(cmp.Or(tc.whenStatusCode, http.StatusOK), cmp.Or(tc.whenResponse, "test")) | ||
| }) | ||
| err := h(c) | ||
| assert.NoError(t, err) | ||
| result := buf.String() | ||
| for replaceTo, replacer := range tc.whenReplacers { | ||
| result = replacer.ReplaceAllString(result, replaceTo) | ||
| } | ||
| assert.Equal(t, tc.expect, result) | ||
| }) | ||
| } | ||
| } | ||
@@ -277,47 +529,1 @@ | ||
| } | ||
| func TestLoggerTemplateWithTimeUnixMilli(t *testing.T) { | ||
| buf := new(bytes.Buffer) | ||
| e := echo.New() | ||
| e.Use(LoggerWithConfig(LoggerConfig{ | ||
| Format: `${time_unix_milli}`, | ||
| Output: buf, | ||
| })) | ||
| e.GET("/", func(c echo.Context) error { | ||
| return c.String(http.StatusOK, "OK") | ||
| }) | ||
| req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
| rec := httptest.NewRecorder() | ||
| e.ServeHTTP(rec, req) | ||
| unixMillis, err := strconv.ParseInt(buf.String(), 10, 64) | ||
| assert.NoError(t, err) | ||
| assert.WithinDuration(t, time.Unix(unixMillis/1000, 0), time.Now(), 3*time.Second) | ||
| } | ||
| func TestLoggerTemplateWithTimeUnixMicro(t *testing.T) { | ||
| buf := new(bytes.Buffer) | ||
| e := echo.New() | ||
| e.Use(LoggerWithConfig(LoggerConfig{ | ||
| Format: `${time_unix_micro}`, | ||
| Output: buf, | ||
| })) | ||
| e.GET("/", func(c echo.Context) error { | ||
| return c.String(http.StatusOK, "OK") | ||
| }) | ||
| req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
| rec := httptest.NewRecorder() | ||
| e.ServeHTTP(rec, req) | ||
| unixMicros, err := strconv.ParseInt(buf.String(), 10, 64) | ||
| assert.NoError(t, err) | ||
| assert.WithinDuration(t, time.Unix(unixMicros/1000000, 0), time.Now(), 3*time.Second) | ||
| } |
+36
-23
@@ -200,2 +200,3 @@ // SPDX-License-Identifier: MIT | ||
| pool *sync.Pool | ||
| timeNow func() time.Time | ||
| } | ||
@@ -212,2 +213,3 @@ | ||
| colorer: color.New(), | ||
| timeNow: time.Now, | ||
| } | ||
@@ -240,2 +242,4 @@ | ||
| // For custom configurations, use LoggerWithConfig instead. | ||
| // | ||
| // Deprecated: please use middleware.RequestLogger or middleware.RequestLoggerWithConfig instead. | ||
| func Logger() echo.MiddlewareFunc { | ||
@@ -265,2 +269,4 @@ return LoggerWithConfig(DefaultLoggerConfig) | ||
| // })) | ||
| // | ||
| // Deprecated: please use middleware.RequestLoggerWithConfig instead. | ||
| func LoggerWithConfig(config LoggerConfig) echo.MiddlewareFunc { | ||
@@ -274,5 +280,14 @@ // Defaults | ||
| } | ||
| writeString := func(buf *bytes.Buffer, in string) (int, error) { return buf.WriteString(in) } | ||
| if config.Format[0] == '{' { // format looks like JSON, so we need to escape invalid characters | ||
| writeString = writeJSONSafeString | ||
| } | ||
| if config.Output == nil { | ||
| config.Output = DefaultLoggerConfig.Output | ||
| } | ||
| timeNow := DefaultLoggerConfig.timeNow | ||
| if config.timeNow != nil { | ||
| timeNow = config.timeNow | ||
| } | ||
@@ -313,17 +328,15 @@ config.template = fasttemplate.New(config.Format, "${", "}") | ||
| case "time_unix": | ||
| return buf.WriteString(strconv.FormatInt(time.Now().Unix(), 10)) | ||
| return buf.WriteString(strconv.FormatInt(timeNow().Unix(), 10)) | ||
| case "time_unix_milli": | ||
| // go 1.17 or later, it supports time#UnixMilli() | ||
| return buf.WriteString(strconv.FormatInt(time.Now().UnixNano()/1000000, 10)) | ||
| return buf.WriteString(strconv.FormatInt(timeNow().UnixMilli(), 10)) | ||
| case "time_unix_micro": | ||
| // go 1.17 or later, it supports time#UnixMicro() | ||
| return buf.WriteString(strconv.FormatInt(time.Now().UnixNano()/1000, 10)) | ||
| return buf.WriteString(strconv.FormatInt(timeNow().UnixMicro(), 10)) | ||
| case "time_unix_nano": | ||
| return buf.WriteString(strconv.FormatInt(time.Now().UnixNano(), 10)) | ||
| return buf.WriteString(strconv.FormatInt(timeNow().UnixNano(), 10)) | ||
| case "time_rfc3339": | ||
| return buf.WriteString(time.Now().Format(time.RFC3339)) | ||
| return buf.WriteString(timeNow().Format(time.RFC3339)) | ||
| case "time_rfc3339_nano": | ||
| return buf.WriteString(time.Now().Format(time.RFC3339Nano)) | ||
| return buf.WriteString(timeNow().Format(time.RFC3339Nano)) | ||
| case "time_custom": | ||
| return buf.WriteString(time.Now().Format(config.CustomTimeFormat)) | ||
| return buf.WriteString(timeNow().Format(config.CustomTimeFormat)) | ||
| case "id": | ||
@@ -334,11 +347,11 @@ id := req.Header.Get(echo.HeaderXRequestID) | ||
| } | ||
| return buf.WriteString(id) | ||
| return writeString(buf, id) | ||
| case "remote_ip": | ||
| return buf.WriteString(c.RealIP()) | ||
| return writeString(buf, c.RealIP()) | ||
| case "host": | ||
| return buf.WriteString(req.Host) | ||
| return writeString(buf, req.Host) | ||
| case "uri": | ||
| return buf.WriteString(req.RequestURI) | ||
| return writeString(buf, req.RequestURI) | ||
| case "method": | ||
| return buf.WriteString(req.Method) | ||
| return writeString(buf, req.Method) | ||
| case "path": | ||
@@ -349,11 +362,11 @@ p := req.URL.Path | ||
| } | ||
| return buf.WriteString(p) | ||
| return writeString(buf, p) | ||
| case "route": | ||
| return buf.WriteString(c.Path()) | ||
| return writeString(buf, c.Path()) | ||
| case "protocol": | ||
| return buf.WriteString(req.Proto) | ||
| return writeString(buf, req.Proto) | ||
| case "referer": | ||
| return buf.WriteString(req.Referer()) | ||
| return writeString(buf, req.Referer()) | ||
| case "user_agent": | ||
| return buf.WriteString(req.UserAgent()) | ||
| return writeString(buf, req.UserAgent()) | ||
| case "status": | ||
@@ -388,3 +401,3 @@ n := res.Status | ||
| } | ||
| return buf.WriteString(cl) | ||
| return writeString(buf, cl) | ||
| case "bytes_out": | ||
@@ -395,7 +408,7 @@ return buf.WriteString(strconv.FormatInt(res.Size, 10)) | ||
| case strings.HasPrefix(tag, "header:"): | ||
| return buf.Write([]byte(c.Request().Header.Get(tag[7:]))) | ||
| return writeString(buf, c.Request().Header.Get(tag[7:])) | ||
| case strings.HasPrefix(tag, "query:"): | ||
| return buf.Write([]byte(c.QueryParam(tag[6:]))) | ||
| return writeString(buf, c.QueryParam(tag[6:])) | ||
| case strings.HasPrefix(tag, "form:"): | ||
| return buf.Write([]byte(c.FormValue(tag[5:]))) | ||
| return writeString(buf, c.FormValue(tag[5:])) | ||
| case strings.HasPrefix(tag, "cookie:"): | ||
@@ -402,0 +415,0 @@ cookie, err := c.Cookie(tag[7:]) |
@@ -7,4 +7,6 @@ // SPDX-License-Identifier: MIT | ||
| import ( | ||
| "github.com/labstack/echo/v4" | ||
| "github.com/stretchr/testify/assert" | ||
| "bytes" | ||
| "encoding/json" | ||
| "errors" | ||
| "log/slog" | ||
| "net/http" | ||
@@ -17,4 +19,101 @@ "net/http/httptest" | ||
| "time" | ||
| "github.com/labstack/echo/v4" | ||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
| func TestRequestLoggerOK(t *testing.T) { | ||
| old := slog.Default() | ||
| t.Cleanup(func() { | ||
| slog.SetDefault(old) | ||
| }) | ||
| buf := new(bytes.Buffer) | ||
| slog.SetDefault(slog.New(slog.NewJSONHandler(buf, nil))) | ||
| e := echo.New() | ||
| e.Use(RequestLogger()) | ||
| e.POST("/test", func(c echo.Context) error { | ||
| return c.String(http.StatusTeapot, "OK") | ||
| }) | ||
| reader := strings.NewReader(`{"foo":"bar"}`) | ||
| req := httptest.NewRequest(http.MethodPost, "/test", reader) | ||
| req.Header.Set(echo.HeaderContentLength, strconv.Itoa(int(reader.Size()))) | ||
| req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) | ||
| req.Header.Set(echo.HeaderXRealIP, "8.8.8.8") | ||
| req.Header.Set("User-Agent", "curl/7.68.0") | ||
| rec := httptest.NewRecorder() | ||
| e.ServeHTTP(rec, req) | ||
| logAttrs := map[string]interface{}{} | ||
| assert.NoError(t, json.Unmarshal(buf.Bytes(), &logAttrs)) | ||
| logAttrs["latency"] = 123 | ||
| logAttrs["time"] = "x" | ||
| expect := map[string]interface{}{ | ||
| "level": "INFO", | ||
| "msg": "REQUEST", | ||
| "method": "POST", | ||
| "uri": "/test", | ||
| "status": float64(418), | ||
| "bytes_in": "13", | ||
| "host": "example.com", | ||
| "bytes_out": float64(2), | ||
| "user_agent": "curl/7.68.0", | ||
| "remote_ip": "8.8.8.8", | ||
| "request_id": "", | ||
| "time": "x", | ||
| "latency": 123, | ||
| } | ||
| assert.Equal(t, expect, logAttrs) | ||
| } | ||
| func TestRequestLoggerError(t *testing.T) { | ||
| old := slog.Default() | ||
| t.Cleanup(func() { | ||
| slog.SetDefault(old) | ||
| }) | ||
| buf := new(bytes.Buffer) | ||
| slog.SetDefault(slog.New(slog.NewJSONHandler(buf, nil))) | ||
| e := echo.New() | ||
| e.Use(RequestLogger()) | ||
| e.GET("/test", func(c echo.Context) error { | ||
| return errors.New("nope") | ||
| }) | ||
| req := httptest.NewRequest(http.MethodGet, "/test", nil) | ||
| rec := httptest.NewRecorder() | ||
| e.ServeHTTP(rec, req) | ||
| logAttrs := map[string]interface{}{} | ||
| assert.NoError(t, json.Unmarshal(buf.Bytes(), &logAttrs)) | ||
| logAttrs["latency"] = 123 | ||
| logAttrs["time"] = "x" | ||
| expect := map[string]interface{}{ | ||
| "level": "ERROR", | ||
| "msg": "REQUEST_ERROR", | ||
| "method": "GET", | ||
| "uri": "/test", | ||
| "status": float64(500), | ||
| "bytes_in": "", | ||
| "host": "example.com", | ||
| "bytes_out": float64(36.0), | ||
| "user_agent": "", | ||
| "remote_ip": "192.0.2.1", | ||
| "request_id": "", | ||
| "error": "nope", | ||
| "latency": 123, | ||
| "time": "x", | ||
| } | ||
| assert.Equal(t, expect, logAttrs) | ||
| } | ||
| func TestRequestLoggerWithConfig(t *testing.T) { | ||
@@ -21,0 +120,0 @@ e := echo.New() |
@@ -7,3 +7,5 @@ // SPDX-License-Identifier: MIT | ||
| import ( | ||
| "context" | ||
| "errors" | ||
| "log/slog" | ||
| "net/http" | ||
@@ -251,2 +253,68 @@ "time" | ||
| // RequestLogger returns a RequestLogger middleware with default configuration which | ||
| // uses default slog.slog logger. | ||
| // | ||
| // To customize slog output format replace slog default logger: | ||
| // For JSON format: `slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))` | ||
| func RequestLogger() echo.MiddlewareFunc { | ||
| config := RequestLoggerConfig{ | ||
| LogLatency: true, | ||
| LogProtocol: false, | ||
| LogRemoteIP: true, | ||
| LogHost: true, | ||
| LogMethod: true, | ||
| LogURI: true, | ||
| LogURIPath: false, | ||
| LogRoutePath: false, | ||
| LogRequestID: true, | ||
| LogReferer: false, | ||
| LogUserAgent: true, | ||
| LogStatus: true, | ||
| LogError: true, | ||
| LogContentLength: true, | ||
| LogResponseSize: true, | ||
| LogHeaders: nil, | ||
| LogQueryParams: nil, | ||
| LogFormValues: nil, | ||
| HandleError: true, // forwards error to the global error handler, so it can decide appropriate status code | ||
| LogValuesFunc: func(c echo.Context, v RequestLoggerValues) error { | ||
| if v.Error == nil { | ||
| slog.LogAttrs(context.Background(), slog.LevelInfo, "REQUEST", | ||
| slog.String("method", v.Method), | ||
| slog.String("uri", v.URI), | ||
| slog.Int("status", v.Status), | ||
| slog.Duration("latency", v.Latency), | ||
| slog.String("host", v.Host), | ||
| slog.String("bytes_in", v.ContentLength), | ||
| slog.Int64("bytes_out", v.ResponseSize), | ||
| slog.String("user_agent", v.UserAgent), | ||
| slog.String("remote_ip", v.RemoteIP), | ||
| slog.String("request_id", v.RequestID), | ||
| ) | ||
| } else { | ||
| slog.LogAttrs(context.Background(), slog.LevelError, "REQUEST_ERROR", | ||
| slog.String("method", v.Method), | ||
| slog.String("uri", v.URI), | ||
| slog.Int("status", v.Status), | ||
| slog.Duration("latency", v.Latency), | ||
| slog.String("host", v.Host), | ||
| slog.String("bytes_in", v.ContentLength), | ||
| slog.Int64("bytes_out", v.ResponseSize), | ||
| slog.String("user_agent", v.UserAgent), | ||
| slog.String("remote_ip", v.RemoteIP), | ||
| slog.String("request_id", v.RequestID), | ||
| slog.String("error", v.Error.Error()), | ||
| ) | ||
| } | ||
| return nil | ||
| }, | ||
| } | ||
| mw, err := config.ToMiddleware() | ||
| if err != nil { | ||
| panic(err) | ||
| } | ||
| return mw | ||
| } | ||
| // ToMiddleware converts RequestLoggerConfig into middleware or returns an error for invalid configuration. | ||
@@ -253,0 +321,0 @@ func (config RequestLoggerConfig) ToMiddleware() (echo.MiddlewareFunc, error) { |
+2
-2
@@ -76,4 +76,4 @@ [](https://sourcegraph.com/github.com/labstack/echo?badge) | ||
| // Middleware | ||
| e.Use(middleware.Logger()) | ||
| e.Use(middleware.Recover()) | ||
| e.Use(middleware.RequestLogger()) // use the default RequestLogger middleware with slog logger | ||
| e.Use(middleware.Recover()) // recover panics as errors for proper error handling | ||
@@ -80,0 +80,0 @@ // Routes |