Repository: ksysoev/make-it-public
Pull request #62: add healthcheck endpoint
Author: login: shan-96
Created: 2025-04-26T13:43:16Z | Merged: 2025-04-27T05:10:56Z

#50 

- add management API base template
- add healthcheck endpoint


Diff:
diff --git a/pkg/api/api.go b/pkg/api/api.go
new file mode 100644
index 0000000..e3b17cb
--- /dev/null
+++ b/pkg/api/api.go
@@ -0,0 +1,71 @@
+// The MIT Server Management API
+
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"time"
+
+	"log/slog"
+)
+
+type Config struct {
+	Listen string `mapstructure:"listen"`
+}
+
+type API struct {
+	config Config
+}
+
+type Endpoint string
+
+const (
+	HealthCheckEndpoint Endpoint = "/health"
+)
+
+func New(cfg Config) *API {
+	return &API{
+		config: cfg,
+	}
+}
+
+// Runs the API management server
+func (api *API) Run(ctx context.Context) error {
+	http.HandleFunc((string(HealthCheckEndpoint)), api.healthCheckHandler)
+	server := &http.Server{
+		Addr:              api.config.Listen,
+		ReadHeaderTimeout: 5 * time.Second,
+		WriteTimeout:      5 * time.Second,
+	}
+
+	go func() {
+		<-ctx.Done()
+
+		_ = server.Close()
+	}()
+
+	if err := server.ListenAndServe(); err != http.ErrServerClosed {
+		return err
+	}
+
+	return nil
+}
+
+// healthCheckHandler returns the API status.
+// This handler can be later modified to cross check required resources
+func (api *API) healthCheckHandler(w http.ResponseWriter, _ *http.Request) {
+	resp := map[string]string{"status": "healthy"}
+
+	w.Header().Set("Content-Type", "application/json")
+
+	err := json.NewEncoder(w).Encode(resp)
+
+	if err != nil {
+		slog.Error("Failed to encode response", "error", err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+
+		return
+	}
+}
diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go
new file mode 100644
index 0000000..871a1e8
--- /dev/null
+++ b/pkg/api/api_test.go
@@ -0,0 +1,75 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+type mockResponseWriter struct {
+	http.ResponseWriter
+}
+
+func (m *mockResponseWriter) Write(_ []byte) (int, error) {
+	return 0, errors.New("mock encoding error")
+}
+
+func TestHealthCheckHandler(t *testing.T) {
+	api := New(Config{Listen: ":8082"})
+	req := httptest.NewRequest(http.MethodGet, "/health", http.NoBody)
+	rr := httptest.NewRecorder()
+	handler := http.HandlerFunc(api.healthCheckHandler)
+
+	handler.ServeHTTP(rr, req)
+	assert.Equal(t, http.StatusOK, rr.Code, "Expected status code 200")
+
+	expectedResponse := map[string]string{"status": "healthy"}
+
+	var actualResponse map[string]string
+	err := json.Unmarshal(rr.Body.Bytes(), &actualResponse)
+
+	assert.NoError(t, err, "Response body should be valid JSON")
+	assert.Equal(t, expectedResponse, actualResponse, "Response body does not match expected")
+}
+
+func TestHealthCheckHandler_JSONEncodeError(t *testing.T) {
+	api := New(Config{Listen: ":8082"})
+	req := httptest.NewRequest(http.MethodGet, "/health", http.NoBody)
+	mockWriter := &mockResponseWriter{ResponseWriter: httptest.NewRecorder()}
+	handler := http.HandlerFunc(api.healthCheckHandler)
+
+	handler.ServeHTTP(mockWriter, req)
+	t.Logf("The test did not panic")
+}
+
+func TestAPIRun(t *testing.T) {
+	api := New(Config{Listen: ":8083"})
+	ctx, cancel := context.WithCancel(context.Background())
+
+	go func() {
+		err := api.Run(ctx)
+		assert.NoError(t, err, "API server should shut down gracefully")
+	}()
+
+	time.Sleep(100 * time.Millisecond)
+
+	resp, err := http.Get("http://localhost:8083/health")
+
+	assert.NoError(t, err, "Health check request should not return an error")
+	assert.Equal(t, http.StatusOK, resp.StatusCode, "Health check should return status 200")
+	assert.Equal(t, resp.Header.Get("Content-Type"), "application/json", "Content-Type should be application/json")
+
+	var response map[string]string
+	err = json.NewDecoder(resp.Body).Decode(&response)
+	assert.NoError(t, err, "Response body should be valid JSON")
+	assert.Equal(t, response["status"], "healthy", "Response body should contain status 'healthy'")
+
+	defer resp.Body.Close()
+	cancel()
+}
diff --git a/pkg/cmd/server/config.go b/pkg/cmd/server/config.go
index bda3651..ae6428e 100644
--- a/pkg/cmd/server/config.go
+++ b/pkg/cmd/server/config.go
@@ -5,6 +5,7 @@ import (
 	"log/slog"
 	"strings"
 
+	"github.com/ksysoev/make-it-public/pkg/api"
 	"github.com/ksysoev/make-it-public/pkg/edge"
 	"github.com/ksysoev/make-it-public/pkg/repo/auth"
 	"github.com/spf13/viper"
@@ -12,6 +13,7 @@ import (
 
 type appConfig struct {
 	Auth     auth.Config    `mapstructure:"auth"`
+	API      api.Config     `mapstructure:"api"`
 	RevProxy revProxyConfig `mapstructure:"reverse_proxy"`
 	HTTP     edge.Config    `mapstructure:"http"`
 }
diff --git a/pkg/cmd/server/config_test.go b/pkg/cmd/server/config_test.go
index 320ed5d..ee1bfab 100644
--- a/pkg/cmd/server/config_test.go
+++ b/pkg/cmd/server/config_test.go
@@ -5,6 +5,7 @@ import (
 	"path/filepath"
 	"testing"
 
+	"github.com/ksysoev/make-it-public/pkg/api"
 	"github.com/ksysoev/make-it-public/pkg/edge"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -16,6 +17,8 @@ http:
   listen: ":8080"
 reverse_proxy:
   listen: ":8081"
+api:
+  listen: ":8082"
 `
 
 	tests := []struct {
@@ -33,6 +36,7 @@ reverse_proxy:
 			expectConfig: &appConfig{
 				HTTP:     edge.Config{Listen: ":8080"},
 				RevProxy: revProxyConfig{Listen: ":8081"},
+				API:      api.Config{Listen: ":8082"},
 			},
 		},
 		{
@@ -49,13 +53,14 @@ reverse_proxy:
 		{
 			name: "valid config with environment overrides",
 			envVars: map[string]string{
-				"HTTP_LISTEN": ":8082",
+				"HTTP_LISTEN": ":8083",
 			},
 			expectError: false,
 			configData:  validConfig,
 			expectConfig: &appConfig{
-				HTTP:     edge.Config{Listen: ":8082"},
+				HTTP:     edge.Config{Listen: ":8083"},
 				RevProxy: revProxyConfig{Listen: ":8081"},
+				API:      api.Config{Listen: ":8082"},
 			},
 		},
 	}
diff --git a/pkg/cmd/server/server.go b/pkg/cmd/server/server.go
index c197a29..fc798c0 100644
--- a/pkg/cmd/server/server.go
+++ b/pkg/cmd/server/server.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"log/slog"
 
+	"github.com/ksysoev/make-it-public/pkg/api"
 	"github.com/ksysoev/make-it-public/pkg/core"
 	"github.com/ksysoev/make-it-public/pkg/edge"
 	"github.com/ksysoev/make-it-public/pkg/repo/auth"
@@ -32,13 +33,15 @@ func RunServerCommand(ctx context.Context, args *args) error {
 
 	revServ := revproxy.New(cfg.RevProxy.Listen, connService)
 	httpServ := edge.New(cfg.HTTP, connService)
+	apiServ := api.New(cfg.API)
 
-	slog.InfoContext(ctx, "server started", "http", cfg.HTTP.Listen, "rev", cfg.RevProxy.Listen)
+	slog.InfoContext(ctx, "server started", "http", cfg.HTTP.Listen, "rev", cfg.RevProxy.Listen, "api", cfg.API.Listen)
 
 	eg, ctx := errgroup.WithContext(ctx)
 
 	eg.Go(func() error { return revServ.Run(ctx) })
 	eg.Go(func() error { return httpServ.Run(ctx) })
+	eg.Go(func() error { return apiServ.Run(ctx) })
 
 	return eg.Wait()
 }
diff --git a/runtime/config.yaml b/runtime/config.yaml
index e12e4e0..5667ca8 100644
--- a/runtime/config.yaml
+++ b/runtime/config.yaml
@@ -2,6 +2,8 @@ http:
   listen: ":8080"
 reverse_proxy:
   listen: ":8081"
+api:
+  listen: ":8082"
 auth:
   redis_addr: "localhost:6379"
   redis_password: ""


Reviews:
user: login: ksysoev
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-26T14:15:11Z
user: login: ksysoev
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-26T14:18:55Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces a new management API specifically for a healthcheck endpoint.  
- Adds an API section to the configuration with a new listener on port :8082  
- Updates the server startup to initialize and run the new API  
- Implements tests to verify the healthcheck functionality

### Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

<details>
<summary>Show a summary per file</summary>

| File                           | Description                                                    |
| ------------------------------ | -------------------------------------------------------------- |
| runtime/config.yaml            | Added API endpoint listening on :8082                          |
| pkg/cmd/server/server.go       | Integrated API server into the main server start routine         |
| pkg/cmd/server/config_test.go  | Updated configuration tests to include API settings              |
| pkg/cmd/server/config.go       | Added API field to the application configuration                  |
| pkg/api/api_test.go            | Added tests for healthcheck endpoint behavior                     |
| pkg/api/api.go                 | Created API module with a healthcheck endpoint implementation       |
</details>





state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T04:19:02Z
user: login: ksysoev
state: APPROVED
author_association: OWNER
submitted_at: 2025-04-27T05:07:58Z

Review comments:
diff_hunk: @@ -0,0 +1,32 @@
+// The MIT Server Management API
+
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+)
+
+type API struct {
+	Listen string `mapstructure:"listen"`
+}
+
+func New(listenAddr string) *API {
+	return &API{
+		Listen: listenAddr,
+	}
+}
+
+// Runs the API management server
+func (api *API) Run() error {
+	http.HandleFunc(("/health"), api.healthCheckHandler)
+	return http.ListenAndServe(api.Listen, nil)
path: pkg/api/api.go
user: login: ksysoev
body: Please add context support to Run method, and shutdown server if context is cancelled
created_at: 2025-04-26T14:15:11Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,32 @@
+// The MIT Server Management API
+
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+)
+
+type API struct {
+	Listen string `mapstructure:"listen"`
+}
+
+func New(listenAddr string) *API {
+	return &API{
+		Listen: listenAddr,
+	}
+}
path: pkg/api/api.go
user: login: ksysoev
body: Please use separate structure for config, and accept it in argument to constructor. Very likely we'll extend it in future, and if we'll do it this way we'll need to modify only this package
created_at: 2025-04-26T14:18:55Z
author_association: OWNER

PR comments:
user: login: codecov-commenter
created_at: 2025-04-26T16:36:33Z
body: ## [Codecov](https://app.codecov.io/gh/ksysoev/make-it-public/pull/62?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kirill+Sysoev) Report
Attention: Patch coverage is `83.78378%` with `6 lines` in your changes missing coverage. Please review.
> Project coverage is 54.66%. Comparing base [(`c773ccd`)](https://app.codecov.io/gh/ksysoev/make-it-public/commit/c773ccdd7c34aa8c66fe29c410cee571a5155b22?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kirill+Sysoev) to head [(`15a1aff`)](https://app.codecov.io/gh/ksysoev/make-it-public/commit/15a1affe99980ddd3b083711b65105f23c3a76e1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kirill+Sysoev).

| [Files with missing lines](https://app.codecov.io/gh/ksysoev/make-it-public/pull/62?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kirill+Sysoev) | Patch % | Lines |
|---|---|---|
| [pkg/api/api.go](https://app.codecov.io/gh/ksysoev/make-it-public/pull/62?src=pr&el=tree&filepath=pkg%2Fapi%2Fapi.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kirill+Sysoev#diff-cGtnL2FwaS9hcGkuZ28=) | 91.17% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/ksysoev/make-it-public/pull/62?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kirill+Sysoev) |
| [pkg/cmd/server/server.go](https://app.codecov.io/gh/ksysoev/make-it-public/pull/62?src=pr&el=tree&filepath=pkg%2Fcmd%2Fserver%2Fserver.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kirill+Sysoev#diff-cGtnL2NtZC9zZXJ2ZXIvc2VydmVyLmdv) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/ksysoev/make-it-public/pull/62?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kirill+Sysoev) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #62      +/-   ##
==========================================
+ Coverage   53.41%   54.66%   +1.24%     
==========================================
  Files          20       21       +1     
  Lines         908      944      +36     
==========================================
+ Hits          485      516      +31     
- Misses        407      411       +4     
- Partials       16       17       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/ksysoev/make-it-public/pull/62?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kirill+Sysoev).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Kirill+Sysoev).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: ksysoev
created_at: 2025-04-27T05:08:14Z
body: Shall I merge?
author_association: OWNER
user: login: shan-96
created_at: 2025-04-27T05:09:44Z
body: > Shall I merge?

@ksysoev yes
author_association: COLLABORATOR