From 226a5d510a3699e92c6d184e1158dd051f1fbca5 Mon Sep 17 00:00:00 2001 From: Viacheslav Poturaev Date: Fri, 4 Nov 2022 11:54:58 +0100 Subject: [PATCH] Add Found method to check if resource is available (#18) --- .github/workflows/bench.yml | 4 +- .github/workflows/cloc.yml | 4 +- .github/workflows/golangci-lint.yml | 11 ++--- .github/workflows/gorelease.yml | 8 ++-- .github/workflows/test-unit.yml | 73 ++++++++++++++++++++--------- .golangci.yml | 17 +++++++ Makefile | 2 +- README.md | 27 +++++++++++ go.mod | 2 +- go.sum | 4 +- server.go | 49 +++++++++++++++++-- server_test.go | 31 ++++++++++-- 12 files changed, 186 insertions(+), 46 deletions(-) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 9471f11..d750b9d 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -21,14 +21,14 @@ env: GO111MODULE: "on" CACHE_BENCHMARK: "off" # Enables benchmark result reuse between runs, may skew latency results. RUN_BASE_BENCHMARK: "on" # Runs benchmark for PR base in case benchmark result is missing. - GO_VERSION: 1.18.x + GO_VERSION: 1.19.x jobs: bench: runs-on: ubuntu-latest steps: - name: Install Go stable if: env.GO_VERSION != 'tip' - uses: actions/setup-go@v2 + uses: actions/setup-go@v3 with: go-version: ${{ env.GO_VERSION }} - name: Install Go tip diff --git a/.github/workflows/cloc.yml b/.github/workflows/cloc.yml index d6ff20d..619ca74 100644 --- a/.github/workflows/cloc.yml +++ b/.github/workflows/cloc.yml @@ -24,7 +24,9 @@ jobs: - name: Count Lines Of Code id: loc run: | - curl -sLO https://github.com/vearutop/sccdiff/releases/download/v1.0.1/linux_amd64.tar.gz && tar xf linux_amd64.tar.gz + curl -sLO https://github.com/vearutop/sccdiff/releases/download/v1.0.3/linux_amd64.tar.gz && tar xf linux_amd64.tar.gz + sccdiff_hash=$(git hash-object ./sccdiff) + [ "$sccdiff_hash" == "ae8a07b687bd3dba60861584efe724351aa7ff63" ] || (echo "::error::unexpected hash for sccdiff, possible tampering: $sccdiff_hash" && exit 1) OUTPUT=$(cd pr && ../sccdiff -basedir ../base) echo "${OUTPUT}" OUTPUT="${OUTPUT//$'\n'/%0A}" diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 7e95aee..bf0bcdb 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -19,16 +19,15 @@ jobs: name: golangci-lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Install Go - uses: actions/setup-go@v2 + - uses: actions/setup-go@v3 with: - go-version: 1.18.x + go-version: 1.19.x + - uses: actions/checkout@v2 - name: golangci-lint - uses: golangci/golangci-lint-action@v3.1.0 + uses: golangci/golangci-lint-action@v3.2.0 with: # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. - version: v1.45.2 + version: v1.50.0 # Optional: working directory, useful for monorepos # working-directory: somedir diff --git a/.github/workflows/gorelease.yml b/.github/workflows/gorelease.yml index 80fa588..6267500 100644 --- a/.github/workflows/gorelease.yml +++ b/.github/workflows/gorelease.yml @@ -9,14 +9,14 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: 1.18.x + GO_VERSION: 1.19.x jobs: gorelease: runs-on: ubuntu-latest steps: - name: Install Go stable if: env.GO_VERSION != 'tip' - uses: actions/setup-go@v2 + uses: actions/setup-go@v3 with: go-version: ${{ env.GO_VERSION }} - name: Install Go tip @@ -35,12 +35,12 @@ jobs: with: path: | ~/go/bin/gorelease - key: ${{ runner.os }}-gorelease + key: ${{ runner.os }}-gorelease-generic - name: Gorelease id: gorelease run: | test -e ~/go/bin/gorelease || go install golang.org/x/exp/cmd/gorelease@latest - OUTPUT=$(gorelease || exit 0) + OUTPUT=$(gorelease 2>&1 || exit 0) echo "${OUTPUT}" OUTPUT="${OUTPUT//$'\n'/%0A}" echo "::set-output name=report::$OUTPUT" diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index f5705cd..94441bd 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -16,18 +16,20 @@ env: GO111MODULE: "on" RUN_BASE_COVERAGE: "on" # Runs test for PR base in case base test coverage is missing. COV_GO_VERSION: 1.18.x # Version of Go to collect coverage + TARGET_DELTA_COV: 90 # Target coverage of changed lines, in percents jobs: test: strategy: matrix: - go-version: [ 1.16.x, 1.17.x, 1.18.x, tip ] + go-version: [ 1.16.x, 1.17.x, 1.18.x, 1.19.x ] runs-on: ubuntu-latest steps: - name: Install Go stable if: matrix.go-version != 'tip' - uses: actions/setup-go@v2 + uses: actions/setup-go@v3 with: go-version: ${{ matrix.go-version }} + - name: Install Go tip if: matrix.go-version == 'tip' run: | @@ -37,8 +39,10 @@ jobs: tar -C ~/sdk/gotip -xzf gotip.tar.gz ~/sdk/gotip/bin/go version echo "PATH=$HOME/go/bin:$HOME/sdk/gotip/bin/:$PATH" >> $GITHUB_ENV + - name: Checkout code uses: actions/checkout@v2 + - name: Go cache uses: actions/cache@v2 with: @@ -51,44 +55,57 @@ jobs: key: ${{ runner.os }}-go-cache-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go-cache + - name: Restore base test coverage id: base-coverage - if: matrix.go-version == env.COV_GO_VERSION + if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' uses: actions/cache@v2 with: path: | unit-base.txt # Use base sha for PR or new commit hash for master/main push in test result key. key: ${{ runner.os }}-unit-test-coverage-${{ (github.event.pull_request.base.sha != github.event.after) && github.event.pull_request.base.sha || github.event.after }} - - name: Checkout base code - if: matrix.go-version == env.COV_GO_VERSION && env.RUN_BASE_COVERAGE == 'on' && steps.base-coverage.outputs.cache-hit != 'true' && github.event.pull_request.base.sha != '' - uses: actions/checkout@v2 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: __base + - name: Run test for base code if: matrix.go-version == env.COV_GO_VERSION && env.RUN_BASE_COVERAGE == 'on' && steps.base-coverage.outputs.cache-hit != 'true' && github.event.pull_request.base.sha != '' run: | - cd __base - make | grep test-unit && (make test-unit && go tool cover -func=./unit.coverprofile | sed -e 's/.go:[0-9]*:\t/.go\t/g' | sed -e 's/\t\t*/\t/g' > ../unit-base.txt) || echo "No test-unit in base" + git fetch origin master ${{ github.event.pull_request.base.sha }} + HEAD=$(git rev-parse HEAD) + git reset --hard ${{ github.event.pull_request.base.sha }} + (make test-unit && go tool cover -func=./unit.coverprofile > unit-base.txt) || echo "No test-unit in base" + git reset --hard $HEAD + - name: Test id: test run: | make test-unit - go tool cover -func=./unit.coverprofile | sed -e 's/.go:[0-9]*:\t/.go\t/g' | sed -e 's/\t\t*/\t/g' > unit.txt - OUTPUT=$(test -e unit-base.txt && (diff unit-base.txt unit.txt || exit 0) || cat unit.txt) - echo "${OUTPUT}" - OUTPUT="${OUTPUT//$'\n'/%0A}" + go tool cover -func=./unit.coverprofile > unit.txt TOTAL=$(grep 'total:' unit.txt) echo "${TOTAL}" - echo "::set-output name=diff::$OUTPUT" echo "::set-output name=total::$TOTAL" - - name: Store base coverage - if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }} - run: cp unit.txt unit-base.txt + + - name: Annotate missing test coverage + id: annotate + if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' + run: | + curl -sLO https://github.com/vearutop/gocovdiff/releases/download/v1.3.6/linux_amd64.tar.gz && tar xf linux_amd64.tar.gz + gocovdiff_hash=$(git hash-object ./gocovdiff) + [ "$gocovdiff_hash" == "8e507e0d671d4d6dfb3612309b72b163492f28eb" ] || (echo "::error::unexpected hash for gocovdiff, possible tampering: $gocovdiff_hash" && exit 1) + git fetch origin master ${{ github.event.pull_request.base.sha }} + REP=$(./gocovdiff -cov unit.coverprofile -gha-annotations gha-unit.txt -delta-cov-file delta-cov-unit.txt -target-delta-cov ${TARGET_DELTA_COV}) + echo "${REP}" + REP="${REP//$'\n'/%0A}" + cat gha-unit.txt + DIFF=$(test -e unit-base.txt && ./gocovdiff -func-cov unit.txt -func-base-cov unit-base.txt || echo "Missing base coverage file") + DIFF="${DIFF//$'\n'/%0A}" + TOTAL=$(cat delta-cov-unit.txt) + echo "::set-output name=rep::$REP" + echo "::set-output name=diff::$DIFF" + echo "::set-output name=total::$TOTAL" + - name: Comment Test Coverage continue-on-error: true - if: matrix.go-version == env.COV_GO_VERSION + if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' uses: marocchino/sticky-pull-request-comment@v2 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -96,13 +113,23 @@ jobs: message: | ### Unit Test Coverage ${{ steps.test.outputs.total }} + ${{ steps.annotate.outputs.total }} +
Coverage of changed lines + + ${{ steps.annotate.outputs.rep }} + +
+
Coverage diff with base branch - ```diff - ${{ steps.test.outputs.diff }} - ``` + ${{ steps.annotate.outputs.diff }} +
+ - name: Store base coverage + if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }} + run: cp unit.txt unit-base.txt + - name: Upload code coverage if: matrix.go-version == env.COV_GO_VERSION uses: codecov/codecov-action@v1 diff --git a/.golangci.yml b/.golangci.yml index 58351e4..cfcb856 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -41,16 +41,33 @@ linters: - tagliatelle - errname - ireturn + - exhaustruct + - nonamedreturns + - nosnakecase + - structcheck + - varcheck + - deadcode + - testableexamples + - dupword issues: exclude-use-default: false exclude-rules: - linters: + - gosec - gomnd - goconst - goerr113 - noctx - funlen - dupl + - structcheck + - unused + - unparam + - nosnakecase path: "_test.go" + - linters: + - errcheck # Error checking omitted for brevity. + - gosec + path: "example_" diff --git a/Makefile b/Makefile index 165a6cd..80dc683 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -#GOLANGCI_LINT_VERSION := "v1.45.0" # Optional configuration to pinpoint golangci-lint version. +#GOLANGCI_LINT_VERSION := "v1.50.0" # Optional configuration to pinpoint golangci-lint version. # The head of Makefile determines location of dev-go to include standard targets. GO ?= go diff --git a/README.md b/README.md index 479117a..4c938ad 100644 --- a/README.md +++ b/README.md @@ -130,3 +130,30 @@ func main() { } } ``` + +### Custom handling of Not Found + +If you need special treatment for resources that are not available in static server, you can use `Found` +to check them before serving. + +```go +fileServer := statigz.FileServer(st) +customHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Serve existing static resource. + if fileServer.Found(r) { + fileServer.ServeHTTP(w, r) + + return + } + + // Do something custom for non-existing resource, for example serve index page. + // (This is an example, serving index instead of 404 might not be the best idea in real life 😅). + r.URL.Path = "/" + fileServer.ServeHTTP(w, r) +}) + +// Plug static assets handler to your server or router. +if err := http.ListenAndServe("localhost:80", customHandler); err != nil { + log.Fatal(err) +} +``` \ No newline at end of file diff --git a/go.mod b/go.mod index 34a07af..5c96880 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.17 require ( github.com/andybalholm/brotli v1.0.4 - github.com/bool64/dev v0.2.9 + github.com/bool64/dev v0.2.22 github.com/stretchr/testify v1.4.0 ) diff --git a/go.sum b/go.sum index 88dcd19..e7dfbec 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/bool64/dev v0.2.9 h1:efyGf5pgx4CYWQpCzPEX8a1PgewaCGaEexXa+IYHT/8= -github.com/bool64/dev v0.2.9/go.mod h1:/csLrm+4oDSsKJRIVS0mrywAonLnYKFG8RvGT7Jh9b8= +github.com/bool64/dev v0.2.22 h1:YJFKBRKplkt+0Emq/5Xk1Z5QRmMNzc1UOJkR3rxJksA= +github.com/bool64/dev v0.2.22/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/server.go b/server.go index e10a19a..96d796d 100644 --- a/server.go +++ b/server.go @@ -67,8 +67,8 @@ var SkipCompressionExt = []string{".gz", ".br", ".gif", ".jpg", ".png", ".webp"} // // Typically, file system would be an embed.FS. // -// //go:embed *.png *.br -// var FS embed.FS +// //go:embed *.png *.br +// var FS embed.FS // // Brotli support is optionally available with brotli.AddEncoding. func FileServer(fs fs.ReadDirFS, options ...func(server *Server)) *Server { @@ -367,6 +367,49 @@ func (s *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) { http.NotFound(rw, req) } +// Found returns true if http.Request would be fulfilled by Server. +// +// This can be useful for custom handling of requests to non-existent resources. +func (s *Server) Found(req *http.Request) bool { + fn := strings.TrimPrefix(req.URL.Path, "/") + ae := req.Header.Get("Accept-Encoding") + + if s.info[fn].isDir { + return true + } + + if fn == "" || strings.HasSuffix(fn, "/") { + fn += "index.html" + } + + if ae != "" { + minInfo, _ := s.minEnc(strings.ToLower(ae), fn) + + if minInfo.hash != "" { + // Copy compressed data into response. + return true + } + } + + // Copy uncompressed data into response. + _, uncompressedFound := s.info[fn] + if uncompressedFound { + return true + } + + // Decompress compressed data into response. + for _, enc := range s.Encodings { + info, found := s.info[fn+enc.FileExt] + if !found || enc.Decoder == nil || info.isDir { + continue + } + + return true + } + + return false +} + // Encoding describes content encoding. type Encoding struct { // FileExt is an extension of file with compressed content, for example ".gz". @@ -435,7 +478,7 @@ func EncodeOnInit(server *Server) { // localRedirect gives a Moved Permanently response. // It does not convert relative paths to absolute paths like Redirect does. // -// Copied go1.17/src/net/http/fs.go:685. +// Copied from go1.17/src/net/http/fs.go:685. func localRedirect(w http.ResponseWriter, r *http.Request, newPath string) { if q := r.URL.RawQuery; q != "" { newPath += "?" + q diff --git a/server_test.go b/server_test.go index fb5aad0..79bf776 100644 --- a/server_test.go +++ b/server_test.go @@ -5,9 +5,10 @@ import ( "embed" "io" "io/fs" - "io/ioutil" + "log" "net/http" "net/http/httptest" + "os" "testing" brotli2 "github.com/andybalholm/brotli" @@ -249,7 +250,7 @@ func TestServer_ServeHTTP_get_gz(t *testing.T) { decoded, err := io.ReadAll(r) assert.NoError(t, err) - raw, err := ioutil.ReadFile("_testdata/swagger.json") + raw, err := os.ReadFile("_testdata/swagger.json") assert.NoError(t, err) assert.Equal(t, raw, decoded) @@ -276,7 +277,7 @@ func TestServer_ServeHTTP_get_br(t *testing.T) { decoded, err := io.ReadAll(r) assert.NoError(t, err) - raw, err := ioutil.ReadFile("_testdata/swagger.json") + raw, err := os.ReadFile("_testdata/swagger.json") assert.NoError(t, err) assert.Equal(t, raw, decoded) @@ -328,9 +329,11 @@ func TestServer_ServeHTTP_sub(t *testing.T) { s.ServeHTTP(rw, req) if found { + assert.True(t, s.Found(req)) assert.Equal(t, "", rw.Header().Get("Content-Encoding")) assert.Equal(t, http.StatusOK, rw.Code, u) } else { + assert.False(t, s.Found(req)) assert.Equal(t, http.StatusNotFound, rw.Code, u) } } @@ -350,3 +353,25 @@ func TestServer_ServeHTTP_sub(t *testing.T) { assert.Equal(t, l, rw.Header().Get("Location")) } } + +func ExampleServer_Found() { + fileServer := statigz.FileServer(st) + customHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Serve existing static resource. + if fileServer.Found(r) { + fileServer.ServeHTTP(w, r) + + return + } + + // Do something custom for non-existing resource, for example serve index page. + // (This is an example, serving index instead of 404 might not be the best idea in real life 😅). + r.URL.Path = "/" + fileServer.ServeHTTP(w, r) + }) + + // Plug static assets handler to your server or router. + if err := http.ListenAndServe("localhost:80", customHandler); err != nil { + log.Fatal(err) + } +}