This is a mock basic ticketing REST API for education purpose.
  • Go 70.5%
  • Nix 29.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-04 14:42:39 +00:00
flake.nix Update flake.nix 2026-08-04 14:42:39 +00:00
go.mod initial commit 2026-08-03 11:11:24 +02:00
main.go initial commit 2026-08-03 11:11:24 +02:00
README.md docs: Add README.md 2026-08-03 11:19:36 +02:00

Education Ticketing Mock Server

A lightweight, single-file, in-memory dummy Education Ticketing API server written in Go. Designed for hands-on Python/API workshops, live coding demonstrations, and testing incident management workflows without needing an external ticketing platform or database.


Table of Contents


Overview

This mock server simulates a simplified Education Ticketing Incident Management endpoint. It maintains all data in-memory (no external database or configuration files needed). It allows workshop attendees to interact via HTTP clients (such as Python's requests module, curl, or Postman) to:

  • Query pre-defined system Configuration Items (CIs).
  • Send incoming system monitoring alerts.
  • Group alerts automatically under active incidents based on unique message keys.
  • Close incidents and clear alerts when resolution events (severity = 0) are received.

Students accessing the environment remotely (e.g., via VS Code Remote SSH) can bind to 0.0.0.0:8080 and interact via http://localhost:8080.


Key Features

  • Zero External Dependencies: Built purely with Go's standard library (net/http, encoding/json, sync).
  • Thread-Safe In-Memory State: Concurrent POST and GET requests are safely managed with a mutex lock.
  • Automatic Fallback: Unrecognized or missing CIs default gracefully to OPS (System Operations team).
  • Incident Lifecycle Management: Groups repeated alerts under the same active incident; closes incidents on clear alerts (severity = 0); opens fresh incidents for subsequent alerts with the same message key.
  • REST-compliant HTTP Status Codes: Returns structured JSON error messages alongside appropriate codes (400 Bad Request, 405 Method Not Allowed).

Configuration Items (CIs)

The server contains a read-only catalog of system Configuration Items:

3PID Description Assignment Group
JES Job Entry Subsystem Mainframe OS
AOC IBM System Automation Mainframe OS
WLP IBM WebSphere Liberty Profile Mainframe WAS
OPS System Operations team Mainframe Operations (Default)

Note: Students cannot modify this list; they can only reference these CIs in incident requests.


Getting Started

Prerequisites

Server Source Code (main.go)

Save the following code to main.go:

package main

import (
 "encoding/json"
 "fmt"
 "log"
 "net/http"
 "strings"
 "sync"
)

// Data Structures
type ConfigurationItem struct {
 PID         string `json:"3PID"`
 Description string `json:"Description"`
 Assignment  string `json:"Assignment"`
}

type Alert struct {
 ID         string `json:"id"`
 MessageKey string `json:"message_key"`
 CI         string `json:"ci"`
 Severity   int    `json:"severity"`
 IncidentID string `json:"incident_id"`
}

type Incident struct {
 ID          string `json:"id"`
 MessageKey  string `json:"message_key"`
 Description string `json:"description"`
 CI          string `json:"ci"`
 Status      string `json:"status"` // "OPEN" or "CLOSED"
}

type IssueRequest struct {
 Description string `json:"description"`
 MessageKey  string `json:"message_key"`
 CI          string `json:"ci"`
 Severity    int    `json:"severity"`
}

type ErrorResponse struct {
 Error string `json:"error"`
}

// In-Memory Storage & Thread Safety
var (
 mu          sync.Mutex
 alertSeq    = 1
 incidentSeq = 1

 alerts    = []Alert{}
 incidents = []*Incident{}

 // Pre-populated product list (Read-Only)
 validCIs = map[string]ConfigurationItem{
  "JES": {PID: "JES", Description: "Job Entry Subsystem", Assignment: "Mainframe OS"},
  "AOC": {PID: "AOC", Description: "IBM System Automation", Assignment: "Mainframe OS"},
  "WLP": {PID: "WLP", Description: "IBM WebSphere Liberty Profile", Assignment: "Mainframe WAS"},
  "OPS": {PID: "OPS", Description: "System Operations team", Assignment: "Mainframe Operations"},
 }
)

func main() {
 http.HandleFunc("/api/ci", handleCIs)
 http.HandleFunc("/api/alerts", handleAlerts)
 http.HandleFunc("/api/issues", handleIssues)
 http.HandleFunc("/api/issue", handleIssue)

 port := ":8080"
 fmt.Printf("Dummy Education Ticketing Mock running at http://localhost%s
", port)
 log.Fatal(http.ListenAndServe(port, nil))
}

// Helper: Find an OPEN incident by message key
func findOpenIncident(messageKey string) *Incident {
 for _, inc := range incidents {
  if inc.MessageKey == messageKey && inc.Status == "OPEN" {
   return inc
  }
 }
 return nil
}

// Helper: Write JSON error response with specific HTTP status code
func writeJSONError(w http.ResponseWriter, message string, statusCode int) {
 w.Header().Set("Content-Type", "application/json")
 w.WriteHeader(statusCode)
 json.NewEncoder(w).Encode(ErrorResponse{Error: message})
}

// Helper: Write standard JSON response
func writeJSON(w http.ResponseWriter, data interface{}, statusCode int) {
 w.Header().Set("Content-Type", "application/json")
 w.WriteHeader(statusCode)
 json.NewEncoder(w).Encode(data)
}

// Handler: GET /api/ci
func handleCIs(w http.ResponseWriter, r *http.Request) {
 if r.Method != http.MethodGet {
  writeJSONError(w, "Method not allowed. Use GET.", http.StatusMethodNotAllowed)
  return
 }

 ciList := make([]ConfigurationItem, 0, len(validCIs))
 for _, ci := range validCIs {
  ciList = append(ciList, ci)
 }

 writeJSON(w, ciList, http.StatusOK)
}

// Handler: GET /api/alerts
func handleAlerts(w http.ResponseWriter, r *http.Request) {
 if r.Method != http.MethodGet {
  writeJSONError(w, "Method not allowed. Use GET.", http.StatusMethodNotAllowed)
  return
 }

 mu.Lock()
 defer mu.Unlock()
 writeJSON(w, alerts, http.StatusOK)
}

// Handler: GET /api/issues
func handleIssues(w http.ResponseWriter, r *http.Request) {
 if r.Method != http.MethodGet {
  writeJSONError(w, "Method not allowed. Use GET.", http.StatusMethodNotAllowed)
  return
 }

 mu.Lock()
 defer mu.Unlock()

 statusFilter := strings.ToUpper(r.URL.Query().Get("status"))

 if statusFilter == "" {
  writeJSON(w, incidents, http.StatusOK)
  return
 }

 filtered := []*Incident{}
 for _, inc := range incidents {
  if inc.Status == statusFilter {
   filtered = append(filtered, inc)
  }
 }

 writeJSON(w, filtered, http.StatusOK)
}

// Handler: POST /api/issue or GET /api/issue?id=xxxxxx
func handleIssue(w http.ResponseWriter, r *http.Request) {
 switch r.Method {
 case http.MethodGet:
  getAlertsByIncident(w, r)
 case http.MethodPost:
  createOrUpdateIssue(w, r)
 default:
  writeJSONError(w, "Method not allowed. Use GET or POST.", http.StatusMethodNotAllowed)
 }
}

// GET /api/issue?id=xxxxxx
func getAlertsByIncident(w http.ResponseWriter, r *http.Request) {
 incID := r.URL.Query().Get("id")
 if incID == "" {
  writeJSONError(w, "Query parameter 'id' is required", http.StatusBadRequest)
  return
 }

 mu.Lock()
 defer mu.Unlock()

 relatedAlerts := []Alert{}
 for _, alert := range alerts {
  if alert.IncidentID == incID {
   relatedAlerts = append(relatedAlerts, alert)
  }
 }

 writeJSON(w, relatedAlerts, http.StatusOK)
}

// POST /api/issue
func createOrUpdateIssue(w http.ResponseWriter, r *http.Request) {
 var req IssueRequest
 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  writeJSONError(w, "Invalid JSON request body", http.StatusBadRequest)
  return
 }

 if strings.TrimSpace(req.MessageKey) == "" {
  writeJSONError(w, "Field 'message_key' is required", http.StatusBadRequest)
  return
 }

 // Validate CI, fallback to OPS if invalid or empty
 ci := req.CI
 if _, exists := validCIs[ci]; !exists {
  ci = "OPS"
 }

 mu.Lock()
 defer mu.Unlock()

 activeIncident := findOpenIncident(req.MessageKey)

 // Handle Severity 0 -> Clear alert & close active incident
 if req.Severity == 0 {
  if activeIncident == nil {
   writeJSONError(w, fmt.Sprintf("No open incident found for message_key '%s' to clear", req.MessageKey), http.StatusBadRequest)
   return
  }

  activeIncident.Status = "CLOSED"

  alertID := fmt.Sprintf("ALT-%05d", alertSeq)
  alertSeq++
  clearingAlert := Alert{
   ID:         alertID,
   MessageKey: req.MessageKey,
   CI:         ci,
   Severity:   0,
   IncidentID: activeIncident.ID,
  }
  alerts = append(alerts, clearingAlert)

  writeJSON(w, map[string]interface{}{
   "status":      "cleared",
   "incident_id": activeIncident.ID,
   "alert_id":    alertID,
   "message":     "Incident closed and alert cleared.",
  }, http.StatusOK)
  return
 }

 // Create a new incident if none is open for this message_key
 if activeIncident == nil {
  incID := fmt.Sprintf("INC-%05d", incidentSeq)
  incidentSeq++

  newInc := &Incident{
   ID:          incID,
   MessageKey:  req.MessageKey,
   Description: req.Description,
   CI:          ci,
   Status:      "OPEN",
  }
  incidents = append(incidents, newInc)
  activeIncident = newInc
 }

 // Attach new alert to current open incident
 alertID := fmt.Sprintf("ALT-%05d", alertSeq)
 alertSeq++

 newAlert := Alert{
  ID:         alertID,
  MessageKey: req.MessageKey,
  CI:         ci,
  Severity:   req.Severity,
  IncidentID: activeIncident.ID,
 }
 alerts = append(alerts, newAlert)

 writeJSON(w, map[string]interface{}{
  "status":      "processed",
  "incident_id": activeIncident.ID,
  "alert_id":    alertID,
  "ci_used":     ci,
 }, http.StatusOK)
}

Running the Server

Start the Go server by running:

go run main.go

By default, the server listens on http://localhost:8080.


API Reference & curl Examples

1. List Configuration Items (GET /api/ci)

Retrieve the list of valid CIs available in the system.

curl -i -X GET http://localhost:8080/api/ci

Sample Response (200 OK):

[
  {
    "3PID": "JES",
    "Description": "Job Entry Subsystem",
    "Assignment": "Mainframe OS"
  },
  {
    "3PID": "AOC",
    "Description": "IBM System Automation",
    "Assignment": "Mainframe OS"
  },
  {
    "3PID": "WLP",
    "Description": "IBM WebSphere Liberty Profile",
    "Assignment": "Mainframe WAS"
  },
  {
    "3PID": "OPS",
    "Description": "System Operations team",
    "Assignment": "Mainframe Operations"
  }
]

2. List All Alerts (GET /api/alerts)

Retrieve all historical alerts received by the server.

curl -i -X GET http://localhost:8080/api/alerts

Sample Response (200 OK):

[
  {
    "id": "ALT-00001",
    "message_key": "job_failure_99",
    "ci": "JES",
    "severity": 3,
    "incident_id": "INC-00001"
  }
]

3. List All Issues / Incidents (GET /api/issues)

Retrieve all incidents (OPEN and CLOSED). Optional filtering by status is available via query parameter.

Get All Incidents

curl -i -X GET http://localhost:8080/api/issues

Filter Open Incidents Only

curl -i -X GET "http://localhost:8080/api/issues?status=OPEN"

Filter Closed Incidents Only

curl -i -X GET "http://localhost:8080/api/issues?status=CLOSED"

Sample Response (200 OK):

[
  {
    "id": "INC-00001",
    "message_key": "job_failure_99",
    "description": "Mainframe Batch Job Failed",
    "ci": "JES",
    "status": "OPEN"
  }
]

4. Get Alerts for a Specific Incident (GET /api/issue?id=<INC_ID>)

Fetch all alerts linked to a specific incident ID.

curl -i -X GET "http://localhost:8080/api/issue?id=INC-00001"

Sample Response (200 OK):

[
  {
    "id": "ALT-00001",
    "message_key": "job_failure_99",
    "ci": "JES",
    "severity": 3,
    "incident_id": "INC-00001"
  },
  {
    "id": "ALT-00002",
    "message_key": "job_failure_99",
    "ci": "JES",
    "severity": 4,
    "incident_id": "INC-00001"
  }
]

5. Create or Update an Incident (POST /api/issue)

Send an alert payload.

  • If no open incident exists for the given message_key, a new incident (INC-xxxxx) is created and an alert (ALT-xxxxx) is attached.
  • If an open incident already exists for the given message_key, no new incident is created; the new alert is linked to the existing open incident.
  • If the specified ci is invalid or missing, it defaults to OPS.

Example A: Trigger initial alert (Creates INC-00001)

curl -i -X POST http://localhost:8080/api/issue \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Job JES001 abended with RC=12",
    "message_key": "jes_job_01",
    "ci": "JES",
    "severity": 3
  }'

