Issues (create/update/partial PUT, notes-as-journals), versions, categories, relations, and name-to-id resolution over trackers/ statuses/priorities. Sentinel errors carry http codes one-line; response bodies are never surfaced so the API key cannot leak through error strings (the fake deliberately echoes the presented key to prove it).
587 lines
17 KiB
Go
587 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"
|
|
"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 []Issue `json:"issues"`
|
|
Total int `json:"total_count"`
|
|
Offset int `json:"offset"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
o := out{Limit: limit}
|
|
for _, 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
|
|
}
|
|
o.Issues = append(o.Issues, *i)
|
|
}
|
|
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)
|
|
}
|