Repository: heathcliff26/go-wol
Pull request #36: api: Move under v1 and implement hosts endpoint for adding/removing hosts
Author: login: heathcliff26
Created: 2025-04-19T18:47:44Z | Merged: 2025-04-19T18:53:17Z

Move the api under as v1.
The wake action is now under /api/v1/wake.

Implement functions for adding/removing hosts.
Fix an error where storage would allow any string to be added as a new MAC.

## Summary by Sourcery

Refactor the API to use versioned routes and implement host management endpoints with proper MAC address validation

New Features:
- Add new API endpoints for adding and removing hosts under /api/v1/hosts
- Implement MAC address validation when adding hosts

Bug Fixes:
- Prevent adding invalid MAC addresses to storage
- Remove previous TODO comments about API implementation

Enhancements:
- Move API routes to versioned structure (/api/v1/)
- Improve error handling for API endpoints
- Update client-side JavaScript to use new API routes

Chores:
- Update server configuration to allow writing to storage
- Remove deprecated API implementation


Diff:
diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go
deleted file mode 100644
index 876dc02..0000000
--- a/pkg/server/api/api.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"log/slog"
-	"net/http"
-
-	"github.com/heathcliff26/go-wol/pkg/wol"
-)
-
-type Response struct {
-	Status string `json:"status"`
-	Reason string `json:"reason"`
-}
-
-func API(res http.ResponseWriter, req *http.Request) {
-	macAddr := req.PathValue("macAddr")
-
-	packet, err := wol.CreatePacket(macAddr)
-	if err != nil {
-		slog.Info("Client send invalid MAC address", slog.String("mac", macAddr), slog.Any("error", err))
-		res.WriteHeader(http.StatusBadRequest)
-		sendResponse(res, "Invalid MAC address")
-		return
-	}
-
-	err = packet.Send("")
-	if err != nil {
-		slog.Info("Failed to send magic packet", slog.String("mac", macAddr), slog.Any("error", err))
-		res.WriteHeader(http.StatusBadRequest)
-		sendResponse(res, "Failed to send magic packet")
-		return
-	}
-
-	slog.Info("Send magic packet", slog.String("mac", macAddr))
-	sendResponse(res, "")
-}
-
-func sendResponse(rw http.ResponseWriter, reason string) {
-	response := Response{
-		Status: "error",
-		Reason: reason,
-	}
-	if reason == "" {
-		response.Status = "ok"
-	}
-
-	b, err := json.MarshalIndent(response, "", "  ")
-	if err != nil {
-		slog.Error("Failed to create Response", "err", err)
-		return
-	}
-
-	_, err = rw.Write(b)
-	if err != nil {
-		slog.Error("Failed to send response to client", "err", err)
-	}
-}
diff --git a/pkg/server/api/api_test.go b/pkg/server/api/api_test.go
deleted file mode 100644
index 173124c..0000000
--- a/pkg/server/api/api_test.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func TestAPI(t *testing.T) {
-	tMatrix := []struct {
-		Name, MAC string
-		Status    int
-		Response  Response
-	}{
-		{
-			Name:   "Success",
-			MAC:    "ff:ff:ff:ff:ff:ff",
-			Status: http.StatusOK,
-			Response: Response{
-				Status: "ok",
-			},
-		},
-		{
-			Name:   "WrongMAC",
-			MAC:    "Not-a-mac-address",
-			Status: http.StatusBadRequest,
-			Response: Response{
-				Status: "error",
-				Reason: "Invalid MAC address",
-			},
-		},
-	}
-
-	for _, tCase := range tMatrix {
-		t.Run(tCase.Name, func(t *testing.T) {
-			assert := assert.New(t)
-
-			mux := http.NewServeMux()
-			mux.HandleFunc("GET /api/{macAddr}", API)
-
-			req := httptest.NewRequest(http.MethodGet, "/api/"+tCase.MAC, nil)
-			rr := httptest.NewRecorder()
-
-			mux.ServeHTTP(rr, req)
-
-			assert.Equal(tCase.Status, rr.Result().StatusCode, "Should return correct status code")
-
-			var res Response
-			err := json.Unmarshal(rr.Body.Bytes(), &res)
-			assert.NoError(err, "Response should be json")
-
-			assert.Equal(tCase.Response, res, "Response should match")
-		})
-	}
-}
diff --git a/pkg/server/api/v1/api.go b/pkg/server/api/v1/api.go
new file mode 100644
index 0000000..c603147
--- /dev/null
+++ b/pkg/server/api/v1/api.go
@@ -0,0 +1,119 @@
+package v1
+
+import (
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"strings"
+
+	"github.com/heathcliff26/go-wol/pkg/server/storage"
+	"github.com/heathcliff26/go-wol/pkg/wol"
+)
+
+type Response struct {
+	Status string `json:"status"`
+	Reason string `json:"reason"`
+}
+
+type apiHandler struct {
+	storage *storage.Storage
+}
+
+func NewRouter(storage *storage.Storage) *http.ServeMux {
+	handler := &apiHandler{
+		storage: storage,
+	}
+
+	router := http.NewServeMux()
+	router.HandleFunc("GET /wake/{macAddr}", WakeHandler)
+	router.HandleFunc("PUT /hosts/{macAddr}/{name}", handler.AddHostHandler)
+	router.HandleFunc("DELETE /hosts/{macAddr}", handler.RemoveHostHandler)
+	return router
+}
+
+// GET /wake/{macAddr}
+// Send a magic packet to the specified MAC address
+func WakeHandler(res http.ResponseWriter, req *http.Request) {
+	macAddr := req.PathValue("macAddr")
+
+	packet, err := wol.CreatePacket(macAddr)
+	if err != nil {
+		slog.Info("Client send invalid MAC address", slog.String("mac", macAddr), slog.Any("error", err))
+		res.WriteHeader(http.StatusBadRequest)
+		sendResponse(res, "Invalid MAC address")
+		return
+	}
+
+	err = packet.Send("")
+	if err != nil {
+		slog.Info("Failed to send magic packet", slog.String("mac", macAddr), slog.Any("error", err))
+		res.WriteHeader(http.StatusBadRequest)
+		sendResponse(res, "Failed to send magic packet")
+		return
+	}
+
+	slog.Info("Send magic packet", slog.String("mac", macAddr))
+	sendResponse(res, "")
+}
+
+// PUT /hosts/{macAddr}/{name}
+// Add a host to the storage
+func (h *apiHandler) AddHostHandler(res http.ResponseWriter, req *http.Request) {
+	macAddr := req.PathValue("macAddr")
+	name := req.PathValue("name")
+
+	err := h.storage.AddHost(macAddr, name)
+	if err != nil {
+		slog.Error("Failed to add host", "mac", macAddr, "name", name, "error", err)
+		if strings.HasSuffix(err.Error(), "invalid MAC address") {
+			res.WriteHeader(http.StatusBadRequest)
+		} else {
+			res.WriteHeader(http.StatusInternalServerError)
+		}
+		sendResponse(res, "Failed to add host")
+		return
+	}
+
+	slog.Info("Added host", slog.String("mac", macAddr), slog.String("name", name))
+	sendResponse(res, "")
+}
+
+// DELETE /hosts/{macAddr}
+// Remove a host from the storage
+func (h *apiHandler) RemoveHostHandler(res http.ResponseWriter, req *http.Request) {
+	macAddr := req.PathValue("macAddr")
+
+	err := h.storage.RemoveHost(macAddr)
+	if err != nil {
+		slog.Error("Failed to remove host", "mac", macAddr, "error", err)
+		res.WriteHeader(http.StatusInternalServerError)
+		sendResponse(res, "Failed to remove host")
+		return
+	}
+
+	slog.Info("Removed host", slog.String("mac", macAddr))
+	sendResponse(res, "")
+}
+
+func sendResponse(rw http.ResponseWriter, reason string) {
+	response := Response{
+		Status: "error",
+		Reason: reason,
+	}
+	if reason == "" {
+		response.Status = "ok"
+	}
+
+	b, err := json.MarshalIndent(response, "", "  ")
+	if err != nil {
+		slog.Error("Failed to create Response", "err", err)
+		return
+	}
+
+	rw.Header().Set("Content-Type", "application/json")
+
+	_, err = rw.Write(b)
+	if err != nil {
+		slog.Error("Failed to send response to client", "err", err)
+	}
+}
diff --git a/pkg/server/api/v1/api_test.go b/pkg/server/api/v1/api_test.go
new file mode 100644
index 0000000..dba3855
--- /dev/null
+++ b/pkg/server/api/v1/api_test.go
@@ -0,0 +1,177 @@
+package v1
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/heathcliff26/go-wol/pkg/server/storage"
+	"github.com/heathcliff26/go-wol/pkg/server/storage/file"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestWakeHandler(t *testing.T) {
+	tMatrix := []struct {
+		Name, MAC string
+		Status    int
+		Response  Response
+	}{
+		{
+			Name:   "Success",
+			MAC:    "ff:ff:ff:ff:ff:ff",
+			Status: http.StatusOK,
+			Response: Response{
+				Status: "ok",
+			},
+		},
+		{
+			Name:   "InvalidMAC",
+			MAC:    "Not-a-mac-address",
+			Status: http.StatusBadRequest,
+			Response: Response{
+				Status: "error",
+				Reason: "Invalid MAC address",
+			},
+		},
+	}
+
+	for _, tCase := range tMatrix {
+		t.Run(tCase.Name, func(t *testing.T) {
+			assert := assert.New(t)
+
+			mux := http.NewServeMux()
+			mux.HandleFunc("GET /api/{macAddr}", WakeHandler)
+
+			req := httptest.NewRequest(http.MethodGet, "/api/"+tCase.MAC, nil)
+			rr := httptest.NewRecorder()
+
+			mux.ServeHTTP(rr, req)
+
+			assert.Equal(tCase.Status, rr.Result().StatusCode, "Should return correct status code")
+
+			var res Response
+			err := json.Unmarshal(rr.Body.Bytes(), &res)
+			assert.NoError(err, "Response should be json")
+
+			assert.Equal(tCase.Response, res, "Response should match")
+		})
+	}
+}
+
+func TestAddHostHandler(t *testing.T) {
+	tMatrix := []struct {
+		Name, MAC, Host string
+		Status          int
+		Response        Response
+	}{
+		{
+			Name:   "Success",
+			MAC:    "00:11:22:33:44:55",
+			Host:   "TestHost",
+			Status: http.StatusOK,
+			Response: Response{
+				Status: "ok",
+			},
+		},
+		{
+			Name:   "InvalidMAC",
+			MAC:    "Invalid-MAC",
+			Host:   "TestHost",
+			Status: http.StatusBadRequest,
+			Response: Response{
+				Status: "error",
+				Reason: "Failed to add host",
+			},
+		},
+	}
+
+	tmpDir := t.TempDir()
+
+	for _, tCase := range tMatrix {
+		t.Run(tCase.Name, func(t *testing.T) {
+			assert := assert.New(t)
+
+			cfg := storage.StorageConfig{
+				Type: "file",
+				File: file.FileBackendConfig{
+					Path: tmpDir + "/" + tCase.Name + "-hosts.yaml",
+				},
+			}
+			fileBackend, err := storage.NewStorage(cfg)
+			if !assert.NoError(err, "Should create file backend without error") {
+				t.FailNow()
+			}
+
+			handler := &apiHandler{storage: fileBackend}
+			mux := http.NewServeMux()
+			mux.HandleFunc("PUT /hosts/{macAddr}/{name}", handler.AddHostHandler)
+
+			req := httptest.NewRequest(http.MethodPut, "/hosts/"+tCase.MAC+"/"+tCase.Host, nil)
+			rr := httptest.NewRecorder()
+
+			mux.ServeHTTP(rr, req)
+
+			assert.Equal(tCase.Status, rr.Result().StatusCode, "Should return correct status code")
+
+			var res Response
+			err = json.Unmarshal(rr.Body.Bytes(), &res)
+			assert.NoError(err, "Response should be json")
+
+			assert.Equal(tCase.Response, res, "Response should match")
+		})
+	}
+}
+
+func TestRemoveHostHandler(t *testing.T) {
+	tMatrix := []struct {
+		Name, MAC string
+		Status    int
+		Response  Response
+	}{
+		{
+			Name:   "Success",
+			MAC:    "00:11:22:33:44:55",
+			Status: http.StatusOK,
+			Response: Response{
+				Status: "ok",
+			},
+		},
+	}
+
+	tmpDir := t.TempDir()
+
+	for _, tCase := range tMatrix {
+		t.Run(tCase.Name, func(t *testing.T) {
+			assert := assert.New(t)
+
+			cfg := storage.StorageConfig{
+				Type: "file",
+				File: file.FileBackendConfig{
+					Path: tmpDir + "/" + tCase.Name + "-hosts.yaml",
+				},
+			}
+			fileBackend, err := storage.NewStorage(cfg)
+			if !assert.NoError(err, "Should create file backend without error") {
+				t.FailNow()
+			}
+
+			handler := &apiHandler{storage: fileBackend}
+			mux := http.NewServeMux()
+			mux.HandleFunc("DELETE /hosts/{macAddr}", handler.RemoveHostHandler)
+
+			req := httptest.NewRequest(http.MethodDelete, "/hosts/"+tCase.MAC, nil)
+			rr := httptest.NewRecorder()
+
+			mux.ServeHTTP(rr, req)
+
+			assert.Equal(tCase.Status, rr.Result().StatusCode, "Should return correct status code")
+
+			var res Response
+			err = json.Unmarshal(rr.Body.Bytes(), &res)
+			assert.NoError(err, "Response should be json")
+
+			assert.Equal(tCase.Response, res, "Response should match")
+		})
+	}
+}
diff --git a/pkg/server/cmd.go b/pkg/server/cmd.go
index e464e50..3260af4 100644
--- a/pkg/server/cmd.go
+++ b/pkg/server/cmd.go
@@ -2,7 +2,6 @@ package server
 
 import (
 	"fmt"
-	"log/slog"
 	"os"
 
 	"github.com/heathcliff26/go-wol/pkg/server/config"
@@ -53,12 +52,6 @@ func run(cmd *cobra.Command) error {
 		return err
 	}
 
-	// TODO: Remove when api functions have been implemented
-	if !cfg.Storage.Readonly {
-		slog.Warn("Writing to storage is not yet supported, ignoring setting")
-	}
-	cfg.Storage.Readonly = true
-
 	server, err := NewServer(cfg.Server, cfg.Storage)
 	if err != nil {
 		return err
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 3be0550..e939408 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -8,7 +8,7 @@ import (
 	"strconv"
 	"strings"
 
-	"github.com/heathcliff26/go-wol/pkg/server/api"
+	api "github.com/heathcliff26/go-wol/pkg/server/api/v1"
 	"github.com/heathcliff26/go-wol/pkg/server/config"
 	"github.com/heathcliff26/go-wol/pkg/server/storage"
 	"github.com/heathcliff26/go-wol/static"
@@ -59,7 +59,7 @@ func (s *Server) Run() error {
 	router := http.NewServeMux()
 	router.HandleFunc("GET /{$}", s.indexHandler)
 	router.HandleFunc("GET /index.html", s.indexHandler)
-	router.HandleFunc("GET /api/{macAddr}", api.API)
+	router.Handle("/api/v1/", http.StripPrefix("/api/v1", api.NewRouter(s.storage)))
 	router.Handle("GET /css/", assetFS)
 	router.Handle("GET /icons/", assetFS)
 	router.Handle("GET /js/", assetFS)
diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go
index 7ba7de8..743470b 100644
--- a/pkg/server/server_test.go
+++ b/pkg/server/server_test.go
@@ -124,7 +124,7 @@ func TestServer(t *testing.T) {
 	t.Run("API", func(t *testing.T) {
 		assert := assert.New(t)
 
-		res, err := http.Get(address + "/api/not-a-mac")
+		res, err := http.Get(address + "/api/v1/wake/not-a-mac")
 		t.Cleanup(func() {
 			res.Body.Close()
 		})
diff --git a/pkg/server/storage/config.go b/pkg/server/storage/config.go
index 6ef68cd..8765d51 100644
--- a/pkg/server/storage/config.go
+++ b/pkg/server/storage/config.go
@@ -3,14 +3,13 @@ package storage
 import "github.com/heathcliff26/go-wol/pkg/server/storage/file"
 
 const (
-	// TODO: Make default true when implementing api
-	DEFAULT_READONLY     = true
+	DEFAULT_READONLY     = false
 	DEFAULT_BACKEND_TYPE = "file"
 )
 
 type StorageConfig struct {
 	Type     string                 `json:"type"`
-	Readonly bool                   `json:"-,omitempty"`
+	Readonly bool                   `json:"readonly,omitempty"`
 	File     file.FileBackendConfig `json:"file,omitempty"`
 }
 
diff --git a/pkg/server/storage/storage.go b/pkg/server/storage/storage.go
index 1cbfdd9..3c2dc25 100644
--- a/pkg/server/storage/storage.go
+++ b/pkg/server/storage/storage.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/heathcliff26/go-wol/pkg/server/storage/file"
 	"github.com/heathcliff26/go-wol/pkg/server/storage/types"
+	"github.com/heathcliff26/go-wol/pkg/wol"
 	"github.com/heathcliff26/go-wol/static"
 )
 
@@ -114,7 +115,12 @@ func (s *Storage) AddHost(mac, host string) error {
 		return fmt.Errorf("storage is readonly")
 	}
 
-	err := s.backend.AddHost(mac, host)
+	_, err := wol.CreatePacket(mac)
+	if err != nil {
+		return fmt.Errorf("invalid MAC address: %w", err)
+	}
+
+	err = s.backend.AddHost(mac, host)
 	if err != nil {
 		return fmt.Errorf("failed to add host: %w", err)
 	}
diff --git a/static/js/client.js b/static/js/client.js
index a648b91..318445c 100644
--- a/static/js/client.js
+++ b/static/js/client.js
@@ -4,7 +4,7 @@ async function wake(macAddr, name = "") {
     const displayName = name != "" ? name + " (" + macAddr + ")" : macAddr
 
     try {
-        const response = await fetch("/api/" + macAddr);
+        const response = await fetch("/api/v1/wake/" + macAddr);
 
         const responseBody = await response.json();
 
@@ -29,13 +29,8 @@ async function addHost() {
     const macAddr = document.getElementById('macAddress').value;
 
     try {
-        const response = await fetch('/api/hosts', {
-            // TODO: Make this PUT and encode mac and name in url instead of body
-            method: 'POST',
-            headers: {
-                'Content-Type': 'application/json',
-            },
-            body: JSON.stringify({ name, macAddr }),
+        const response = await fetch('/api/v1/hosts/'+macAddr+"/"+name, {
+            method: 'PUT',
         });
 
         const responseBody = await response.json();
@@ -59,7 +54,7 @@ async function deleteHost(macAddr, name) {
     }
 
     try {
-        const response = await fetch(`/api/hosts/${macAddr}`, {
+        const response = await fetch(`/api/v1/hosts/${macAddr}`, {
             method: 'DELETE',
         });
 


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR moves API endpoints under the v1 namespace and implements new endpoints for adding and removing hosts while fixing a MAC address validation issue.  
- Updated client-side API requests to use the new /api/v1/ endpoints.  
- Enhanced storage.AddHost to validate MAC addresses using wol.CreatePacket.  
- Updated routing in the server to reflect the new API structure and removed deprecated API code.

### Reviewed Changes

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

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

| File | Description |
| ---- | ----------- |
| static/js/client.js | Updated client endpoint paths for wake, add, and delete host functions and switched HTTP method to PUT for adding hosts. |
| pkg/server/storage/storage.go | Added MAC address validation using wol.CreatePacket before adding a host. |
| pkg/server/storage/config.go | Updated JSON tag for Readonly and changed the default readonly setting from true to false. |
| pkg/server/server.go | Modified API route registration to map to the new /api/v1/ endpoints. |
| pkg/server/cmd.go | Removed the temporary readonly override warning and related code. |
| pkg/server/api/v1/api.go | Created new API handlers grouped under the v1 namespace. |
| pkg/server/api/v1/api_test.go | Added tests for the new API endpoints for wake, add, and remove host operations, though one route pattern does not match the production configuration. |
| pkg/server/api/api_test.go & pkg/server/api/api.go | Removed deprecated API tests and implementation. |
</details>





state: COMMENTED
author_association: NONE
submitted_at: 2025-04-19T18:48:03Z
user: login: sourcery-ai[bot]
body: Hey @heathcliff26 - I've reviewed your changes - here's some feedback:

**Overall Comments**:
- Consider using a consistent style for the API endpoints, either all using path parameters or all using query parameters.
- The client-side code still references the old API endpoint in the wake function, which needs to be updated to the new v1 endpoint.

<details>
<summary>Here's what I looked at during the review</summary>

- 🟢 **General issues**: all looks good
- 🟡 **Security**: 1 issue found
- 🟢 **Review instructions**: all looks good
- 🟡 **Testing**: 2 issues found
- 🟡 **Complexity**: 1 issue found
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-19T18:48:44Z

Review comments:
diff_hunk: @@ -29,13 +29,8 @@ async function addHost() {
     const macAddr = document.getElementById('macAddress').value;
 
     try {
-        const response = await fetch('/api/hosts', {
-            // TODO: Make this PUT and encode mac and name in url instead of body
-            method: 'POST',
-            headers: {
-                'Content-Type': 'application/json',
-            },
-            body: JSON.stringify({ name, macAddr }),
+        const response = await fetch('/api/v1/hosts/'+macAddr+"/"+name, {
path: static/js/client.js
user: login: sourcery-ai[bot]
body: **🚨 suggestion (security):** Consider URL encoding host parameters.

Since 'name' (and possibly 'macAddr') is directly embedded in the URL, URL-encoding these values would avoid potential issues if they contain special characters or spaces.

Suggested implementation:

```javascript
        const response = await fetch('/api/v1/hosts/' + encodeURIComponent(macAddr) + "/" + encodeURIComponent(name), {

```

```javascript
        const response = await fetch(`/api/v1/hosts/${encodeURIComponent(macAddr)}`, {

```
created_at: 2025-04-19T18:48:44Z
author_association: NONE
diff_hunk: @@ -0,0 +1,177 @@
+package v1
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/heathcliff26/go-wol/pkg/server/storage"
+	"github.com/heathcliff26/go-wol/pkg/server/storage/file"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestWakeHandler(t *testing.T) {
+	tMatrix := []struct {
+		Name, MAC string
+		Status    int
+		Response  Response
+	}{
+		{
+			Name:   "Success",
+			MAC:    "ff:ff:ff:ff:ff:ff",
+			Status: http.StatusOK,
+			Response: Response{
+				Status: "ok",
+			},
+		},
+		{
+			Name:   "InvalidMAC",
+			MAC:    "Not-a-mac-address",
+			Status: http.StatusBadRequest,
+			Response: Response{
+				Status: "error",
+				Reason: "Invalid MAC address",
+			},
+		},
+	}
+
+	for _, tCase := range tMatrix {
+		t.Run(tCase.Name, func(t *testing.T) {
+			assert := assert.New(t)
+
+			mux := http.NewServeMux()
+			mux.HandleFunc("GET /api/{macAddr}", WakeHandler)
+
+			req := httptest.NewRequest(http.MethodGet, "/api/"+tCase.MAC, nil)
+			rr := httptest.NewRecorder()
+
+			mux.ServeHTTP(rr, req)
+
+			assert.Equal(tCase.Status, rr.Result().StatusCode, "Should return correct status code")
+
+			var res Response
+			err := json.Unmarshal(rr.Body.Bytes(), &res)
+			assert.NoError(err, "Response should be json")
+
+			assert.Equal(tCase.Response, res, "Response should match")
+		})
+	}
+}
+
+func TestAddHostHandler(t *testing.T) {
path: pkg/server/api/v1/api_test.go
user: login: sourcery-ai[bot]
body: **suggestion (testing):** Test edge cases for AddHostHandler

It would be beneficial to add tests for edge cases like adding a host with an empty name, adding a host with a very long name, adding a host with special characters in the name, and adding a duplicate MAC address.

Suggested implementation:

```golang
		{
			Name:   "Success",
			MAC:    "00:11:22:33:44:55",
			Host:   "TestHost",
			Status: http.StatusOK,
			Response: Response{
				Status: "ok",
			},
		},
		{
			Name:   "Empty Name",
			MAC:    "00:11:22:33:44:56",
			Host:   "",
			Status: http.StatusBadRequest,
			Response: Response{
				Status: "error: empty host name",
			},
		},
		{
			Name:   "Very Long Name",
			MAC:    "00:11:22:33:44:57",
			Host:   "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", // sample long name
			Status: http.StatusBadRequest,
			Response: Response{
				Status: "error: host name too long",
			},
		},
		{
			Name:   "Special Characters",
			MAC:    "00:11:22:33:44:58",
			Host:   "Host@#%!$",
			Status: http.StatusBadRequest,
			Response: Response{
				Status: "error: invalid characters in host name",
			},
		},
		{
			Name:   "Duplicate MAC Address",
			MAC:    "00:11:22:33:44:55", // same as the first test case
			Host:   "AnotherHost",
			Status: http.StatusConflict,
			Response: Response{
				Status: "error: duplicate MAC address",
			},
		},

```

Make sure that the AddHostHandler function is able to recognize these edge cases and return the corresponding status codes and response messages. Adjust the expected response messages/status codes in this test if your production code returns different values.
created_at: 2025-04-19T18:48:44Z
author_association: NONE
diff_hunk: @@ -0,0 +1,177 @@
+package v1
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/heathcliff26/go-wol/pkg/server/storage"
+	"github.com/heathcliff26/go-wol/pkg/server/storage/file"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestWakeHandler(t *testing.T) {
+	tMatrix := []struct {
+		Name, MAC string
+		Status    int
+		Response  Response
+	}{
+		{
+			Name:   "Success",
+			MAC:    "ff:ff:ff:ff:ff:ff",
+			Status: http.StatusOK,
+			Response: Response{
+				Status: "ok",
+			},
+		},
+		{
+			Name:   "InvalidMAC",
+			MAC:    "Not-a-mac-address",
+			Status: http.StatusBadRequest,
+			Response: Response{
+				Status: "error",
+				Reason: "Invalid MAC address",
+			},
+		},
+	}
+
+	for _, tCase := range tMatrix {
+		t.Run(tCase.Name, func(t *testing.T) {
+			assert := assert.New(t)
+
+			mux := http.NewServeMux()
+			mux.HandleFunc("GET /api/{macAddr}", WakeHandler)
+
+			req := httptest.NewRequest(http.MethodGet, "/api/"+tCase.MAC, nil)
+			rr := httptest.NewRecorder()
+
+			mux.ServeHTTP(rr, req)
+
+			assert.Equal(tCase.Status, rr.Result().StatusCode, "Should return correct status code")
+
+			var res Response
+			err := json.Unmarshal(rr.Body.Bytes(), &res)
+			assert.NoError(err, "Response should be json")
+
+			assert.Equal(tCase.Response, res, "Response should match")
+		})
+	}
+}
+
+func TestAddHostHandler(t *testing.T) {
+	tMatrix := []struct {
+		Name, MAC, Host string
+		Status          int
+		Response        Response
+	}{
+		{
+			Name:   "Success",
+			MAC:    "00:11:22:33:44:55",
+			Host:   "TestHost",
+			Status: http.StatusOK,
+			Response: Response{
+				Status: "ok",
+			},
+		},
+		{
+			Name:   "InvalidMAC",
+			MAC:    "Invalid-MAC",
+			Host:   "TestHost",
+			Status: http.StatusBadRequest,
+			Response: Response{
+				Status: "error",
+				Reason: "Failed to add host",
+			},
+		},
+	}
+
+	tmpDir := t.TempDir()
+
+	for _, tCase := range tMatrix {
+		t.Run(tCase.Name, func(t *testing.T) {
+			assert := assert.New(t)
+
+			cfg := storage.StorageConfig{
+				Type: "file",
+				File: file.FileBackendConfig{
+					Path: tmpDir + "/" + tCase.Name + "-hosts.yaml",
+				},
+			}
+			fileBackend, err := storage.NewStorage(cfg)
+			if !assert.NoError(err, "Should create file backend without error") {
+				t.FailNow()
+			}
+
+			handler := &apiHandler{storage: fileBackend}
+			mux := http.NewServeMux()
+			mux.HandleFunc("PUT /hosts/{macAddr}/{name}", handler.AddHostHandler)
+
+			req := httptest.NewRequest(http.MethodPut, "/hosts/"+tCase.MAC+"/"+tCase.Host, nil)
+			rr := httptest.NewRecorder()
+
+			mux.ServeHTTP(rr, req)
+
+			assert.Equal(tCase.Status, rr.Result().StatusCode, "Should return correct status code")
+
+			var res Response
+			err = json.Unmarshal(rr.Body.Bytes(), &res)
+			assert.NoError(err, "Response should be json")
+
+			assert.Equal(tCase.Response, res, "Response should match")
+		})
+	}
+}
+
+func TestRemoveHostHandler(t *testing.T) {
path: pkg/server/api/v1/api_test.go
user: login: sourcery-ai[bot]
body: **suggestion (testing):** Test edge cases for RemoveHostHandler

Consider adding tests for edge cases like removing a non-existent host, removing a host with an invalid MAC address, and removing a host with special characters in the MAC address.
created_at: 2025-04-19T18:48:44Z
author_association: NONE
diff_hunk: @@ -0,0 +1,119 @@
+package v1
+
+import (
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"strings"
+
+	"github.com/heathcliff26/go-wol/pkg/server/storage"
+	"github.com/heathcliff26/go-wol/pkg/wol"
+)
+
+type Response struct {
+	Status string `json:"status"`
+	Reason string `json:"reason"`
+}
+
+type apiHandler struct {
+	storage *storage.Storage
+}
+
+func NewRouter(storage *storage.Storage) *http.ServeMux {
path: pkg/server/api/v1/api.go
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider using a dedicated router library like gorilla/mux to simplify route definitions by separating method verification from the URL path and leveraging standard parameter extraction

The custom route definitions mix HTTP methods and path parameters, making it harder to follow what's happening. Consider using a more conventional routing approach. For example, you can switch to a well-known router like [gorilla/mux](https://github.com/gorilla/mux) to simplify method restrictions and parameter extraction:

```go
import (
	"github.com/gorilla/mux"
	"net/http"
)

func NewRouter(storage *storage.Storage) *mux.Router {
	handler := &apiHandler{ storage: storage }
	router := mux.NewRouter()
	router.HandleFunc("/wake/{macAddr}", WakeHandler).Methods("GET")
	router.HandleFunc("/hosts/{macAddr}/{name}", handler.AddHostHandler).Methods("PUT")
	router.HandleFunc("/hosts/{macAddr}", handler.RemoveHostHandler).Methods("DELETE")
	return router
}
```

This change separates method verification from the URL path and leverages standard parameter extraction, reducing the overall complexity without altering functionality.
created_at: 2025-04-19T18:48:44Z
author_association: NONE

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-04-19T18:47:49Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Reviewer's Guide by Sourcery

This pull request moves the API under `/api/v1`, implements the hosts endpoint for adding/removing hosts, and fixes an error where storage would allow any string to be added as a new MAC. The API endpoints are implemented using `net/http` and the storage backend is used to store the hosts. Input validation was added for MAC addresses.

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| API endpoints are now located under the `/api/v1` path. | <ul><li>Updated the wake endpoint to `/api/v1/wake/{macAddr}`.</li><li>The hosts endpoint is now located under `/api/v1/hosts`.</li><li>The hosts endpoint now uses PUT for adding hosts and DELETE for removing hosts.</li></ul> | `static/js/client.js`<br/>`pkg/server/server.go`<br/>`pkg/server/server_test.go` |
| Implemented API endpoints for adding and removing hosts. | <ul><li>Added `AddHostHandler` to handle PUT requests to `/api/v1/hosts/{macAddr}/{name}`.</li><li>Added `RemoveHostHandler` to handle DELETE requests to `/api/v1/hosts/{macAddr}`.</li><li>Added input validation for MAC addresses when adding hosts.</li></ul> | `pkg/server/api/v1/api.go`<br/>`pkg/server/api/v1/api_test.go` |
| The storage is no longer read-only by default. | <ul><li>Set the default value of `Readonly` to `false` in the storage configuration.</li><li>Removed the warning log when the storage is not read-only.</li></ul> | `pkg/server/cmd.go`<br/>`pkg/server/storage/config.go` |
| Added input validation for MAC addresses when adding hosts. | <ul><li>The `AddHost` function now validates the MAC address before adding it to the storage.</li></ul> | `pkg/server/storage/storage.go` |

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!
- **Generate a plan of action for an issue:** Comment `@sourcery-ai plan` on
  an issue to generate a plan of action for it.

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: NONE
user: login: coveralls
created_at: 2025-04-19T18:49:18Z
body: ## Pull Request Test Coverage Report for [Build 14551932966](https://coveralls.io/builds/73299278)


### Details

* **66** of **83**   **(79.52%)**  changed or added relevant lines in **3** files are covered.
* **1** unchanged line in **1** file lost coverage.
* Overall coverage increased (+**0.5%**) to **85.714%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [pkg/server/api/v1/api.go](https://coveralls.io/builds/73299278/source?filename=pkg%2Fserver%2Fapi%2Fv1%2Fapi.go#L49) | 60 | 77 | 77.92%
<!-- | **Total:** | **66** | **83** | **79.52%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [pkg/server/cmd.go](https://coveralls.io/builds/73299278/source?filename=pkg%2Fserver%2Fcmd.go#L55) | 1 | 27.03% |
<!-- | **Total:** | **1** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/73299278/badge)](https://coveralls.io/builds/73299278) |
| :-- | --: |
| Change from base [Build 14551053296](https://coveralls.io/builds/73298989): |  0.5% |
| Covered Lines: | 618 |
| Relevant Lines: | 721 |

---
##### 💛  - [Coveralls](https://coveralls.io)

author_association: NONE