Sample Response (200 OK):

{
  "alert_id": "ALT-00001",
  "ci_used": "JES",
  "incident_id": "INC-00001",
  "status": "processed"
}

Example B: Subsequent alert with same message_key (Attaches to existing INC-00001)

curl -i -X POST http://localhost:8080/api/issue \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Job JES001 retry failed",
    "message_key": "jes_job_01",
    "ci": "JES",
    "severity": 4
  }'

Sample Response (200 OK):

{
  "alert_id": "ALT-00002",
  "ci_used": "JES",
  "incident_id": "INC-00001",
  "status": "processed"
}

Example C: Alert with invalid CI (Falls back to OPS)

curl -i -X POST http://localhost:8080/api/issue \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Unknown system glitch",
    "message_key": "unk_001",
    "ci": "UNKNOWN_SYSTEM",
    "severity": 2
  }'

Sample Response (200 OK):

{
  "alert_id": "ALT-00003",
  "ci_used": "OPS",
  "incident_id": "INC-00002",
  "status": "processed"
}

6. Clear Alert and Close Incident (POST /api/issue with severity: 0)

Sending an alert with severity: 0 clears the alert state and transitions the matching open incident to CLOSED.

curl -i -X POST http://localhost:8080/api/issue \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Job completed successfully after manual restart",
    "message_key": "jes_job_01",
    "ci": "JES",
    "severity": 0
  }'

