diff --git a/.changeset/better-lines-design.md b/.changeset/better-lines-design.md new file mode 100644 index 000000000..da34267f3 --- /dev/null +++ b/.changeset/better-lines-design.md @@ -0,0 +1,5 @@ +--- +"@gram/cli": minor +--- + +Support function uploads diff --git a/cli/internal/api/assets.go b/cli/internal/api/assets.go index 2e525542f..fa312bfae 100644 --- a/cli/internal/api/assets.go +++ b/cli/internal/api/assets.go @@ -40,7 +40,7 @@ func NewAssetsClient(options *AssetsClientOptions) *AssetsClient { return &AssetsClient{client: client} } -type UploadOpenAPIv3Request struct { +type UploadAssetForm struct { APIKey string ProjectSlug string Reader io.ReadCloser @@ -50,8 +50,8 @@ type UploadOpenAPIv3Request struct { func (c *AssetsClient) UploadOpenAPIv3( ctx context.Context, - req *UploadOpenAPIv3Request, -) (*assets.UploadOpenAPIv3Result, error) { + req *UploadAssetForm, +) (*assets.Asset, error) { payload := &assets.UploadOpenAPIv3Form{ ApikeyToken: &req.APIKey, ProjectSlugInput: &req.ProjectSlug, @@ -65,5 +65,25 @@ func (c *AssetsClient) UploadOpenAPIv3( return nil, fmt.Errorf("failed to upload OpenAPI asset: %w", err) } - return result, nil + return result.Asset, nil +} + +func (c *AssetsClient) UploadFunctions( + ctx context.Context, + req *UploadAssetForm, +) (*assets.Asset, error) { + payload := &assets.UploadFunctionsForm{ + ApikeyToken: &req.APIKey, + ProjectSlugInput: &req.ProjectSlug, + SessionToken: nil, + ContentType: req.ContentType, + ContentLength: req.ContentLength, + } + + result, err := c.client.UploadFunctions(ctx, payload, req.Reader) + if err != nil { + return nil, fmt.Errorf("failed to upload OpenAPI asset: %w", err) + } + + return result.Asset, nil } diff --git a/cli/internal/api/deployments.go b/cli/internal/api/deployments.go index 7594d300b..95a225950 100644 --- a/cli/internal/api/deployments.go +++ b/cli/internal/api/deployments.go @@ -49,6 +49,7 @@ type CreateDeploymentRequest struct { ProjectSlug string IdempotencyKey string OpenAPIv3Assets []*deployments.AddOpenAPIv3DeploymentAssetForm + Functions []*deployments.AddFunctionsForm } // CreateDeployment creates a remote deployment. @@ -65,7 +66,7 @@ func (c *DeploymentsClient) CreateDeployment( ProjectSlugInput: &req.ProjectSlug, IdempotencyKey: req.IdempotencyKey, Openapiv3Assets: req.OpenAPIv3Assets, - Functions: []*deployments.AddFunctionsForm{}, + Functions: req.Functions, SessionToken: nil, GithubRepo: nil, GithubPr: nil, @@ -168,10 +169,11 @@ func (c *DeploymentsClient) GetActiveDeployment( // EvolveRequest lists the assets to add to a deployment. type EvolveRequest struct { - Assets []*deployments.AddOpenAPIv3DeploymentAssetForm - APIKey secret.Secret - DeploymentID string - ProjectSlug string + OpenAPIv3Assets []*deployments.AddOpenAPIv3DeploymentAssetForm + Functions []*deployments.AddFunctionsForm + APIKey secret.Secret + DeploymentID string + ProjectSlug string } // Evolve adds assets to an existing deployment. @@ -184,8 +186,8 @@ func (c *DeploymentsClient) Evolve( ApikeyToken: &key, ProjectSlugInput: &req.ProjectSlug, DeploymentID: &req.DeploymentID, - UpsertOpenapiv3Assets: req.Assets, - UpsertFunctions: []*deployments.AddFunctionsForm{}, + UpsertOpenapiv3Assets: req.OpenAPIv3Assets, + UpsertFunctions: req.Functions, ExcludeOpenapiv3Assets: []string{}, ExcludeFunctions: []string{}, ExcludePackages: []string{}, diff --git a/cli/internal/app/upload.go b/cli/internal/app/upload.go index 725ff3363..a5c141782 100644 --- a/cli/internal/app/upload.go +++ b/cli/internal/app/upload.go @@ -49,6 +49,11 @@ Example: Usage: "The URL-friendly slug for the asset", Required: true, }, + &cli.StringFlag{ + Name: "runtime", + Usage: "Runtime to use for function execution (required for functions)", + Required: false, + }, }, Action: func(c *cli.Context) error { ctx, cancel := signal.NotifyContext( @@ -102,5 +107,6 @@ func parseSource(c *cli.Context) deploy.Source { Location: c.String("location"), Name: c.String("name"), Slug: c.String("slug"), + Runtime: c.String("runtime"), } } diff --git a/cli/internal/deploy/config.go b/cli/internal/deploy/config.go index 1928315dd..3f66b3b33 100644 --- a/cli/internal/deploy/config.go +++ b/cli/internal/deploy/config.go @@ -23,9 +23,10 @@ type SourceType string const ( SourceTypeOpenAPIV3 SourceType = "openapiv3" + SourceTypeFunction SourceType = "function" ) -var AllowedTypes = []SourceType{SourceTypeOpenAPIV3} +var AllowedTypes = []SourceType{SourceTypeOpenAPIV3, SourceTypeFunction} type Source struct { Type SourceType `json:"type" yaml:"type" toml:"type"` @@ -38,12 +39,20 @@ type Source struct { // Slug is the human readable public id of the asset. Slug string `json:"slug" yaml:"slug" toml:"slug"` + + // Runtime is the runtime to use for function execution (required for functions). + // Allowed values are: nodejs:22, python:3.12 + Runtime string `json:"runtime,omitempty" yaml:"runtime,omitempty" toml:"runtime,omitempty"` } // Validate returns an error if the source is missing required fields. func (s Source) Validate() error { if !isSupportedType(s) { - return fmt.Errorf("source has unsupported type %q (allowed types: %s)", s.Type, SourceTypeOpenAPIV3) + return fmt.Errorf( + "source has unsupported type %q (allowed types: %v)", + s.Type, + AllowedTypes, + ) } if s.Location == "" { @@ -55,6 +64,11 @@ func (s Source) Validate() error { if s.Slug == "" { return fmt.Errorf("source is missing required field 'slug'") } + if s.Type == SourceTypeFunction && s.Runtime == "" { + return fmt.Errorf( + "source of type 'function' is missing required field 'runtime' (allowed values: nodejs:22, python:3.12)", + ) + } return nil } diff --git a/cli/internal/deploy/example_usage_test.go b/cli/internal/deploy/example_usage_test.go index d19592e19..66e2df735 100644 --- a/cli/internal/deploy/example_usage_test.go +++ b/cli/internal/deploy/example_usage_test.go @@ -21,6 +21,7 @@ func TestSource_MissingRequiredFields(t *testing.T) { Location: "/path/to/file", Name: "", Slug: "valid-slug", + Runtime: "nodejs:22", }, wantErr: "source is missing required field 'name'", }, @@ -31,6 +32,7 @@ func TestSource_MissingRequiredFields(t *testing.T) { Location: "/path/to/file", Name: "Valid Name", Slug: "", + Runtime: "nodejs:22", }, wantErr: "source is missing required field 'slug'", }, @@ -41,16 +43,40 @@ func TestSource_MissingRequiredFields(t *testing.T) { Location: "/path/to/file", Name: "", Slug: "", + Runtime: "nodejs:22", }, wantErr: "source is missing required field 'name'", }, { - name: "valid source", + name: "missing runtime for function", + source: Source{ + Type: SourceTypeFunction, + Location: "/path/to/file.zip", + Name: "Example function", + Slug: "example-function", + Runtime: "", + }, + wantErr: "source of type 'function' is missing required field 'runtime'", + }, + { + name: "valid function", + source: Source{ + Type: SourceTypeFunction, + Location: "/path/to/file.zip", + Name: "Example function", + Slug: "example-function", + Runtime: "nodejs:22", + }, + wantErr: "", + }, + { + name: "valid openapiv3", source: Source{ Type: SourceTypeOpenAPIV3, Location: "/path/to/file", Name: "Valid Name", Slug: "valid-slug", + Runtime: "nodejs:22", }, wantErr: "", }, @@ -83,28 +109,86 @@ func TestValidateUniqueNames(t *testing.T) { { name: "unique names", sources: []Source{ - {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "api-one"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "api-three"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "api-one", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "api-three", Runtime: "nodejs:22"}, + }, + wantErr: "", + }, + { + name: "duplicate names", + sources: []Source{ + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "Duplicate API", Slug: "api-one", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "Duplicate API", Slug: "api-three", Runtime: "nodejs:22"}, + }, + wantErr: "source names must be unique: ['Duplicate API' (2 times)]", + }, + { + name: "multiple duplicate names", + sources: []Source{ + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "First Duplicate", Slug: "api-one", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "Second Duplicate", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "First Duplicate", Slug: "api-three", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/four", Name: "Second Duplicate", Slug: "api-four", Runtime: "nodejs:22"}, + }, + wantErr: "source names must be unique:", + }, + { + name: "empty sources", + sources: []Source{}, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateUniqueNames(tt.sources) + + if tt.wantErr == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + } + }) + } +} +func TestFunctionMissingRuntime(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sources []Source + wantErr string + }{ + { + name: "", + sources: []Source{ + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "api-one", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "api-three", Runtime: "nodejs:22"}, }, wantErr: "", }, { name: "duplicate names", sources: []Source{ - {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "Duplicate API", Slug: "api-one"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "Duplicate API", Slug: "api-three"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "Duplicate API", Slug: "api-one", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "Duplicate API", Slug: "api-three", Runtime: "nodejs:22"}, }, wantErr: "source names must be unique: ['Duplicate API' (2 times)]", }, { name: "multiple duplicate names", sources: []Source{ - {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "First Duplicate", Slug: "api-one"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "Second Duplicate", Slug: "api-two"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "First Duplicate", Slug: "api-three"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/four", Name: "Second Duplicate", Slug: "api-four"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "First Duplicate", Slug: "api-one", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "Second Duplicate", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "First Duplicate", Slug: "api-three", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/four", Name: "Second Duplicate", Slug: "api-four", Runtime: "nodejs:22"}, }, wantErr: "source names must be unique:", }, @@ -142,28 +226,28 @@ func TestValidateUniqueSlugs(t *testing.T) { { name: "unique slugs", sources: []Source{ - {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "api-one"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "api-three"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "api-one", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "api-three", Runtime: "nodejs:22"}, }, wantErr: "", }, { name: "duplicate slugs", sources: []Source{ - {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "duplicate-slug"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "duplicate-slug"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "duplicate-slug", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "duplicate-slug", Runtime: "nodejs:22"}, }, wantErr: "source slugs must be unique: ['duplicate-slug' (2 times)]", }, { name: "multiple duplicate slugs", sources: []Source{ - {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "first-duplicate"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "second-duplicate"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "first-duplicate"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/four", Name: "API Four", Slug: "second-duplicate"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "first-duplicate", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "second-duplicate", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "first-duplicate", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/four", Name: "API Four", Slug: "second-duplicate", Runtime: "nodejs:22"}, }, wantErr: "source slugs must be unique:", }, diff --git a/cli/internal/deploy/source_reader_test.go b/cli/internal/deploy/source_reader_test.go index a50f7b982..8cb3029e2 100644 --- a/cli/internal/deploy/source_reader_test.go +++ b/cli/internal/deploy/source_reader_test.go @@ -32,6 +32,7 @@ info: Location: testFile, Name: "Test API", Slug: "test-api", + Runtime: "nodejs:22", } reader := NewSourceReader(source) @@ -73,6 +74,7 @@ func TestSourceReader_JSONFile(t *testing.T) { Location: testFile, Name: "Test JSON API", Slug: "test-json-api", + Runtime: "nodejs:22", } reader := NewSourceReader(source) @@ -101,6 +103,7 @@ func TestSourceReader_NonexistentFile(t *testing.T) { Location: "/nonexistent/path/file.yaml", Name: "Nonexistent API", Slug: "nonexistent-api", + Runtime: "nodejs:22", } reader := NewSourceReader(source) @@ -132,6 +135,7 @@ func TestSourceReader_RemoteURL(t *testing.T) { Location: staticServer.URL + "/api-spec.yaml", Name: "Remote API", Slug: "remote-api", + Runtime: "nodejs:22", } reader := NewSourceReader(source) @@ -165,6 +169,7 @@ func TestSourceReader_RemoteURL_Error(t *testing.T) { Location: server.URL + "/nonexistent.yaml", Name: "Remote API", Slug: "remote-api", + Runtime: "nodejs:22", } reader := NewSourceReader(source) diff --git a/cli/internal/deploy/upload.go b/cli/internal/deploy/upload.go index a869d78b6..0552027c3 100644 --- a/cli/internal/deploy/upload.go +++ b/cli/internal/deploy/upload.go @@ -7,7 +7,7 @@ import ( "github.com/speakeasy-api/gram/cli/internal/api" "github.com/speakeasy-api/gram/cli/internal/secret" - "github.com/speakeasy-api/gram/server/gen/deployments" + "github.com/speakeasy-api/gram/server/gen/assets" "github.com/speakeasy-api/gram/server/gen/types" ) @@ -25,34 +25,48 @@ func (ur *UploadRequest) Read(ctx context.Context) (io.ReadCloser, int64, error) return reader, size, nil } +type UploadResponse struct { + AssetID string + Name string + Slug types.Slug + Runtime string +} + func Upload( ctx context.Context, assetsClient *api.AssetsClient, req *UploadRequest, -) (*deployments.AddOpenAPIv3DeploymentAssetForm, error) { +) (*UploadResponse, error) { rc, length, err := req.SourceReader.Read(ctx) if err != nil { return nil, fmt.Errorf("failed to read source: %w", err) } source := req.SourceReader.Source - - uploadRes, err := assetsClient.UploadOpenAPIv3(ctx, &api.UploadOpenAPIv3Request{ + assetForm := &api.UploadAssetForm{ APIKey: req.APIKey.Reveal(), ProjectSlug: req.ProjectSlug, Reader: rc, ContentType: req.SourceReader.GetContentType(), ContentLength: length, - }) + } + + var asset *assets.Asset + switch source.Type { + case SourceTypeOpenAPIV3: + asset, err = assetsClient.UploadOpenAPIv3(ctx, assetForm) + case SourceTypeFunction: + asset, err = assetsClient.UploadFunctions(ctx, assetForm) + } + if err != nil { msg := "failed to upload asset in project '%s' for source %s: %w" return nil, fmt.Errorf(msg, req.ProjectSlug, source.Location, err) } - - return &deployments.AddOpenAPIv3DeploymentAssetForm{ - AssetID: uploadRes.Asset.ID, + return &UploadResponse{ + AssetID: asset.ID, Name: source.Name, Slug: types.Slug(source.Slug), + Runtime: source.Runtime, }, nil - } diff --git a/cli/internal/deploy/workflow.go b/cli/internal/deploy/workflow.go index c80e2cdae..fad69b72d 100644 --- a/cli/internal/deploy/workflow.go +++ b/cli/internal/deploy/workflow.go @@ -37,7 +37,8 @@ type Workflow struct { Params WorkflowParams AssetsClient *api.AssetsClient DeploymentsClient *api.DeploymentsClient - NewAssets []*deployments.AddOpenAPIv3DeploymentAssetForm + NewOpenAPIAssets []*deployments.AddOpenAPIv3DeploymentAssetForm + NewFunctionAssets []*deployments.AddFunctionsForm Deployment *types.Deployment Err error } @@ -63,7 +64,8 @@ func NewWorkflow( Params: params, AssetsClient: nil, DeploymentsClient: nil, - NewAssets: nil, + NewOpenAPIAssets: nil, + NewFunctionAssets: nil, Deployment: nil, Err: nil, } @@ -94,13 +96,20 @@ func (s *Workflow) UploadAssets( return s } - s.Logger.InfoContext(ctx, "uploading files") - newAssets := make( + s.Logger.InfoContext(ctx, "uploading assets") + + newOpenAPIAssets := make( []*deployments.AddOpenAPIv3DeploymentAssetForm, + 0, + len(sources), + ) + newFunctionAssets := make( + []*deployments.AddFunctionsForm, + 0, len(sources), ) - for idx, source := range sources { + for _, source := range sources { if err := source.Validate(); err != nil { return s.Fail(fmt.Errorf("invalid source: %w", err)) } @@ -115,9 +124,27 @@ func (s *Workflow) UploadAssets( return s.Fail(fmt.Errorf("failed to upload asset: %w", err)) } - newAssets[idx] = asset + switch source.Type { + case SourceTypeOpenAPIV3: + form := &deployments.AddOpenAPIv3DeploymentAssetForm{ + AssetID: asset.AssetID, + Name: asset.Name, + Slug: asset.Slug, + } + newOpenAPIAssets = append(newOpenAPIAssets, form) + case SourceTypeFunction: + form := &deployments.AddFunctionsForm{ + AssetID: asset.AssetID, + Name: asset.Name, + Slug: asset.Slug, + Runtime: asset.Runtime, + } + newFunctionAssets = append(newFunctionAssets, form) + } } - s.NewAssets = newAssets + + s.NewOpenAPIAssets = newOpenAPIAssets + s.NewFunctionAssets = newFunctionAssets return s } @@ -137,10 +164,11 @@ func (s *Workflow) EvolveDeployment( slog.String("deployment_id", s.Deployment.ID), ) evolved, err := s.DeploymentsClient.Evolve(ctx, api.EvolveRequest{ - Assets: s.NewAssets, - APIKey: s.Params.APIKey, - DeploymentID: s.Deployment.ID, - ProjectSlug: s.Params.ProjectSlug, + OpenAPIv3Assets: s.NewOpenAPIAssets, + Functions: s.NewFunctionAssets, + APIKey: s.Params.APIKey, + DeploymentID: s.Deployment.ID, + ProjectSlug: s.Params.ProjectSlug, }) if err != nil { return s.Fail(fmt.Errorf("failed to evolve deployment: %w", err)) @@ -169,7 +197,8 @@ func (s *Workflow) CreateDeployment( createReq := api.CreateDeploymentRequest{ APIKey: s.Params.APIKey, IdempotencyKey: idem, - OpenAPIv3Assets: s.NewAssets, + OpenAPIv3Assets: s.NewOpenAPIAssets, + Functions: s.NewFunctionAssets, ProjectSlug: s.Params.ProjectSlug, } result, err := s.DeploymentsClient.CreateDeployment(ctx, createReq)