Skip to content

Commit a44056c

Browse files
authored
feat: Add project API endpoints (#51)
* feat: Add project models * Add project query functions * Add organization parameter query * Add project URL parameter parse * Add project create and list endpoints * Add test for organization provided * Remove unimplemented routes * Decrease conn timeout * Add test for UnbiasedModulo32 * Fix expected value * Add single user endpoint * Add query for project versions * Fix linting errors * Add comments * Add test for invalid archive * Check unauthenticated endpoints * Add check if no change happened * Ensure context close ends listener * Fix parallel test run * Test empty * Fix organization param comment
1 parent 52e50fc commit a44056c

37 files changed

+2121
-22
lines changed

.golangci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ linters:
234234
- misspell
235235
- nilnil
236236
- noctx
237+
- paralleltest
237238
- revive
238239
- rowserrcheck
239240
- sqlclosecheck

Makefile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ database/dump.sql: $(wildcard database/migrations/*.sql)
1111
go run database/dump/main.go
1212

1313
# Generates Go code for querying the database.
14-
database/generate: database/dump.sql database/query.sql
14+
database/generate: fmt/sql database/dump.sql database/query.sql
1515
cd database && sqlc generate && rm db_tmp.go
1616
cd database && gofmt -w -r 'Querier -> querier' *.go
1717
cd database && gofmt -w -r 'Queries -> sqlQuerier' *.go
@@ -27,12 +27,13 @@ else
2727
endif
2828
.PHONY: fmt/prettier
2929

30-
fmt/sql:
30+
fmt/sql: ./database/query.sql
3131
npx sql-formatter \
3232
--language postgresql \
3333
--lines-between-queries 2 \
3434
./database/query.sql \
3535
--output ./database/query.sql
36+
sed -i 's/@ /@/g' ./database/query.sql
3637

3738
fmt: fmt/prettier fmt/sql
3839
.PHONY: fmt

coderd/cmd/root_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
)
1111

1212
func TestRoot(t *testing.T) {
13+
t.Parallel()
1314
ctx, cancelFunc := context.WithCancel(context.Background())
1415
go cancelFunc()
1516
err := cmd.Root().ExecuteContext(ctx)

coderd/coderd.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ type Options struct {
2020

2121
// New constructs the Coder API into an HTTP handler.
2222
func New(options *Options) http.Handler {
23+
projects := &projects{
24+
Database: options.Database,
25+
}
2326
users := &users{
2427
Database: options.Database,
2528
}
@@ -44,6 +47,25 @@ func New(options *Options) http.Handler {
4447
r.Get("/{user}/organizations", users.userOrganizations)
4548
})
4649
})
50+
r.Route("/projects", func(r chi.Router) {
51+
r.Use(
52+
httpmw.ExtractAPIKey(options.Database, nil),
53+
)
54+
r.Get("/", projects.allProjects)
55+
r.Route("/{organization}", func(r chi.Router) {
56+
r.Use(httpmw.ExtractOrganizationParam(options.Database))
57+
r.Get("/", projects.allProjectsForOrganization)
58+
r.Post("/", projects.createProject)
59+
r.Route("/{project}", func(r chi.Router) {
60+
r.Use(httpmw.ExtractProjectParameter(options.Database))
61+
r.Get("/", projects.project)
62+
r.Route("/versions", func(r chi.Router) {
63+
r.Get("/", projects.projectVersions)
64+
r.Post("/", projects.createProjectVersion)
65+
})
66+
})
67+
})
68+
})
4769
})
4870
r.NotFound(site.Handler().ServeHTTP)
4971
return r

coderd/coderdtest/coderdtest_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ func TestMain(m *testing.M) {
1313
}
1414

1515
func TestNew(t *testing.T) {
16+
t.Parallel()
1617
server := coderdtest.New(t)
1718
_ = server.RandomInitialUser(t)
1819
}

coderd/projects.go

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package coderd
2+
3+
import (
4+
"archive/tar"
5+
"bytes"
6+
"database/sql"
7+
"errors"
8+
"fmt"
9+
"net/http"
10+
"time"
11+
12+
"github.com/go-chi/render"
13+
"github.com/google/uuid"
14+
15+
"github.com/moby/moby/pkg/namesgenerator"
16+
17+
"github.com/coder/coder/database"
18+
"github.com/coder/coder/httpapi"
19+
"github.com/coder/coder/httpmw"
20+
)
21+
22+
// Project is the JSON representation of a Coder project.
23+
// This type matches the database object for now, but is
24+
// abstracted for ease of change later on.
25+
type Project database.Project
26+
27+
// ProjectVersion is the JSON representation of a Coder project version.
28+
type ProjectVersion struct {
29+
ID uuid.UUID `json:"id"`
30+
ProjectID uuid.UUID `json:"project_id"`
31+
CreatedAt time.Time `json:"created_at"`
32+
UpdatedAt time.Time `json:"updated_at"`
33+
Name string `json:"name"`
34+
StorageMethod database.ProjectStorageMethod `json:"storage_method"`
35+
}
36+
37+
// CreateProjectRequest enables callers to create a new Project.
38+
type CreateProjectRequest struct {
39+
Name string `json:"name" validate:"username,required"`
40+
Provisioner database.ProvisionerType `json:"provisioner" validate:"oneof=terraform cdr-basic,required"`
41+
}
42+
43+
// CreateProjectVersionRequest enables callers to create a new Project Version.
44+
type CreateProjectVersionRequest struct {
45+
Name string `json:"name,omitempty" validate:"username"`
46+
StorageMethod database.ProjectStorageMethod `json:"storage_method" validate:"oneof=inline-archive,required"`
47+
StorageSource []byte `json:"storage_source" validate:"max=1048576,required"`
48+
}
49+
50+
type projects struct {
51+
Database database.Store
52+
}
53+
54+
// allProjects lists all projects across organizations for a user.
55+
func (p *projects) allProjects(rw http.ResponseWriter, r *http.Request) {
56+
apiKey := httpmw.APIKey(r)
57+
organizations, err := p.Database.GetOrganizationsByUserID(r.Context(), apiKey.UserID)
58+
if err != nil {
59+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
60+
Message: fmt.Sprintf("get organizations: %s", err.Error()),
61+
})
62+
return
63+
}
64+
organizationIDs := make([]string, 0, len(organizations))
65+
for _, organization := range organizations {
66+
organizationIDs = append(organizationIDs, organization.ID)
67+
}
68+
projects, err := p.Database.GetProjectsByOrganizationIDs(r.Context(), organizationIDs)
69+
if errors.Is(err, sql.ErrNoRows) {
70+
err = nil
71+
}
72+
if err != nil {
73+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
74+
Message: fmt.Sprintf("get projects: %s", err.Error()),
75+
})
76+
return
77+
}
78+
render.Status(r, http.StatusOK)
79+
render.JSON(rw, r, projects)
80+
}
81+
82+
// allProjectsForOrganization lists all projects for a specific organization.
83+
func (p *projects) allProjectsForOrganization(rw http.ResponseWriter, r *http.Request) {
84+
organization := httpmw.OrganizationParam(r)
85+
projects, err := p.Database.GetProjectsByOrganizationIDs(r.Context(), []string{organization.ID})
86+
if errors.Is(err, sql.ErrNoRows) {
87+
err = nil
88+
}
89+
if err != nil {
90+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
91+
Message: fmt.Sprintf("get projects: %s", err.Error()),
92+
})
93+
return
94+
}
95+
render.Status(r, http.StatusOK)
96+
render.JSON(rw, r, projects)
97+
}
98+
99+
// createProject makes a new project in an organization.
100+
func (p *projects) createProject(rw http.ResponseWriter, r *http.Request) {
101+
var createProject CreateProjectRequest
102+
if !httpapi.Read(rw, r, &createProject) {
103+
return
104+
}
105+
organization := httpmw.OrganizationParam(r)
106+
_, err := p.Database.GetProjectByOrganizationAndName(r.Context(), database.GetProjectByOrganizationAndNameParams{
107+
OrganizationID: organization.ID,
108+
Name: createProject.Name,
109+
})
110+
if err == nil {
111+
httpapi.Write(rw, http.StatusConflict, httpapi.Response{
112+
Message: fmt.Sprintf("project %q already exists", createProject.Name),
113+
Errors: []httpapi.Error{{
114+
Field: "name",
115+
Code: "exists",
116+
}},
117+
})
118+
return
119+
}
120+
if !errors.Is(err, sql.ErrNoRows) {
121+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
122+
Message: fmt.Sprintf("get project by name: %s", err.Error()),
123+
})
124+
return
125+
}
126+
127+
project, err := p.Database.InsertProject(r.Context(), database.InsertProjectParams{
128+
ID: uuid.New(),
129+
CreatedAt: database.Now(),
130+
UpdatedAt: database.Now(),
131+
OrganizationID: organization.ID,
132+
Name: createProject.Name,
133+
Provisioner: createProject.Provisioner,
134+
})
135+
if err != nil {
136+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
137+
Message: fmt.Sprintf("insert project: %s", err),
138+
})
139+
return
140+
}
141+
render.Status(r, http.StatusCreated)
142+
render.JSON(rw, r, project)
143+
}
144+
145+
// project returns a single project parsed from the URL path.
146+
func (*projects) project(rw http.ResponseWriter, r *http.Request) {
147+
project := httpmw.ProjectParam(r)
148+
149+
render.Status(r, http.StatusOK)
150+
render.JSON(rw, r, project)
151+
}
152+
153+
// projectVersions lists versions for a single project.
154+
func (p *projects) projectVersions(rw http.ResponseWriter, r *http.Request) {
155+
project := httpmw.ProjectParam(r)
156+
157+
history, err := p.Database.GetProjectHistoryByProjectID(r.Context(), project.ID)
158+
if errors.Is(err, sql.ErrNoRows) {
159+
err = nil
160+
}
161+
if err != nil {
162+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
163+
Message: fmt.Sprintf("get project history: %s", err),
164+
})
165+
return
166+
}
167+
versions := make([]ProjectVersion, 0)
168+
for _, version := range history {
169+
versions = append(versions, convertProjectHistory(version))
170+
}
171+
render.Status(r, http.StatusOK)
172+
render.JSON(rw, r, versions)
173+
}
174+
175+
func (p *projects) createProjectVersion(rw http.ResponseWriter, r *http.Request) {
176+
var createProjectVersion CreateProjectVersionRequest
177+
if !httpapi.Read(rw, r, &createProjectVersion) {
178+
return
179+
}
180+
181+
switch createProjectVersion.StorageMethod {
182+
case database.ProjectStorageMethodInlineArchive:
183+
tarReader := tar.NewReader(bytes.NewReader(createProjectVersion.StorageSource))
184+
_, err := tarReader.Next()
185+
if err != nil {
186+
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
187+
Message: "the archive must be a tar",
188+
})
189+
return
190+
}
191+
default:
192+
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
193+
Message: fmt.Sprintf("unsupported storage method %s", createProjectVersion.StorageMethod),
194+
})
195+
return
196+
}
197+
198+
project := httpmw.ProjectParam(r)
199+
history, err := p.Database.InsertProjectHistory(r.Context(), database.InsertProjectHistoryParams{
200+
ID: uuid.New(),
201+
ProjectID: project.ID,
202+
CreatedAt: database.Now(),
203+
UpdatedAt: database.Now(),
204+
Name: namesgenerator.GetRandomName(1),
205+
StorageMethod: createProjectVersion.StorageMethod,
206+
StorageSource: createProjectVersion.StorageSource,
207+
})
208+
if err != nil {
209+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
210+
Message: fmt.Sprintf("insert project history: %s", err),
211+
})
212+
return
213+
}
214+
215+
// TODO: A job to process the new version should occur here.
216+
217+
render.Status(r, http.StatusCreated)
218+
render.JSON(rw, r, convertProjectHistory(history))
219+
}
220+
221+
func convertProjectHistory(history database.ProjectHistory) ProjectVersion {
222+
return ProjectVersion{
223+
ID: history.ID,
224+
ProjectID: history.ProjectID,
225+
CreatedAt: history.CreatedAt,
226+
UpdatedAt: history.UpdatedAt,
227+
Name: history.Name,
228+
}
229+
}

0 commit comments

Comments
 (0)