Sample Response (200 OK):

{
  "alert_id": "ALT-00004",
  "incident_id": "INC-00001",
  "message": "Incident closed and alert cleared.",
  "status": "cleared"
}

HTTP Status Codes & Error Handling

The API uses standard HTTP response codes and returns structured JSON error messages in the format {"error": "message"}.

1. 400 Bad Request — Missing message_key

curl -i -X POST http://localhost:8080/api/issue \
  -H "Content-Type: application/json" \
  -d '{"description": "Missing key test", "severity": 1}'

Response (400 Bad Request):

{
  "error": "Field 'message_key' is required"
}

2. 400 Bad Request — Clearing a Non-existent/Closed Incident

Attempting to send severity: 0 for a message_key that has no currently OPEN incident:

curl -i -X POST http://localhost:8080/api/issue \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Clear non-existent job",
    "message_key": "non_existent_key",
    "severity": 0
  }'

Response (400 Bad Request):

{
  "error": "No open incident found for message_key 'non_existent_key' to clear"
}

3. 400 Bad Request — Missing Query Parameter

curl -i -X GET http://localhost:8080/api/issue

Response (400 Bad Request):

{
  "error": "Query parameter 'id' is required"
}

4. 405 Method Not Allowed — Invalid HTTP Verb

Attempting to send a DELETE or POST request to a GET-only endpoint:

