diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7ff97bba2968d..c0127c92be752 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3101,6 +3101,34 @@ const docTemplate = `{ } } }, + "/templates": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Templates" + ], + "summary": "Get all templates", + "operationId": "get-all-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Template" + } + } + } + } + } + }, "/templates/{template}": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index bc6fcc19142a9..cc3e37a0433ca 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2725,6 +2725,30 @@ } } }, + "/templates": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Templates"], + "summary": "Get all templates", + "operationId": "get-all-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Template" + } + } + } + } + } + }, "/templates/{template}": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index f92e3008604c3..288eca9a4dbaf 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -827,7 +827,7 @@ func New(options *Options) *API { r.Post("/templateversions", api.postTemplateVersionsByOrganization) r.Route("/templates", func(r chi.Router) { r.Post("/", api.postTemplateByOrganization) - r.Get("/", api.templatesByOrganization) + r.Get("/", api.templatesByOrganization()) r.Get("/examples", api.templateExamples) r.Route("/{templatename}", func(r chi.Router) { r.Get("/", api.templateByOrganizationAndName) @@ -869,20 +869,25 @@ func New(options *Options) *API { }) }) }) - r.Route("/templates/{template}", func(r chi.Router) { + r.Route("/templates", func(r chi.Router) { r.Use( apiKeyMiddleware, - httpmw.ExtractTemplateParam(options.Database), ) - r.Get("/daus", api.templateDAUs) - r.Get("/", api.template) - r.Delete("/", api.deleteTemplate) - r.Patch("/", api.patchTemplateMeta) - r.Route("/versions", func(r chi.Router) { - r.Post("/archive", api.postArchiveTemplateVersions) - r.Get("/", api.templateVersionsByTemplate) - r.Patch("/", api.patchActiveTemplateVersion) - r.Get("/{templateversionname}", api.templateVersionByName) + r.Get("/", api.fetchTemplates(nil)) + r.Route("/{template}", func(r chi.Router) { + r.Use( + httpmw.ExtractTemplateParam(options.Database), + ) + r.Get("/daus", api.templateDAUs) + r.Get("/", api.template) + r.Delete("/", api.deleteTemplate) + r.Patch("/", api.patchTemplateMeta) + r.Route("/versions", func(r chi.Router) { + r.Post("/archive", api.postArchiveTemplateVersions) + r.Get("/", api.templateVersionsByTemplate) + r.Patch("/", api.patchActiveTemplateVersion) + r.Get("/{templateversionname}", api.templateVersionByName) + }) }) }) r.Route("/templateversions/{templateversion}", func(r chi.Router) { diff --git a/coderd/templates.go b/coderd/templates.go index b4c546814737e..3027321fdbba2 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -435,55 +435,78 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque // @Param organization path string true "Organization ID" format(uuid) // @Success 200 {array} codersdk.Template // @Router /organizations/{organization}/templates [get] -func (api *API) templatesByOrganization(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - organization := httpmw.OrganizationParam(r) +func (api *API) templatesByOrganization() http.HandlerFunc { + // TODO: Should deprecate this endpoint and make it akin to /workspaces with + // a filter. There isn't a need to make the organization filter argument + // part of the query url. + // mutate the filter to only include templates from the given organization. + return api.fetchTemplates(func(r *http.Request, arg *database.GetTemplatesWithFilterParams) { + organization := httpmw.OrganizationParam(r) + arg.OrganizationID = organization.ID + }) +} - p := httpapi.NewQueryParamParser() - values := r.URL.Query() +// @Summary Get all templates +// @ID get-all-templates +// @Security CoderSessionToken +// @Produce json +// @Tags Templates +// @Success 200 {array} codersdk.Template +// @Router /templates [get] +func (api *API) fetchTemplates(mutate func(r *http.Request, arg *database.GetTemplatesWithFilterParams)) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + p := httpapi.NewQueryParamParser() + values := r.URL.Query() + + deprecated := sql.NullBool{} + if values.Has("deprecated") { + deprecated = sql.NullBool{ + Bool: p.Boolean(values, false, "deprecated"), + Valid: true, + } + } + if len(p.Errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid query params.", + Validations: p.Errors, + }) + return + } - deprecated := sql.NullBool{} - if values.Has("deprecated") { - deprecated = sql.NullBool{ - Bool: p.Boolean(values, false, "deprecated"), - Valid: true, + prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceTemplate.Type) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error preparing sql filter.", + Detail: err.Error(), + }) + return } - } - if len(p.Errors) > 0 { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid query params.", - Validations: p.Errors, - }) - return - } - prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceTemplate.Type) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error preparing sql filter.", - Detail: err.Error(), - }) - return - } + args := database.GetTemplatesWithFilterParams{ + Deprecated: deprecated, + } + if mutate != nil { + mutate(r, &args) + } - // Filter templates based on rbac permissions - templates, err := api.Database.GetAuthorizedTemplates(ctx, database.GetTemplatesWithFilterParams{ - OrganizationID: organization.ID, - Deprecated: deprecated, - }, prepared) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } + // Filter templates based on rbac permissions + templates, err := api.Database.GetAuthorizedTemplates(ctx, args, prepared) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching templates in organization.", - Detail: err.Error(), - }) - return - } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching templates in organization.", + Detail: err.Error(), + }) + return + } - httpapi.Write(ctx, rw, http.StatusOK, api.convertTemplates(templates)) + httpapi.Write(ctx, rw, http.StatusOK, api.convertTemplates(templates)) + } } // @Summary Get templates by organization and template name diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 7aebaf41b1e1b..2813f713f5ea2 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -438,6 +438,42 @@ func TestTemplatesByOrganization(t *testing.T) { templates, err := client.TemplatesByOrganization(ctx, user.OrganizationID) require.NoError(t, err) require.Len(t, templates, 2) + + // Listing all should match + templates, err = client.Templates(ctx) + require.NoError(t, err) + require.Len(t, templates, 2) + }) + t.Run("MultipleOrganizations", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + org2 := coderdtest.CreateOrganization(t, client, coderdtest.CreateOrganizationOptions{}) + user, _ := coderdtest.CreateAnotherUser(t, client, org2.ID) + + // 2 templates in first organization + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + version2 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + coderdtest.CreateTemplate(t, client, owner.OrganizationID, version2.ID) + + // 2 in the second organization + version3 := coderdtest.CreateTemplateVersion(t, client, org2.ID, nil) + version4 := coderdtest.CreateTemplateVersion(t, client, org2.ID, nil) + coderdtest.CreateTemplate(t, client, org2.ID, version3.ID) + coderdtest.CreateTemplate(t, client, org2.ID, version4.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // All 4 are viewable by the owner + templates, err := client.Templates(ctx) + require.NoError(t, err) + require.Len(t, templates, 4) + + // Only 2 are viewable by the org user + templates, err = user.Templates(ctx) + require.NoError(t, err) + require.Len(t, templates, 2) }) } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index bc9e2514b2c15..e494018258e48 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -362,6 +362,25 @@ func (c *Client) TemplatesByOrganization(ctx context.Context, organizationID uui return templates, json.NewDecoder(res.Body).Decode(&templates) } +// Templates lists all viewable templates +func (c *Client) Templates(ctx context.Context) ([]Template, error) { + res, err := c.Request(ctx, http.MethodGet, + "/api/v2/templates", + nil, + ) + if err != nil { + return nil, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var templates []Template + return templates, json.NewDecoder(res.Body).Decode(&templates) +} + // TemplateByName finds a template inside the organization provided with a case-insensitive name. func (c *Client) TemplateByName(ctx context.Context, organizationID uuid.UUID, name string) (Template, error) { if name == "" { diff --git a/docs/api/templates.md b/docs/api/templates.md index de0498c3de87b..b85811f41d0b8 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -617,6 +617,132 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get all templates + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/templates \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /templates` + +### Example responses + +> 200 Response + +```json +[ + { + "active_user_count": 0, + "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", + "activity_bump_ms": 0, + "allow_user_autostart": true, + "allow_user_autostop": true, + "allow_user_cancel_workspace_jobs": true, + "autostart_requirement": { + "days_of_week": ["monday"] + }, + "autostop_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, + "build_time_stats": { + "property1": { + "p50": 123, + "p95": 146 + }, + "property2": { + "p50": 123, + "p95": 146 + } + }, + "created_at": "2019-08-24T14:15:22Z", + "created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f", + "created_by_name": "string", + "default_ttl_ms": 0, + "deprecated": true, + "deprecation_message": "string", + "description": "string", + "display_name": "string", + "failure_ttl_ms": 0, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_port_share_level": "owner", + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "provisioner": "terraform", + "require_active_version": true, + "time_til_dormant_autodelete_ms": 0, + "time_til_dormant_ms": 0, + "updated_at": "2019-08-24T14:15:22Z" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | --------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Template](schemas.md#codersdktemplate) | + +
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: