issue list/show/create/update, version list/create, category list/create, relation create. Names resolve case-insensitively against server enumerations; -o json everywhere; exit 0/1/2 with one-line stderr carrying the http status for API failures.
593 lines
17 KiB
Go
593 lines
17 KiB
Go
// Package fakeredmine is a stateful in-memory Redmine REST fake used by
|
|
// the test suite and the smoke run. The real tracker is NEVER contacted.
|
|
// It implements exactly the endpoints the redmine client uses, enforces
|
|
// X-Redmine-API-Key auth on every one of them, and records every request
|
|
// so tests can assert round-trips. Error responses deliberately echo the
|
|
// presented key back in the body: any test that survives that proves the
|
|
// client never surfaces response bodies (key-redaction guarantee).
|
|
package fakeredmine
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Request is one recorded exchange (auth checked, body captured).
|
|
type Request struct {
|
|
Method string
|
|
Path string // path without query
|
|
Query string // raw query
|
|
Body string
|
|
}
|
|
|
|
// Version is the roadmap-milestone shape.
|
|
type Version struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
DueDate string `json:"due_date,omitempty"`
|
|
Status string `json:"status"` // open|locked|closed
|
|
Sharing string `json:"sharing,omitempty"`
|
|
}
|
|
|
|
// Category is the issue-category shape.
|
|
type Category struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// JournalEntry is one history note.
|
|
type JournalEntry struct {
|
|
ID int `json:"id"`
|
|
UserID int `json:"user_id"`
|
|
Notes string `json:"notes"`
|
|
CreatedOn string `json:"created_on"`
|
|
}
|
|
|
|
// Issue is the stored issue (server side, write-friendly scalars).
|
|
type Issue struct {
|
|
ID int `json:"id"`
|
|
Subject string `json:"subject"`
|
|
Description string `json:"description"`
|
|
ProjectID string `json:"project_id"`
|
|
TrackerID int `json:"tracker_id"`
|
|
StatusID int `json:"status_id"`
|
|
PriorityID int `json:"priority_id"`
|
|
CategoryID int `json:"category_id,omitempty"`
|
|
FixedVersionID int `json:"fixed_version_id,omitempty"`
|
|
ParentIssueID int `json:"parent_issue_id,omitempty"`
|
|
EstimatedHours *float64 `json:"estimated_hours,omitempty"`
|
|
DoneRatio int `json:"done_ratio,omitempty"`
|
|
DueDate string `json:"due_date,omitempty"`
|
|
Notes string `json:"notes,omitempty"` // write-only: create/update note -> journal
|
|
CreatedOn string `json:"created_on"`
|
|
UpdatedOn string `json:"updated_on"`
|
|
Journals []JournalEntry `json:"journals,omitempty"`
|
|
}
|
|
|
|
// Relation is the issue-relation shape.
|
|
type Relation struct {
|
|
ID int `json:"id"`
|
|
IssueID int `json:"issue_id"`
|
|
IssueToID int `json:"issue_to_id"`
|
|
RelationType string `json:"relation_type"`
|
|
Delay int `json:"delay,omitempty"`
|
|
}
|
|
|
|
// Server is the fake Redmine.
|
|
type Server struct {
|
|
URL string
|
|
APIKey string
|
|
apiKeySet bool
|
|
|
|
mu sync.Mutex
|
|
srv *httptest.Server
|
|
requests []Request
|
|
issues map[int]*Issue
|
|
versions map[int]Version
|
|
categories map[int]Category
|
|
relations map[int]Relation
|
|
nextID int
|
|
// Fail, when non-nil, is returned instead of normal handling; it is
|
|
// consumed by the first request that sees it.
|
|
Fail *FailSpec
|
|
|
|
Trackers []IDName
|
|
Statuses []Status
|
|
Priorities []IDName
|
|
}
|
|
|
|
// FailSpec pins the next response (error-mapping tests).
|
|
type FailSpec struct {
|
|
Status int
|
|
Body string
|
|
}
|
|
|
|
// IDName is a bare enumeration entry.
|
|
type IDName struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// Status adds the is_closed flag to an enumeration entry.
|
|
type Status struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
IsClosed bool `json:"is_closed"`
|
|
}
|
|
|
|
// New starts a fake on a random port with Redmine's default
|
|
// enumerations and an empty MOPAC project.
|
|
func New(apiKey string) *Server {
|
|
s := &Server{
|
|
APIKey: apiKey,
|
|
apiKeySet: true,
|
|
issues: map[int]*Issue{},
|
|
versions: map[int]Version{},
|
|
categories: map[int]Category{},
|
|
relations: map[int]Relation{},
|
|
nextID: 100,
|
|
Trackers: []IDName{
|
|
{1, "Bug"}, {2, "Feature"}, {3, "Support"}, {4, "Task"},
|
|
},
|
|
Statuses: []Status{
|
|
{1, "New", false}, {2, "In Progress", false},
|
|
{3, "Done", true}, {4, "Rejected", true},
|
|
},
|
|
Priorities: []IDName{
|
|
{1, "Low"}, {2, "Normal"}, {3, "High"}, {4, "Urgent"}, {5, "Immediate"},
|
|
},
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", s.handler)
|
|
s.srv = httptest.NewServer(mux)
|
|
s.URL = s.srv.URL
|
|
return s
|
|
}
|
|
|
|
// Close shuts the fake down.
|
|
func (s *Server) Close() { s.srv.Close() }
|
|
|
|
// Requests returns a copy of the recorded exchanges.
|
|
func (s *Server) Requests() []Request {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]Request, len(s.requests))
|
|
copy(out, s.requests)
|
|
return out
|
|
}
|
|
|
|
// AddIssue seeds an issue directly (bypassing REST) and returns its id.
|
|
func (s *Server) AddIssue(i Issue) int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.nextID++
|
|
i.ID = s.nextID
|
|
now := "2026-08-29T12:00:00Z"
|
|
if i.CreatedOn == "" {
|
|
i.CreatedOn = now
|
|
}
|
|
if i.UpdatedOn == "" {
|
|
i.UpdatedOn = now
|
|
}
|
|
s.issues[i.ID] = &i
|
|
return i.ID
|
|
}
|
|
|
|
// Issue returns a deep-ish copy of a stored issue (for assertions).
|
|
func (s *Server) Issue(id int) (Issue, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
i, ok := s.issues[id]
|
|
if !ok {
|
|
return Issue{}, false
|
|
}
|
|
return *i, true
|
|
}
|
|
|
|
// AddVersion / AddCategory seed enumerations directly.
|
|
func (s *Server) AddVersion(v Version) int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.nextID++
|
|
v.ID = s.nextID
|
|
s.versions[v.ID] = v
|
|
return v.ID
|
|
}
|
|
|
|
func (s *Server) AddCategory(name string) int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.nextID++
|
|
s.categories[s.nextID] = Category{ID: s.nextID, Name: name}
|
|
return s.nextID
|
|
}
|
|
|
|
func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
|
|
key := ""
|
|
if s.apiKeySet {
|
|
key = r.Header.Get("X-Redmine-API-Key")
|
|
}
|
|
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
|
|
s.mu.Lock()
|
|
if s.Fail != nil {
|
|
spec := *s.Fail
|
|
s.Fail = nil
|
|
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, string(body)})
|
|
s.mu.Unlock()
|
|
// The body deliberately embeds the presented key: surviving this
|
|
// proves the client drops response bodies from error strings.
|
|
w.WriteHeader(spec.Status)
|
|
fmt.Fprintf(w, spec.Body, key)
|
|
return
|
|
}
|
|
if s.apiKeySet && key != s.APIKey {
|
|
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, ""})
|
|
s.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
fmt.Fprintf(w, `{"errors":["Invalid HTTP auth: key %s was rejected"]}`, key)
|
|
return
|
|
}
|
|
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, string(body)})
|
|
|
|
var (
|
|
respStatus = http.StatusOK
|
|
respBody string
|
|
)
|
|
path := r.URL.Path
|
|
q := r.URL.Query()
|
|
switch {
|
|
case r.Method == http.MethodGet && path == "/issues.json":
|
|
respStatus, respBody = s.listIssues(q)
|
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/issues/") && strings.HasSuffix(path, ".json"):
|
|
id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/issues/"), ".json"))
|
|
respStatus, respBody = s.getIssue(id, q.Get("include"))
|
|
case r.Method == http.MethodPost && path == "/issues.json":
|
|
respStatus, respBody = s.createIssue(body)
|
|
case r.Method == http.MethodPut && strings.HasPrefix(path, "/issues/") && strings.HasSuffix(path, ".json"):
|
|
id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/issues/"), ".json"))
|
|
respStatus, respBody = s.updateIssue(id, body)
|
|
case r.Method == http.MethodPost && strings.HasSuffix(path, "/relations.json"):
|
|
from, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/issues/"), "/relations.json"))
|
|
respStatus, respBody = s.createRelation(from, body)
|
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/versions.json"):
|
|
respStatus, respBody = s.listVersions()
|
|
case r.Method == http.MethodPost && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/versions.json"):
|
|
respStatus, respBody = s.createVersion(body)
|
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/issue_categories.json"):
|
|
respStatus, respBody = s.listCategories()
|
|
case r.Method == http.MethodPost && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/issue_categories.json"):
|
|
respStatus, respBody = s.createCategory(body)
|
|
case r.Method == http.MethodGet && path == "/trackers.json":
|
|
respStatus, respBody = jsonReply(map[string]any{"trackers": s.Trackers})
|
|
case r.Method == http.MethodGet && path == "/issue_statuses.json":
|
|
respStatus, respBody = jsonReply(map[string]any{"issue_statuses": s.Statuses})
|
|
case r.Method == http.MethodGet && path == "/enumerations/issue_priorities.json":
|
|
respStatus, respBody = jsonReply(map[string]any{"issue_priorities": s.Priorities})
|
|
default:
|
|
respStatus, respBody = http.StatusNotFound, `{"errors":["Not found"]}`
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(respStatus)
|
|
if respBody != "" {
|
|
io.WriteString(w, respBody)
|
|
}
|
|
}
|
|
|
|
func jsonReply(v any) (int, string) {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return http.StatusInternalServerError, `{"errors":["marshal"]}`
|
|
}
|
|
return http.StatusOK, string(b)
|
|
}
|
|
|
|
func (s *Server) listIssues(q url.Values) (int, string) {
|
|
// Redmine defaults to OPEN issues only; status_id=* lifts it.
|
|
wantStatus := "open"
|
|
if v := q.Get("status_id"); v != "" {
|
|
wantStatus = v
|
|
}
|
|
var proj, ver string
|
|
proj = q.Get("project_id")
|
|
ver = q.Get("fixed_version_id")
|
|
limit := 25
|
|
if v := q.Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
limit = n
|
|
}
|
|
}
|
|
type out struct {
|
|
Issues []map[string]any `json:"issues"`
|
|
Total int `json:"total_count"`
|
|
Offset int `json:"offset"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
o := out{Limit: limit}
|
|
var ids []int
|
|
for id, i := range s.issues {
|
|
if proj != "" && i.ProjectID != proj {
|
|
continue
|
|
}
|
|
if wantStatus == "open" && s.statusIsClosed(i.StatusID) {
|
|
continue
|
|
}
|
|
if wantStatus == "closed" && !s.statusIsClosed(i.StatusID) {
|
|
continue
|
|
}
|
|
if ver != "" && strconv.Itoa(i.FixedVersionID) != ver {
|
|
continue
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Ints(ids)
|
|
for _, id := range ids {
|
|
o.Issues = append(o.Issues, issueView(s, *s.issues[id], false))
|
|
}
|
|
o.Total = len(o.Issues)
|
|
if len(o.Issues) > limit {
|
|
o.Issues = o.Issues[:limit]
|
|
}
|
|
return jsonReply(o)
|
|
}
|
|
|
|
func (s *Server) statusIsClosed(id int) bool {
|
|
for _, st := range s.Statuses {
|
|
if st.ID == id {
|
|
return st.IsClosed
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Server) getIssue(id int, include string) (int, string) {
|
|
i, ok := s.issues[id]
|
|
if !ok {
|
|
return http.StatusNotFound, `{"errors":["Issue not found"]}`
|
|
}
|
|
out := map[string]any{"issue": issueView(s, *i, include == "journals")}
|
|
return jsonReply(out)
|
|
}
|
|
|
|
func (s *Server) createIssue(body []byte) (int, string) {
|
|
var p struct {
|
|
Issue Issue `json:"issue"`
|
|
}
|
|
if err := json.Unmarshal(body, &p); err != nil {
|
|
return http.StatusBadRequest, `{"errors":["bad json"]}`
|
|
}
|
|
if p.Issue.Subject == "" || p.Issue.ProjectID == "" {
|
|
return http.StatusUnprocessableEntity, `{"errors":["Subject and project cannot be blank"]}`
|
|
}
|
|
s.nextID++
|
|
i := p.Issue
|
|
i.ID = s.nextID
|
|
i.CreatedOn = "2026-08-29T12:00:00Z"
|
|
i.UpdatedOn = i.CreatedOn
|
|
if i.Notes != "" { // notes on create become the first journal
|
|
i.Journals = append(i.Journals, JournalEntry{ID: 1, UserID: 5, Notes: i.Notes, CreatedOn: i.CreatedOn})
|
|
}
|
|
s.issues[i.ID] = &i
|
|
return http.StatusCreated, `{"issue":` + mustJSON(issueView(s, i, false)) + `}`
|
|
}
|
|
|
|
func (s *Server) updateIssue(id int, body []byte) (int, string) {
|
|
i, ok := s.issues[id]
|
|
if !ok {
|
|
return http.StatusNotFound, `{"errors":["Issue not found"]}`
|
|
}
|
|
var p struct {
|
|
Issue Issue `json:"issue"`
|
|
}
|
|
if err := json.Unmarshal(body, &p); err != nil {
|
|
return http.StatusBadRequest, `{"errors":["bad json"]}`
|
|
}
|
|
if p.Issue.Subject != "" {
|
|
i.Subject = p.Issue.Subject
|
|
}
|
|
if p.Issue.Description != "" {
|
|
i.Description = p.Issue.Description
|
|
}
|
|
if p.Issue.TrackerID != 0 {
|
|
i.TrackerID = p.Issue.TrackerID
|
|
}
|
|
if p.Issue.StatusID != 0 {
|
|
i.StatusID = p.Issue.StatusID
|
|
}
|
|
if p.Issue.PriorityID != 0 {
|
|
i.PriorityID = p.Issue.PriorityID
|
|
}
|
|
if p.Issue.CategoryID != 0 {
|
|
i.CategoryID = p.Issue.CategoryID
|
|
}
|
|
if p.Issue.FixedVersionID != 0 {
|
|
i.FixedVersionID = p.Issue.FixedVersionID
|
|
}
|
|
if p.Issue.EstimatedHours != nil {
|
|
i.EstimatedHours = p.Issue.EstimatedHours
|
|
}
|
|
if p.Issue.DoneRatio != 0 {
|
|
i.DoneRatio = p.Issue.DoneRatio
|
|
}
|
|
if p.Issue.DueDate != "" {
|
|
i.DueDate = p.Issue.DueDate
|
|
}
|
|
if p.Issue.Notes != "" {
|
|
i.Journals = append(i.Journals, JournalEntry{
|
|
ID: len(i.Journals) + 1, UserID: 5, Notes: p.Issue.Notes, CreatedOn: "2026-08-29T13:00:00Z",
|
|
})
|
|
}
|
|
i.UpdatedOn = "2026-08-29T13:00:00Z"
|
|
return http.StatusNoContent, ""
|
|
}
|
|
|
|
func (s *Server) createRelation(from int, body []byte) (int, string) {
|
|
var p struct {
|
|
Relation Relation `json:"relation"`
|
|
}
|
|
if err := json.Unmarshal(body, &p); err != nil {
|
|
return http.StatusBadRequest, `{"errors":["bad json"]}`
|
|
}
|
|
if _, ok := s.issues[from]; !ok {
|
|
return http.StatusNotFound, `{"errors":["Issue not found"]}`
|
|
}
|
|
if _, ok := s.issues[p.Relation.IssueToID]; !ok {
|
|
return http.StatusUnprocessableEntity, `{"errors":["Issue not found"]}`
|
|
}
|
|
t := p.Relation.RelationType
|
|
if t != "blocks" && t != "relates" && t != "precedes" && t != "copied_to" && t != "duplicates" {
|
|
return http.StatusUnprocessableEntity, `{"errors":["relation_type is not included in the list"]}`
|
|
}
|
|
s.nextID++
|
|
rel := Relation{ID: s.nextID, IssueID: from, IssueToID: p.Relation.IssueToID, RelationType: t}
|
|
s.relations[rel.ID] = rel
|
|
return http.StatusCreated, `{"relation":` + mustJSON(rel) + `}`
|
|
}
|
|
|
|
func (s *Server) listVersions() (int, string) {
|
|
var vs []Version
|
|
for _, v := range s.versions {
|
|
vs = append(vs, v)
|
|
}
|
|
return jsonReply(map[string]any{"versions": vs})
|
|
}
|
|
|
|
func (s *Server) createVersion(body []byte) (int, string) {
|
|
var p struct {
|
|
Version Version `json:"version"`
|
|
}
|
|
if err := json.Unmarshal(body, &p); err != nil {
|
|
return http.StatusBadRequest, `{"errors":["bad json"]}`
|
|
}
|
|
if p.Version.Name == "" {
|
|
return http.StatusUnprocessableEntity, `{"errors":["Name cannot be blank"]}`
|
|
}
|
|
for _, v := range s.versions {
|
|
if v.Name == p.Version.Name {
|
|
return http.StatusUnprocessableEntity, `{"errors":["Name has already been taken"]}`
|
|
}
|
|
}
|
|
s.nextID++
|
|
v := p.Version
|
|
v.ID = s.nextID
|
|
if v.Status == "" {
|
|
v.Status = "open"
|
|
}
|
|
s.versions[v.ID] = v
|
|
return http.StatusCreated, `{"version":` + mustJSON(v) + `}`
|
|
}
|
|
|
|
func (s *Server) listCategories() (int, string) {
|
|
var cs []Category
|
|
for _, c := range s.categories {
|
|
cs = append(cs, c)
|
|
}
|
|
return jsonReply(map[string]any{"issue_categories": cs})
|
|
}
|
|
|
|
func (s *Server) createCategory(body []byte) (int, string) {
|
|
var p struct {
|
|
Category Category `json:"issue_category"`
|
|
}
|
|
if err := json.Unmarshal(body, &p); err != nil {
|
|
return http.StatusBadRequest, `{"errors":["bad json"]}`
|
|
}
|
|
if p.Category.Name == "" {
|
|
return http.StatusUnprocessableEntity, `{"errors":["Name cannot be blank"]}`
|
|
}
|
|
for _, c := range s.categories {
|
|
if c.Name == p.Category.Name {
|
|
return http.StatusUnprocessableEntity, `{"errors":["Name has already been taken"]}`
|
|
}
|
|
}
|
|
s.nextID++
|
|
c := Category{ID: s.nextID, Name: p.Category.Name}
|
|
s.categories[c.ID] = c
|
|
return http.StatusCreated, `{"issue_category":` + mustJSON(c) + `}`
|
|
}
|
|
|
|
// issueView projects the stored issue into Redmine's READ shape
|
|
// (nested objects for project/tracker/status/priority/category/version).
|
|
func issueView(s *Server, i Issue, withJournals bool) map[string]any {
|
|
v := map[string]any{
|
|
"id": i.ID,
|
|
"subject": i.Subject,
|
|
"description": i.Description,
|
|
"created_on": i.CreatedOn,
|
|
"updated_on": i.UpdatedOn,
|
|
}
|
|
v["project"] = map[string]any{"id": 1, "name": i.ProjectID, "identifier": i.ProjectID}
|
|
if n := enumName(s.Trackers, i.TrackerID); n != "" {
|
|
v["tracker"] = map[string]any{"id": i.TrackerID, "name": n}
|
|
}
|
|
if st := statusByID(s.Statuses, i.StatusID); st != nil {
|
|
v["status"] = map[string]any{"id": st.ID, "name": st.Name, "is_closed": st.IsClosed}
|
|
}
|
|
if n := enumName(s.Priorities, i.PriorityID); n != "" {
|
|
v["priority"] = map[string]any{"id": i.PriorityID, "name": n}
|
|
}
|
|
if i.CategoryID != 0 {
|
|
if c, ok := s.categories[i.CategoryID]; ok {
|
|
v["category"] = map[string]any{"id": c.ID, "name": c.Name}
|
|
}
|
|
}
|
|
if i.FixedVersionID != 0 {
|
|
if ver, ok := s.versions[i.FixedVersionID]; ok {
|
|
v["fixed_version"] = map[string]any{"id": ver.ID, "name": ver.Name}
|
|
}
|
|
}
|
|
if i.ParentIssueID != 0 {
|
|
v["parent"] = map[string]any{"id": i.ParentIssueID}
|
|
}
|
|
if i.EstimatedHours != nil {
|
|
v["estimated_hours"] = *i.EstimatedHours
|
|
}
|
|
if i.DoneRatio != 0 {
|
|
v["done_ratio"] = i.DoneRatio
|
|
}
|
|
if i.DueDate != "" {
|
|
v["due_date"] = i.DueDate
|
|
}
|
|
if withJournals && len(i.Journals) > 0 {
|
|
v["journals"] = i.Journals
|
|
}
|
|
return v
|
|
}
|
|
|
|
func enumName(list []IDName, id int) string {
|
|
for _, e := range list {
|
|
if e.ID == id {
|
|
return e.Name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func statusByID(list []Status, id int) *Status {
|
|
for i := range list {
|
|
if list[i].ID == id {
|
|
return &list[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mustJSON(v any) string {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(b)
|
|
}
|