curl -i -X DELETE http://localhost:8080/api/ci

Response (405 Method Not Allowed):

{
  "error": "Method not allowed. Use GET."
}

Python Hands-on Client Example

Here is a Python client script using requests that demonstrates interaction with the Education Ticketing endpoints during workshop exercises:

import requests

BASE_URL = "http://localhost:8080/api"

# 1. Fetch products / CIs
ci_res = requests.get(f"{BASE_URL}/ci").json()
print("Available CIs:", ci_res)

# 2. Trigger an Incident (Valid CI)
payload = {
    "description": "Mainframe Batch Job Failed",
    "message_key": "job_failure_99",
    "ci": "JES",
    "severity": 3,
}
res1 = requests.post(f"{BASE_URL}/issue", json=payload).json()
print("
First Alert Sent:", res1)

# 3. Trigger another alert for same Message Key (Attaches to same INC)
res2 = requests.post(f"{BASE_URL}/issue", json=payload).json()
print("Second Alert Sent:", res2)

# 4. Trigger with invalid CI (Falls back to OPS)
invalid_ci_payload = {
    "description": "Unknown system issue",
    "message_key": "unknown_01",
    "ci": "INVALID_CI",
    "severity": 2,
}
res3 = requests.post(f"{BASE_URL}/issue", json=invalid_ci_payload).json()
print("
Invalid CI Fallback Test:", res3)

# 5. Clear Incident (Severity = 0)
clear_payload = {
    "description": "Resolved",
    "message_key": "job_failure_99",
    "ci": "JES",
    "severity": 0,
}
res4 = requests.post(f"{BASE_URL}/issue", json=clear_payload).json()
print("
Clear Incident Test:", res4)

# 6. Fetch all incidents (Open & Closed)
incidents = requests.get(f"{BASE_URL}/issues").json()
print("
All Incidents:", incidents)

Incident Lifecycle Rules

                       POST /api/issue (severity > 0)
                                     │
                        Does OPEN incident exist
                         for this message_key?
                                /         \
                               /           \
                             NO             YES
                             /               \
                            v                 v
                  Create NEW Incident     Attach Alert to
                   (Status: "OPEN")       EXISTING Incident
                            │                 │
                            └────────┬────────┘
                                     │
                       POST /api/issue (severity = 0)
                                     │
                        Set Incident Status = "CLOSED"
                                     │
                       Next POST with same message_key
                        creates a BRAND NEW Incident