Skip to content

feat: add examples to api #5331

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Dec 9, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cli/templateinit.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/spf13/cobra"

"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/examples"
"github.com/coder/coder/provisionersdk"
)
Expand All @@ -22,7 +23,7 @@ func templateInit() *cobra.Command {
return err
}
exampleNames := []string{}
exampleByName := map[string]examples.Example{}
exampleByName := map[string]codersdk.TemplateExample{}
for _, example := range exampleList {
name := fmt.Sprintf(
"%s\n%s\n%s\n",
Expand Down
1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ func New(options *Options) *API {
r.Post("/", api.postTemplateByOrganization)
r.Get("/", api.templatesByOrganization)
r.Get("/{templatename}", api.templateByOrganizationAndName)
r.Get("/examples", api.templateExamples)
})
r.Route("/members", func(r chi.Router) {
r.Get("/roles", api.assignableOrgRoles)
Expand Down
6 changes: 5 additions & 1 deletion coderd/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import (
"github.com/coder/coder/codersdk"
)

const (
uploadFileContentTypeHeader = "application/x-tar"
)

func (api *API) postFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
Expand All @@ -32,7 +36,7 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")

switch contentType {
case "application/x-tar":
case uploadFileContentTypeHeader:
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Unsupported content type header %q.", contentType),
Expand Down
24 changes: 24 additions & 0 deletions coderd/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/examples"
)

// Auto-importable templates. These can be auto-imported after the first user
Expand Down Expand Up @@ -564,6 +565,29 @@ func (api *API) templateDAUs(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, resp)
}

func (api *API) templateExamples(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
organization = httpmw.OrganizationParam(r)
)

if !api.Authorize(r, rbac.ActionRead, rbac.ResourceTemplate.InOrg(organization.ID)) {
httpapi.ResourceNotFound(rw)
return
}

ex, err := examples.List()
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching examples.",
Detail: err.Error(),
})
return
}

httpapi.Write(ctx, rw, http.StatusOK, ex)
}

type autoImportTemplateOpts struct {
name string
archive []byte
Expand Down
81 changes: 72 additions & 9 deletions coderd/templateversions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package coderd

import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
Expand All @@ -23,6 +25,7 @@ import (
"github.com/coder/coder/coderd/provisionerdserver"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/examples"
)

func (api *API) templateVersion(rw http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -834,19 +837,79 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
// Ensures the "owner" is properly applied.
tags := provisionerdserver.MutateTags(apiKey.UserID, req.ProvisionerTags)

file, err := api.Database.GetFileByID(ctx, req.FileID)
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "File not found.",
if req.ExampleID != "" && req.FileID != uuid.Nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "You cannot specify both an example_id and a file_id.",
})
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching file.",
Detail: err.Error(),

var file database.File
var err error
// if example id is specified we need to copy the embedded tar into a new file in the database
if req.ExampleID != "" {
if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceFile.WithOwner(apiKey.UserID.String())) {
httpapi.Forbidden(rw)
return
}
// ensure we can read the file that either already exists or will be created
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceFile.WithOwner(apiKey.UserID.String())) {
httpapi.Forbidden(rw)
return
}

// lookup template tar from embedded examples
tar, err := examples.Archive(req.ExampleID)
if err != nil {
if xerrors.Is(err, examples.ErrNotFound) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Example not found.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching example.",
Detail: err.Error(),
})
return
}

// upload a copy of the template tar as a file in the database
hashBytes := sha256.Sum256(tar)
hash := hex.EncodeToString(hashBytes[:])
file, err = api.Database.InsertFile(ctx, database.InsertFileParams{
ID: uuid.New(),
Hash: hash,
CreatedBy: apiKey.UserID,
CreatedAt: database.Now(),
Mimetype: uploadFileContentTypeHeader,
Data: tar,
})
return
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error creating file.",
Detail: err.Error(),
})
return
}
}

if req.FileID != uuid.Nil {
file, err = api.Database.GetFileByID(ctx, req.FileID)
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "File not found.",
})
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching file.",
Detail: err.Error(),
})
return
}
}

