Skip to content

Commit aa1819c

Browse files
authored
Added TUI, etc (#148)
1 parent e4fd527 commit aa1819c

14 files changed

Lines changed: 766 additions & 20 deletions

File tree

pkg/cmd/server.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ func (s *RunServer) Run(ctx server.Cmd) error {
125125
return fmt.Errorf("catchall: %w", err)
126126
}
127127

128+
// Serve requests through the wrapped router so CORS, CSRF protection and
129+
// router middleware are applied to the registered routes.
130+
srv.SetHandler(router)
131+
128132
// Bind to the server's address to ensure it's available
129133
if err := srv.Listen(); err != nil {
130134
return err

pkg/httprequest/read.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ func Read(r *http.Request, v interface{}) error {
4141
return readString(r, v)
4242
case types.ContentTypeFormData:
4343
return readFormData(r, v)
44+
case types.ContentTypeForm:
45+
return readFormURLEncoded(r, v)
4446
}
4547

4648
// Cannot handle this content type
@@ -162,6 +164,19 @@ func readFormData(r *http.Request, v any) error {
162164
return nil
163165
}
164166

167+
func readFormURLEncoded(r *http.Request, v any) error {
168+
if err := r.ParseForm(); err != nil {
169+
return errBadRequest.With(err.Error())
170+
}
171+
if r.PostForm == nil {
172+
return httpresponse.ErrBadRequest.With("Missing form data")
173+
}
174+
if err := Query(r.PostForm, v); err != nil {
175+
return err
176+
}
177+
return nil
178+
}
179+
165180
// fileHeaderPath returns the sanitised logical path for a multipart file part.
166181
// It prefers the X-Path header (set by go-client when the path contains
167182
// directory components, since the stdlib strips directories from the

pkg/httprequest/read_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"mime/multipart"
66
"net/http"
7+
"net/url"
78
"strings"
89
"testing"
910

@@ -136,6 +137,33 @@ func Test_Read_FormData(t *testing.T) {
136137
})
137138
}
138139

140+
func Test_Read_FormURLEncoded(t *testing.T) {
141+
assert := assert.New(t)
142+
143+
t.Run("WithFields", func(t *testing.T) {
144+
type payload struct {
145+
Name string `json:"name"`
146+
Age string `json:"age"`
147+
Scopes []string `json:"scope"`
148+
}
149+
150+
values := url.Values{
151+
"name": {"alice"},
152+
"age": {"30"},
153+
"scope": {"openid", "profile"},
154+
}
155+
r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(values.Encode()))
156+
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
157+
158+
var p payload
159+
err := httprequest.Read(r, &p)
160+
assert.NoError(err)
161+
assert.Equal("alice", p.Name)
162+
assert.Equal("30", p.Age)
163+
assert.Equal([]string{"openid", "profile"}, p.Scopes)
164+
})
165+
}
166+
139167
func Test_Read_FormData_JSON(t *testing.T) {
140168
assert := assert.New(t)
141169

pkg/httprouter/security.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ func (r *Router) RegisterSecurityScheme(name string, scheme SecurityScheme) erro
2929
if _, exists := r.security[name]; exists {
3030
return httpresponse.ErrConflict.Withf("security scheme %q already registered", name)
3131
}
32-
3332
r.spec.AddSecurityScheme(name, scheme.Spec())
3433
r.security[name] = scheme
3534
return nil

pkg/httpserver/httpserver.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
type server struct {
2323
http http.Server
2424
listener net.Listener
25+
mux *http.ServeMux
2526
serverName string
2627
}
2728

@@ -71,7 +72,8 @@ func New(listen string, tls *tls.Config, opts ...Opt) (*server, error) {
7172
listen = ListenAddr(listen, tls != nil && len(tls.Certificates) > 0)
7273

7374
// Set other options
74-
server.http.Handler = http.DefaultServeMux
75+
server.mux = http.NewServeMux()
76+
server.http.Handler = server.mux
7577
server.http.Addr = listen
7678
if tls != nil && len(tls.Certificates) > 0 {
7779
server.http.TLSConfig = tls
@@ -94,7 +96,18 @@ func New(listen string, tls *tls.Config, opts ...Opt) (*server, error) {
9496

9597
// Router returns the server's http.ServeMux for registering handlers.
9698
func (server *server) Router() *http.ServeMux {
97-
return server.http.Handler.(*http.ServeMux)
99+
return server.mux
100+
}
101+
102+
// SetHandler replaces the HTTP handler used to serve requests.
103+
// Route registration should continue to use Router, which always returns the
104+
// dedicated internal ServeMux.
105+
func (server *server) SetHandler(handler http.Handler) {
106+
if handler == nil {
107+
server.http.Handler = server.mux
108+
return
109+
}
110+
server.http.Handler = handler
98111
}
99112

100113
// Addr returns the listen address. After [Listen] has been called this

pkg/openapi/markdown.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package openapi
2+
3+
import (
4+
"bytes"
5+
_ "embed"
6+
"errors"
7+
"strings"
8+
9+
// Packages
10+
tokenizer "github.com/mutablelogic/go-tokenizer"
11+
ast "github.com/mutablelogic/go-tokenizer/pkg/ast"
12+
markdown "github.com/mutablelogic/go-tokenizer/pkg/markdown"
13+
)
14+
15+
///////////////////////////////////////////////////////////////////////////////
16+
// TYPES
17+
18+
type MarkdownDoc struct {
19+
root ast.Node
20+
sections []Section
21+
}
22+
23+
type Section struct {
24+
Level int
25+
Title string
26+
Body string
27+
}
28+
29+
///////////////////////////////////////////////////////////////////////////////
30+
// LIFECYCLE
31+
32+
func ParseMarkdown(data []byte) (doc *MarkdownDoc) {
33+
return &MarkdownDoc{
34+
root: markdown.Parse(bytes.NewReader(data), tokenizer.Pos{}),
35+
sections: parseSections(string(data)),
36+
}
37+
}
38+
39+
///////////////////////////////////////////////////////////////////////////////
40+
// PUBLIC METHODS
41+
42+
func (doc *MarkdownDoc) Sections() []Section {
43+
if doc == nil {
44+
return nil
45+
}
46+
return doc.sections
47+
}
48+
49+
func (doc *MarkdownDoc) Section(level int, title string) Section {
50+
if doc == nil {
51+
return Section{}
52+
}
53+
title = normalizeTitle(title)
54+
for _, section := range doc.sections {
55+
if section.Level == level && strings.EqualFold(section.Title, title) {
56+
return section
57+
}
58+
}
59+
return Section{}
60+
}
61+
62+
// FindSection returns the first heading whose text matches title.
63+
func (doc *MarkdownDoc) FindSection(title string) *markdown.Heading {
64+
if doc == nil || doc.root == nil {
65+
return nil
66+
}
67+
title = strings.TrimSpace(title)
68+
if title == "" {
69+
return nil
70+
}
71+
72+
var section *markdown.Heading
73+
_ = ast.Walk(doc.root, func(node ast.Node, depth int) error {
74+
heading, ok := node.(*markdown.Heading)
75+
if !ok {
76+
return nil
77+
}
78+
if strings.EqualFold(markdownNodeText(heading), title) {
79+
section = heading
80+
return errors.New("found markdown section")
81+
}
82+
return nil
83+
})
84+
return section
85+
}
86+
87+
///////////////////////////////////////////////////////////////////////////////
88+
// PRIVATE METHODS
89+
90+
func markdownNodeText(node ast.Node) string {
91+
if node == nil {
92+
return ""
93+
}
94+
95+
var text strings.Builder
96+
_ = ast.Walk(node, func(node ast.Node, depth int) error {
97+
switch node := node.(type) {
98+
case *markdown.Text:
99+
text.WriteString(node.Value())
100+
case *markdown.Code:
101+
text.WriteString(node.String())
102+
}
103+
return nil
104+
})
105+
106+
return strings.TrimSpace(text.String())
107+
}
108+
109+
func parseSections(content string) []Section {
110+
lines := strings.Split(content, "\n")
111+
sections := make([]Section, 0, len(lines))
112+
113+
var current *Section
114+
for _, line := range lines {
115+
if level, title, ok := parseHeading(line); ok {
116+
if current != nil {
117+
current.Body = strings.Trim(current.Body, "\n")
118+
sections = append(sections, *current)
119+
}
120+
current = &Section{Level: level, Title: title}
121+
continue
122+
}
123+
if current == nil {
124+
current = &Section{}
125+
}
126+
current.Body += line + "\n"
127+
}
128+
if current != nil {
129+
current.Body = strings.Trim(current.Body, "\n")
130+
sections = append(sections, *current)
131+
}
132+
133+
return sections
134+
}
135+
136+
func parseHeading(line string) (level int, title string, ok bool) {
137+
trimmed := strings.TrimLeft(line, " ")
138+
if len(trimmed) == 0 || trimmed[0] != '#' {
139+
return 0, "", false
140+
}
141+
i := 0
142+
for i < len(trimmed) && trimmed[i] == '#' {
143+
i++
144+
}
145+
if i > 6 {
146+
return 0, "", false
147+
}
148+
if i < len(trimmed) && trimmed[i] != ' ' && trimmed[i] != '\t' {
149+
return 0, "", false
150+
}
151+
title = strings.TrimSpace(trimmed[i:])
152+
title = strings.TrimRight(title, "# ")
153+
title = normalizeTitle(title)
154+
return i, title, true
155+
}
156+
157+
func normalizeTitle(title string) string {
158+
title = strings.TrimSpace(title)
159+
for _, marker := range []string{"`", "**", "__", "*", "_"} {
160+
if strings.HasPrefix(title, marker) && strings.HasSuffix(title, marker) && len(title) >= 2*len(marker) {
161+
title = strings.TrimSpace(title[len(marker) : len(title)-len(marker)])
162+
}
163+
}
164+
return title
165+
}

pkg/openapi/markdown_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package openapi
2+
3+
import (
4+
"testing"
5+
6+
assert "github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestParseMarkdownSections(t *testing.T) {
10+
const input = `# Top
11+
12+
Preamble text.
13+
14+
## Section A
15+
16+
Body of A.
17+
18+
### Subsection A1
19+
20+
Body of A1.
21+
22+
## Section B
23+
24+
Body of B.
25+
`
26+
27+
doc := ParseMarkdown([]byte(input))
28+
29+
t.Run("Sections", func(t *testing.T) {
30+
assert := assert.New(t)
31+
sections := doc.Sections()
32+
assert.Len(sections, 4)
33+
34+
assert.Equal(1, sections[0].Level)
35+
assert.Equal("Top", sections[0].Title)
36+
assert.Contains(sections[0].Body, "Preamble text.")
37+
38+
assert.Equal(2, sections[1].Level)
39+
assert.Equal("Section A", sections[1].Title)
40+
41+
assert.Equal(3, sections[2].Level)
42+
assert.Equal("Subsection A1", sections[2].Title)
43+
44+
assert.Equal(2, sections[3].Level)
45+
assert.Equal("Section B", sections[3].Title)
46+
})
47+
48+
t.Run("SectionLookup", func(t *testing.T) {
49+
assert := assert.New(t)
50+
s := doc.Section(2, "Section A")
51+
assert.Contains(s.Body, "Body of A.")
52+
53+
s = doc.Section(2, "section a")
54+
assert.Equal("Section A", s.Title)
55+
56+
s = doc.Section(2, "Nonexistent")
57+
assert.Empty(s.Title)
58+
})
59+
}
60+
61+
func TestParseMarkdownPreamble(t *testing.T) {
62+
const input = `Some text before any heading.
63+
64+
# First
65+
Content.
66+
`
67+
doc := ParseMarkdown([]byte(input))
68+
assert := assert.New(t)
69+
70+
sections := doc.Sections()
71+
assert.Len(sections, 2)
72+
73+
assert.Equal(0, sections[0].Level)
74+
assert.Equal("", sections[0].Title)
75+
assert.Contains(sections[0].Body, "Some text before any heading.")
76+
77+
assert.Equal(1, sections[1].Level)
78+
assert.Equal("First", sections[1].Title)
79+
}
80+
81+
func TestParseMarkdownClosingHashes(t *testing.T) {
82+
doc := ParseMarkdown([]byte("## Foo ##\n\nbar"))
83+
s := doc.Section(2, "Foo")
84+
assert.Contains(t, s.Body, "bar")
85+
}
86+
87+
func TestParseMarkdownInlineCodeHeading(t *testing.T) {
88+
doc := ParseMarkdown([]byte("## `GET /auth/config`\n\nbody"))
89+
s := doc.Section(2, "GET /auth/config")
90+
assert.Equal(t, "GET /auth/config", s.Title)
91+
assert.Equal(t, "body", s.Body)
92+
}
93+
94+
func TestParseMarkdownEmpty(t *testing.T) {
95+
doc := ParseMarkdown(nil)
96+
assert.Len(t, doc.Sections(), 1)
97+
assert.Equal(t, 0, doc.Sections()[0].Level)
98+
}

0 commit comments

Comments
 (0)