Skip to content

Commit b8d3b6f

Browse files
committed
Add project create and list endpoints
1 parent 49750fb commit b8d3b6f

26 files changed

+437
-48
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

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: 9 additions & 2 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
}
@@ -45,10 +48,14 @@ func New(options *Options) http.Handler {
4548
})
4649
})
4750
r.Route("/projects", func(r chi.Router) {
51+
r.Use(
52+
httpmw.ExtractAPIKey(options.Database, nil),
53+
)
54+
r.Get("/", projects.allProjects)
4855
r.Route("/{organization}", func(r chi.Router) {
4956
r.Use(httpmw.ExtractOrganizationParam(options.Database))
50-
r.Get("/", nil) // List projects
51-
r.Post("/", nil) // Create project
57+
r.Get("/", projects.allProjectsForOrganization)
58+
r.Post("/", projects.createProject)
5259
r.Route("/{project}", func(r chi.Router) {
5360
r.Get("/", nil) // Get a single project
5461
r.Patch("/", nil) // Update a project

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: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package coderd
2+
3+
import (
4+
"database/sql"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
9+
"github.com/go-chi/render"
10+
"github.com/google/uuid"
11+
12+
"github.com/coder/coder/database"
13+
"github.com/coder/coder/httpapi"
14+
"github.com/coder/coder/httpmw"
15+
)
16+
17+
// Project is the JSON representation of a Coder project.
18+
// This type matches the database object for now, but is
19+
// abstracted for ease of change later on.
20+
type Project database.Project
21+
22+
type CreateProjectRequest struct {
23+
Name string `json:"name" validate:"username,required"`
24+
Provisioner database.ProvisionerType `json:"provisioner" validate:"oneof=terraform cdr-basic,required"`
25+
}
26+
27+
type projects struct {
28+
Database database.Store
29+
}
30+
31+
// allProjects lists all projects across organizations for a user.
32+
func (p *projects) allProjects(rw http.ResponseWriter, r *http.Request) {
33+
apiKey := httpmw.APIKey(r)
34+
organizations, err := p.Database.GetOrganizationsByUserID(r.Context(), apiKey.UserID)
35+
if err != nil {
36+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
37+
Message: fmt.Sprintf("get organizations: %s", err.Error()),
38+
})
39+
return
40+
}
41+
organizationIDs := make([]string, 0, len(organizations))
42+
for _, organization := range organizations {
43+
organizationIDs = append(organizationIDs, organization.ID)
44+
}
45+
projects, err := p.Database.GetProjectsByOrganizationIDs(r.Context(), organizationIDs)
46+
if errors.Is(err, sql.ErrNoRows) {
47+
err = nil
48+
}
49+
if err != nil {
50+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
51+
Message: fmt.Sprintf("get projects: %s", err.Error()),
52+
})
53+
return
54+
}
55+
render.Status(r, http.StatusOK)
56+
render.JSON(rw, r, projects)
57+
}
58+
59+
// allProjectsForOrganization lists all projects for a specific organization.
60+
func (p *projects) allProjectsForOrganization(rw http.ResponseWriter, r *http.Request) {
61+
organization := httpmw.OrganizationParam(r)
62+
projects, err := p.Database.GetProjectsByOrganizationIDs(r.Context(), []string{organization.ID})
63+
if errors.Is(err, sql.ErrNoRows) {
64+
err = nil
65+
}
66+
if err != nil {
67+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
68+
Message: fmt.Sprintf("get projects: %s", err.Error()),
69+
})
70+
return
71+
}
72+
render.Status(r, http.StatusOK)
73+
render.JSON(rw, r, projects)
74+
}
75+
76+
// createProject makes a new project in an organization.
77+
func (p *projects) createProject(rw http.ResponseWriter, r *http.Request) {
78+
var createProject CreateProjectRequest
79+
if !httpapi.Read(rw, r, &createProject) {
80+
return
81+
}
82+
organization := httpmw.OrganizationParam(r)
83+
_, err := p.Database.GetProjectByOrganizationAndName(r.Context(), database.GetProjectByOrganizationAndNameParams{
84+
OrganizationID: organization.ID,
85+
Name: createProject.Name,
86+
})
87+
if err == nil {
88+
httpapi.Write(rw, http.StatusConflict, httpapi.Response{
89+
Message: fmt.Sprintf("project %q already exists", createProject.Name),
90+
Errors: []httpapi.Error{{
91+
Field: "name",
92+
Code: "exists",
93+
}},
94+
})
95+
return
96+
}
97+
if !errors.Is(err, sql.ErrNoRows) {
98+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
99+
Message: fmt.Sprintf("get project by name: %s", err.Error()),
100+
})
101+
return
102+
}
103+
104+
project, err := p.Database.InsertProject(r.Context(), database.InsertProjectParams{
105+
ID: uuid.New(),
106+
CreatedAt: database.Now(),
107+
UpdatedAt: database.Now(),
108+
OrganizationID: organization.ID,
109+
Name: createProject.Name,
110+
Provisioner: createProject.Provisioner,
111+
})
112+
if err != nil {
113+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
114+
Message: fmt.Sprintf("insert project: %s", err),
115+
})
116+
return
117+
}
118+
render.Status(r, http.StatusCreated)
119+
render.JSON(rw, r, project)
120+
}