if !api.Authorize(r, rbac.ActionRead, file) {
Expand Down
3 changes: 2 additions & 1 deletion codersdk/organizations.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ type CreateTemplateVersionRequest struct {
// TemplateID optionally associates a version with a template.
TemplateID uuid.UUID `json:"template_id,omitempty"`
StorageMethod ProvisionerStorageMethod `json:"storage_method" validate:"oneof=file,required"`
FileID uuid.UUID `json:"file_id" validate:"required"`
FileID uuid.UUID `json:"file_id,omitempty" validate:"required_without=ExampleID"`
ExampleID string `json:"example_id,omitempty" validate:"required_without=FileID"`
Provisioner ProvisionerType `json:"provisioner" validate:"oneof=terraform echo,required"`
ProvisionerTags map[string]string `json:"tags"`

Expand Down
8 changes: 8 additions & 0 deletions codersdk/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ type UpdateTemplateMeta struct {
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"`
}

type TemplateExample struct {
ID string `json:"id"`
URL string `json:"url"`
Name string `json:"name"`
Description string `json:"description"`
Markdown string `json:"markdown"`
}

// Template returns a single template.
func (c *Client) Template(ctx context.Context, template uuid.UUID) (Template, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s", template), nil)
Expand Down
21 changes: 8 additions & 13 deletions examples/examples.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,25 @@ import (
"github.com/gohugoio/hugo/parser/pageparser"
"golang.org/x/sync/singleflight"
"golang.org/x/xerrors"

"github.com/coder/coder/codersdk"
)

var (
//go:embed templates
files embed.FS

exampleBasePath = "https://github.com/coder/coder/tree/main/examples/templates/"
examples = make([]Example, 0)
examples = make([]codersdk.TemplateExample, 0)
parseExamples sync.Once
archives = singleflight.Group{}
ErrNotFound = xerrors.New("example not found")
)

type Example struct {
ID string `json:"id"`
URL string `json:"url"`
Name string `json:"name"`
Description string `json:"description"`
Markdown string `json:"markdown"`
}

const rootDir = "templates"

// List returns all embedded examples.
func List() ([]Example, error) {
func List() ([]codersdk.TemplateExample, error) {
var returnError error
parseExamples.Do(func() {
files, err := fs.Sub(files, rootDir)
Expand Down Expand Up @@ -92,7 +87,7 @@ func List() ([]Example, error) {
return
}

examples = append(examples, Example{
examples = append(examples, codersdk.TemplateExample{
ID: exampleID,
URL: exampleURL,
Name: name,
Expand All @@ -112,7 +107,7 @@ func Archive(exampleID string) ([]byte, error) {
return nil, xerrors.Errorf("list: %w", err)
}

var selected Example
var selected codersdk.TemplateExample
for _, example := range examples {
if example.ID != exampleID {
continue
Expand All @@ -122,7 +117,7 @@ func Archive(exampleID string) ([]byte, error) {
}

if selected.ID == "" {
return nil, xerrors.Errorf("example with id %q not found", exampleID)
return nil, xerrors.Errorf("example with id %q not found: %w", exampleID, ErrNotFound)
}

exampleFiles, err := fs.Sub(files, path.Join(rootDir, exampleID))
Expand Down
12 changes: 11 additions & 1 deletion site/src/api/typesGenerated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ export interface CreateTemplateVersionRequest {
readonly name?: string
readonly template_id?: string
readonly storage_method: ProvisionerStorageMethod
readonly file_id: string
readonly file_id?: string
readonly example_id?: string
readonly provisioner: ProvisionerType
readonly tags: Record<string, string>
readonly parameter_values?: CreateParameterRequest[]
Expand Down Expand Up @@ -668,6 +669,15 @@ export interface TemplateDAUsResponse {
readonly entries: DAUEntry[]
}

// From codersdk/templates.go
export interface TemplateExample {
readonly id: string
readonly url: string
readonly name: string
readonly description: string
readonly markdown: string
}

// From codersdk/templates.go
export interface TemplateGroup extends Group {
readonly role: TemplateRole
Expand Down