diff --git a/docs/advanced-guide/serving-static-files/page.md b/docs/advanced-guide/serving-static-files/page.md new file mode 100644 index 000000000..e1d856bc5 --- /dev/null +++ b/docs/advanced-guide/serving-static-files/page.md @@ -0,0 +1,75 @@ +# Serving Static Files using GoFr + +Often, we are required to serve static content such as a default profile image, a favicon, or a background image for our +web application. We want to have a mechanism to serve that static content without the hassle of implementing it from scratch. + +GoFr provides a default mechanism where if a `static` folder is available in the directory of the application, +it automatically provides an endpoint with `/static/`, here filename refers to the file we want to get static content to be served. + +Example project structure: + +```dotenv +project_folder +| +|---configs +| .env +|---static +| img1.jpeg +| img2.png +| img3.jpeg +| main.go +| main_test.go +``` + +main.go code: + +```go +package main + +import "gofr.dev/pkg/gofr" + +func main(){ + app := gofr.New() + app.Run() +} +``` + +Additionally, if we want to serve more static endpoints, we have a dedicated function called `AddStaticFiles()` +which takes 2 parameters `endpoint` and the `filepath` of the static folder which we want to serve. + +Example project structure: + +```dotenv +project_folder +| +|---configs +| .env +|---static +| img1.jpeg +| img2.png +| img3.jpeg +|---public +| |---css +| | main.css +| |---js +| | main.js +| | index.html +| main.go +| main_test.go +``` + +main.go file: + +```go +package main + +import "gofr.dev/pkg/gofr" + +func main(){ + app := gofr.New() + app.AddStaticFiles("public", "./public") + app.Run() +} +``` + +In the above example, both endpoints `/public` and `/static` are available for the app to render the static content. diff --git a/pkg/gofr/gofr.go b/pkg/gofr/gofr.go index 5ffad731a..90066cf6b 100644 --- a/pkg/gofr/gofr.go +++ b/pkg/gofr/gofr.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "os" + "path/filepath" "strconv" "strings" "sync" @@ -30,6 +31,8 @@ import ( "gofr.dev/pkg/gofr/service" ) +const defaultPublicStaticDir = "static" + // App is the main application in the GoFr framework. type App struct { // Config can be used by applications to fetch custom configurations from environment or file. @@ -92,6 +95,14 @@ func New() *App { app.subscriptionManager = newSubscriptionManager(app.container) + // static fileserver + currentWd, _ := os.Getwd() + checkDirectory := filepath.Join(currentWd, defaultPublicStaticDir) + + if _, err = os.Stat(checkDirectory); err == nil { + app.AddStaticFiles(defaultPublicStaticDir, checkDirectory) + } + return app } @@ -439,3 +450,22 @@ func contains(elems []string, v string) bool { return false } + +func (a *App) AddStaticFiles(endpoint, filePath string) { + a.httpRegistered = true + + // update file path based on current directory if it starts with ./ + if strings.HasPrefix(filePath, "./") { + currentWorkingDir, _ := os.Getwd() + filePath = filepath.Join(currentWorkingDir, filePath) + } + + endpoint = "/" + strings.TrimPrefix(endpoint, "/") + + if _, err := os.Stat(filePath); err != nil { + a.container.Logger.Errorf("error in registering '%s' static endpoint, error: %v", endpoint, err) + return + } + + a.httpServer.router.AddStaticFiles(endpoint, filePath) +} diff --git a/pkg/gofr/gofr_test.go b/pkg/gofr/gofr_test.go index f91160bb9..1953839a0 100644 --- a/pkg/gofr/gofr_test.go +++ b/pkg/gofr/gofr_test.go @@ -561,3 +561,141 @@ func Test_AddCronJob_Success(t *testing.T) { assert.Truef(t, pass, "unable to add cron job to cron table") } + +func TestStaticHandler(t *testing.T) { + const indexHTML = "indexTest.html" + + // Generating some files for testing + htmlContent := []byte("Test Static File

Testing Static File

