Skip to content

Commit a861ed0

Browse files
committed
Add base CLI and API
0 parents  commit a861ed0

33 files changed

+1730
-0
lines changed

Diff for: .gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.DS_Store
2+
bin
3+
coverage
4+
extensions

Diff for: Makefile

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#TODO: build
2+
3+
lint: lint/go
4+
.PHONY: lint
5+
6+
lint/go:
7+
golangci-lint run
8+
.PHONY: lint/go
9+
10+
test-clean:
11+
go clean -testcache
12+
.PHONY: test-clean
13+
14+
test: test-clean
15+
gotestsum -- -v -short -coverprofile coverage ./...
16+
.PHONY: test
17+
18+
coverage:
19+
go tool cover -func=coverage
20+
.PHONY: coverage
21+
22+
TAG=$(shell git describe --always)
23+
24+
build:
25+
GOOS=darwin GOARCH=amd64 go build -ldflags "-X github.com/coder/code-marketplace/buildinfo.tag=$(TAG)" -o bin/code-marketplace-mac-amd64 ./cmd/marketplace/main.go
26+
GOOS=darwin GOARCH=arm64 go build -ldflags "-X github.com/coder/code-marketplace/buildinfo.tag=$(TAG)" -o bin/code-marketplace-mac-arm64 ./cmd/marketplace/main.go
27+
GOOS=linux GOARCH=amd64 go build -ldflags "-X github.com/coder/code-marketplace/buildinfo.tag=$(TAG)" -o bin/code-marketplace-linux-amd64 ./cmd/marketplace/main.go
28+
GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/coder/code-marketplace/buildinfo.tag=$(TAG)" -o bin/code-marketplace-linux-arm64 ./cmd/marketplace/main.go
29+
GOOS=windows GOARCH=amd64 go build -ldflags "-X github.com/coder/code-marketplace/buildinfo.tag=$(TAG)" -o bin/code-marketplace-windows-amd64 ./cmd/marketplace/main.go
30+
GOOS=windows GOARCH=arm64 go build -ldflags "-X github.com/coder/code-marketplace/buildinfo.tag=$(TAG)" -o bin/code-marketplace-windows-arm64 ./cmd/marketplace/main.go
31+
.PHONY: build

Diff for: api/api.go

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/go-chi/chi/v5"
7+
"github.com/go-chi/chi/v5/middleware"
8+
"github.com/go-chi/cors"
9+
10+
"cdr.dev/slog"
11+
"github.com/coder/code-marketplace/api/httpmw"
12+
)
13+
14+
type Options struct {
15+
ExtDir string
16+
Logger slog.Logger
17+
// Set to <0 to disable.
18+
RateLimit int
19+
}
20+
21+
type API struct {
22+
Handler http.Handler
23+
}
24+
25+
// New creates a new API server.
26+
func New(options *Options) *API {
27+
if options.RateLimit == 0 {
28+
options.RateLimit = 512
29+
}
30+
31+
r := chi.NewRouter()
32+
33+
cors := cors.New(cors.Options{
34+
AllowedOrigins: []string{"*"},
35+
AllowedMethods: []string{"POST", "GET", "OPTIONS"},
36+
AllowedHeaders: []string{"*"},
37+
AllowCredentials: true,
38+
MaxAge: 300,
39+
})
40+
41+
r.Use(
42+
cors.Handler,
43+
httpmw.RateLimitPerMinute(options.RateLimit),
44+
middleware.GetHead,
45+
httpmw.AttachRequestID,
46+
httpmw.Recover(options.Logger),
47+
httpmw.AttachBuildInfo,
48+
httpmw.Logger(options.Logger),
49+
)
50+
51+
r.Get("/", func(rw http.ResponseWriter, r *http.Request) {
52+
httpapi.WriteBytes(rw, http.StatusOK, []byte("Marketplace is running"))
53+
})
54+
55+
return &API{
56+
Handler: r,
57+
}
58+
}

Diff for: api/api_test.go

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package api_test
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
11+
"cdr.dev/slog"
12+
"cdr.dev/slog/sloggers/slogtest"
13+
"github.com/coder/code-marketplace/api"
14+
)
15+
16+
func TestServer(t *testing.T) {
17+
t.Parallel()
18+
19+
api := api.New(&api.Options{
20+
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
21+
ExtDir: filepath.Join(t.TempDir(), "extensions"),
22+
})
23+
24+
server := httptest.NewServer(api.Handler)
25+
defer server.Close()
26+
27+
resp, err := http.Get(server.URL + "/non-existent")
28+
require.NoError(t, err)
29+
require.Equal(t, http.StatusNotFound, resp.StatusCode)
30+
31+
resp, err = http.Get(server.URL)
32+
require.NoError(t, err)
33+
require.Equal(t, http.StatusOK, resp.StatusCode)
34+
}

