Build a Threat Event REST API in Go
Build a Go REST API to store and retrieve cybersecurity threat events.
Introduction
30 Second Summary
Every time you log into an app, a security system somewhere is watching for suspicious activity. Brute force attacks, port scans, unauthorized access attempts. These threats need to be captured, stored, and analyzed in real time.
In this project, you will build a REST API in Go that manages cybersecurity threat events. You'll create endpoints for adding, listing, retrieving, and deleting threats using only Go's standard library.
What You'll Build
A locally running REST API that accepts, stores, and retrieves cybersecurity threat events using Go's net/http package.
By the end of this project, you'll have:
- A Go module with clean project structure using the idiomatic cmd/ and internal/ layout.
- HTTP handlers for full CRUD operations on threat events with proper HTTP status codes and JSON request/response handling.
- In-memory storage using Go maps and slices with concurrent-safe access.
- Secret Mission: Add query parameter filtering so users can search threats by severity or event type.
Are there any prerequisites?
You need basic programming knowledge (variables, functions, loops). No prior Go experience is required. This is Project 1 of 7 in the "Threat Detection Pipeline in Go" series.
Set Up Your Go Project
Every Go project starts with a module definition and a clean directory structure. The module tells Go how to find and organize your code's packages.
In this step, you'll verify your Go setup, create a project that follows Go's standard layout, and spin up a basic HTTP server to confirm everything is connected.
In this step, get ready to:
- Verify your Go installation and VS Code tools.
- Create the project directory with Go's idiomatic layout.
- Run a basic HTTP server and test it with curl.
Verify your Go and VS Code setup
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
The Go extension for VS Code gives you code completion, formatting, and error diagnostics as you write Go. Let's make sure it's installed.
- Click the Extensions icon in the left sidebar (it looks like four squares).
- Type Go in the search bar at the top of the Extensions panel.
- Look for the extension called Go by the publisher Go Team at Google (extension ID: golang.Go).
✔️ I see it's already installed
You're all set. Move on to the next instruction below.
ⓧ It's not installed yet
- Click Install on the Go extension by Go Team at Google.
- Wait for the installation to finish. You'll see the button change to Disable and Uninstall when it's done.
The Go extension relies on a set of command-line tools (like gopls, the Go language server) to power its features. Let's make sure those tools are installed and up to date.
- Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the Command Palette.
- Type Go: Install/Update Tools and select it from the dropdown.
- Select all the tools in the list, then click OK.
You'll see output in the terminal as each tool installs. Wait until you see a message confirming all tools are installed.
Now let's confirm Go itself is ready. This project requires Go 1.22 or newer for its enhanced routing features.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
- Check your Go version by running this command:
go version
✔️ I see Go 1.22 or newer
You should see something like go version go1.26.2 darwin/arm64. Any version 1.22 or higher works perfectly.
ⓧ Command not found or older version
Go needs to be installed or updated before you can continue.
- Visit the Go downloads page and download the installer for your operating system.
- Run the installer and follow the prompts.
- Close and reopen your terminal, then run go version again.
Still not working?
Make sure you restarted VS Code after installing Go. The terminal session needs to pick up the new PATH. Help me fix my Go installation.
Create the project directory and initialize a Go module
Go projects follow a standard directory layout. The cmd/ folder holds your executable entry points. The internal/ folder holds private application code that only your project can import.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir threat-api
cd threat-api
A Go module is the top-level unit that groups all your packages together. Initializing a module creates a go.mod file that tracks your module name and Go version.
- Initialize your Go module by running this command:
go mod init threat-api
- Confirm the go.mod file was created by running:
cat go.mod
You should see your module name (threat-api) and a Go version number.
Don't see go.mod?
Make sure you ran go mod init threat-api from inside the threat-api folder. Run pwd to check your current directory.
Help me troubleshoot my Go module initialization.
- Create the directory structure by running this command:
mkdir -p cmd/api internal/models
- Confirm the directories exist by running:
ls -R
You should see cmd/api, internal/models, and go.mod listed in the output.
Why cmd/ and internal/?
This layout follows the official Go project structure. The cmd/api/ folder holds the entry point for your API server. You could add more entry points later (like cmd/worker/) for different executables.
The internal/ folder is special in Go. The compiler enforces that code inside internal/ can only be imported by code within the same parent directory. This keeps your private logic truly private.
- Open the threat-api project folder in VS Code by running this command:
code .
VS Code may open a new window
If VS Code opens a new window, close the old one and continue working in the new window. The new window will have your threat-api folder as the workspace root.
Create a basic HTTP server
Before building any real features, you want to confirm that Go can serve HTTP responses from your project. A minimal server that returns a text message is the fastest way to verify your setup end to end.
- In the VS Code Explorer sidebar, expand the cmd folder, then the api folder inside it.
- Right-click the api folder and select New File.
- Name the file main.go and press Enter.
- Add the following code to main.go:
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
// Create a new request multiplexer for routing
mux := http.NewServeMux()
// Handle GET requests to the root path
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
// Return 404 for any path other than exactly "/"
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
fmt.Fprintln(w, "Threat Detection API is running")
})
// Start the server on port 8080
fmt.Println("Starting Threat Detection API on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
What does this code do?
- http.NewServeMux() creates a ServeMux, Go's built-in request router. It decides which handler function to call based on the incoming URL and HTTP method.
- "GET /" is Go 1.22's enhanced routing syntax. The method name (GET) is part of the pattern, so this handler only fires for GET requests.
- The if r.URL.Path != "/" check ensures that only the exact root path returns the welcome message. Any other path gets a 404.
- log.Fatal(http.ListenAndServe(":8080", mux)) starts the server on port 8080. If the server fails to start, log.Fatal prints the error and exits the program.
- Save main.go by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Open the integrated terminal by pressing Ctrl+`.
- Start your server by running this command:
go run ./cmd/api
You should see Starting Threat Detection API on :8080 printed in the terminal. The server is now running and waiting for requests.
Your terminal is now occupied by the running server, so you'll need a second terminal to send a test request.
- Open a new terminal tab by clicking the + icon in the top-right corner of the VS Code terminal panel.
- Test your server by running this command:
curl http://localhost:8080/
You should see Threat Detection API is running printed in the terminal.
Seeing 'connection refused'?
Make sure your server is still running in the first terminal tab. Look for the Starting Threat Detection API on :8080 message there.
If you see address already in use, another process is using port 8080. Stop it by pressing Ctrl+C in the other terminal, then run go run ./cmd/api again.
Help me debug my Go HTTP server.
- Stop the running server by switching to the first terminal tab and pressing Ctrl+C.
✔️ Awesome, I've got everything!
Make sure you've saved cmd/api/main.go before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each file at this point in the project.
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
fmt.Fprintln(w, "Threat Detection API is running")
})
fmt.Println("Starting Threat Detection API on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
module threat-api
go 1.22
Your Go version number may differ
The go.mod file records the Go version you have installed. If your file says go 1.26 instead of go 1.22, that's perfectly fine. Any version 1.22 or newer works for this project.
Your Go project is set up and your server is responding. Next up, you'll define the threat event data model and build an in-memory store to hold your security data.
Define the Threat Event Model and In-Memory Store
Your project structure is in place and your server responds on localhost:8080. Now it needs data to work with.
Every API needs two things: a data model that defines the shape of the information it handles, and a store that holds that data in memory. In this step, you'll build both for your threat events.
In this step, get ready to:
- Define a ThreatEvent struct with JSON tags for API serialization.
- Build a thread-safe in-memory store with CRUD operations.
- Wire the store into your main server file.
Create the threat event model
Each threat event in your system needs a consistent shape. In Go, you define this shape using a struct.
You'll also create a separate request struct for incoming data. Fields like ID and timestamp are generated by the server, so they don't belong in the request body.
- Create a new file called threat.go inside the internal/models/ folder.
- Copy the following code into internal/models/threat.go:
package models
import "time"
// ThreatEvent represents a single security threat detected by the system
type ThreatEvent struct {
ID int `json:"id"`
SourceIP string `json:"source_ip"`
EventType string `json:"event_type"`
Severity int `json:"severity"`
Description string `json:"description"`
Timestamp time.Time `json:"timestamp"`
}
// CreateThreatEventRequest holds the fields a client sends when creating a new threat
type CreateThreatEventRequest struct {
SourceIP string `json:"source_ip"`
EventType string `json:"event_type"`
Severity int `json:"severity"`
Description string `json:"description"`
}
What does this code do?
You defined two Go structs in this file:
- ThreatEvent is the full threat record. It includes an ID and Timestamp that the server generates automatically.
- CreateThreatEventRequest only includes the fields a client sends when creating a new event. The server fills in the rest.
- The backtick annotations like json:"source_ip" are struct tags. They tell Go's JSON encoder to use snake_case names like source_ip instead of the Go field name SourceIP.
- Save internal/models/threat.go.
- Verify the file compiles by running:
go build ./internal/models
If there's no output, the file compiled successfully. Go only prints something when there's an error.
Seeing a compile error?
- Check that the file is saved inside internal/models/ and named exactly threat.go.
- Make sure the first line says package models. The package name must match the folder name.
- Verify the struct tags use backticks, not regular double quotes. Backticks are the key to the left of the 1 key on your keyboard.
- Still stuck? Help me fix a compile error in my threat.go model file.
Build the in-memory store
Your model defines the shape of a threat event. Now you need a place to keep them. You'll build an in-memory store using Go's built-in map type for fast lookups by ID.
Go's HTTP server handles each request in its own goroutine. Multiple requests can read and write to the store at the same time. A mutex prevents those concurrent operations from corrupting your data.
- Create the internal/store/ directory by running:
mkdir internal/store
- Confirm the directory exists by running:
ls internal/
You should see both models and store listed.
- Create a new file called store.go inside internal/store/.
- Add the store struct and constructor by copying this code into internal/store/store.go:
package store
import (
"sync"
"time"
"threat-api/internal/models"
)
// ThreatStore holds all threat events in memory with safe concurrent access
type ThreatStore struct {
mu sync.RWMutex
threats map[int]models.ThreatEvent
nextID int
}
// NewThreatStore creates an empty store ready to accept events
func NewThreatStore() *ThreatStore {
return &ThreatStore{
threats: make(map[int]models.ThreatEvent),
nextID: 1,
}
}
What does this code do?
- sync.RWMutex is a read-write lock. It allows multiple goroutines to read at the same time, but only one can write. This keeps your data safe without slowing down read-heavy traffic.
- threats is a Go map that stores each event by its integer ID for O(1) lookups.
- nextID is a counter that auto-increments each time a new event is added. It starts at 1.
- make(map[int]models.ThreatEvent) initializes an empty map. In Go, maps must be initialized before use or you'll get a nil pointer panic.
- Save store.go.
You won't see a visible change yet. The next chunk adds the methods that use this struct.
- In internal/store/store.go, add the Add method below the NewThreatStore function:
// Add creates a new threat event with an auto-generated ID and timestamp
func (s *ThreatStore) Add(req models.CreateThreatEventRequest) models.ThreatEvent {
s.mu.Lock()
defer s.mu.Unlock()
event := models.ThreatEvent{
ID: s.nextID,
SourceIP: req.SourceIP,
EventType: req.EventType,
Severity: req.Severity,
Description: req.Description,
Timestamp: time.Now(),
}
s.threats[s.nextID] = event
s.nextID++
return event
}
What does this code do?
- s.mu.Lock() acquires an exclusive write lock. defer s.mu.Unlock() guarantees the lock is released when the function exits, even if an error occurs.
- The method takes a CreateThreatEventRequest (the client's input) and fills in the server-generated fields: ID from the counter and Timestamp from the current time.
- After storing the event, it increments nextID so the next event gets a unique number.
- Save store.go.
You won't see a change yet. The remaining store methods come next.
- Below the Add method, add the remaining read and delete methods:
// GetAll returns every threat event in the store
func (s *ThreatStore) GetAll() []models.ThreatEvent {
s.mu.RLock()
defer s.mu.RUnlock()
events := make([]models.ThreatEvent, 0, len(s.threats))
for _, event := range s.threats {
events = append(events, event)
}
return events
}
// GetByID finds a single threat event by its ID
func (s *ThreatStore) GetByID(id int) (models.ThreatEvent, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
event, ok := s.threats[id]
return event, ok
}
// Delete removes a threat event and reports whether it existed
func (s *ThreatStore) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.threats[id]; !ok {
return false
}
delete(s.threats, id)
return true
}
What does this code do?
- GetAll collects every event from the map into a slice and returns it. It uses RLock (a read lock) so multiple list requests can run at the same time.
- GetByID returns two values: the event and a boolean. This is Go's standard pattern for signaling whether a map lookup succeeded.
- Delete first checks if the event exists. If it does, it removes it with Go's built-in delete function and returns true. If not, it returns false.
- Save store.go.
- Verify the store package compiles by running:
go build ./internal/store
No output means a clean compile. Your store is ready.
Seeing an import error?
- If you see cannot find module providing package threat-api/internal/models, make sure your go.mod file is in the project root (the same folder that contains cmd/ and internal/).
- Check that the module name in go.mod is exactly threat-api. The import path in store.go must match.
- Make sure you are running the command from your project root folder (the threat-api folder on your Desktop), not from inside internal/store/.
- Still stuck? Help me fix an import error in my Go store package.
✔️ Awesome, I've got everything!
Make sure you've saved store.go before moving on.
ⓧ I'd like to double check the full code
Here is the complete internal/store/store.go file. Compare it against yours and fix any differences.
package store
import (
"sync"
"time"
"threat-api/internal/models"
)
type ThreatStore struct {
mu sync.RWMutex
threats map[int]models.ThreatEvent
nextID int
}
func NewThreatStore() *ThreatStore {
return &ThreatStore{
threats: make(map[int]models.ThreatEvent),
nextID: 1,
}
}
func (s *ThreatStore) Add(req models.CreateThreatEventRequest) models.ThreatEvent {
s.mu.Lock()
defer s.mu.Unlock()
event := models.ThreatEvent{
ID: s.nextID,
SourceIP: req.SourceIP,
EventType: req.EventType,
Severity: req.Severity,
Description: req.Description,
Timestamp: time.Now(),
}
s.threats[s.nextID] = event
s.nextID++
return event
}
func (s *ThreatStore) GetAll() []models.ThreatEvent {
s.mu.RLock()
defer s.mu.RUnlock()
events := make([]models.ThreatEvent, 0, len(s.threats))
for _, event := range s.threats {
events = append(events, event)
}
return events
}
func (s *ThreatStore) GetByID(id int) (models.ThreatEvent, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
event, ok := s.threats[id]
return event, ok
}
func (s *ThreatStore) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.threats[id]; !ok {
return false
}
delete(s.threats, id)
return true
}
Why a mutex for a simple project?
Go's HTTP server launches a new goroutine for every incoming request. Even on your local machine, two curl requests fired at the same time could try to write to the map simultaneously.
Adding a mutex from the start is a Go best practice that prevents subtle data corruption bugs in production.
Wire the store into your server
The model and store are built. Now you need to connect the store to your running server so the HTTP handlers (which you'll build in the next step) can access it.
- Open cmd/api/main.go in VS Code.
- Find the import block at the top of the file:
import (
"fmt"
"log"
"net/http"
)
- Replace the entire import block with this updated version that adds the store and handlers packages:
import (
"fmt"
"log"
"net/http"
"threat-api/internal/handlers"
"threat-api/internal/store"
)
Why the blank line in the import block?
Go convention groups standard library imports at the top and third-party or local imports below, separated by a blank line. Your project's packages (threat-api/internal/...) go in the second group.
- Next, find this line inside the main function:
func main() {
mux := http.NewServeMux()
- Add these two lines between func main() { and mux := http.NewServeMux(), so the top of your function looks like this:
func main() {
threatStore := store.NewThreatStore()
handler := handlers.NewThreatHandler(threatStore)
mux := http.NewServeMux()
What's happening here?
- store.NewThreatStore() creates a fresh in-memory store. All threat events will live here while the server is running.
- handlers.NewThreatHandler(threatStore) creates a handler that holds a reference to the store. In the next step, you'll add methods to this handler that read and write threat events.
The handlers package doesn't exist yet. You need to create a minimal version so your project compiles.
- Create the internal/handlers/ directory by running:
mkdir internal/handlers
- Create a new file called threats.go inside internal/handlers/.
- Copy this starter code into internal/handlers/threats.go:
package handlers
import (
"threat-api/internal/store"
)
// ThreatHandler connects HTTP routes to the threat store
type ThreatHandler struct {
store *store.ThreatStore
}
// NewThreatHandler creates a handler with a reference to the given store
func NewThreatHandler(s *store.ThreatStore) *ThreatHandler {
return &ThreatHandler{store: s}
}
What does this code do?
This is the skeleton for your handler package. ThreatHandler is a struct that holds a pointer to your ThreatStore. In the next step, you'll add HTTP handler methods to it.
- Save both internal/handlers/threats.go and cmd/api/main.go.
- Verify the entire project compiles by running:
go build ./cmd/api
No output means a clean compile. Your store, model, and handler skeleton are all wired together.
Seeing 'handler declared and not used'?
Go requires every declared variable to be used. If you see this error, it means the handler variable in main.go is not referenced anywhere yet. This will be resolved in the next step when you register routes. For now, temporarily add this line after the handler declaration:
_ = handler // Will be used when routes are registered
The blank identifier _ tells Go you are intentionally not using the variable yet. You'll remove this line in the next step.
Still stuck? Help me fix a compile error in my main.go after wiring in the store and handler.
✔️ Awesome, I've got everything!
Make sure all three files are saved before moving on.
ⓧ I'd like to double check the full code
Here are all the files you should have at this point. Compare each one against yours.
package main
import (
"fmt"
"log"
"net/http"
"threat-api/internal/handlers"
"threat-api/internal/store"
)
func main() {
threatStore := store.NewThreatStore()
handler := handlers.NewThreatHandler(threatStore)
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
fmt.Fprintln(w, "Threat Detection API is running")
})
fmt.Println("Starting Threat Detection API on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
package models
import "time"
type ThreatEvent struct {
ID int `json:"id"`
SourceIP string `json:"source_ip"`
EventType string `json:"event_type"`
Severity int `json:"severity"`
Description string `json:"description"`
Timestamp time.Time `json:"timestamp"`
}
type CreateThreatEventRequest struct {
SourceIP string `json:"source_ip"`
EventType string `json:"event_type"`
Severity int `json:"severity"`
Description string `json:"description"`
}
package store
import (
"sync"
"time"
"threat-api/internal/models"
)
type ThreatStore struct {
mu sync.RWMutex
threats map[int]models.ThreatEvent
nextID int
}
func NewThreatStore() *ThreatStore {
return &ThreatStore{
threats: make(map[int]models.ThreatEvent),
nextID: 1,
}
}
func (s *ThreatStore) Add(req models.CreateThreatEventRequest) models.ThreatEvent {
s.mu.Lock()
defer s.mu.Unlock()
event := models.ThreatEvent{
ID: s.nextID,
SourceIP: req.SourceIP,
EventType: req.EventType,
Severity: req.Severity,
Description: req.Description,
Timestamp: time.Now(),
}
s.threats[s.nextID] = event
s.nextID++
return event
}
func (s *ThreatStore) GetAll() []models.ThreatEvent {
s.mu.RLock()
defer s.mu.RUnlock()
events := make([]models.ThreatEvent, 0, len(s.threats))
for _, event := range s.threats {
events = append(events, event)
}
return events
}
func (s *ThreatStore) GetByID(id int) (models.ThreatEvent, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
event, ok := s.threats[id]
return event, ok
}
func (s *ThreatStore) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.threats[id]; !ok {
return false
}
delete(s.threats, id)
return true
}
package handlers
import (
"threat-api/internal/store"
)
type ThreatHandler struct {
store *store.ThreatStore
}
func NewThreatHandler(s *store.ThreatStore) *ThreatHandler {
return &ThreatHandler{store: s}
}
Your data model and store are ready to go. Next up, you'll build the HTTP handlers that let users create, list, find, and delete threat events through the API.
Build the REST API Handlers
Your threat model and data store are in place. Now it's time to connect them to the outside world through HTTP handlers.
Handlers are the functions that receive incoming requests, call the right store method, and send back a JSON response with the correct status code. In this step, you'll build handlers for all four CRUD operations and wire them into your server's router.
In this step, get ready to:
- Create HTTP handlers for listing, creating, retrieving, and deleting threat events.
- Register routes using Go's method-based ServeMux patterns.
- Test all endpoints with curl to confirm everything works.
Create the list and create handlers
Your API needs a place to define how each HTTP request is handled. You'll create a dedicated handlers package with functions for listing all threats and creating new ones.
- Create the internal/handlers/ directory by running this command:
mkdir internal/handlers
- Create a new file called threats.go inside internal/handlers/, then copy in the following code:
package handlers
import (
"encoding/json"
"net/http"
"threat-api/internal/models"
"threat-api/internal/store"
)
// ThreatHandler holds a reference to the threat store
type ThreatHandler struct {
store *store.ThreatStore
}
// NewThreatHandler creates a handler with the given store
func NewThreatHandler(s *store.ThreatStore) *ThreatHandler {
return &ThreatHandler{store: s}
}
// HandleListThreats returns all threat events as JSON
func (h *ThreatHandler) HandleListThreats(w http.ResponseWriter, r *http.Request) {
events := h.store.GetAll()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(events)
}
// HandleCreateThreat parses a JSON request and adds a new threat event
func (h *ThreatHandler) HandleCreateThreat(w http.ResponseWriter, r *http.Request) {
var req models.CreateThreatEventRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error": "invalid JSON body"}`, http.StatusBadRequest)
return
}
// Validate required fields
if req.SourceIP == "" || req.EventType == "" {
http.Error(w, `{"error": "source_ip and event_type are required"}`, http.StatusBadRequest)
return
}
// Validate severity range
if req.Severity < 1 || req.Severity > 5 {
http.Error(w, `{"error": "severity must be between 1 and 5"}`, http.StatusBadRequest)
return
}
event := h.store.Add(req)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(event)
}
What does this code do?
ThreatHandler is a struct that holds a pointer to your ThreatStore. This lets every handler method access the same store instance without using a global variable.
HandleListThreats calls GetAll() and writes the result as JSON. HandleCreateThreat does more work. It decodes the request body, checks that source_ip and event_type are present, validates that severity is between 1 and 5, then adds the event and returns a 201 Created status.
- Save threats.go.
- Check the VS Code editor for any red underlines. If there are none, your handler code is free of syntax errors.
Seeing red underlines in VS Code?
- Make sure the file is saved inside internal/handlers/, not in the project root or another folder.
- Check that the first line of the file says package handlers. The package name must match the folder name.
- Confirm the import paths match exactly. The module path threat-api/internal/models must match the module name in your go.mod.
Help me fix red underlines in my Go handler file.
Add the get and delete handlers
Your API can list and create threats. Now it needs to retrieve a single threat by its ID and delete threats that are no longer relevant.
- In threats.go, add "strconv" to your import block (right after "net/http"). This package converts the ID from a URL string into an integer.
- Copy the following code below HandleCreateThreat:
// HandleGetThreat retrieves a single threat event by ID
func (h *ThreatHandler) HandleGetThreat(w http.ResponseWriter, r *http.Request) {
// Extract the {id} path parameter as a string
idStr := r.PathValue("id")
// Convert string ID to integer
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, `{"error": "invalid threat ID"}`, http.StatusBadRequest)
return
}
event, ok := h.store.GetByID(id)
if !ok {
http.Error(w, `{"error": "threat not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(event)
}
// HandleDeleteThreat removes a threat event by ID
func (h *ThreatHandler) HandleDeleteThreat(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, `{"error": "invalid threat ID"}`, http.StatusBadRequest)
return
}
if !h.store.Delete(id) {
http.Error(w, `{"error": "threat not found"}`, http.StatusNotFound)
return
}
// 204 No Content means success with no response body
w.WriteHeader(http.StatusNoContent)
}
What does this code do?
Both handlers use r.PathValue("id") to extract the {id} segment from the URL. This is a Go 1.22 feature that pulls named path parameters directly from the request.
HandleGetThreat converts the string ID to an integer with strconv.Atoi, then looks up the threat. If the ID doesn't exist, it returns a 404 Not Found. HandleDeleteThreat follows the same pattern but returns 204 No Content on success, meaning the deletion worked but there's nothing to send back.
- Save threats.go.
- Check VS Code for red underlines. There should be none.
Seeing an error about strconv?
Make sure you added "strconv" to the import block at the top of threats.go. It should sit with the other standard library imports, before the blank line that separates them from your project imports.
Help me fix my Go import errors in threats.go.
✔️ Awesome, I've got everything!
Great. Double check you've saved threats.go before moving on.
ⓧ I'd like to double check the full code
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"threat-api/internal/models"
"threat-api/internal/store"
)
type ThreatHandler struct {
store *store.ThreatStore
}
func NewThreatHandler(s *store.ThreatStore) *ThreatHandler {
return &ThreatHandler{store: s}
}
func (h *ThreatHandler) HandleListThreats(w http.ResponseWriter, r *http.Request) {
events := h.store.GetAll()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(events)
}
func (h *ThreatHandler) HandleCreateThreat(w http.ResponseWriter, r *http.Request) {
var req models.CreateThreatEventRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error": "invalid JSON body"}`, http.StatusBadRequest)
return
}
if req.SourceIP == "" || req.EventType == "" {
http.Error(w, `{"error": "source_ip and event_type are required"}`, http.StatusBadRequest)
return
}
if req.Severity < 1 || req.Severity > 5 {
http.Error(w, `{"error": "severity must be between 1 and 5"}`, http.StatusBadRequest)
return
}
event := h.store.Add(req)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(event)
}
func (h *ThreatHandler) HandleGetThreat(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, `{"error": "invalid threat ID"}`, http.StatusBadRequest)
return
}
event, ok := h.store.GetByID(id)
if !ok {
http.Error(w, `{"error": "threat not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(event)
}
func (h *ThreatHandler) HandleDeleteThreat(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, `{"error": "invalid threat ID"}`, http.StatusBadRequest)
return
}
if !h.store.Delete(id) {
http.Error(w, `{"error": "threat not found"}`, http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
Register routes and test all endpoints
Your handlers exist but the server doesn't know when to call them yet. You need to register each handler on a specific HTTP method and URL path so that Go's ServeMux router can match incoming requests to the right function.
- In cmd/api/main.go, find the closing brace of the GET / handler. It looks like this:
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
fmt.Fprintln(w, "Threat Detection API is running")
})
- Add these four route registrations directly below that block:
// Register threat event routes with method-based patterns
mux.HandleFunc("GET /threats", handler.HandleListThreats)
mux.HandleFunc("POST /threats", handler.HandleCreateThreat)
mux.HandleFunc("GET /threats/{id}", handler.HandleGetThreat)
mux.HandleFunc("DELETE /threats/{id}", handler.HandleDeleteThreat)
What is method-based routing?
Go 1.22 introduced enhanced routing in ServeMux. By prefixing a pattern with GET, POST, or DELETE, the router only matches requests that use that specific HTTP method.
The {id} segment is a path parameter. When someone requests /threats/42, Go captures 42 and makes it available through r.PathValue("id"). Before Go 1.22, you needed a third-party router like gorilla/mux for this.
- Save main.go.
- Verify the project compiles by running:
go build ./cmd/api
If the command completes with no output, your project compiled successfully.
Seeing a compile error?
- If you see could not import threat-api/internal/handlers, make sure threats.go is saved inside the internal/handlers/ directory.
- If you see handler.HandleListThreats undefined, check that your function names in threats.go start with a capital letter. Go only exports capitalized identifiers.
Help me fix my Go compile error.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.go before moving on to testing.
ⓧ I'd like to double check the full code
package main
import (
"fmt"
"log"
"net/http"
"threat-api/internal/handlers"
"threat-api/internal/store"
)
func main() {
threatStore := store.NewThreatStore()
handler := handlers.NewThreatHandler(threatStore)
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
fmt.Fprintln(w, "Threat Detection API is running")
})
mux.HandleFunc("GET /threats", handler.HandleListThreats)
mux.HandleFunc("POST /threats", handler.HandleCreateThreat)
mux.HandleFunc("GET /threats/{id}", handler.HandleGetThreat)
mux.HandleFunc("DELETE /threats/{id}", handler.HandleDeleteThreat)
fmt.Println("Starting Threat Detection API on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Now it's time to see your API in action. You'll start the server and test every endpoint using curl.
- Start the server by running:
go run ./cmd/api
You should see Starting Threat Detection API on :8080 printed in the terminal. The server is now running and waiting for requests.
- Open a second terminal window. Your server needs to keep running in the first one.
- Create a threat event by running this curl command:
curl -X POST http://localhost:8080/threats \
-H "Content-Type: application/json" \
-d '{"source_ip": "192.168.1.100", "event_type": "brute_force", "severity": 4, "description": "Multiple failed SSH login attempts"}'
You should see a JSON response with an id of 1 and a timestamp that the server generated for you.
- List all threats by running:
curl http://localhost:8080/threats
You should see a JSON array containing the threat you just created.
- Retrieve that specific threat by its ID:
curl http://localhost:8080/threats/1
You should see the same threat event, returned as a single JSON object.
- Delete the threat by running:
curl -X DELETE http://localhost:8080/threats/1 -v
What should I see?
The -v flag shows verbose output so you can see the status code. Look for the line < HTTP/1.1 204 No Content in the response. A 204 means the deletion succeeded.
- Confirm the threat is gone by listing all threats again:
curl http://localhost:8080/threats
You should see an empty array [], confirming the threat was deleted.
- Stop the server by pressing Ctrl+C in the terminal where it's running.
Getting Connection refused?
- Make sure go run ./cmd/api is still running in your first terminal window. If it stopped, start it again.
- Check that you're running curl in a separate terminal, not the same one where the server is running.
- Verify the URL uses port 8080. If another process is using that port, you'll see an address already in use error in the server terminal.
Help me fix my curl connection error.
Your REST API is fully functional. You can create, list, retrieve, and delete threat events, all using Go's standard library. Next up, you'll push this further in the Secret Mission by adding query parameter filtering so users can search threats by severity or type.
Secret mission
Add Filtering and Search
Your API returns every threat event at once. In a real security operations center, analysts need to zero in on specific severity levels or attack types. Can you add query parameter filtering to make your API more practical?
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The threat-api project folder on your Desktop.
- The VS Code Go extension (golang.Go).
- The local HTTP server running on port 8080.
Keep everything running
No action needed. Choose this if you're still actively building or plan to continue with the next project in the series.
- Your project files stay on your Desktop.
- If the server is still running in your terminal, you can leave it or stop it with Ctrl+C and restart it anytime with go run ./cmd/api.
Pause - I'll come back to this later
Shut down running processes to free up your terminal. Your project files stay on disk so you can pick up where you left off.
- Stop the server by pressing Ctrl+C in the terminal where it is running.
- Keep the threat-api folder on your Desktop. You can restart the server later by navigating into the folder and running go run ./cmd/api.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Stop the server by pressing Ctrl+C in the terminal if it is still running.
- Delete the threat-api folder from your Desktop by running this command:
rm -rf ~/Desktop/threat-api
- Optionally, uninstall the VS Code Go extension by opening the Extensions sidebar in VS Code, searching for Go, and clicking Uninstall. Keep it installed if you plan to do more Go projects.
Mission accomplished
That's a Wrap!
Nice work! You just built a fully functional REST API for a threat detection system using nothing but Go's standard library.
You've learned how to:
- Build a complete REST API with CRUD operations, proper HTTP status codes, and error handling using only Go's standard library.
- Use Go 1.22+ enhanced ServeMux routing with method matching and path parameters.
- Structure a Go project using the idiomatic cmd/ and internal/ layout with structs, maps, and JSON handling.
- Secret Mission: Added query parameter filtering so users can search threats by severity and event type.
Ready to quiz yourself?