coderd/projects_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package coderd_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/coder/coderd"
10+
"github.com/coder/coder/coderd/coderdtest"
11+
"github.com/coder/coder/database"
12+
)
13+
14+
func TestProjects(t *testing.T) {
15+
t.Parallel()
16+
17+
t.Run("Create", func(t *testing.T) {
18+
t.Parallel()
19+
server := coderdtest.New(t)
20+
user := server.RandomInitialUser(t)
21+
_, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{
22+
Name: "someproject",
23+
Provisioner: database.ProvisionerTypeTerraform,
24+
})
25+
require.NoError(t, err)
26+
})
27+
28+
t.Run("AlreadyExists", func(t *testing.T) {
29+
t.Parallel()
30+
server := coderdtest.New(t)
31+
user := server.RandomInitialUser(t)
32+
_, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{
33+
Name: "someproject",
34+
Provisioner: database.ProvisionerTypeTerraform,
35+
})
36+
require.NoError(t, err)
37+
_, err = server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{
38+
Name: "someproject",
39+
Provisioner: database.ProvisionerTypeTerraform,
40+
})
41+
require.Error(t, err)
42+
})
43+
44+
t.Run("ListEmpty", func(t *testing.T) {
45+
t.Parallel()
46+
server := coderdtest.New(t)
47+
_ = server.RandomInitialUser(t)
48+
projects, err := server.Client.Projects(context.Background(), "")
49+
require.NoError(t, err)
50+
require.Len(t, projects, 0)
51+
})
52+
53+
t.Run("List", func(t *testing.T) {
54+
t.Parallel()
55+
server := coderdtest.New(t)
56+
user := server.RandomInitialUser(t)
57+
_, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{
58+
Name: "someproject",
59+
Provisioner: database.ProvisionerTypeTerraform,
60+
})
61+
require.NoError(t, err)
62+
// Ensure global query works.
63+
projects, err := server.Client.Projects(context.Background(), "")
64+
require.NoError(t, err)
65+
require.Len(t, projects, 1)
66+
67+
// Ensure specified query works.
68+
projects, err = server.Client.Projects(context.Background(), user.Organization)
69+
require.NoError(t, err)
70+
require.Len(t, projects, 1)
71+
})
72+
}

coderd/userpassword/userpassword_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import (
99
)
1010