") + + createPublicDirectory(t, defaultPublicStaticDir, htmlContent) + + defer os.Remove("static/indexTest.html") + + createPublicDirectory(t, "testdir", htmlContent) + + defer os.RemoveAll("testdir") + + app := New() + + app.AddStaticFiles("gofrTest", "./testdir") + + app.httpRegistered = true + app.httpServer.port = 8022 + + go app.Run() + time.Sleep(1 * time.Second) + + host := "http://localhost:8022" + + tests := []struct { + desc string + method string + path string + statusCode int + expectedBody string + expectedBodyLength int + expectedResponseHeaderType string + }{ + { + desc: "check file content index.html", method: http.MethodGet, path: "/" + defaultPublicStaticDir + "/" + indexHTML, + statusCode: http.StatusOK, expectedBodyLength: len(htmlContent), + expectedResponseHeaderType: "text/html; charset=utf-8", expectedBody: string(htmlContent), + }, + { + desc: "check public endpoint", method: http.MethodGet, + path: "/" + defaultPublicStaticDir, statusCode: http.StatusNotFound, + }, + { + desc: "check file content index.html in custom dir", method: http.MethodGet, path: "/" + "gofrTest" + "/" + indexHTML, + statusCode: http.StatusOK, expectedBodyLength: len(htmlContent), + expectedResponseHeaderType: "text/html; charset=utf-8", expectedBody: string(htmlContent), + }, + { + desc: "check public endpoint in custom dir", method: http.MethodGet, path: "/" + "gofrTest", + statusCode: http.StatusNotFound, + }, + } + + for i, tc := range tests { + request, err := http.NewRequestWithContext(context.Background(), tc.method, host+tc.path, http.NoBody) + if err != nil { + t.Fatalf("TEST[%d], Failed to create request, error: %s", i, err) + } + + request.Header.Set("Content-Type", "application/json") + + client := http.Client{} + + resp, err := client.Do(request) + if err != nil { + t.Fatalf("TEST[%d], Request failed, error: %s", i, err) + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("TEST[%d], Failed to read response body, error: %s", i, err) + } + + body := string(bodyBytes) + + assert.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Equal(t, tc.statusCode, resp.StatusCode, "TEST[%d], Failed with Status Body.\n%s", i, tc.desc) + + if tc.expectedBody != "" { + assert.Contains(t, body, tc.expectedBody, "TEST[%d], Failed with Expected Body.\n%s", i, tc.desc) + } + + if tc.expectedBodyLength != 0 { + contentLength := resp.Header.Get("Content-Length") + assert.Equal(t, strconv.Itoa(tc.expectedBodyLength), contentLength, "TEST[%d], Failed at Content-Length.\n%s", i, tc.desc) + } + + if tc.expectedResponseHeaderType != "" { + assert.Equal(t, + tc.expectedResponseHeaderType, + resp.Header.Get("Content-Type"), + "TEST[%d], Failed at Expected Content-Type.\n%s", i, tc.desc) + } + + resp.Body.Close() + } +} + +func TestStaticHandlerInvalidFilePath(t *testing.T) { + // Generating some files for testing + logs := testutil.StderrOutputForFunc(func() { + app := New() + + app.AddStaticFiles("gofrTest", ".//,.!@#$%^&") + }) + + assert.Contains(t, logs, "no such file or directory") + assert.Contains(t, logs, "error in registering '/gofrTest' static endpoint") +} + +func createPublicDirectory(t *testing.T, defaultPublicStaticDir string, htmlContent []byte) { + t.Helper() + + const indexHTML = "indexTest.html" + + directory := "./" + defaultPublicStaticDir + if _, err := os.Stat(directory); err != nil { + if err = os.Mkdir("./"+defaultPublicStaticDir, os.ModePerm); err != nil { + t.Fatalf("Couldn't create a "+defaultPublicStaticDir+" directory, error: %s", err) + } + } + + file, err := os.Create(filepath.Join(directory, indexHTML)) + + if err != nil { + t.Fatalf("Couldn't create %s file", indexHTML) + } + + _, err = file.Write(htmlContent) + if err != nil { + t.Fatalf("Couldn't write to %s file", indexHTML) + } + + file.Close() +} diff --git a/pkg/gofr/http/router.go b/pkg/gofr/http/router.go index ced1c57ab..9b8c8ca5d 100644 --- a/pkg/gofr/http/router.go +++ b/pkg/gofr/http/router.go @@ -2,10 +2,12 @@ package http import ( "net/http" - - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "os" + "path/filepath" + "strings" "github.com/gorilla/mux" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) // Router is responsible for routing HTTP request. @@ -45,3 +47,36 @@ func (rou *Router) UseMiddleware(mws ...Middleware) { rou.Use(middlewares...) } + +type staticFileConfig struct { + directoryName string +} + +func (rou *Router) AddStaticFiles(endpoint, dirName string) { + cfg := staticFileConfig{directoryName: dirName} + + fileServer := http.FileServer(http.Dir(cfg.directoryName)) + rou.Router.NewRoute().PathPrefix(endpoint + "/").Handler(http.StripPrefix(endpoint, cfg.staticHandler(fileServer))) +} + +func (staticConfig staticFileConfig) staticHandler(fileServer http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + url := r.URL.Path + + filePath := strings.Split(url, "/") + + fileName := filePath[len(filePath)-1] + + const defaultSwaggerFileName = "openapi.json" + + if _, err := os.Stat(filepath.Join(staticConfig.directoryName, url)); fileName == defaultSwaggerFileName && err == nil { + w.WriteHeader(http.StatusForbidden) + + _, _ = w.Write([]byte("403 forbidden")) + + return + } + + fileServer.ServeHTTP(w, r) + }) +} diff --git a/pkg/gofr/http/router_test.go b/pkg/gofr/http/router_test.go index 9c599f21e..afc6dba7f 100644 --- a/pkg/gofr/http/router_test.go +++ b/pkg/gofr/http/router_test.go @@ -3,7 +3,10 @@ package http import ( "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" @@ -66,3 +69,69 @@ func TestRouterWithMiddleware(t *testing.T) { testHeaderValue := rec.Header().Get("X-Test-Middleware") assert.Equal(t, "applied", testHeaderValue, "Test_UseMiddleware Failed! header value mismatch.") } + +func TestRouter_AddStaticFiles(t *testing.T) { + cfg := map[string]string{"HTTP_PORT": "8000", "LOG_LEVEL": "INFO"} + _ = container.NewContainer(config.NewMockConfig(cfg)) + + createTestFileAndDirectory(t, "testDir") + + defer os.RemoveAll("testDir") + + time.Sleep(1 * time.Second) + + currentWorkingDir, _ := os.Getwd() + + // Create a new router instance using the mock container + router := NewRouter() + router.AddStaticFiles("/gofr", currentWorkingDir+"/testDir") + + // Send a request to the test handler + req := httptest.NewRequest("GET", "/gofr/indexTest.html", http.NoBody) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + // Verify the response + assert.Equal(t, http.StatusOK, rec.Code) + + // Send a request to the test handler + req = httptest.NewRequest("GET", "/gofr/openapi.json", http.NoBody) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + + // Verify the response + assert.Equal(t, http.StatusForbidden, rec.Code) +} + +func createTestFileAndDirectory(t *testing.T, dirName string) { + t.Helper() + + htmlContent := []byte("Test Static File

