Skip to content

Commit

Permalink
feat: Add project API endpoints (#51)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
kylecarbs committed Jan 24, 2022
1 parent 52e50fc commit a44056c
Show file tree
Hide file tree
Showing 37 changed files with 2,121 additions and 22 deletions.
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ linters:
- misspell
- nilnil
- noctx
- paralleltest
- revive
- rowserrcheck
- sqlclosecheck
Expand Down
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ database/dump.sql: $(wildcard database/migrations/*.sql)
go run database/dump/main.go

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

fmt/sql:
fmt/sql: ./database/query.sql
npx sql-formatter \
--language postgresql \
--lines-between-queries 2 \
./database/query.sql \
--output ./database/query.sql
sed -i 's/@ /@/g' ./database/query.sql

fmt: fmt/prettier fmt/sql
.PHONY: fmt
Expand Down
1 change: 1 addition & 0 deletions coderd/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
)

func TestRoot(t *testing.T) {
t.Parallel()
ctx, cancelFunc := context.WithCancel(context.Background())
go cancelFunc()
err := cmd.Root().ExecuteContext(ctx)
Expand Down
22 changes: 22 additions & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ type Options struct {

// New constructs the Coder API into an HTTP handler.
func New(options *Options) http.Handler {
projects := &projects{
Database: options.Database,
}
users := &users{
Database: options.Database,
}
Expand All @@ -44,6 +47,25 @@ func New(options *Options) http.Handler {
r.Get("/{user}/organizations", users.userOrganizations)
})
})
r.Route("/projects", func(r chi.Router) {
r.Use(
httpmw.ExtractAPIKey(options.Database, nil),
)
r.Get("/", projects.allProjects)
r.Route("/{organization}", func(r chi.Router) {
r.Use(httpmw.ExtractOrganizationParam(options.Database))
r.Get("/", projects.allProjectsForOrganization)
r.Post("/", projects.createProject)
r.Route("/{project}", func(r chi.Router) {
r.Use(httpmw.ExtractProjectParameter(options.Database))
r.Get("/", projects.project)
r.Route("/versions", func(r chi.Router) {
r.Get("/", projects.projectVersions)
r.Post("/", projects.createProjectVersion)
})
})
})
})
})
r.NotFound(site.Handler().ServeHTTP)
return r
Expand Down
1 change: 1 addition & 0 deletions coderd/coderdtest/coderdtest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func TestMain(m *testing.M) {
}

func TestNew(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_ = server.RandomInitialUser(t)
}
229 changes: 229 additions & 0 deletions coderd/projects.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package coderd

import (
"archive/tar"
"bytes"
"database/sql"
"errors"
"fmt"
"net/http"
"time"

"github.com/go-chi/render"
"github.com/google/uuid"

"github.com/moby/moby/pkg/namesgenerator"

"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
"github.com/coder/coder/httpmw"
)

// Project is the JSON representation of a Coder project.
// This type matches the database object for now, but is
// abstracted for ease of change later on.
type Project database.Project

// ProjectVersion is the JSON representation of a Coder project version.
type ProjectVersion struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Name string `json:"name"`
StorageMethod database.ProjectStorageMethod `json:"storage_method"`
}

// CreateProjectRequest enables callers to create a new Project.
type CreateProjectRequest struct {
Name string `json:"name" validate:"username,required"`
Provisioner database.ProvisionerType `json:"provisioner" validate:"oneof=terraform cdr-basic,required"`
}

// CreateProjectVersionRequest enables callers to create a new Project Version.
type CreateProjectVersionRequest struct {
Name string `json:"name,omitempty" validate:"username"`
StorageMethod database.ProjectStorageMethod `json:"storage_method" validate:"oneof=inline-archive,required"`
StorageSource []byte `json:"storage_source" validate:"max=1048576,required"`
}

type projects struct {
Database database.Store
}

// allProjects lists all projects across organizations for a user.
func (p *projects) allProjects(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
organizations, err := p.Database.GetOrganizationsByUserID(r.Context(), apiKey.UserID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get organizations: %s", err.Error()),
})
return
}
organizationIDs := make([]string, 0, len(organizations))
for _, organization := range organizations {
organizationIDs = append(organizationIDs, organization.ID)
}
projects, err := p.Database.GetProjectsByOrganizationIDs(r.Context(), organizationIDs)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get projects: %s", err.Error()),
})
return
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, projects)
}

// allProjectsForOrganization lists all projects for a specific organization.
func (p *projects) allProjectsForOrganization(rw http.ResponseWriter, r *http.Request) {
organization := httpmw.OrganizationParam(r)
projects, err := p.Database.GetProjectsByOrganizationIDs(r.Context(), []string{organization.ID})
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get projects: %s", err.Error()),
})
return
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, projects)
}

// createProject makes a new project in an organization.
func (p *projects) createProject(rw http.ResponseWriter, r *http.Request) {
var createProject CreateProjectRequest
if !httpapi.Read(rw, r, &createProject) {
return
}
organization := httpmw.OrganizationParam(r)
_, err := p.Database.GetProjectByOrganizationAndName(r.Context(), database.GetProjectByOrganizationAndNameParams{
OrganizationID: organization.ID,
Name: createProject.Name,
})
if err == nil {
httpapi.Write(rw, http.StatusConflict, httpapi.Response{
Message: fmt.Sprintf("project %q already exists", createProject.Name),
Errors: []httpapi.Error{{
Field: "name",
Code: "exists",
}},
})
return
}
if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get project by name: %s", err.Error()),
})
return
}

project, err := p.Database.InsertProject(r.Context(), database.InsertProjectParams{
ID: uuid.New(),
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
OrganizationID: organization.ID,
Name: createProject.Name,
Provisioner: createProject.Provisioner,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("insert project: %s", err),
})
return
}
render.Status(r, http.StatusCreated)
render.JSON(rw, r, project)
}

// project returns a single project parsed from the URL path.
func (*projects) project(rw http.ResponseWriter, r *http.Request) {
project := httpmw.ProjectParam(r)

render.Status(r, http.StatusOK)
render.JSON(rw, r, project)
}

// projectVersions lists versions for a single project.
func (p *projects) projectVersions(rw http.ResponseWriter, r *http.Request) {
project := httpmw.ProjectParam(r)

history, err := p.Database.GetProjectHistoryByProjectID(r.Context(), project.ID)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get project history: %s", err),
})
return
}
versions := make([]ProjectVersion, 0)
for _, version := range history {
versions = append(versions, convertProjectHistory(version))
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, versions)
}

func (p *projects) createProjectVersion(rw http.ResponseWriter, r *http.Request) {
var createProjectVersion CreateProjectVersionRequest
if !httpapi.Read(rw, r, &createProjectVersion) {
return
}

switch createProjectVersion.StorageMethod {
case database.ProjectStorageMethodInlineArchive:
tarReader := tar.NewReader(bytes.NewReader(createProjectVersion.StorageSource))
_, err := tarReader.Next()
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: "the archive must be a tar",
})
return
}
default:
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: fmt.Sprintf("unsupported storage method %s", createProjectVersion.StorageMethod),
})
return
}

project := httpmw.ProjectParam(r)
history, err := p.Database.InsertProjectHistory(r.Context(), database.InsertProjectHistoryParams{
ID: uuid.New(),
ProjectID: project.ID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
Name: namesgenerator.GetRandomName(1),
StorageMethod: createProjectVersion.StorageMethod,
StorageSource: createProjectVersion.StorageSource,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("insert project history: %s", err),
})
return
}

// TODO: A job to process the new version should occur here.

render.Status(r, http.StatusCreated)
render.JSON(rw, r, convertProjectHistory(history))
}

func convertProjectHistory(history database.ProjectHistory) ProjectVersion {
return ProjectVersion{
ID: history.ID,
ProjectID: history.ProjectID,
CreatedAt: history.CreatedAt,
UpdatedAt: history.UpdatedAt,
Name: history.Name,
}
}

0 comments on commit a44056c

Please sign in to comment.