From 51c742bb4b98137682e683159010f57a9e9bf703 Mon Sep 17 00:00:00 2001 From: Qiao Han Date: Wed, 18 Oct 2023 17:32:32 +0800 Subject: [PATCH] fix: add storage ls tests --- internal/storage/ls/ls.go | 126 +++++---- internal/storage/ls/ls_test.go | 498 +++++++++++++++++++++++++++++++++ internal/utils/misc.go | 4 + 3 files changed, 577 insertions(+), 51 deletions(-) create mode 100644 internal/storage/ls/ls_test.go diff --git a/internal/storage/ls/ls.go b/internal/storage/ls/ls.go index 7d3b776d8..5c4a1f7cd 100644 --- a/internal/storage/ls/ls.go +++ b/internal/storage/ls/ls.go @@ -25,59 +25,50 @@ func Run(ctx context.Context, objectPath string, recursive bool, fsys afero.Fs) if err != nil { return err } - paths, err := ListStoragePaths(ctx, projectRef, remotePath) - if err != nil { - return err + callback := func(objectPath string) error { + fmt.Println(objectPath) + return nil } if recursive { - basePath := remotePath - if !strings.HasSuffix(remotePath, "/") { - basePath, _ = path.Split(remotePath) - } - var result []string - for i := len(paths) - 1; i >= 0; i-- { - name := paths[i] - if !strings.HasSuffix(name, "/") { - result = append(result, name) - continue - } - dirPath := basePath + name - children, err := ListStoragePathsAll(ctx, projectRef, dirPath) - if err != nil { - return err - } - result = append(result, children...) - } - paths = result + return IterateStoragePathsAll(ctx, projectRef, remotePath, callback) } - if len(paths) > 0 { - fmt.Println(strings.Join(paths, "\n")) - } - return nil + return IterateStoragePaths(ctx, projectRef, remotePath, callback) } +var errInvalidURL = errors.New("URL must match pattern ss:///bucket/prefix") + func ParseStorageURL(objectPath string) (string, error) { parsed, err := url.Parse(objectPath) if err != nil { return "", err } if strings.ToLower(parsed.Scheme) != STORAGE_SCHEME || len(parsed.Path) == 0 || len(parsed.Host) > 0 { - return "", errors.New("URL must match pattern ss:///bucket/prefix") + return "", errInvalidURL } return parsed.Path, nil } func ListStoragePaths(ctx context.Context, projectRef, remotePath string) ([]string, error) { var result []string + err := IterateStoragePaths(ctx, projectRef, remotePath, func(objectName string) error { + result = append(result, objectName) + return nil + }) + return result, err +} + +func IterateStoragePaths(ctx context.Context, projectRef, remotePath string, callback func(objectName string) error) error { bucket, prefix := SplitBucketPrefix(remotePath) if len(bucket) == 0 || (len(prefix) == 0 && !strings.HasSuffix(remotePath, "/")) { buckets, err := client.ListStorageBuckets(ctx, projectRef) if err != nil { - return nil, err + return err } for _, b := range buckets { if strings.HasPrefix(b.Name, bucket) { - result = append(result, b.Name+"/") + if err := callback(b.Name + "/"); err != nil { + return err + } } } } else { @@ -85,14 +76,16 @@ func ListStoragePaths(ctx context.Context, projectRef, remotePath string) ([]str for i := 0; i < pages; i++ { objects, err := client.ListStorageObjects(ctx, projectRef, bucket, prefix, i) if err != nil { - return nil, err + return err } for _, o := range objects { name := o.Name if o.Id == nil { name += "/" } - result = append(result, name) + if err := callback(name); err != nil { + return err + } } if len(objects) == client.PAGE_LIMIT { // TODO: show interactive prompt? @@ -101,43 +94,74 @@ func ListStoragePaths(ctx context.Context, projectRef, remotePath string) ([]str } } } - return result, nil + return nil } func SplitBucketPrefix(objectPath string) (string, string) { if objectPath == "" || objectPath == "/" { return "", "" } - sep := strings.IndexByte(objectPath[1:], '/') + start := 0 + if objectPath[0] == '/' { + start = 1 + } + sep := strings.IndexByte(objectPath[start:], '/') if sep < 0 { - return objectPath[1:], "" + return objectPath[start:], "" } - return objectPath[1 : sep+1], objectPath[sep+2:] + return objectPath[start : sep+start], objectPath[sep+start+1:] } // Expects remotePath to be terminated by "/" func ListStoragePathsAll(ctx context.Context, projectRef, remotePath string) ([]string, error) { var result []string - queue := make([]string, 0) - queue = append(queue, remotePath) - for len(queue) > 0 { - dirPath := queue[len(queue)-1] - queue = queue[:len(queue)-1] - paths, err := ListStoragePaths(ctx, projectRef, dirPath) - if err != nil { - return result, err - } - if len(paths) == 0 { - result = append(result, dirPath) + err := IterateStoragePathsAll(ctx, projectRef, remotePath, func(objectPath string) error { + result = append(result, objectPath) + return nil + }) + return result, err +} + +func IterateStoragePathsAll(ctx context.Context, projectRef, remotePath string, callback func(objectPath string) error) error { + basePath := remotePath + if !strings.HasSuffix(remotePath, "/") { + basePath, _ = path.Split(remotePath) + } + // BFS so we can list paths in increasing depth + dirQueue := make([]string, 0) + // We don't know if user passed in a directory or file, so query storage first. + if err := IterateStoragePaths(ctx, projectRef, remotePath, func(objectName string) error { + objectPath := basePath + objectName + if strings.HasSuffix(objectName, "/") { + dirQueue = append(dirQueue, objectPath) + return nil } - for _, objectName := range paths { + return callback(objectPath) + }); err != nil { + return err + } + for len(dirQueue) > 0 { + dirPath := dirQueue[len(dirQueue)-1] + dirQueue = dirQueue[:len(dirQueue)-1] + empty := true + if err := IterateStoragePaths(ctx, projectRef, dirPath, func(objectName string) error { + empty = false objectPath := dirPath + objectName if strings.HasSuffix(objectName, "/") { - queue = append(queue, objectPath) - } else { - result = append(result, objectPath) + dirQueue = append(dirQueue, objectPath) + return nil + } + return callback(objectPath) + }); err != nil { + return err + } + // Also report empty buckets + bucket, prefix := SplitBucketPrefix(dirPath) + if empty && len(prefix) == 0 { + if err := callback(bucket + "/"); err != nil { + return err } } } - return result, nil + return nil } diff --git a/internal/storage/ls/ls_test.go b/internal/storage/ls/ls_test.go new file mode 100644 index 000000000..77148d733 --- /dev/null +++ b/internal/storage/ls/ls_test.go @@ -0,0 +1,498 @@ +package ls + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/storage/client" + "github.com/supabase/cli/internal/testing/apitest" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/api" + "gopkg.in/h2non/gock.v1" +) + +func TestStorageLS(t *testing.T) { + t.Run("lists buckets", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.NewMemMapFs() + projectRef := apitest.RandomProjectRef() + require.NoError(t, afero.WriteFile(fsys, utils.ProjectRefPath, []byte(projectRef), 0644)) + // Setup valid access token + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + // Setup mock api + defer gock.OffAll() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{{ + Name: "service_role", + ApiKey: "service-key", + }}) + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Get("/storage/v1/bucket"). + Reply(http.StatusOK). + JSON([]client.BucketResponse{}) + // Run test + err := Run(context.Background(), "ss:///", false, fsys) + // Check error + assert.NoError(t, err) + }) + + t.Run("throws error on invalid URL", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.NewMemMapFs() + // Run test + err := Run(context.Background(), "", false, fsys) + // Check error + assert.ErrorIs(t, err, errInvalidURL) + }) + + t.Run("throws error on invalid project", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.NewMemMapFs() + // Run test + err := Run(context.Background(), "ss:///", false, fsys) + // Check error + assert.ErrorIs(t, err, utils.ErrNotLinked) + }) + + t.Run("lists objects recursive", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.NewMemMapFs() + projectRef := apitest.RandomProjectRef() + require.NoError(t, afero.WriteFile(fsys, utils.ProjectRefPath, []byte(projectRef), 0644)) + // Setup valid access token + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + // Setup mock api + defer gock.OffAll() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{{ + Name: "service_role", + ApiKey: "service-key", + }}) + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Get("/storage/v1/bucket"). + Reply(http.StatusOK). + JSON([]client.BucketResponse{{ + Id: "private", + Name: "private", + CreatedAt: "2023-10-13T17:48:58.491Z", + UpdatedAt: "2023-10-13T17:48:58.491Z", + }}) + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Post("/storage/v1/object/list/private"). + Reply(http.StatusOK). + JSON([]client.ObjectResponse{}) + // Run test + err := Run(context.Background(), "ss:///", true, fsys) + // Check error + assert.NoError(t, err) + }) +} + +func TestListStoragePaths(t *testing.T) { + // Setup valid project ref + projectRef := apitest.RandomProjectRef() + // Setup valid access token + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + t.Run("lists bucket paths by prefix", func(t *testing.T) { + // Setup mock api + defer gock.OffAll() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{{ + Name: "service_role", + ApiKey: "service-key", + }}) + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Get("/storage/v1/bucket"). + Reply(http.StatusOK). + JSON([]client.BucketResponse{{ + Id: "test", + Name: "test", + Public: true, + CreatedAt: "2023-10-13T17:48:58.491Z", + UpdatedAt: "2023-10-13T17:48:58.491Z", + }, { + Id: "private", + Name: "private", + CreatedAt: "2023-10-13T17:48:58.491Z", + UpdatedAt: "2023-10-13T17:48:58.491Z", + }}) + // Run test + paths, err := ListStoragePaths(context.Background(), projectRef, "te") + // Check error + assert.NoError(t, err) + assert.ElementsMatch(t, []string{"test/"}, paths) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("throws error on bucket service unavailable", func(t *testing.T) { + // Setup mock api + defer gock.OffAll() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{{ + Name: "service_role", + ApiKey: "service-key", + }}) + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Get("/storage/v1/bucket"). + Reply(http.StatusServiceUnavailable) + // Run test + paths, err := ListStoragePaths(context.Background(), projectRef, "/") + // Check error + assert.ErrorContains(t, err, "Error status 503:") + assert.Empty(t, paths) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("lists object paths by prefix", func(t *testing.T) { + // Setup mock api + defer gock.OffAll() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{{ + Name: "service_role", + ApiKey: "service-key", + }}) + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Post("/storage/v1/object/list/bucket"). + Reply(http.StatusOK). + JSON([]client.ObjectResponse{{ + Name: "folder", + }, { + Name: "abstract.pdf", + Id: utils.Ptr("9b7f9f48-17a6-4ca8-b14a-39b0205a63e9"), + UpdatedAt: utils.Ptr("2023-10-13T18:08:22.068Z"), + CreatedAt: utils.Ptr("2023-10-13T18:08:22.068Z"), + LastAccessedAt: utils.Ptr("2023-10-13T18:08:22.068Z"), + Metadata: &client.ObjectMetadata{ + ETag: `"887ea9be3c68e6f2fca7fd2d7c77d8fe"`, + Size: 82702, + Mimetype: "application/pdf", + CacheControl: "max-age=3600", + LastModified: "2023-10-13T18:08:22.000Z", + ContentLength: 82702, + HttpStatusCode: 200, + }, + }}) + // Run test + paths, err := ListStoragePaths(context.Background(), projectRef, "bucket/") + // Check error + assert.NoError(t, err) + assert.ElementsMatch(t, []string{"folder/", "abstract.pdf"}, paths) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("throws error on object service unavailable", func(t *testing.T) { + // Setup mock api + defer gock.OffAll() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{{ + Name: "service_role", + ApiKey: "service-key", + }}) + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Post("/storage/v1/object/list/bucket"). + Reply(http.StatusServiceUnavailable) + // Run test + paths, err := ListStoragePaths(context.Background(), projectRef, "bucket/") + // Check error + assert.ErrorContains(t, err, "Error status 503:") + assert.Empty(t, paths) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("lists object paths with pagination", func(t *testing.T) { + // Setup mock api + defer gock.OffAll() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{{ + Name: "service_role", + ApiKey: "service-key", + }}) + expected := make([]string, client.PAGE_LIMIT) + resp := make([]client.ObjectResponse, client.PAGE_LIMIT) + for i := 0; i < len(resp); i++ { + resp[i] = client.ObjectResponse{Name: fmt.Sprintf("dir_%d", i)} + expected[i] = resp[i].Name + "/" + } + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Post("/storage/v1/object/list/bucket"). + JSON(client.ListObjectsQuery{ + Prefix: "", + Search: "dir", + Limit: client.PAGE_LIMIT, + Offset: 0, + }). + Reply(http.StatusOK). + JSON(resp) + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Post("/storage/v1/object/list/bucket"). + JSON(client.ListObjectsQuery{ + Prefix: "", + Search: "dir", + Limit: client.PAGE_LIMIT, + Offset: client.PAGE_LIMIT, + }). + Reply(http.StatusOK). + JSON([]client.ObjectResponse{}) + // Run test + paths, err := ListStoragePaths(context.Background(), projectRef, "/bucket/dir") + // Check error + assert.NoError(t, err) + assert.ElementsMatch(t, expected, paths) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} + +func TestListStoragePathsAll(t *testing.T) { + // Setup valid project ref + projectRef := apitest.RandomProjectRef() + // Setup valid access token + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + t.Run("lists nested object paths", func(t *testing.T) { + // Setup mock api + defer gock.OffAll() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{{ + Name: "service_role", + ApiKey: "service-key", + }}) + // List buckets + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Get("/storage/v1/bucket"). + Reply(http.StatusOK). + JSON([]client.BucketResponse{{ + Id: "test", + Name: "test", + Public: true, + CreatedAt: "2023-10-13T17:48:58.491Z", + UpdatedAt: "2023-10-13T17:48:58.491Z", + }, { + Id: "private", + Name: "private", + CreatedAt: "2023-10-13T17:48:58.491Z", + UpdatedAt: "2023-10-13T17:48:58.491Z", + }}) + // List folders + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Post("/storage/v1/object/list/test"). + JSON(client.ListObjectsQuery{ + Prefix: "", + Search: "", + Limit: client.PAGE_LIMIT, + Offset: 0, + }). + Reply(http.StatusOK). + JSON([]client.ObjectResponse{}) + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Post("/storage/v1/object/list/private"). + JSON(client.ListObjectsQuery{ + Prefix: "", + Search: "", + Limit: client.PAGE_LIMIT, + Offset: 0, + }). + Reply(http.StatusOK). + JSON([]client.ObjectResponse{{ + Name: "folder", + }}) + // List files + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Post("/storage/v1/object/list/private"). + JSON(client.ListObjectsQuery{ + Prefix: "folder/", + Search: "", + Limit: client.PAGE_LIMIT, + Offset: 0, + }). + Reply(http.StatusOK). + JSON([]client.ObjectResponse{{ + Name: "abstract.pdf", + Id: utils.Ptr("9b7f9f48-17a6-4ca8-b14a-39b0205a63e9"), + UpdatedAt: utils.Ptr("2023-10-13T18:08:22.068Z"), + CreatedAt: utils.Ptr("2023-10-13T18:08:22.068Z"), + LastAccessedAt: utils.Ptr("2023-10-13T18:08:22.068Z"), + Metadata: &client.ObjectMetadata{ + ETag: `"887ea9be3c68e6f2fca7fd2d7c77d8fe"`, + Size: 82702, + Mimetype: "application/pdf", + CacheControl: "max-age=3600", + LastModified: "2023-10-13T18:08:22.000Z", + ContentLength: 82702, + HttpStatusCode: 200, + }, + }}) + // Run test + paths, err := ListStoragePathsAll(context.Background(), projectRef, "") + // Check error + assert.NoError(t, err) + assert.ElementsMatch(t, []string{"private/folder/abstract.pdf", "test/"}, paths) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("returns partial result on error", func(t *testing.T) { + // Setup mock api + defer gock.OffAll() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{{ + Name: "service_role", + ApiKey: "service-key", + }}) + // List folders + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Post("/storage/v1/object/list/private"). + JSON(client.ListObjectsQuery{ + Prefix: "", + Search: "", + Limit: client.PAGE_LIMIT, + Offset: 0, + }). + Reply(http.StatusOK). + JSON([]client.ObjectResponse{{ + Name: "error", + }, { + Name: "abstract.pdf", + Id: utils.Ptr("9b7f9f48-17a6-4ca8-b14a-39b0205a63e9"), + UpdatedAt: utils.Ptr("2023-10-13T18:08:22.068Z"), + CreatedAt: utils.Ptr("2023-10-13T18:08:22.068Z"), + LastAccessedAt: utils.Ptr("2023-10-13T18:08:22.068Z"), + Metadata: &client.ObjectMetadata{ + ETag: `"887ea9be3c68e6f2fca7fd2d7c77d8fe"`, + Size: 82702, + Mimetype: "application/pdf", + CacheControl: "max-age=3600", + LastModified: "2023-10-13T18:08:22.000Z", + ContentLength: 82702, + HttpStatusCode: 200, + }, + }}) + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Post("/storage/v1/object/list/private"). + JSON(client.ListObjectsQuery{ + Prefix: "empty/", + Search: "", + Limit: client.PAGE_LIMIT, + Offset: 0, + }). + Reply(http.StatusOK). + JSON([]client.ObjectResponse{}) + gock.New("https://" + utils.GetSupabaseHost(projectRef)). + Post("/storage/v1/object/list/private"). + JSON(client.ListObjectsQuery{ + Prefix: "error/", + Search: "", + Limit: client.PAGE_LIMIT, + Offset: 0, + }). + Reply(http.StatusServiceUnavailable) + // Run test + paths, err := ListStoragePathsAll(context.Background(), projectRef, "private/") + // Check error + assert.ErrorContains(t, err, "Error status 503:") + assert.ElementsMatch(t, []string{"private/abstract.pdf"}, paths) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} + +func TestSplitBucketPrefix(t *testing.T) { + t.Run("splits empty path", func(t *testing.T) { + bucket, prefix := SplitBucketPrefix("") + assert.Equal(t, bucket, "") + assert.Equal(t, prefix, "") + }) + + t.Run("splits root path", func(t *testing.T) { + bucket, prefix := SplitBucketPrefix("/") + assert.Equal(t, bucket, "") + assert.Equal(t, prefix, "") + }) + + t.Run("splits no slash", func(t *testing.T) { + bucket, prefix := SplitBucketPrefix("bucket") + assert.Equal(t, bucket, "bucket") + assert.Equal(t, prefix, "") + }) + + t.Run("splits prefix slash", func(t *testing.T) { + bucket, prefix := SplitBucketPrefix("/bucket") + assert.Equal(t, bucket, "bucket") + assert.Equal(t, prefix, "") + }) + + t.Run("splits suffix slash", func(t *testing.T) { + bucket, prefix := SplitBucketPrefix("bucket/") + assert.Equal(t, bucket, "bucket") + assert.Equal(t, prefix, "") + }) + + t.Run("splits file path", func(t *testing.T) { + bucket, prefix := SplitBucketPrefix("/bucket/folder/name.png") + assert.Equal(t, bucket, "bucket") + assert.Equal(t, prefix, "folder/name.png") + }) + + t.Run("splits dir path", func(t *testing.T) { + bucket, prefix := SplitBucketPrefix("/bucket/folder/") + assert.Equal(t, bucket, "bucket") + assert.Equal(t, prefix, "folder/") + }) +} + +func TestParseStorageURL(t *testing.T) { + t.Run("parses valid url", func(t *testing.T) { + path, err := ParseStorageURL("ss:///bucket/folder/name.png") + assert.NoError(t, err) + assert.Equal(t, path, "/bucket/folder/name.png") + }) + + t.Run("throws error on invalid host", func(t *testing.T) { + path, err := ParseStorageURL("ss://bucket") + assert.ErrorIs(t, err, errInvalidURL) + assert.Empty(t, path) + }) + + t.Run("throws error on missing path", func(t *testing.T) { + path, err := ParseStorageURL("ss:") + assert.ErrorIs(t, err, errInvalidURL) + assert.Empty(t, path) + }) + + t.Run("throws error on invalid scheme", func(t *testing.T) { + path, err := ParseStorageURL(".") + assert.ErrorIs(t, err, errInvalidURL) + assert.Empty(t, path) + }) + + t.Run("throws error on invalid url", func(t *testing.T) { + path, err := ParseStorageURL(":") + assert.ErrorContains(t, err, "missing protocol scheme") + assert.Empty(t, path) + }) +} diff --git a/internal/utils/misc.go b/internal/utils/misc.go index b986c19e9..f64f708c5 100644 --- a/internal/utils/misc.go +++ b/internal/utils/misc.go @@ -326,3 +326,7 @@ func ValidateFunctionSlug(slug string) error { return nil } + +func Ptr[T any](v T) *T { + return &v +}