Testing Static File

") + + const indexHTML = "indexTest.html" + + directory := "./" + dirName + + if err := os.Mkdir("./"+dirName, os.ModePerm); err != nil { + t.Fatalf("Couldn't create a "+dirName+" directory, error: %s", err) + } + + file, err := os.Create(filepath.Join(directory, indexHTML)) + if err != nil { + t.Fatalf("Couldn't create %s file", indexHTML) + } + + _, err = file.Write(htmlContent) + if err != nil { + t.Fatalf("Couldn't write to %s file", indexHTML) + } + + file.Close() + + file, err = os.Create(filepath.Join(directory, "openapi.json")) + if err != nil { + t.Fatalf("Couldn't create %s file", indexHTML) + } + + file.Close() +} diff --git a/pkg/gofr/swagger_test.go b/pkg/gofr/swagger_test.go index 46f1eff04..ec4d2dfcc 100644 --- a/pkg/gofr/swagger_test.go +++ b/pkg/gofr/swagger_test.go @@ -98,7 +98,7 @@ func TestSwaggerHandler(t *testing.T) { } if strings.Split(fileResponse.ContentType, ";")[0] != tc.contentType { - t.Errorf("Expected content type 'application/json', got '%s'", fileResponse.ContentType) + t.Errorf("Expected content type '%s', got '%s'", tc.contentType, fileResponse.ContentType) } } }