Diff for: api/httpapi/httpapi.go

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package httpapi
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
8+
"github.com/google/uuid"
9+
)
10+
11+
type ErrorResponse struct {
12+
Message string `json:"message"`
13+
Detail string `json:"detail"`
14+
RequestID uuid.UUID `json:"requestId,omitempty"`
15+
}
16+
17+
// WriteBytes tries to write the provided bytes and errors if unable.
18+
func WriteBytes(rw http.ResponseWriter, status int, bytes []byte) {
19+
rw.WriteHeader(status)
20+
_, err := rw.Write(bytes)
21+
if err != nil {
22+
http.Error(rw, err.Error(), http.StatusInternalServerError)
23+
return
24+
}
25+
}
26+
27+
// Write outputs a standardized format to an HTTP response body.
28+
func Write(rw http.ResponseWriter, status int, response interface{}) {
29+
buf := &bytes.Buffer{}
30+
enc := json.NewEncoder(buf)
31+
enc.SetEscapeHTML(true)
32+
err := enc.Encode(response)
33+
if err != nil {
34+
http.Error(rw, err.Error(), http.StatusInternalServerError)
35+
return
36+
}
37+
rw.Header().Set("Content-Type", "application/json; charset=utf-8")
38+
WriteBytes(rw, status, buf.Bytes())
39+
}

Diff for: api/httpapi/httpapi_test.go

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package httpapi_test
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/google/uuid"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/coder/code-marketplace/api/httpapi"
13+
)
14+
15+
type TestResponse struct {
16+
Message string
17+
}
18+
19+
func TestWrite(t *testing.T) {
20+
t.Parallel()
21+
22+
t.Run("OK", func(t *testing.T) {
23+
t.Parallel()
24+
25+
message := TestResponse{Message: "foo"}
26+
rw := httptest.NewRecorder()
27+
httpapi.Write(rw, http.StatusOK, message)
28+
require.Equal(t, http.StatusOK, rw.Code)
29+
30+
var m TestResponse
31+
err := json.NewDecoder(rw.Body).Decode(&m)
32+
require.NoError(t, err)
33+
require.Equal(t, message, m)
34+
})
35+
36+
t.Run("Error", func(t *testing.T) {
37+
t.Parallel()
38+
39+
message := httpapi.ErrorResponse{Message: "foo", Detail: "bar", RequestID: uuid.New()}
40+
rw := httptest.NewRecorder()
41+
httpapi.Write(rw, http.StatusMethodNotAllowed, message)
42+
require.Equal(t, http.StatusMethodNotAllowed, rw.Code)
43+
44+
var m httpapi.ErrorResponse
45+
err := json.NewDecoder(rw.Body).Decode(&m)
46+
require.NoError(t, err)
47+
require.Equal(t, message, m)
48+
})
49+
50+
t.Run("Malformed", func(t *testing.T) {
51+
t.Parallel()
52+
53+
rw := httptest.NewRecorder()
54+
httpapi.Write(rw, http.StatusMethodNotAllowed, "no")
55+
// This will still be the original code since it was already set.
56+
require.Equal(t, http.StatusMethodNotAllowed, rw.Code)
57+
58+
var m httpapi.ErrorResponse
59+
err := json.NewDecoder(rw.Body).Decode(&m)
60+
require.Error(t, err)
61+
})
62+
}

Diff for: api/httpapi/status_writer.go

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package httpapi
2+
3+
import (
4+
"bufio"
5+
"net"
6+
"net/http"
7+
8+
"golang.org/x/xerrors"
9+
)
10+
11+
var _ http.ResponseWriter = (*StatusWriter)(nil)
12+
var _ http.Hijacker = (*StatusWriter)(nil)
13+
14+
// StatusWriter intercepts the status of the request and the response body up to
15+
// maxBodySize if Status >= 400. It is guaranteed to be the ResponseWriter
16+
// directly downstream from Middleware.
17+
type StatusWriter struct {
18+
http.ResponseWriter
19+
Status int
20+
Hijacked bool
21+
responseBody []byte
22+
23+
wroteHeader bool
24+
}
25+
26+
func (w *StatusWriter) WriteHeader(status int) {
27+
if !w.wroteHeader {
28+
w.Status = status
29+
w.wroteHeader = true
30+
}
31+
w.ResponseWriter.WriteHeader(status)
32+
}
33+
34+
func (w *StatusWriter) Write(b []byte) (int, error) {
35+
const maxBodySize = 4096
36+
37+
if !w.wroteHeader {
38+
w.Status = http.StatusOK
39+
w.wroteHeader = true
40+
}
41+
42+
if w.Status >= http.StatusBadRequest {
43+
// This is technically wrong as multiple calls to write will simply
44+
// overwrite w.ResponseBody but we typically only write to the response body
45+
// once and this field is only used for logging.
46+
w.responseBody = make([]byte, minInt(len(b), maxBodySize))
47+
copy(w.responseBody, b)
48+
}
49+
50+
return w.ResponseWriter.Write(b)
51+
}
52+
53+
func minInt(a, b int) int {
54+
if a < b {
55+
return a
56+
}
57+
return b
58+
}
59+
60+
func (w *StatusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
61+
hijacker, ok := w.ResponseWriter.(http.Hijacker)
62+
if !ok {
63+
return nil, nil, xerrors.Errorf("%T is not a http.Hijacker", w.ResponseWriter)
64+
}
65+
w.Hijacked = true
66+
67+
return hijacker.Hijack()
68+
}
69+
70+
func (w *StatusWriter) ResponseBody() []byte {
71+
return w.responseBody
72+
}

0 commit comments

Comments
 (0)