1111
func TestUserPassword(t *testing.T) {
12+
t.Parallel()
1213
t.Run("Legacy", func(t *testing.T) {
14+
t.Parallel()
1315
// Ensures legacy v1 passwords function for v2.
1416
// This has is manually generated using a print statement from v1 code.
1517
equal, err := userpassword.Compare("$pbkdf2-sha256$65535$z8c1p1C2ru9EImBP1I+ZNA$pNjE3Yk0oG0PmJ0Je+y7ENOVlSkn/b0BEqqdKsq6Y97wQBq0xT+lD5bWJpyIKJqQICuPZcEaGDKrXJn8+SIHRg", "tomato")
@@ -18,6 +20,7 @@ func TestUserPassword(t *testing.T) {
1820
})
1921

2022
t.Run("Same", func(t *testing.T) {
23+
t.Parallel()
2124
hash, err := userpassword.Hash("password")
2225
require.NoError(t, err)
2326
equal, err := userpassword.Compare(hash, "password")
@@ -26,6 +29,7 @@ func TestUserPassword(t *testing.T) {
2629
})
2730

2831
t.Run("Different", func(t *testing.T) {
32+
t.Parallel()
2933
hash, err := userpassword.Hash("password")
3034
require.NoError(t, err)
3135
equal, err := userpassword.Compare(hash, "notpassword")
@@ -34,12 +38,14 @@ func TestUserPassword(t *testing.T) {
3438
})
3539

3640
t.Run("Invalid", func(t *testing.T) {
41+
t.Parallel()
3742
equal, err := userpassword.Compare("invalidhash", "password")
3843
require.False(t, equal)
3944
require.Error(t, err)
4045
})
4146

4247
t.Run("InvalidParts", func(t *testing.T) {
48+
t.Parallel()
4349
equal, err := userpassword.Compare("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", "test")
4450
require.False(t, equal)
4551
require.Error(t, err)

coderd/users_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ func TestUsers(t *testing.T) {
3535
})
3636

3737
t.Run("Login", func(t *testing.T) {
38+
t.Parallel()
3839
server := coderdtest.New(t)
3940
user := server.RandomInitialUser(t)
4041
_, err := server.Client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{

codersdk/projects.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package codersdk
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
9+
"github.com/coder/coder/coderd"
10+
)
11+
12+
// Projects lists projects inside an organization.
13+
// If organization is an empty string, all projects will be returned
14+
// for the authenticated user.
15+
func (c *Client) Projects(ctx context.Context, organization string) ([]coderd.Project, error) {
16+
route := "/api/v2/projects"
17+
if organization != "" {
18+
route = fmt.Sprintf("/api/v2/projects/%s", organization)
19+
}
20+
res, err := c.request(ctx, http.MethodGet, route, nil)
21+
if err != nil {
22+
return nil, err
23+
}
24+
defer res.Body.Close()
25+
if res.StatusCode != http.StatusOK {
26+
return nil, readBodyAsError(res)
27+
}
28+
var projects []coderd.Project
29+
return projects, json.NewDecoder(res.Body).Decode(&projects)
30+
}
31+
32+
// CreateProject creates a new project inside an organization.
33+
func (c *Client) CreateProject(ctx context.Context, organization string, request coderd.CreateProjectRequest) (coderd.Project, error) {
34+
res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/projects/%s", organization), request)
35+
if err != nil {
36+
return coderd.Project{}, err
37+
}
38+
defer res.Body.Close()
39+
if res.StatusCode != http.StatusCreated {
40+
return coderd.Project{}, readBodyAsError(res)
41+
}
42+
var project coderd.Project
43+
return project, json.NewDecoder(res.Body).Decode(&project)
44+
}

codersdk/projects_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package codersdk_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/coder/coderd"
10+
"github.com/coder/coder/coderd/coderdtest"
11+
"github.com/coder/coder/database"
12+
)
13+
14+
func TestProjects(t *testing.T) {
15+
t.Parallel()
16+
17+
t.Run("UnauthenticatedList", func(t *testing.T) {
18+
t.Parallel()
19+
server := coderdtest.New(t)
20+
_, err := server.Client.Projects(context.Background(), "")
21+
require.Error(t, err)
22+
})
23+
24+
t.Run("List", func(t *testing.T) {
25+
t.Parallel()
26+
server := coderdtest.New(t)
27+
_ = server.RandomInitialUser(t)
28+
_, err := server.Client.Projects(context.Background(), "")
29+
require.NoError(t, err)
30+
})
31+
32+
t.Run("UnauthenticatedCreate", func(t *testing.T) {
33+
t.Parallel()
34+
server := coderdtest.New(t)
35+
_, err := server.Client.CreateProject(context.Background(), "", coderd.CreateProjectRequest{})
36+
require.Error(t, err)
37+
})
38+
39+
t.Run("Create", func(t *testing.T) {
40+
t.Parallel()
41+
server := coderdtest.New(t)
42+
user := server.RandomInitialUser(t)
43+
_, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{
44+
Name: "bananas",
45+
Provisioner: database.ProvisionerTypeTerraform,
46+
})
47+
require.NoError(t, err)
48+
})
49+
}

0 commit comments

Comments
 (0)