Repository: apigee/registry
Pull request #775 (2022-10-11 00:13:22)

Add simple test of "registry upload bulk protos"
This tests `registry upload bulk protos` by uploading a directory containing descriptions of two Google APIs and confirming that two specs are uploaded and that each zipped spec includes the expected number of proto and metadata files.

codecov[bot]: # [Codecov](https://codecov.io/gh/apigee/registry/pull/775?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee) Report
> Merging [#775](https://codecov.io/gh/apigee/registry/pull/775?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee) (3269be9) into [main](https://codecov.io/gh/apigee/registry/commit/ef5b61a9c7b618c93557fb691930ee7bdd2da2e1?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee) (ef5b61a) will **decrease** coverage by `1.65%`.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##             main     #775      +/-   ##
==========================================
- Coverage   56.29%   54.64%   -1.66%     
==========================================
  Files          93       97       +4     
  Lines        7894     8517     +623     
==========================================
+ Hits         4444     4654     +210     
- Misses       3022     3403     +381     
- Partials      428      460      +32     
```


| [Impacted Files](https://codecov.io/gh/apigee/registry/pull/775?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee) | Coverage Δ | |
|---|---|---|
| [cmd/registry/cmd/upload/bulk/protos.go](https://codecov.io/gh/apigee/registry/pull/775/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC91cGxvYWQvYnVsay9wcm90b3MuZ28=) | `64.63% <100.00%> (ø)` | |
| [cmd/registry/cmd/upload/bulk/discovery.go](https://codecov.io/gh/apigee/registry/pull/775/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC91cGxvYWQvYnVsay9kaXNjb3ZlcnkuZ28=) | `3.37% <0.00%> (ø)` | |
| [cmd/registry/cmd/upload/bulk/bulk.go](https://codecov.io/gh/apigee/registry/pull/775/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC91cGxvYWQvYnVsay9idWxrLmdv) | `100.00% <0.00%> (ø)` | |
| [cmd/registry/cmd/upload/bulk/openapi.go](https://codecov.io/gh/apigee/registry/pull/775/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC91cGxvYWQvYnVsay9vcGVuYXBpLmdv) | `10.15% <0.00%> (ø)` | |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee)


Patch:
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index c702345..0984e97 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -61,6 +61,9 @@ jobs:
     - name: Install
       run: go install ./...
 
+    - name: Install protoc (needed for registry upload tests)
+      uses: arduino/setup-protoc@v1
+
     - name: Test everything with SQLite
       run: go test ./...
 
diff --git a/cmd/registry/cmd/upload/bulk/protos.go b/cmd/registry/cmd/upload/bulk/protos.go
index c9ef518..453e1cd 100644
--- a/cmd/registry/cmd/upload/bulk/protos.go
+++ b/cmd/registry/cmd/upload/bulk/protos.go
@@ -372,7 +372,7 @@ func referencedProtos(protos []string, root string) ([]string, error) {
 		return nil, err
 	}
 	defer os.RemoveAll(tempDir)
-	args := []string{"-o", tempDir + "/proto.pb"}
+	args := []string{"-o", tempDir + "/proto.pb", "--include_imports"}
 	args = append(args, protos...)
 	cmd := exec.Command("protoc", args...)
 	cmd.Dir = root
@@ -395,11 +395,9 @@ func protosFromFileDescriptorSet(filename string) ([]string, error) {
 	}
 	filenameset := make(map[string]bool)
 	for _, file := range fds.File {
-		filenameset[*file.Name] = true
-		for _, dependency := range file.Dependency {
-			if !strings.HasPrefix(dependency, "google/protobuf/") {
-				filenameset[dependency] = true
-			}
+		filename = *file.Name
+		if !strings.HasPrefix(filename, "google/protobuf/") {
+			filenameset[filename] = true
 		}
 	}
 	filenames := make([]string, 0)


Test patch:
diff --git a/cmd/registry/cmd/upload/bulk/protos_test.go b/cmd/registry/cmd/upload/bulk/protos_test.go
new file mode 100644
index 0000000..a8a31dc
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/protos_test.go
@@ -0,0 +1,146 @@
+// Copyright 2022 Google LLC. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bulk
+
+import (
+	"context"
+	"log"
+	"sort"
+	"strings"
+	"testing"
+
+	"github.com/apigee/registry/cmd/registry/core"
+	"github.com/apigee/registry/pkg/connection"
+	"github.com/apigee/registry/rpc"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+)
+
+func TestProtos(t *testing.T) {
+	const (
+		projectID   = "protos-test"
+		projectName = "projects/" + projectID
+	)
+	// Create a registry client.
+	ctx := context.Background()
+	registryClient, err := connection.NewRegistryClient(ctx)
+	if err != nil {
+		t.Fatalf("Error creating client: %+v", err)
+	}
+	defer registryClient.Close()
+	adminClient, err := connection.NewAdminClient(ctx)
+	if err != nil {
+		t.Fatalf("Error creating client: %+v", err)
+	}
+	defer adminClient.Close()
+	// Clear the test project.
+	err = adminClient.DeleteProject(ctx, &rpc.DeleteProjectRequest{
+		Name:  projectName,
+		Force: true,
+	})
+	if err != nil && status.Code(err) != codes.NotFound {
+		t.Fatalf("Error deleting test project: %+v", err)
+	}
+	// Create the test project.
+	_, err = adminClient.CreateProject(ctx, &rpc.CreateProjectRequest{
+		ProjectId: projectID,
+		Project: &rpc.Project{
+			DisplayName: "Test",
+			Description: "A test catalog",
+		},
+	})
+	if err != nil {
+		t.Fatalf("Error creating project %s", err)
+	}
+	cmd := Command()
+	args := []string{"protos", "testdata/protos", "--project-id", projectID}
+	cmd.SetArgs(args)
+	if err := cmd.Execute(); err != nil {
+		t.Fatalf("Execute() with args %+v returned error: %s", args, err)
+	}
+	tests := []struct {
+		desc              string
+		spec              string
+		wantProtoCount    int
+		wantMetadataCount int
+	}{
+		{
+			desc:              "Apigee Registry",
+			spec:              "apis/apigeeregistry/versions/v1/specs/google-cloud-apigeeregistry-v1.zip",
+			wantProtoCount:    11,
+			wantMetadataCount: 2,
+		},
+		{
+			desc:              "Example Library",
+			spec:              "apis/library-example/versions/v1/specs/google-example-library-v1.zip",
+			wantProtoCount:    6,
+			wantMetadataCount: 3,
+		},
+	}
+	for _, test := range tests {
+		// Get the uploaded spec
+		result, err := registryClient.GetApiSpecContents(ctx, &rpc.GetApiSpecContentsRequest{
+			Name: "projects/" + projectID + "/locations/global/" + test.spec,
+		})
+		if err != nil {
+			t.Fatalf("unable to fetch spec %s", test.spec)
+		}
+		// Verify the content type.
+		if result.ContentType != "application/x.protobuf+zip" {
+			t.Errorf("Invalid mime type for %s: %s", test.spec, result.ContentType)
+		}
+		// Verify that the zip contains the expected number of files
+		m, err := core.UnzipArchiveToMap(result.Data)
+		if err != nil {
+			t.Fatalf("unable to unzip spec %s", test.spec)
+		}
+		proto_filenames := make([]string, 0)
+		metadata_filenames := make([]string, 0)
+		for filename := range m {
+			if strings.HasSuffix(filename, ".proto") {
+				proto_filenames = append(proto_filenames, filename)
+			} else {
+				metadata_filenames = append(metadata_filenames, filename)
+			}
+		}
+		if len(proto_filenames) != test.wantProtoCount {
+			t.Errorf("Archive contains incorrect number of proto files (%d, expected %d)",
+				len(proto_filenames),
+				test.wantProtoCount)
+			sort.Strings(proto_filenames)
+			for i, s := range proto_filenames {
+				log.Printf("%d: %s", i, s)
+			}
+		}
+		if len(metadata_filenames) != test.wantMetadataCount {
+			t.Errorf("Archive contains incorrect number of metadata files (%d, expected %d)",
+				len(metadata_filenames),
+				test.wantMetadataCount)
+			sort.Strings(metadata_filenames)
+			for i, s := range metadata_filenames {
+				log.Printf("%d: %s", i, s)
+			}
+		}
+	}
+	// Delete the test project.
+	req := &rpc.DeleteProjectRequest{
+		Name:  projectName,
+		Force: true,
+	}
+	err = adminClient.DeleteProject(ctx, req)
+	if err != nil {
+		t.Fatalf("Failed to delete test project: %s", err)
+	}
+}
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/annotations.proto b/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/annotations.proto
new file mode 100644
index 0000000..efdab3d
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/annotations.proto
@@ -0,0 +1,31 @@
+// Copyright 2015 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package google.api;
+
+import "google/api/http.proto";
+import "google/protobuf/descriptor.proto";
+
+option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations";
+option java_multiple_files = true;
+option java_outer_classname = "AnnotationsProto";
+option java_package = "com.google.api";
+option objc_class_prefix = "GAPI";
+
+extend google.protobuf.MethodOptions {
+  // See `HttpRule`.
+  HttpRule http = 72295728;
+}
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/client.proto b/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/client.proto
new file mode 100644
index 0000000..3b3fd0c
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/client.proto
@@ -0,0 +1,99 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package google.api;
+
+import "google/protobuf/descriptor.proto";
+
+option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations";
+option java_multiple_files = true;
+option java_outer_classname = "ClientProto";
+option java_package = "com.google.api";
+option objc_class_prefix = "GAPI";
+
+extend google.protobuf.MethodOptions {
+  // A definition of a client library method signature.
+  //
+  // In client libraries, each proto RPC corresponds to one or more methods
+  // which the end user is able to call, and calls the underlying RPC.
+  // Normally, this method receives a single argument (a struct or instance
+  // corresponding to the RPC request object). Defining this field will
+  // add one or more overloads providing flattened or simpler method signatures
+  // in some languages.
+  //
+  // The fields on the method signature are provided as a comma-separated
+  // string.
+  //
+  // For example, the proto RPC and annotation:
+  //
+  //   rpc CreateSubscription(CreateSubscriptionRequest)
+  //       returns (Subscription) {
+  //     option (google.api.method_signature) = "name,topic";
+  //   }
+  //
+  // Would add the following Java overload (in addition to the method accepting
+  // the request object):
+  //
+  //   public final Subscription createSubscription(String name, String topic)
+  //
+  // The following backwards-compatibility guidelines apply:
+  //
+  //   * Adding this annotation to an unannotated method is backwards
+  //     compatible.
+  //   * Adding this annotation to a method which already has existing
+  //     method signature annotations is backwards compatible if and only if
+  //     the new method signature annotation is last in the sequence.
+  //   * Modifying or removing an existing method signature annotation is
+  //     a breaking change.
+  //   * Re-ordering existing method signature annotations is a breaking
+  //     change.
+  repeated string method_signature = 1051;
+}
+
+extend google.protobuf.ServiceOptions {
+  // The hostname for this service.
+  // This should be specified with no prefix or protocol.
+  //
+  // Example:
+  //
+  //   service Foo {
+  //     option (google.api.default_host) = "foo.googleapi.com";
+  //     ...
+  //   }
+  string default_host = 1049;
+
+  // OAuth scopes needed for the client.
+  //
+  // Example:
+  //
+  //   service Foo {
+  //     option (google.api.oauth_scopes) = \
+  //       "https://www.googleapis.com/auth/cloud-platform";
+  //     ...
+  //   }
+  //
+  // If there is more than one scope, use a comma-separated string:
+  //
+  // Example:
+  //
+  //   service Foo {
+  //     option (google.api.oauth_scopes) = \
+  //       "https://www.googleapis.com/auth/cloud-platform,"
+  //       "https://www.googleapis.com/auth/monitoring";
+  //     ...
+  //   }
+  string oauth_scopes = 1050;
+}
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/field_behavior.proto b/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/field_behavior.proto
new file mode 100644
index 0000000..c4abe3b
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/field_behavior.proto
@@ -0,0 +1,90 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package google.api;
+
+import "google/protobuf/descriptor.proto";
+
+option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations";
+option java_multiple_files = true;
+option java_outer_classname = "FieldBehaviorProto";
+option java_package = "com.google.api";
+option objc_class_prefix = "GAPI";
+
+extend google.protobuf.FieldOptions {
+  // A designation of a specific field behavior (required, output only, etc.)
+  // in protobuf messages.
+  //
+  // Examples:
+  //
+  //   string name = 1 [(google.api.field_behavior) = REQUIRED];
+  //   State state = 1 [(google.api.field_behavior) = OUTPUT_ONLY];
+  //   google.protobuf.Duration ttl = 1
+  //     [(google.api.field_behavior) = INPUT_ONLY];
+  //   google.protobuf.Timestamp expire_time = 1
+  //     [(google.api.field_behavior) = OUTPUT_ONLY,
+  //      (google.api.field_behavior) = IMMUTABLE];
+  repeated google.api.FieldBehavior field_behavior = 1052;
+}
+
+// An indicator of the behavior of a given field (for example, that a field
+// is required in requests, or given as output but ignored as input).
+// This **does not** change the behavior in protocol buffers itself; it only
+// denotes the behavior and may affect how API tooling handles the field.
+//
+// Note: This enum **may** receive new values in the future.
+enum FieldBehavior {
+  // Conventional default for enums. Do not use this.
+  FIELD_BEHAVIOR_UNSPECIFIED = 0;
+
+  // Specifically denotes a field as optional.
+  // While all fields in protocol buffers are optional, this may be specified
+  // for emphasis if appropriate.
+  OPTIONAL = 1;
+
+  // Denotes a field as required.
+  // This indicates that the field **must** be provided as part of the request,
+  // and failure to do so will cause an error (usually `INVALID_ARGUMENT`).
+  REQUIRED = 2;
+
+  // Denotes a field as output only.
+  // This indicates that the field is provided in responses, but including the
+  // field in a request does nothing (the server *must* ignore it and
+  // *must not* throw an error as a result of the field's presence).
+  OUTPUT_ONLY = 3;
+
+  // Denotes a field as input only.
+  // This indicates that the field is provided in requests, and the
+  // corresponding field is not included in output.
+  INPUT_ONLY = 4;
+
+  // Denotes a field as immutable.
+  // This indicates that the field may be set once in a request to create a
+  // resource, but may not be changed thereafter.
+  IMMUTABLE = 5;
+
+  // Denotes that a (repeated) field is an unordered list.
+  // This indicates that the service may provide the elements of the list
+  // in any arbitrary  order, rather than the order the user originally
+  // provided. Additionally, the list's order may or may not be stable.
+  UNORDERED_LIST = 6;
+
+  // Denotes that this field returns a non-empty default value if not set.
+  // This indicates that if the user provides the empty value in a request,
+  // a non-empty value will be returned. The user will not be aware of what
+  // non-empty value to expect.
+  NON_EMPTY_DEFAULT = 7;
+}
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/http.proto b/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/http.proto
new file mode 100644
index 0000000..113fa93
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/http.proto
@@ -0,0 +1,375 @@
+// Copyright 2015 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package google.api;
+
+option cc_enable_arenas = true;
+option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations";
+option java_multiple_files = true;
+option java_outer_classname = "HttpProto";
+option java_package = "com.google.api";
+option objc_class_prefix = "GAPI";
+
+// Defines the HTTP configuration for an API service. It contains a list of
+// [HttpRule][google.api.HttpRule], each specifying the mapping of an RPC method
+// to one or more HTTP REST API methods.
+message Http {
+  // A list of HTTP configuration rules that apply to individual API methods.
+  //
+  // **NOTE:** All service configuration rules follow "last one wins" order.
+  repeated HttpRule rules = 1;
+
+  // When set to true, URL path parameters will be fully URI-decoded except in
+  // cases of single segment matches in reserved expansion, where "%2F" will be
+  // left encoded.
+  //
+  // The default behavior is to not decode RFC 6570 reserved characters in multi
+  // segment matches.
+  bool fully_decode_reserved_expansion = 2;
+}
+
+// # gRPC Transcoding
+//
+// gRPC Transcoding is a feature for mapping between a gRPC method and one or
+// more HTTP REST endpoints. It allows developers to build a single API service
+// that supports both gRPC APIs and REST APIs. Many systems, including [Google
+// APIs](https://github.com/googleapis/googleapis),
+// [Cloud Endpoints](https://cloud.google.com/endpoints), [gRPC
+// Gateway](https://github.com/grpc-ecosystem/grpc-gateway),
+// and [Envoy](https://github.com/envoyproxy/envoy) proxy support this feature
+// and use it for large scale production services.
+//
+// `HttpRule` defines the schema of the gRPC/REST mapping. The mapping specifies
+// how different portions of the gRPC request message are mapped to the URL
+// path, URL query parameters, and HTTP request body. It also controls how the
+// gRPC response message is mapped to the HTTP response body. `HttpRule` is
+// typically specified as an `google.api.http` annotation on the gRPC method.
+//
+// Each mapping specifies a URL path template and an HTTP method. The path
+// template may refer to one or more fields in the gRPC request message, as long
+// as each field is a non-repeated field with a primitive (non-message) type.
+// The path template controls how fields of the request message are mapped to
+// the URL path.
+//
+// Example:
+//
+//     service Messaging {
+//       rpc GetMessage(GetMessageRequest) returns (Message) {
+//         option (google.api.http) = {
+//             get: "/v1/{name=messages/*}"
+//         };
+//       }
+//     }
+//     message GetMessageRequest {
+//       string name = 1; // Mapped to URL path.
+//     }
+//     message Message {
+//       string text = 1; // The resource content.
+//     }
+//
+// This enables an HTTP REST to gRPC mapping as below:
+//
+// HTTP | gRPC
+// -----|-----
+// `GET /v1/messages/123456`  | `GetMessage(name: "messages/123456")`
+//
+// Any fields in the request message which are not bound by the path template
+// automatically become HTTP query parameters if there is no HTTP request body.
+// For example:
+//
+//     service Messaging {
+//       rpc GetMessage(GetMessageRequest) returns (Message) {
+//         option (google.api.http) = {
+//             get:"/v1/messages/{message_id}"
+//         };
+//       }
+//     }
+//     message GetMessageRequest {
+//       message SubMessage {
+//         string subfield = 1;
+//       }
+//       string message_id = 1; // Mapped to URL path.
+//       int64 revision = 2;    // Mapped to URL query parameter `revision`.
+//       SubMessage sub = 3;    // Mapped to URL query parameter `sub.subfield`.
+//     }
+//
+// This enables a HTTP JSON to RPC mapping as below:
+//
+// HTTP | gRPC
+// -----|-----
+// `GET /v1/messages/123456?revision=2&sub.subfield=foo` |
+// `GetMessage(message_id: "123456" revision: 2 sub: SubMessage(subfield:
+// "foo"))`
+//
+// Note that fields which are mapped to URL query parameters must have a
+// primitive type or a repeated primitive type or a non-repeated message type.
+// In the case of a repeated type, the parameter can be repeated in the URL
+// as `...?param=A&param=B`. In the case of a message type, each field of the
+// message is mapped to a separate parameter, such as
+// `...?foo.a=A&foo.b=B&foo.c=C`.
+//
+// For HTTP methods that allow a request body, the `body` field
+// specifies the mapping. Consider a REST update method on the
+// message resource collection:
+//
+//     service Messaging {
+//       rpc UpdateMessage(UpdateMessageRequest) returns (Message) {
+//         option (google.api.http) = {
+//           patch: "/v1/messages/{message_id}"
+//           body: "message"
+//         };
+//       }
+//     }
+//     message UpdateMessageRequest {
+//       string message_id = 1; // mapped to the URL
+//       Message message = 2;   // mapped to the body
+//     }
+//
+// The following HTTP JSON to RPC mapping is enabled, where the
+// representation of the JSON in the request body is determined by
+// protos JSON encoding:
+//
+// HTTP | gRPC
+// -----|-----
+// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id:
+// "123456" message { text: "Hi!" })`
+//
+// The special name `*` can be used in the body mapping to define that
+// every field not bound by the path template should be mapped to the
+// request body.  This enables the following alternative definition of
+// the update method:
+//
+//     service Messaging {
+//       rpc UpdateMessage(Message) returns (Message) {
+//         option (google.api.http) = {
+//           patch: "/v1/messages/{message_id}"
+//           body: "*"
+//         };
+//       }
+//     }
+//     message Message {
+//       string message_id = 1;
+//       string text = 2;
+//     }
+//
+//
+// The following HTTP JSON to RPC mapping is enabled:
+//
+// HTTP | gRPC
+// -----|-----
+// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id:
+// "123456" text: "Hi!")`
+//
+// Note that when using `*` in the body mapping, it is not possible to
+// have HTTP parameters, as all fields not bound by the path end in
+// the body. This makes this option more rarely used in practice when
+// defining REST APIs. The common usage of `*` is in custom methods
+// which don't use the URL at all for transferring data.
+//
+// It is possible to define multiple HTTP methods for one RPC by using
+// the `additional_bindings` option. Example:
+//
+//     service Messaging {
+//       rpc GetMessage(GetMessageRequest) returns (Message) {
+//         option (google.api.http) = {
+//           get: "/v1/messages/{message_id}"
+//           additional_bindings {
+//             get: "/v1/users/{user_id}/messages/{message_id}"
+//           }
+//         };
+//       }
+//     }
+//     message GetMessageRequest {
+//       string message_id = 1;
+//       string user_id = 2;
+//     }
+//
+// This enables the following two alternative HTTP JSON to RPC mappings:
+//
+// HTTP | gRPC
+// -----|-----
+// `GET /v1/messages/123456` | `GetMessage(message_id: "123456")`
+// `GET /v1/users/me/messages/123456` | `GetMessage(user_id: "me" message_id:
+// "123456")`
+//
+// ## Rules for HTTP mapping
+//
+// 1. Leaf request fields (recursive expansion nested messages in the request
+//    message) are classified into three categories:
+//    - Fields referred by the path template. They are passed via the URL path.
+//    - Fields referred by the [HttpRule.body][google.api.HttpRule.body]. They are passed via the HTTP
+//      request body.
+//    - All other fields are passed via the URL query parameters, and the
+//      parameter name is the field path in the request message. A repeated
+//      field can be represented as multiple query parameters under the same
+//      name.
+//  2. If [HttpRule.body][google.api.HttpRule.body] is "*", there is no URL query parameter, all fields
+//     are passed via URL path and HTTP request body.
+//  3. If [HttpRule.body][google.api.HttpRule.body] is omitted, there is no HTTP request body, all
+//     fields are passed via URL path and URL query parameters.
+//
+// ### Path template syntax
+//
+//     Template = "/" Segments [ Verb ] ;
+//     Segments = Segment { "/" Segment } ;
+//     Segment  = "*" | "**" | LITERAL | Variable ;
+//     Variable = "{" FieldPath [ "=" Segments ] "}" ;
+//     FieldPath = IDENT { "." IDENT } ;
+//     Verb     = ":" LITERAL ;
+//
+// The syntax `*` matches a single URL path segment. The syntax `**` matches
+// zero or more URL path segments, which must be the last part of the URL path
+// except the `Verb`.
+//
+// The syntax `Variable` matches part of the URL path as specified by its
+// template. A variable template must not contain other variables. If a variable
+// matches a single path segment, its template may be omitted, e.g. `{var}`
+// is equivalent to `{var=*}`.
+//
+// The syntax `LITERAL` matches literal text in the URL path. If the `LITERAL`
+// contains any reserved character, such characters should be percent-encoded
+// before the matching.
+//
+// If a variable contains exactly one path segment, such as `"{var}"` or
+// `"{var=*}"`, when such a variable is expanded into a URL path on the client
+// side, all characters except `[-_.~0-9a-zA-Z]` are percent-encoded. The
+// server side does the reverse decoding. Such variables show up in the
+// [Discovery
+// Document](https://developers.google.com/discovery/v1/reference/apis) as
+// `{var}`.
+//
+// If a variable contains multiple path segments, such as `"{var=foo/*}"`
+// or `"{var=**}"`, when such a variable is expanded into a URL path on the
+// client side, all characters except `[-_.~/0-9a-zA-Z]` are percent-encoded.
+// The server side does the reverse decoding, except "%2F" and "%2f" are left
+// unchanged. Such variables show up in the
+// [Discovery
+// Document](https://developers.google.com/discovery/v1/reference/apis) as
+// `{+var}`.
+//
+// ## Using gRPC API Service Configuration
+//
+// gRPC API Service Configuration (service config) is a configuration language
+// for configuring a gRPC service to become a user-facing product. The
+// service config is simply the YAML representation of the `google.api.Service`
+// proto message.
+//
+// As an alternative to annotating your proto file, you can configure gRPC
+// transcoding in your service config YAML files. You do this by specifying a
+// `HttpRule` that maps the gRPC method to a REST endpoint, achieving the same
+// effect as the proto annotation. This can be particularly useful if you
+// have a proto that is reused in multiple services. Note that any transcoding
+// specified in the service config will override any matching transcoding
+// configuration in the proto.
+//
+// Example:
+//
+//     http:
+//       rules:
+//         # Selects a gRPC method and applies HttpRule to it.
+//         - selector: example.v1.Messaging.GetMessage
+//           get: /v1/messages/{message_id}/{sub.subfield}
+//
+// ## Special notes
+//
+// When gRPC Transcoding is used to map a gRPC to JSON REST endpoints, the
+// proto to JSON conversion must follow the [proto3
+// specification](https://developers.google.com/protocol-buffers/docs/proto3#json).
+//
+// While the single segment variable follows the semantics of
+// [RFC 6570](https://tools.ietf.org/html/rfc6570) Section 3.2.2 Simple String
+// Expansion, the multi segment variable **does not** follow RFC 6570 Section
+// 3.2.3 Reserved Expansion. The reason is that the Reserved Expansion
+// does not expand special characters like `?` and `#`, which would lead
+// to invalid URLs. As the result, gRPC Transcoding uses a custom encoding
+// for multi segment variables.
+//
+// The path variables **must not** refer to any repeated or mapped field,
+// because client libraries are not capable of handling such variable expansion.
+//
+// The path variables **must not** capture the leading "/" character. The reason
+// is that the most common use case "{var}" does not capture the leading "/"
+// character. For consistency, all path variables must share the same behavior.
+//
+// Repeated message fields must not be mapped to URL query parameters, because
+// no client library can support such complicated mapping.
+//
+// If an API needs to use a JSON array for request or response body, it can map
+// the request or response body to a repeated field. However, some gRPC
+// Transcoding implementations may not support this feature.
+message HttpRule {
+  // Selects a method to which this rule applies.
+  //
+  // Refer to [selector][google.api.DocumentationRule.selector] for syntax details.
+  string selector = 1;
+
+  // Determines the URL pattern is matched by this rules. This pattern can be
+  // used with any of the {get|put|post|delete|patch} methods. A custom method
+  // can be defined using the 'custom' field.
+  oneof pattern {
+    // Maps to HTTP GET. Used for listing and getting information about
+    // resources.
+    string get = 2;
+
+    // Maps to HTTP PUT. Used for replacing a resource.
+    string put = 3;
+
+    // Maps to HTTP POST. Used for creating a resource or performing an action.
+    string post = 4;
+
+    // Maps to HTTP DELETE. Used for deleting a resource.
+    string delete = 5;
+
+    // Maps to HTTP PATCH. Used for updating a resource.
+    string patch = 6;
+
+    // The custom pattern is used for specifying an HTTP method that is not
+    // included in the `pattern` field, such as HEAD, or "*" to leave the
+    // HTTP method unspecified for this rule. The wild-card rule is useful
+    // for services that provide content to Web (HTML) clients.
+    CustomHttpPattern custom = 8;
+  }
+
+  // The name of the request field whose value is mapped to the HTTP request
+  // body, or `*` for mapping all request fields not captured by the path
+  // pattern to the HTTP body, or omitted for not having any HTTP request body.
+  //
+  // NOTE: the referred field must be present at the top-level of the request
+  // message type.
+  string body = 7;
+
+  // Optional. The name of the response field whose value is mapped to the HTTP
+  // response body. When omitted, the entire response message will be used
+  // as the HTTP response body.
+  //
+  // NOTE: The referred field must be present at the top-level of the response
+  // message type.
+  string response_body = 12;
+
+  // Additional HTTP bindings for the selector. Nested bindings must
+  // not contain an `additional_bindings` field themselves (that is,
+  // the nesting may only be one level deep).
+  repeated HttpRule additional_bindings = 11;
+}
+
+// A custom pattern is used for defining custom HTTP verb.
+message CustomHttpPattern {
+  // The name of this custom HTTP verb.
+  string kind = 1;
+
+  // The path matched by this custom verb.
+  string path = 2;
+}
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/httpbody.proto b/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/httpbody.proto
new file mode 100644
index 0000000..00c80ab
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/httpbody.proto
@@ -0,0 +1,81 @@
+// Copyright 2015 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package google.api;
+
+import "google/protobuf/any.proto";
+
+option cc_enable_arenas = true;
+option go_package = "google.golang.org/genproto/googleapis/api/httpbody;httpbody";
+option java_multiple_files = true;
+option java_outer_classname = "HttpBodyProto";
+option java_package = "com.google.api";
+option objc_class_prefix = "GAPI";
+
+// Message that represents an arbitrary HTTP body. It should only be used for
+// payload formats that can't be represented as JSON, such as raw binary or
+// an HTML page.
+//
+//
+// This message can be used both in streaming and non-streaming API methods in
+// the request as well as the response.
+//
+// It can be used as a top-level request field, which is convenient if one
+// wants to extract parameters from either the URL or HTTP template into the
+// request fields and also want access to the raw HTTP body.
+//
+// Example:
+//
+//     message GetResourceRequest {
+//       // A unique request id.
+//       string request_id = 1;
+//
+//       // The raw HTTP body is bound to this field.
+//       google.api.HttpBody http_body = 2;
+//
+//     }
+//
+//     service ResourceService {
+//       rpc GetResource(GetResourceRequest)
+//         returns (google.api.HttpBody);
+//       rpc UpdateResource(google.api.HttpBody)
+//         returns (google.protobuf.Empty);
+//
+//     }
+//
+// Example with streaming methods:
+//
+//     service CaldavService {
+//       rpc GetCalendar(stream google.api.HttpBody)
+//         returns (stream google.api.HttpBody);
+//       rpc UpdateCalendar(stream google.api.HttpBody)
+//         returns (stream google.api.HttpBody);
+//
+//     }
+//
+// Use of this type only changes how the request and response bodies are
+// handled, all other features will continue to work unchanged.
+message HttpBody {
+  // The HTTP Content-Type header value specifying the content type of the body.
+  string content_type = 1;
+
+  // The HTTP request/response body as raw binary.
+  bytes data = 2;
+
+  // Application specific response metadata. Must be set in the first response
+  // for streaming APIs.
+  repeated google.protobuf.Any extensions = 3;
+}
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/resource.proto b/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/resource.proto
new file mode 100644
index 0000000..0ce0344
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/api/resource.proto
@@ -0,0 +1,238 @@
+// Copyright 2018 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package google.api;
+
+import "google/protobuf/descriptor.proto";
+
+option cc_enable_arenas = true;
+option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations";
+option java_multiple_files = true;
+option java_outer_classname = "ResourceProto";
+option java_package = "com.google.api";
+option objc_class_prefix = "GAPI";
+
+extend google.protobuf.FieldOptions {
+  // An annotation that describes a resource reference, see
+  // [ResourceReference][].
+  google.api.ResourceReference resource_reference = 1055;
+}
+
+extend google.protobuf.FileOptions {
+  // An annotation that describes a resource definition without a corresponding
+  // message; see [ResourceDescriptor][].
+  repeated google.api.ResourceDescriptor resource_definition = 1053;
+}
+
+extend google.protobuf.MessageOptions {
+  // An annotation that describes a resource definition, see
+  // [ResourceDescriptor][].
+  google.api.ResourceDescriptor resource = 1053;
+}
+
+// A simple descriptor of a resource type.
+//
+// ResourceDescriptor annotates a resource message (either by means of a
+// protobuf annotation or use in the service config), and associates the
+// resource's schema, the resource type, and the pattern of the resource name.
+//
+// Example:
+//
+//     message Topic {
+//       // Indicates this message defines a resource schema.
+//       // Declares the resource type in the format of {service}/{kind}.
+//       // For Kubernetes resources, the format is {api group}/{kind}.
+//       option (google.api.resource) = {
+//         type: "pubsub.googleapis.com/Topic"
+//         pattern: "projects/{project}/topics/{topic}"
+//       };
+//     }
+//
+// The ResourceDescriptor Yaml config will look like:
+//
+//     resources:
+//     - type: "pubsub.googleapis.com/Topic"
+//       pattern: "projects/{project}/topics/{topic}"
+//
+// Sometimes, resources have multiple patterns, typically because they can
+// live under multiple parents.
+//
+// Example:
+//
+//     message LogEntry {
+//       option (google.api.resource) = {
+//         type: "logging.googleapis.com/LogEntry"
+//         pattern: "projects/{project}/logs/{log}"
+//         pattern: "folders/{folder}/logs/{log}"
+//         pattern: "organizations/{organization}/logs/{log}"
+//         pattern: "billingAccounts/{billing_account}/logs/{log}"
+//       };
+//     }
+//
+// The ResourceDescriptor Yaml config will look like:
+//
+//     resources:
+//     - type: 'logging.googleapis.com/LogEntry'
+//       pattern: "projects/{project}/logs/{log}"
+//       pattern: "folders/{folder}/logs/{log}"
+//       pattern: "organizations/{organization}/logs/{log}"
+//       pattern: "billingAccounts/{billing_account}/logs/{log}"
+message ResourceDescriptor {
+  // A description of the historical or future-looking state of the
+  // resource pattern.
+  enum History {
+    // The "unset" value.
+    HISTORY_UNSPECIFIED = 0;
+
+    // The resource originally had one pattern and launched as such, and
+    // additional patterns were added later.
+    ORIGINALLY_SINGLE_PATTERN = 1;
+
+    // The resource has one pattern, but the API owner expects to add more
+    // later. (This is the inverse of ORIGINALLY_SINGLE_PATTERN, and prevents
+    // that from being necessary once there are multiple patterns.)
+    FUTURE_MULTI_PATTERN = 2;
+  }
+
+  // A flag representing a specific style that a resource claims to conform to.
+  enum Style {
+    // The unspecified value. Do not use.
+    STYLE_UNSPECIFIED = 0;
+
+    // This resource is intended to be "declarative-friendly".
+    //
+    // Declarative-friendly resources must be more strictly consistent, and
+    // setting this to true communicates to tools that this resource should
+    // adhere to declarative-friendly expectations.
+    //
+    // Note: This is used by the API linter (linter.aip.dev) to enable
+    // additional checks.
+    DECLARATIVE_FRIENDLY = 1;
+  }
+
+  // The resource type. It must be in the format of
+  // {service_name}/{resource_type_kind}. The `resource_type_kind` must be
+  // singular and must not include version numbers.
+  //
+  // Example: `storage.googleapis.com/Bucket`
+  //
+  // The value of the resource_type_kind must follow the regular expression
+  // /[A-Za-z][a-zA-Z0-9]+/. It should start with an upper case character and
+  // should use PascalCase (UpperCamelCase). The maximum number of
+  // characters allowed for the `resource_type_kind` is 100.
+  string type = 1;
+
+  // Optional. The relative resource name pattern associated with this resource
+  // type. The DNS prefix of the full resource name shouldn't be specified here.
+  //
+  // The path pattern must follow the syntax, which aligns with HTTP binding
+  // syntax:
+  //
+  //     Template = Segment { "/" Segment } ;
+  //     Segment = LITERAL | Variable ;
+  //     Variable = "{" LITERAL "}" ;
+  //
+  // Examples:
+  //
+  //     - "projects/{project}/topics/{topic}"
+  //     - "projects/{project}/knowledgeBases/{knowledge_base}"
+  //
+  // The components in braces correspond to the IDs for each resource in the
+  // hierarchy. It is expected that, if multiple patterns are provided,
+  // the same component name (e.g. "project") refers to IDs of the same
+  // type of resource.
+  repeated string pattern = 2;
+
+  // Optional. The field on the resource that designates the resource name
+  // field. If omitted, this is assumed to be "name".
+  string name_field = 3;
+
+  // Optional. The historical or future-looking state of the resource pattern.
+  //
+  // Example:
+  //
+  //     // The InspectTemplate message originally only supported resource
+  //     // names with organization, and project was added later.
+  //     message InspectTemplate {
+  //       option (google.api.resource) = {
+  //         type: "dlp.googleapis.com/InspectTemplate"
+  //         pattern:
+  //         "organizations/{organization}/inspectTemplates/{inspect_template}"
+  //         pattern: "projects/{project}/inspectTemplates/{inspect_template}"
+  //         history: ORIGINALLY_SINGLE_PATTERN
+  //       };
+  //     }
+  History history = 4;
+
+  // The plural name used in the resource name and permission names, such as
+  // 'projects' for the resource name of 'projects/{project}' and the permission
+  // name of 'cloudresourcemanager.googleapis.com/projects.get'. It is the same
+  // concept of the `plural` field in k8s CRD spec
+  // https://kubernetes.io/docs/tasks/access-kubernetes-api/custom-resources/custom-resource-definitions/
+  //
+  // Note: The plural form is required even for singleton resources. See
+  // https://aip.dev/156
+  string plural = 5;
+
+  // The same concept of the `singular` field in k8s CRD spec
+  // https://kubernetes.io/docs/tasks/access-kubernetes-api/custom-resources/custom-resource-definitions/
+  // Such as "project" for the `resourcemanager.googleapis.com/Project` type.
+  string singular = 6;
+
+  // Style flag(s) for this resource.
+  // These indicate that a resource is expected to conform to a given
+  // style. See the specific style flags for additional information.
+  repeated Style style = 10;
+}
+
+// Defines a proto annotation that describes a string field that refers to
+// an API resource.
+message ResourceReference {
+  // The resource type that the annotated field references.
+  //
+  // Example:
+  //
+  //     message Subscription {
+  //       string topic = 2 [(google.api.resource_reference) = {
+  //         type: "pubsub.googleapis.com/Topic"
+  //       }];
+  //     }
+  //
+  // Occasionally, a field may reference an arbitrary resource. In this case,
+  // APIs use the special value * in their resource reference.
+  //
+  // Example:
+  //
+  //     message GetIamPolicyRequest {
+  //       string resource = 2 [(google.api.resource_reference) = {
+  //         type: "*"
+  //       }];
+  //     }
+  string type = 1;
+
+  // The resource type of a child collection that the annotated field
+  // references. This is useful for annotating the `parent` field that
+  // doesn't have a fixed resource type.
+  //
+  // Example:
+  //
+  //     message ListLogEntriesRequest {
+  //       string parent = 1 [(google.api.resource_reference) = {
+  //         child_type: "logging.googleapis.com/LogEntry"
+  //       };
+  //     }
+  string child_type = 2;
+}
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/BUILD.bazel b/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/BUILD.bazel
new file mode 100644
index 0000000..f42ceec
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/BUILD.bazel
@@ -0,0 +1,405 @@
+# This file was automatically generated by BuildFileGenerator
+# https://github.com/googleapis/rules_gapic/tree/master/bazel
+
+# Most of the manual changes to this file will be overwritten.
+# It's **only** allowed to change the following rule attribute values:
+# - names of *_gapic_assembly_* rules
+# - certain parameters of *_gapic_library rules, including but not limited to:
+#    * extra_protoc_parameters
+#    * extra_protoc_file_parameters
+# The complete list of preserved parameters can be found in the source code.
+
+# This is an API workspace, having public visibility by default makes perfect sense.
+package(default_visibility = ["//visibility:public"])
+
+##############################################################################
+# Common
+##############################################################################
+load("@rules_proto//proto:defs.bzl", "proto_library")
+load("@com_google_googleapis_imports//:imports.bzl", "proto_library_with_info")
+
+proto_library(
+    name = "apigeeregistry_proto",
+    srcs = [
+        "provisioning_service.proto",
+        "registry_models.proto",
+        "registry_service.proto",
+    ],
+    deps = [
+        "//google/api:annotations_proto",
+        "//google/api:client_proto",
+        "//google/api:field_behavior_proto",
+        "//google/api:httpbody_proto",
+        "//google/api:resource_proto",
+        "//google/longrunning:operations_proto",
+        "@com_google_protobuf//:empty_proto",
+        "@com_google_protobuf//:field_mask_proto",
+        "@com_google_protobuf//:timestamp_proto",
+    ],
+)
+
+proto_library_with_info(
+    name = "apigeeregistry_proto_with_info",
+    deps = [
+        ":apigeeregistry_proto",
+        "//google/cloud:common_resources_proto",
+        "//google/cloud/location:location_proto",
+        "//google/iam/v1:iam_policy_proto",
+    ],
+)
+
+##############################################################################
+# Java
+##############################################################################
+load(
+    "@com_google_googleapis_imports//:imports.bzl",
+    "java_gapic_assembly_gradle_pkg",
+    "java_gapic_library",
+    "java_gapic_test",
+    "java_grpc_library",
+    "java_proto_library",
+)
+
+java_proto_library(
+    name = "apigeeregistry_java_proto",
+    deps = [":apigeeregistry_proto"],
+)
+
+java_grpc_library(
+    name = "apigeeregistry_java_grpc",
+    srcs = [":apigeeregistry_proto"],
+    deps = [":apigeeregistry_java_proto"],
+)
+
+java_gapic_library(
+    name = "apigeeregistry_java_gapic",
+    srcs = [":apigeeregistry_proto_with_info"],
+    gapic_yaml = None,
+    grpc_service_config = "apigeeregistry_grpc_service_config.json",
+    service_yaml = "apigeeregistry_v1.yaml",
+    test_deps = [
+        ":apigeeregistry_java_grpc",
+        "//google/cloud/location:location_java_grpc",
+        "//google/iam/v1:iam_java_grpc",
+    ],
+    transport = "grpc+rest",
+    deps = [
+        ":apigeeregistry_java_proto",
+        "//google/api:api_java_proto",
+        "//google/cloud/location:location_java_proto",
+        "//google/iam/v1:iam_java_proto",
+    ],
+)
+
+java_gapic_test(
+    name = "apigeeregistry_java_gapic_test_suite",
+    test_classes = [
+        "com.google.cloud.apigeeregistry.v1.ProvisioningClientHttpJsonTest",
+        "com.google.cloud.apigeeregistry.v1.ProvisioningClientTest",
+        "com.google.cloud.apigeeregistry.v1.RegistryClientHttpJsonTest",
+        "com.google.cloud.apigeeregistry.v1.RegistryClientTest",
+    ],
+    runtime_deps = [":apigeeregistry_java_gapic_test"],
+)
+
+# Open Source Packages
+java_gapic_assembly_gradle_pkg(
+    name = "google-cloud-apigeeregistry-v1-java",
+    include_samples = True,
+    transport = "grpc+rest",
+    deps = [
+        ":apigeeregistry_java_gapic",
+        ":apigeeregistry_java_grpc",
+        ":apigeeregistry_java_proto",
+        ":apigeeregistry_proto",
+    ],
+)
+
+##############################################################################
+# Go
+##############################################################################
+load(
+    "@com_google_googleapis_imports//:imports.bzl",
+    "go_gapic_assembly_pkg",
+    "go_gapic_library",
+    "go_proto_library",
+    "go_test",
+)
+
+go_proto_library(
+    name = "apigeeregistry_go_proto",
+    compilers = ["@io_bazel_rules_go//proto:go_grpc"],
+    importpath = "google.golang.org/genproto/googleapis/cloud/apigeeregistry/v1",
+    protos = [":apigeeregistry_proto"],
+    deps = [
+        "//google/api:annotations_go_proto",
+        "//google/api:httpbody_go_proto",
+        "//google/longrunning:longrunning_go_proto",
+    ],
+)
+
+go_gapic_library(
+    name = "apigeeregistry_go_gapic",
+    srcs = [":apigeeregistry_proto_with_info"],
+    grpc_service_config = "apigeeregistry_grpc_service_config.json",
+    importpath = "cloud.google.com/go/apigeeregistry/apiv1;apigeeregistry",
+    metadata = True,
+    service_yaml = "apigeeregistry_v1.yaml",
+    deps = [
+        ":apigeeregistry_go_proto",
+        "//google/api:httpbody_go_proto",
+        "//google/cloud/location:location_go_proto",
+        "//google/iam/v1:iam_go_proto",
+        "//google/longrunning:longrunning_go_proto",
+        "@com_google_cloud_go//longrunning:go_default_library",
+        "@com_google_cloud_go//longrunning/autogen:go_default_library",
+    ],
+)
+
+go_test(
+    name = "apigeeregistry_go_gapic_test",
+    srcs = [":apigeeregistry_go_gapic_srcjar_test"],
+    embed = [":apigeeregistry_go_gapic"],
+    importpath = "cloud.google.com/go/apigeeregistry/apiv1",
+)
+
+# Open Source Packages
+go_gapic_assembly_pkg(
+    name = "gapi-cloud-apigeeregistry-v1-go",
+    deps = [
+        ":apigeeregistry_go_gapic",
+        ":apigeeregistry_go_gapic_srcjar-metadata.srcjar",
+        ":apigeeregistry_go_gapic_srcjar-test.srcjar",
+        ":apigeeregistry_go_proto",
+    ],
+)
+
+##############################################################################
+# Python
+##############################################################################
+load(
+    "@com_google_googleapis_imports//:imports.bzl",
+    "py_gapic_assembly_pkg",
+    "py_gapic_library",
+    "py_test",
+)
+
+py_gapic_library(
+    name = "apigeeregistry_py_gapic",
+    srcs = [":apigeeregistry_proto"],
+    grpc_service_config = "apigeeregistry_grpc_service_config.json",
+    opt_args = [
+        "warehouse-package-name=google-cloud-apigee-registry",
+        "python-gapic-namespace=google.cloud",
+        "python-gapic-name=apigee_registry",
+    ],
+    service_yaml = "apigeeregistry_v1.yaml",
+)
+
+py_test(
+    name = "apigeeregistry_py_gapic_test",
+    srcs = [
+        "apigeeregistry_py_gapic_pytest.py",
+        "apigeeregistry_py_gapic_test.py",
+    ],
+    legacy_create_init = False,
+    deps = [
+        ":apigeeregistry_py_gapic",
+        "//google/iam/v1:iam_policy_py_proto",
+    ],
+)
+
+# Open Source Packages
+py_gapic_assembly_pkg(
+    name = "apigeeregistry-v1-py",
+    deps = [
+        ":apigeeregistry_py_gapic",
+    ],
+)
+
+##############################################################################
+# PHP
+##############################################################################
+load(
+    "@com_google_googleapis_imports//:imports.bzl",
+    "php_gapic_assembly_pkg",
+    "php_gapic_library",
+    "php_grpc_library",
+    "php_proto_library",
+)
+
+php_proto_library(
+    name = "apigeeregistry_php_proto",
+    deps = [":apigeeregistry_proto"],
+)
+
+php_grpc_library(
+    name = "apigeeregistry_php_grpc",
+    srcs = [":apigeeregistry_proto"],
+    deps = [":apigeeregistry_php_proto"],
+)
+
+php_gapic_library(
+    name = "apigeeregistry_php_gapic",
+    srcs = [":apigeeregistry_proto_with_info"],
+    grpc_service_config = "apigeeregistry_grpc_service_config.json",
+    service_yaml = "apigeeregistry_v1.yaml",
+    deps = [
+        ":apigeeregistry_php_grpc",
+        ":apigeeregistry_php_proto",
+    ],
+)
+
+# Open Source Packages
+php_gapic_assembly_pkg(
+    name = "google-cloud-apigeeregistry-v1-php",
+    deps = [
+        ":apigeeregistry_php_gapic",
+        ":apigeeregistry_php_grpc",
+        ":apigeeregistry_php_proto",
+    ],
+)
+
+##############################################################################
+# Node.js
+##############################################################################
+load(
+    "@com_google_googleapis_imports//:imports.bzl",
+    "nodejs_gapic_assembly_pkg",
+    "nodejs_gapic_library",
+)
+
+nodejs_gapic_library(
+    name = "apigeeregistry_nodejs_gapic",
+    package_name = "@google-cloud/apigee-registry",
+    src = ":apigeeregistry_proto_with_info",
+    extra_protoc_parameters = ["metadata"],
+    grpc_service_config = "apigeeregistry_grpc_service_config.json",
+    package = "google.cloud.apigeeregistry.v1",
+    service_yaml = "apigeeregistry_v1.yaml",
+    deps = [],
+)
+
+nodejs_gapic_assembly_pkg(
+    name = "apigeeregistry-v1-nodejs",
+    deps = [
+        ":apigeeregistry_nodejs_gapic",
+        ":apigeeregistry_proto",
+    ],
+)
+
+##############################################################################
+# Ruby
+##############################################################################
+load(
+    "@com_google_googleapis_imports//:imports.bzl",
+    "ruby_cloud_gapic_library",
+    "ruby_gapic_assembly_pkg",
+    "ruby_grpc_library",
+    "ruby_proto_library",
+)
+
+ruby_proto_library(
+    name = "apigeeregistry_ruby_proto",
+    deps = [":apigeeregistry_proto"],
+)
+
+ruby_grpc_library(
+    name = "apigeeregistry_ruby_grpc",
+    srcs = [":apigeeregistry_proto"],
+    deps = [":apigeeregistry_ruby_proto"],
+)
+
+ruby_cloud_gapic_library(
+    name = "apigeeregistry_ruby_gapic",
+    srcs = [":apigeeregistry_proto_with_info"],
+    extra_protoc_parameters = [
+        "ruby-cloud-api-id=apigeeregistry.googleapis.com",
+        "ruby-cloud-api-shortname=apigeeregistry",
+        "ruby-cloud-gem-name=google-cloud-apigee_registry-v1",
+        "ruby-cloud-product-url=https://cloud.google.com/apigee/docs/api-hub/get-started-registry-api/",
+    ],
+    grpc_service_config = "apigeeregistry_grpc_service_config.json",
+    ruby_cloud_description = "The Apigee Registry API allows teams to upload and share machine-readable descriptions of APIs that are in use and in development. These descriptions include API specifications in standard formats like OpenAPI, the Google API Discovery Service Format, and the Protocol Buffers Language. These API specifications can be used by tools like linters, browsers, documentation generators, test runners, proxies, and API client and server generators. The Registry API itself can be seen as a machine-readable enterprise API catalog designed to back online directories, portals, and workflow managers.",
+    ruby_cloud_title = "Apigee Registry V1",
+    service_yaml = "apigeeregistry_v1.yaml",
+    deps = [
+        ":apigeeregistry_ruby_grpc",
+        ":apigeeregistry_ruby_proto",
+    ],
+)
+
+# Open Source Packages
+ruby_gapic_assembly_pkg(
+    name = "google-cloud-apigeeregistry-v1-ruby",
+    deps = [
+        ":apigeeregistry_ruby_gapic",
+        ":apigeeregistry_ruby_grpc",
+        ":apigeeregistry_ruby_proto",
+    ],
+)
+
+##############################################################################
+# C#
+##############################################################################
+load(
+    "@com_google_googleapis_imports//:imports.bzl",
+    "csharp_gapic_assembly_pkg",
+    "csharp_gapic_library",
+    "csharp_grpc_library",
+    "csharp_proto_library",
+)
+
+csharp_proto_library(
+    name = "apigeeregistry_csharp_proto",
+    deps = [":apigeeregistry_proto"],
+)
+
+csharp_grpc_library(
+    name = "apigeeregistry_csharp_grpc",
+    srcs = [":apigeeregistry_proto"],
+    deps = [":apigeeregistry_csharp_proto"],
+)
+
+csharp_gapic_library(
+    name = "apigeeregistry_csharp_gapic",
+    srcs = [":apigeeregistry_proto_with_info"],
+    common_resources_config = "@gax_dotnet//:Google.Api.Gax/ResourceNames/CommonResourcesConfig.json",
+    grpc_service_config = "apigeeregistry_grpc_service_config.json",
+    service_yaml = "apigeeregistry_v1.yaml",
+    deps = [
+        ":apigeeregistry_csharp_grpc",
+        ":apigeeregistry_csharp_proto",
+    ],
+)
+
+# Open Source Packages
+csharp_gapic_assembly_pkg(
+    name = "google-cloud-apigeeregistry-v1-csharp",
+    deps = [
+        ":apigeeregistry_csharp_gapic",
+        ":apigeeregistry_csharp_grpc",
+        ":apigeeregistry_csharp_proto",
+    ],
+)
+
+##############################################################################
+# C++
+##############################################################################
+load(
+    "@com_google_googleapis_imports//:imports.bzl",
+    "cc_grpc_library",
+    "cc_proto_library",
+)
+
+cc_proto_library(
+    name = "apigeeregistry_cc_proto",
+    deps = [":apigeeregistry_proto"],
+)
+
+cc_grpc_library(
+    name = "apigeeregistry_cc_grpc",
+    srcs = [":apigeeregistry_proto"],
+    grpc_only = True,
+    deps = [":apigeeregistry_cc_proto"],
+)
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/apigeeregistry_grpc_service_config.json b/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/apigeeregistry_grpc_service_config.json
new file mode 100644
index 0000000..ed5d75f
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/apigeeregistry_grpc_service_config.json
@@ -0,0 +1,168 @@
+{
+  "methodConfig": [
+    {
+      "name": [
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "ListApis"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "GetApi"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "CreateApi"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "UpdateApi"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "DeleteApi"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "ListApiVersions"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "GetApiVersion"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "CreateApiVersion"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "UpdateApiVersion"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "DeleteApiVersion"
+        },
+
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "ListApiSpecs"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "GetApiSpec"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "GetApiSpecContents"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "CreateApiSpec"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "UpdateApiSpec"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "DeleteApiSpec"
+        },
+
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "TagApiSpecRevision"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "ListApiSpecRevisions"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "DeleteApiSpecRevision"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "ListApiDeployments"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "GetApiDeployment"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "CreateApiDeployment"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "UpdateApiDeployment"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "DeleteApiDeployment"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "TagApiDeploymentRevision"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "ListApiDeploymentRevisions"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "DeleteApiDeploymentRevision"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "ListArtifacts"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "GetArtifact"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "GetArtifactContents"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "CreateArtifact"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "ReplaceArtifact"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "DeleteArtifact"
+        }
+      ],
+      "timeout": "60s",
+      "retryPolicy": {
+        "maxAttempts": 5,
+        "initialBackoff": "0.200s",
+        "maxBackoff": "60s",
+        "backoffMultiplier": 1.3,
+        "retryableStatusCodes": [
+          "ABORTED",
+          "CANCELLED",
+          "DEADLINE_EXCEEDED",
+          "UNAVAILABLE"
+        ]
+      }
+    },
+    {
+      "name": [
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "RollbackApiSpec"
+        },
+        {
+          "service": "google.cloud.apigeeregistry.v1.Registry",
+          "method": "RollbackApiDeployment"
+        }
+      ],
+      "timeout": "60s"
+    }
+  ]
+}
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/apigeeregistry_v1.yaml b/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/apigeeregistry_v1.yaml
new file mode 100644
index 0000000..e4fd7c7
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/apigeeregistry_v1.yaml
@@ -0,0 +1,159 @@
+type: google.api.Service
+config_version: 3
+name: apigeeregistry.googleapis.com
+title: Apigee Registry API
+
+apis:
+- name: google.cloud.apigeeregistry.v1.Provisioning
+- name: google.cloud.apigeeregistry.v1.Registry
+- name: google.cloud.location.Locations
+- name: google.iam.v1.IAMPolicy
+- name: google.longrunning.Operations
+
+types:
+- name: google.cloud.apigeeregistry.v1.OperationMetadata
+
+documentation:
+  rules:
+  - selector: google.cloud.location.Locations.GetLocation
+    description: Gets information about a location.
+
+  - selector: google.cloud.location.Locations.ListLocations
+    description: Lists information about the supported locations for this service.
+
+  - selector: google.iam.v1.IAMPolicy.GetIamPolicy
+    description: |-
+      Gets the access control policy for a resource. Returns an empty policy
+      if the resource exists and does not have a policy set.
+
+  - selector: google.iam.v1.IAMPolicy.SetIamPolicy
+    description: |-
+      Sets the access control policy on the specified resource. Replaces
+      any existing policy.
+
+      Can return `NOT_FOUND`, `INVALID_ARGUMENT`, and `PERMISSION_DENIED`
+      errors.
+
+  - selector: google.iam.v1.IAMPolicy.TestIamPermissions
+    description: |-
+      Returns permissions that a caller has on the specified resource. If the
+      resource does not exist, this will return an empty set of
+      permissions, not a `NOT_FOUND` error.
+
+      Note: This operation is designed to be used for building
+      permission-aware UIs and command-line tools, not for authorization
+      checking. This operation may "fail open" without warning.
+
+backend:
+  rules:
+  - selector: 'google.cloud.apigeeregistry.v1.Provisioning.*'
+    deadline: 60.0
+  - selector: 'google.cloud.apigeeregistry.v1.Registry.*'
+    deadline: 60.0
+  - selector: google.cloud.location.Locations.GetLocation
+    deadline: 60.0
+  - selector: google.cloud.location.Locations.ListLocations
+    deadline: 60.0
+  - selector: 'google.iam.v1.IAMPolicy.*'
+    deadline: 60.0
+  - selector: 'google.longrunning.Operations.*'
+    deadline: 60.0
+
+http:
+  rules:
+  - selector: google.cloud.location.Locations.GetLocation
+    get: '/v1/{name=projects/*/locations/*}'
+  - selector: google.cloud.location.Locations.ListLocations
+    get: '/v1/{name=projects/*}/locations'
+  - selector: google.iam.v1.IAMPolicy.GetIamPolicy
+    get: '/v1/{resource=projects/*/locations/*/apis/*}:getIamPolicy'
+    additional_bindings:
+    - get: '/v1/{resource=projects/*/locations/*/apis/*/deployments/*}:getIamPolicy'
+    - get: '/v1/{resource=projects/*/locations/*/apis/*/versions/*}:getIamPolicy'
+    - get: '/v1/{resource=projects/*/locations/*/apis/*/versions/*/specs/*}:getIamPolicy'
+    - get: '/v1/{resource=projects/*/locations/*/artifacts/*}:getIamPolicy'
+    - get: '/v1/{resource=projects/*/locations/*/apis/*/artifacts/*}:getIamPolicy'
+    - get: '/v1/{resource=projects/*/locations/*/apis/*/versions/*/artifacts/*}:getIamPolicy'
+    - get: '/v1/{resource=projects/*/locations/*/apis/*/versions/*/specs/*/artifacts/*}:getIamPolicy'
+    - get: '/v1/{resource=projects/*/locations/*/instances/*}:getIamPolicy'
+    - get: '/v1/{resource=projects/*/locations/*/runtime}:getIamPolicy'
+  - selector: google.iam.v1.IAMPolicy.SetIamPolicy
+    post: '/v1/{resource=projects/*/locations/*/apis/*}:setIamPolicy'
+    body: '*'
+    additional_bindings:
+    - post: '/v1/{resource=projects/*/locations/*/apis/*/deployments/*}:setIamPolicy'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/apis/*/versions/*}:setIamPolicy'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/apis/*/versions/*/specs/*}:setIamPolicy'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/artifacts/*}:setIamPolicy'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/apis/*/artifacts/*}:setIamPolicy'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/apis/*/versions/*/artifacts/*}:setIamPolicy'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/apis/*/versions/*/specs/*/artifacts/*}:setIamPolicy'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/instances/*}:setIamPolicy'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/runtime}:setIamPolicy'
+      body: '*'
+  - selector: google.iam.v1.IAMPolicy.TestIamPermissions
+    post: '/v1/{resource=projects/*/locations/*/apis/*}:testIamPermissions'
+    body: '*'
+    additional_bindings:
+    - post: '/v1/{resource=projects/*/locations/*/apis/*/deployments/*}:testIamPermissions'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/apis/*/versions/*}:testIamPermissions'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/apis/*/versions/*/specs/*}:testIamPermissions'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/artifacts/*}:testIamPermissions'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/apis/*/artifacts/*}:testIamPermissions'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/apis/*/versions/*/artifacts/*}:testIamPermissions'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/apis/*/versions/*/specs/*/artifacts/*}:testIamPermissions'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/instances/*}:testIamPermissions'
+      body: '*'
+    - post: '/v1/{resource=projects/*/locations/*/runtime}:testIamPermissions'
+      body: '*'
+  - selector: google.longrunning.Operations.CancelOperation
+    post: '/v1/{name=projects/*/locations/*/operations/*}:cancel'
+    body: '*'
+  - selector: google.longrunning.Operations.DeleteOperation
+    delete: '/v1/{name=projects/*/locations/*/operations/*}'
+  - selector: google.longrunning.Operations.GetOperation
+    get: '/v1/{name=projects/*/locations/*/operations/*}'
+  - selector: google.longrunning.Operations.ListOperations
+    get: '/v1/{name=projects/*/locations/*}/operations'
+
+authentication:
+  rules:
+  - selector: 'google.cloud.apigeeregistry.v1.Provisioning.*'
+    oauth:
+      canonical_scopes: |-
+        https://www.googleapis.com/auth/cloud-platform
+  - selector: 'google.cloud.apigeeregistry.v1.Registry.*'
+    oauth:
+      canonical_scopes: |-
+        https://www.googleapis.com/auth/cloud-platform
+  - selector: google.cloud.location.Locations.GetLocation
+    oauth:
+      canonical_scopes: |-
+        https://www.googleapis.com/auth/cloud-platform
+  - selector: google.cloud.location.Locations.ListLocations
+    oauth:
+      canonical_scopes: |-
+        https://www.googleapis.com/auth/cloud-platform
+  - selector: 'google.iam.v1.IAMPolicy.*'
+    oauth:
+      canonical_scopes: |-
+        https://www.googleapis.com/auth/cloud-platform
+  - selector: 'google.longrunning.Operations.*'
+    oauth:
+      canonical_scopes: |-
+        https://www.googleapis.com/auth/cloud-platform
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/provisioning_service.proto b/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/provisioning_service.proto
new file mode 100644
index 0000000..9e7baee
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/provisioning_service.proto
@@ -0,0 +1,205 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package google.cloud.apigeeregistry.v1;
+
+import "google/api/annotations.proto";
+import "google/api/client.proto";
+import "google/api/field_behavior.proto";
+import "google/api/resource.proto";
+import "google/longrunning/operations.proto";
+import "google/protobuf/timestamp.proto";
+
+option csharp_namespace = "Google.Cloud.ApigeeRegistry.V1";
+option go_package = "google.golang.org/genproto/googleapis/cloud/apigeeregistry/v1;apigeeregistry";
+option java_multiple_files = true;
+option java_outer_classname = "ProvisioningServiceProto";
+option java_package = "com.google.cloud.apigeeregistry.v1";
+option php_namespace = "Google\\Cloud\\ApigeeRegistry\\V1";
+option ruby_package = "Google::Cloud::ApigeeRegistry::V1";
+
+// The service that is used for managing the data plane provisioning of the
+// Registry.
+service Provisioning {
+  option (google.api.default_host) = "apigeeregistry.googleapis.com";
+  option (google.api.oauth_scopes) = "https://www.googleapis.com/auth/cloud-platform";
+
+  // Provisions instance resources for the Registry.
+  rpc CreateInstance(CreateInstanceRequest) returns (google.longrunning.Operation) {
+    option (google.api.http) = {
+      post: "/v1/{parent=projects/*/locations/*}/instances"
+      body: "instance"
+    };
+    option (google.api.method_signature) = "parent,instance,instance_id";
+    option (google.longrunning.operation_info) = {
+      response_type: "Instance"
+      metadata_type: "OperationMetadata"
+    };
+  }
+
+  // Deletes the Registry instance.
+  rpc DeleteInstance(DeleteInstanceRequest) returns (google.longrunning.Operation) {
+    option (google.api.http) = {
+      delete: "/v1/{name=projects/*/locations/*/instances/*}"
+    };
+    option (google.api.method_signature) = "name";
+    option (google.longrunning.operation_info) = {
+      response_type: "google.protobuf.Empty"
+      metadata_type: "OperationMetadata"
+    };
+  }
+
+  // Gets details of a single Instance.
+  rpc GetInstance(GetInstanceRequest) returns (Instance) {
+    option (google.api.http) = {
+      get: "/v1/{name=projects/*/locations/*/instances/*}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+}
+
+// Request message for CreateInstance.
+message CreateInstanceRequest {
+  // Required. Parent resource of the Instance, of the form: `projects/*/locations/*`
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "locations.googleapis.com/Location"
+    }
+  ];
+
+  // Required. Identifier to assign to the Instance. Must be unique within scope of the
+  // parent resource.
+  string instance_id = 2 [(google.api.field_behavior) = REQUIRED];
+
+  // Required. The Instance.
+  Instance instance = 3 [(google.api.field_behavior) = REQUIRED];
+}
+
+// Request message for DeleteInstance.
+message DeleteInstanceRequest {
+  // Required. The name of the Instance to delete.
+  // Format: `projects/*/locations/*/instances/*`.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/Instance"
+    }
+  ];
+}
+
+// Request message for GetInstance.
+message GetInstanceRequest {
+  // Required. The name of the Instance to retrieve.
+  // Format: `projects/*/locations/*/instances/*`.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/Instance"
+    }
+  ];
+}
+
+// Represents the metadata of the long-running operation.
+message OperationMetadata {
+  // The time the operation was created.
+  google.protobuf.Timestamp create_time = 1;
+
+  // The time the operation finished running.
+  google.protobuf.Timestamp end_time = 2;
+
+  // Server-defined resource path for the target of the operation.
+  string target = 3;
+
+  // Name of the verb executed by the operation.
+  string verb = 4;
+
+  // Human-readable status of the operation, if any.
+  string status_message = 5;
+
+  // Identifies whether the user has requested cancellation
+  // of the operation. Operations that have successfully been cancelled
+  // have [Operation.error][] value with a [google.rpc.Status.code][google.rpc.Status.code] of 1,
+  // corresponding to `Code.CANCELLED`.
+  bool cancellation_requested = 6;
+
+  // API version used to start the operation.
+  string api_version = 7;
+}
+
+// An Instance represents the instance resources of the Registry.
+// Currently, only one instance is allowed for each project.
+message Instance {
+  option (google.api.resource) = {
+    type: "apigeeregistry.googleapis.com/Instance"
+    pattern: "projects/{project}/locations/{location}/instances/{instance}"
+  };
+
+  // State of the Instance.
+  enum State {
+    // The default value. This value is used if the state is omitted.
+    STATE_UNSPECIFIED = 0;
+
+    // The Instance has not been initialized or has been deleted.
+    INACTIVE = 1;
+
+    // The Instance is being created.
+    CREATING = 2;
+
+    // The Instance has been created and is ready for use.
+    ACTIVE = 3;
+
+    // The Instance is being updated.
+    UPDATING = 4;
+
+    // The Instance is being deleted.
+    DELETING = 5;
+
+    // The Instance encountered an error during a state change.
+    FAILED = 6;
+  }
+
+  // Available configurations to provision an Instance.
+  message Config {
+    // Output only. The GCP location where the Instance resides.
+    string location = 1 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+    // Required. The Customer Managed Encryption Key (CMEK) used for data encryption.
+    // The CMEK name should follow the format of
+    // `projects/([^/]+)/locations/([^/]+)/keyRings/([^/]+)/cryptoKeys/([^/]+)`,
+    // where the `location` must match InstanceConfig.location.
+    string cmek_key_name = 2 [(google.api.field_behavior) = REQUIRED];
+  }
+
+  // Format: `projects/*/locations/*/instance`.
+  // Currently only `locations/global` is supported.
+  string name = 1;
+
+  // Output only. Creation timestamp.
+  google.protobuf.Timestamp create_time = 2 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // Output only. Last update timestamp.
+  google.protobuf.Timestamp update_time = 3 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // Output only. The current state of the Instance.
+  State state = 4 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // Output only. Extra information of Instance.State if the state is `FAILED`.
+  string state_message = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // Required. Config of the Instance.
+  Config config = 6 [(google.api.field_behavior) = REQUIRED];
+}
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/registry_models.proto b/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/registry_models.proto
new file mode 100644
index 0000000..8411f9e
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/registry_models.proto
@@ -0,0 +1,364 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package google.cloud.apigeeregistry.v1;
+
+import "google/api/field_behavior.proto";
+import "google/api/resource.proto";
+import "google/protobuf/timestamp.proto";
+
+option csharp_namespace = "Google.Cloud.ApigeeRegistry.V1";
+option go_package = "google.golang.org/genproto/googleapis/cloud/apigeeregistry/v1;apigeeregistry";
+option java_multiple_files = true;
+option java_outer_classname = "RegistryModelsProto";
+option java_package = "com.google.cloud.apigeeregistry.v1";
+option php_namespace = "Google\\Cloud\\ApigeeRegistry\\V1";
+option ruby_package = "Google::Cloud::ApigeeRegistry::V1";
+
+// A top-level description of an API.
+// Produced by producers and are commitments to provide services.
+message Api {
+  option (google.api.resource) = {
+    type: "apigeeregistry.googleapis.com/Api"
+    pattern: "projects/{project}/locations/{location}/apis/{api}"
+  };
+
+  // Resource name.
+  string name = 1;
+
+  // Human-meaningful name.
+  string display_name = 2;
+
+  // A detailed description.
+  string description = 3;
+
+  // Output only. Creation timestamp.
+  google.protobuf.Timestamp create_time = 4 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // Output only. Last update timestamp.
+  google.protobuf.Timestamp update_time = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // A user-definable description of the availability of this service.
+  // Format: free-form, but we expect single words that describe availability,
+  // e.g., "NONE", "TESTING", "PREVIEW", "GENERAL", "DEPRECATED", "SHUTDOWN".
+  string availability = 6;
+
+  // The recommended version of the API.
+  // Format: `apis/{api}/versions/{version}`
+  string recommended_version = 7 [(google.api.resource_reference) = {
+                                    type: "apigeeregistry.googleapis.com/ApiVersion"
+                                  }];
+
+  // The recommended deployment of the API.
+  // Format: `apis/{api}/deployments/{deployment}`
+  string recommended_deployment = 8 [(google.api.resource_reference) = {
+                                       type: "apigeeregistry.googleapis.com/ApiDeployment"
+                                     }];
+
+  // Labels attach identifying metadata to resources. Identifying metadata can
+  // be used to filter list operations.
+  //
+  // Label keys and values can be no longer than 64 characters
+  // (Unicode codepoints), can only contain lowercase letters, numeric
+  // characters, underscores, and dashes. International characters are allowed.
+  // No more than 64 user labels can be associated with one resource (System
+  // labels are excluded).
+  //
+  // See https://goo.gl/xmQnxf for more information and examples of labels.
+  // System reserved label keys are prefixed with
+  // `apigeeregistry.googleapis.com/` and cannot be changed.
+  map<string, string> labels = 9;
+
+  // Annotations attach non-identifying metadata to resources.
+  //
+  // Annotation keys and values are less restricted than those of labels, but
+  // should be generally used for small values of broad interest. Larger, topic-
+  // specific metadata should be stored in Artifacts.
+  map<string, string> annotations = 10;
+}
+
+// Describes a particular version of an API.
+// ApiVersions are what consumers actually use.
+message ApiVersion {
+  option (google.api.resource) = {
+    type: "apigeeregistry.googleapis.com/ApiVersion"
+    pattern: "projects/{project}/locations/{location}/apis/{api}/versions/{version}"
+  };
+
+  // Resource name.
+  string name = 1;
+
+  // Human-meaningful name.
+  string display_name = 2;
+
+  // A detailed description.
+  string description = 3;
+
+  // Output only. Creation timestamp.
+  google.protobuf.Timestamp create_time = 4 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // Output only. Last update timestamp.
+  google.protobuf.Timestamp update_time = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // A user-definable description of the lifecycle phase of this API version.
+  // Format: free-form, but we expect single words that describe API maturity,
+  // e.g., "CONCEPT", "DESIGN", "DEVELOPMENT", "STAGING", "PRODUCTION",
+  // "DEPRECATED", "RETIRED".
+  string state = 6;
+
+  // Labels attach identifying metadata to resources. Identifying metadata can
+  // be used to filter list operations.
+  //
+  // Label keys and values can be no longer than 64 characters
+  // (Unicode codepoints), can only contain lowercase letters, numeric
+  // characters, underscores and dashes. International characters are allowed.
+  // No more than 64 user labels can be associated with one resource (System
+  // labels are excluded).
+  //
+  // See https://goo.gl/xmQnxf for more information and examples of labels.
+  // System reserved label keys are prefixed with
+  // `apigeeregistry.googleapis.com/` and cannot be changed.
+  map<string, string> labels = 7;
+
+  // Annotations attach non-identifying metadata to resources.
+  //
+  // Annotation keys and values are less restricted than those of labels, but
+  // should be generally used for small values of broad interest. Larger, topic-
+  // specific metadata should be stored in Artifacts.
+  map<string, string> annotations = 8;
+}
+
+// Describes a version of an API in a structured way.
+// ApiSpecs provide formal descriptions that consumers can use to use a version.
+// ApiSpec resources are intended to be fully-resolved descriptions of an
+// ApiVersion. When specs consist of multiple files, these should be bundled
+// together (e.g., in a zip archive) and stored as a unit. Multiple specs can
+// exist to provide representations in different API description formats.
+// Synchronization of these representations would be provided by tooling and
+// background services.
+message ApiSpec {
+  option (google.api.resource) = {
+    type: "apigeeregistry.googleapis.com/ApiSpec"
+    pattern: "projects/{project}/locations/{location}/apis/{api}/versions/{version}/specs/{spec}"
+  };
+
+  // Resource name.
+  string name = 1;
+
+  // A possibly-hierarchical name used to refer to the spec from other specs.
+  string filename = 2;
+
+  // A detailed description.
+  string description = 3;
+
+  // Output only. Immutable. The revision ID of the spec.
+  // A new revision is committed whenever the spec contents are changed.
+  // The format is an 8-character hexadecimal string.
+  string revision_id = 4 [
+    (google.api.field_behavior) = IMMUTABLE,
+    (google.api.field_behavior) = OUTPUT_ONLY
+  ];
+
+  // Output only. Creation timestamp; when the spec resource was created.
+  google.protobuf.Timestamp create_time = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // Output only. Revision creation timestamp; when the represented revision was created.
+  google.protobuf.Timestamp revision_create_time = 6 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // Output only. Last update timestamp: when the represented revision was last modified.
+  google.protobuf.Timestamp revision_update_time = 7 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // A style (format) descriptor for this spec that is specified as a Media Type
+  // (https://en.wikipedia.org/wiki/Media_type). Possible values include
+  // `application/vnd.apigee.proto`, `application/vnd.apigee.openapi`, and
+  // `application/vnd.apigee.graphql`, with possible suffixes representing
+  // compression types. These hypothetical names are defined in the vendor tree
+  // defined in RFC6838 (https://tools.ietf.org/html/rfc6838) and are not final.
+  // Content types can specify compression. Currently only GZip compression is
+  // supported (indicated with "+gzip").
+  string mime_type = 8;
+
+  // Output only. The size of the spec file in bytes. If the spec is gzipped, this is the
+  // size of the uncompressed spec.
+  int32 size_bytes = 9 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // Output only. A SHA-256 hash of the spec's contents. If the spec is gzipped, this is
+  // the hash of the uncompressed spec.
+  string hash = 10 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // The original source URI of the spec (if one exists).
+  // This is an external location that can be used for reference purposes
+  // but which may not be authoritative since this external resource may
+  // change after the spec is retrieved.
+  string source_uri = 11;
+
+  // Input only. The contents of the spec.
+  // Provided by API callers when specs are created or updated.
+  // To access the contents of a spec, use GetApiSpecContents.
+  bytes contents = 12 [(google.api.field_behavior) = INPUT_ONLY];
+
+  // Labels attach identifying metadata to resources. Identifying metadata can
+  // be used to filter list operations.
+  //
+  // Label keys and values can be no longer than 64 characters
+  // (Unicode codepoints), can only contain lowercase letters, numeric
+  // characters, underscores and dashes. International characters are allowed.
+  // No more than 64 user labels can be associated with one resource (System
+  // labels are excluded).
+  //
+  // See https://goo.gl/xmQnxf for more information and examples of labels.
+  // System reserved label keys are prefixed with
+  // `apigeeregistry.googleapis.com/` and cannot be changed.
+  map<string, string> labels = 14;
+
+  // Annotations attach non-identifying metadata to resources.
+  //
+  // Annotation keys and values are less restricted than those of labels, but
+  // should be generally used for small values of broad interest. Larger, topic-
+  // specific metadata should be stored in Artifacts.
+  map<string, string> annotations = 15;
+}
+
+// Describes a service running at particular address that
+// provides a particular version of an API. ApiDeployments have revisions which
+// correspond to different configurations of a single deployment in time.
+// Revision identifiers should be updated whenever the served API spec or
+// endpoint address changes.
+message ApiDeployment {
+  option (google.api.resource) = {
+    type: "apigeeregistry.googleapis.com/ApiDeployment"
+    pattern: "projects/{project}/locations/{location}/apis/{api}/deployments/{deployment}"
+  };
+
+  // Resource name.
+  string name = 1;
+
+  // Human-meaningful name.
+  string display_name = 2;
+
+  // A detailed description.
+  string description = 3;
+
+  // Output only. Immutable. The revision ID of the deployment.
+  // A new revision is committed whenever the deployment contents are changed.
+  // The format is an 8-character hexadecimal string.
+  string revision_id = 4 [
+    (google.api.field_behavior) = IMMUTABLE,
+    (google.api.field_behavior) = OUTPUT_ONLY
+  ];
+
+  // Output only. Creation timestamp; when the deployment resource was created.
+  google.protobuf.Timestamp create_time = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // Output only. Revision creation timestamp; when the represented revision was created.
+  google.protobuf.Timestamp revision_create_time = 6 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // Output only. Last update timestamp: when the represented revision was last modified.
+  google.protobuf.Timestamp revision_update_time = 7 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // The full resource name (including revision ID) of the spec of the API being
+  // served by the deployment. Changes to this value will update the revision.
+  // Format: `apis/{api}/deployments/{deployment}`
+  string api_spec_revision = 8 [(google.api.resource_reference) = {
+                                  type: "apigeeregistry.googleapis.com/ApiSpec"
+                                }];
+
+  // The address where the deployment is serving. Changes to this value will
+  // update the revision.
+  string endpoint_uri = 9;
+
+  // The address of the external channel of the API (e.g., the Developer
+  // Portal). Changes to this value will not affect the revision.
+  string external_channel_uri = 10;
+
+  // Text briefly identifying the intended audience of the API. Changes to this
+  // value will not affect the revision.
+  string intended_audience = 11;
+
+  // Text briefly describing how to access the endpoint. Changes to this value
+  // will not affect the revision.
+  string access_guidance = 12;
+
+  // Labels attach identifying metadata to resources. Identifying metadata can
+  // be used to filter list operations.
+  //
+  // Label keys and values can be no longer than 64 characters
+  // (Unicode codepoints), can only contain lowercase letters, numeric
+  // characters, underscores and dashes. International characters are allowed.
+  // No more than 64 user labels can be associated with one resource (System
+  // labels are excluded).
+  //
+  // See https://goo.gl/xmQnxf for more information and examples of labels.
+  // System reserved label keys are prefixed with
+  // `apigeeregistry.googleapis.com/` and cannot be changed.
+  map<string, string> labels = 14;
+
+  // Annotations attach non-identifying metadata to resources.
+  //
+  // Annotation keys and values are less restricted than those of labels, but
+  // should be generally used for small values of broad interest. Larger, topic-
+  // specific metadata should be stored in Artifacts.
+  map<string, string> annotations = 15;
+}
+
+// Artifacts of resources. Artifacts are unique (single-value) per resource
+// and are used to store metadata that is too large or numerous to be stored
+// directly on the resource. Since artifacts are stored separately from parent
+// resources, they should generally be used for metadata that is needed
+// infrequently, i.e., not for display in primary views of the resource but
+// perhaps displayed or downloaded upon request. The `ListArtifacts` method
+// allows artifacts to be quickly enumerated and checked for presence without
+// downloading their (potentially-large) contents.
+message Artifact {
+  option (google.api.resource) = {
+    type: "apigeeregistry.googleapis.com/Artifact"
+    pattern: "projects/{project}/locations/{location}/artifacts/{artifact}"
+    pattern: "projects/{project}/locations/{location}/apis/{api}/artifacts/{artifact}"
+    pattern: "projects/{project}/locations/{location}/apis/{api}/versions/{version}/artifacts/{artifact}"
+    pattern: "projects/{project}/locations/{location}/apis/{api}/versions/{version}/specs/{spec}/artifacts/{artifact}"
+    pattern: "projects/{project}/locations/{location}/apis/{api}/deployments/{deployment}/artifacts/{artifact}"
+  };
+
+  // Resource name.
+  string name = 1;
+
+  // Output only. Creation timestamp.
+  google.protobuf.Timestamp create_time = 2 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // Output only. Last update timestamp.
+  google.protobuf.Timestamp update_time = 3 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // A content type specifier for the artifact.
+  // Content type specifiers are Media Types
+  // (https://en.wikipedia.org/wiki/Media_type) with a possible "schema"
+  // parameter that specifies a schema for the stored information.
+  // Content types can specify compression. Currently only GZip compression is
+  // supported (indicated with "+gzip").
+  string mime_type = 4;
+
+  // Output only. The size of the artifact in bytes. If the artifact is gzipped, this is
+  // the size of the uncompressed artifact.
+  int32 size_bytes = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // Output only. A SHA-256 hash of the artifact's contents. If the artifact is gzipped,
+  // this is the hash of the uncompressed artifact.
+  string hash = 6 [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // Input only. The contents of the artifact.
+  // Provided by API callers when artifacts are created or replaced.
+  // To access the contents of an artifact, use GetArtifactContents.
+  bytes contents = 7 [(google.api.field_behavior) = INPUT_ONLY];
+}
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/registry_service.proto b/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/registry_service.proto
new file mode 100644
index 0000000..1eec64e
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/cloud/apigeeregistry/v1/registry_service.proto
@@ -0,0 +1,1133 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package google.cloud.apigeeregistry.v1;
+
+import "google/api/annotations.proto";
+import "google/api/client.proto";
+import "google/api/field_behavior.proto";
+import "google/api/httpbody.proto";
+import "google/api/resource.proto";
+import "google/cloud/apigeeregistry/v1/registry_models.proto";
+import "google/protobuf/empty.proto";
+import "google/protobuf/field_mask.proto";
+
+option csharp_namespace = "Google.Cloud.ApigeeRegistry.V1";
+option go_package = "google.golang.org/genproto/googleapis/cloud/apigeeregistry/v1;apigeeregistry";
+option java_multiple_files = true;
+option java_outer_classname = "RegistryServiceProto";
+option java_package = "com.google.cloud.apigeeregistry.v1";
+option php_namespace = "Google\\Cloud\\ApigeeRegistry\\V1";
+option ruby_package = "Google::Cloud::ApigeeRegistry::V1";
+
+// The Registry service allows teams to manage descriptions of APIs.
+service Registry {
+  option (google.api.default_host) = "apigeeregistry.googleapis.com";
+  option (google.api.oauth_scopes) = "https://www.googleapis.com/auth/cloud-platform";
+
+  // Returns matching APIs.
+  rpc ListApis(ListApisRequest) returns (ListApisResponse) {
+    option (google.api.http) = {
+      get: "/v1/{parent=projects/*/locations/*}/apis"
+    };
+    option (google.api.method_signature) = "parent";
+  }
+
+  // Returns a specified API.
+  rpc GetApi(GetApiRequest) returns (Api) {
+    option (google.api.http) = {
+      get: "/v1/{name=projects/*/locations/*/apis/*}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Creates a specified API.
+  rpc CreateApi(CreateApiRequest) returns (Api) {
+    option (google.api.http) = {
+      post: "/v1/{parent=projects/*/locations/*}/apis"
+      body: "api"
+    };
+    option (google.api.method_signature) = "parent,api,api_id";
+  }
+
+  // Used to modify a specified API.
+  rpc UpdateApi(UpdateApiRequest) returns (Api) {
+    option (google.api.http) = {
+      patch: "/v1/{api.name=projects/*/locations/*/apis/*}"
+      body: "api"
+    };
+    option (google.api.method_signature) = "api,update_mask";
+  }
+
+  // Removes a specified API and all of the resources that it
+  // owns.
+  rpc DeleteApi(DeleteApiRequest) returns (google.protobuf.Empty) {
+    option (google.api.http) = {
+      delete: "/v1/{name=projects/*/locations/*/apis/*}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Returns matching versions.
+  rpc ListApiVersions(ListApiVersionsRequest) returns (ListApiVersionsResponse) {
+    option (google.api.http) = {
+      get: "/v1/{parent=projects/*/locations/*/apis/*}/versions"
+    };
+    option (google.api.method_signature) = "parent";
+  }
+
+  // Returns a specified version.
+  rpc GetApiVersion(GetApiVersionRequest) returns (ApiVersion) {
+    option (google.api.http) = {
+      get: "/v1/{name=projects/*/locations/*/apis/*/versions/*}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Creates a specified version.
+  rpc CreateApiVersion(CreateApiVersionRequest) returns (ApiVersion) {
+    option (google.api.http) = {
+      post: "/v1/{parent=projects/*/locations/*/apis/*}/versions"
+      body: "api_version"
+    };
+    option (google.api.method_signature) = "parent,api_version,api_version_id";
+  }
+
+  // Used to modify a specified version.
+  rpc UpdateApiVersion(UpdateApiVersionRequest) returns (ApiVersion) {
+    option (google.api.http) = {
+      patch: "/v1/{api_version.name=projects/*/locations/*/apis/*/versions/*}"
+      body: "api_version"
+    };
+    option (google.api.method_signature) = "api_version,update_mask";
+  }
+
+  // Removes a specified version and all of the resources that
+  // it owns.
+  rpc DeleteApiVersion(DeleteApiVersionRequest) returns (google.protobuf.Empty) {
+    option (google.api.http) = {
+      delete: "/v1/{name=projects/*/locations/*/apis/*/versions/*}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Returns matching specs.
+  rpc ListApiSpecs(ListApiSpecsRequest) returns (ListApiSpecsResponse) {
+    option (google.api.http) = {
+      get: "/v1/{parent=projects/*/locations/*/apis/*/versions/*}/specs"
+    };
+    option (google.api.method_signature) = "parent";
+  }
+
+  // Returns a specified spec.
+  rpc GetApiSpec(GetApiSpecRequest) returns (ApiSpec) {
+    option (google.api.http) = {
+      get: "/v1/{name=projects/*/locations/*/apis/*/versions/*/specs/*}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Returns the contents of a specified spec.
+  // If specs are stored with GZip compression, the default behavior
+  // is to return the spec uncompressed (the mime_type response field
+  // indicates the exact format returned).
+  rpc GetApiSpecContents(GetApiSpecContentsRequest) returns (google.api.HttpBody) {
+    option (google.api.http) = {
+      get: "/v1/{name=projects/*/locations/*/apis/*/versions/*/specs/*}:getContents"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Creates a specified spec.
+  rpc CreateApiSpec(CreateApiSpecRequest) returns (ApiSpec) {
+    option (google.api.http) = {
+      post: "/v1/{parent=projects/*/locations/*/apis/*/versions/*}/specs"
+      body: "api_spec"
+    };
+    option (google.api.method_signature) = "parent,api_spec,api_spec_id";
+  }
+
+  // Used to modify a specified spec.
+  rpc UpdateApiSpec(UpdateApiSpecRequest) returns (ApiSpec) {
+    option (google.api.http) = {
+      patch: "/v1/{api_spec.name=projects/*/locations/*/apis/*/versions/*/specs/*}"
+      body: "api_spec"
+    };
+    option (google.api.method_signature) = "api_spec,update_mask";
+  }
+
+  // Removes a specified spec, all revisions, and all child
+  // resources (e.g., artifacts).
+  rpc DeleteApiSpec(DeleteApiSpecRequest) returns (google.protobuf.Empty) {
+    option (google.api.http) = {
+      delete: "/v1/{name=projects/*/locations/*/apis/*/versions/*/specs/*}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Adds a tag to a specified revision of a spec.
+  rpc TagApiSpecRevision(TagApiSpecRevisionRequest) returns (ApiSpec) {
+    option (google.api.http) = {
+      post: "/v1/{name=projects/*/locations/*/apis/*/versions/*/specs/*}:tagRevision"
+      body: "*"
+    };
+  }
+
+  // Lists all revisions of a spec.
+  // Revisions are returned in descending order of revision creation time.
+  rpc ListApiSpecRevisions(ListApiSpecRevisionsRequest) returns (ListApiSpecRevisionsResponse) {
+    option (google.api.http) = {
+      get: "/v1/{name=projects/*/locations/*/apis/*/versions/*/specs/*}:listRevisions"
+    };
+  }
+
+  // Sets the current revision to a specified prior revision.
+  // Note that this creates a new revision with a new revision ID.
+  rpc RollbackApiSpec(RollbackApiSpecRequest) returns (ApiSpec) {
+    option (google.api.http) = {
+      post: "/v1/{name=projects/*/locations/*/apis/*/versions/*/specs/*}:rollback"
+      body: "*"
+    };
+  }
+
+  // Deletes a revision of a spec.
+  rpc DeleteApiSpecRevision(DeleteApiSpecRevisionRequest) returns (ApiSpec) {
+    option (google.api.http) = {
+      delete: "/v1/{name=projects/*/locations/*/apis/*/versions/*/specs/*}:deleteRevision"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Returns matching deployments.
+  rpc ListApiDeployments(ListApiDeploymentsRequest) returns (ListApiDeploymentsResponse) {
+    option (google.api.http) = {
+      get: "/v1/{parent=projects/*/locations/*/apis/*}/deployments"
+    };
+    option (google.api.method_signature) = "parent";
+  }
+
+  // Returns a specified deployment.
+  rpc GetApiDeployment(GetApiDeploymentRequest) returns (ApiDeployment) {
+    option (google.api.http) = {
+      get: "/v1/{name=projects/*/locations/*/apis/*/deployments/*}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Creates a specified deployment.
+  rpc CreateApiDeployment(CreateApiDeploymentRequest) returns (ApiDeployment) {
+    option (google.api.http) = {
+      post: "/v1/{parent=projects/*/locations/*/apis/*}/deployments"
+      body: "api_deployment"
+    };
+    option (google.api.method_signature) = "parent,api_deployment,api_deployment_id";
+  }
+
+  // Used to modify a specified deployment.
+  rpc UpdateApiDeployment(UpdateApiDeploymentRequest) returns (ApiDeployment) {
+    option (google.api.http) = {
+      patch: "/v1/{api_deployment.name=projects/*/locations/*/apis/*/deployments/*}"
+      body: "api_deployment"
+    };
+    option (google.api.method_signature) = "api_deployment,update_mask";
+  }
+
+  // Removes a specified deployment, all revisions, and all
+  // child resources (e.g., artifacts).
+  rpc DeleteApiDeployment(DeleteApiDeploymentRequest) returns (google.protobuf.Empty) {
+    option (google.api.http) = {
+      delete: "/v1/{name=projects/*/locations/*/apis/*/deployments/*}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Adds a tag to a specified revision of a
+  // deployment.
+  rpc TagApiDeploymentRevision(TagApiDeploymentRevisionRequest) returns (ApiDeployment) {
+    option (google.api.http) = {
+      post: "/v1/{name=projects/*/locations/*/apis/*/deployments/*}:tagRevision"
+      body: "*"
+    };
+  }
+
+  // Lists all revisions of a deployment.
+  // Revisions are returned in descending order of revision creation time.
+  rpc ListApiDeploymentRevisions(ListApiDeploymentRevisionsRequest) returns (ListApiDeploymentRevisionsResponse) {
+    option (google.api.http) = {
+      get: "/v1/{name=projects/*/locations/*/apis/*/deployments/*}:listRevisions"
+    };
+  }
+
+  // Sets the current revision to a specified prior
+  // revision. Note that this creates a new revision with a new revision ID.
+  rpc RollbackApiDeployment(RollbackApiDeploymentRequest) returns (ApiDeployment) {
+    option (google.api.http) = {
+      post: "/v1/{name=projects/*/locations/*/apis/*/deployments/*}:rollback"
+      body: "*"
+    };
+  }
+
+  // Deletes a revision of a deployment.
+  rpc DeleteApiDeploymentRevision(DeleteApiDeploymentRevisionRequest) returns (ApiDeployment) {
+    option (google.api.http) = {
+      delete: "/v1/{name=projects/*/locations/*/apis/*/deployments/*}:deleteRevision"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Returns matching artifacts.
+  rpc ListArtifacts(ListArtifactsRequest) returns (ListArtifactsResponse) {
+    option (google.api.http) = {
+      get: "/v1/{parent=projects/*/locations/*}/artifacts"
+      additional_bindings {
+        get: "/v1/{parent=projects/*/locations/*/apis/*}/artifacts"
+      }
+      additional_bindings {
+        get: "/v1/{parent=projects/*/locations/*/apis/*/versions/*}/artifacts"
+      }
+      additional_bindings {
+        get: "/v1/{parent=projects/*/locations/*/apis/*/versions/*/specs/*}/artifacts"
+      }
+      additional_bindings {
+        get: "/v1/{parent=projects/*/locations/*/apis/*/deployments/*}/artifacts"
+      }
+    };
+    option (google.api.method_signature) = "parent";
+  }
+
+  // Returns a specified artifact.
+  rpc GetArtifact(GetArtifactRequest) returns (Artifact) {
+    option (google.api.http) = {
+      get: "/v1/{name=projects/*/locations/*/artifacts/*}"
+      additional_bindings {
+        get: "/v1/{name=projects/*/locations/*/apis/*/artifacts/*}"
+      }
+      additional_bindings {
+        get: "/v1/{name=projects/*/locations/*/apis/*/versions/*/artifacts/*}"
+      }
+      additional_bindings {
+        get: "/v1/{name=projects/*/locations/*/apis/*/versions/*/specs/*/artifacts/*}"
+      }
+      additional_bindings {
+        get: "/v1/{name=projects/*/locations/*/apis/*/deployments/*/artifacts/*}"
+      }
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Returns the contents of a specified artifact.
+  // If artifacts are stored with GZip compression, the default behavior
+  // is to return the artifact uncompressed (the mime_type response field
+  // indicates the exact format returned).
+  rpc GetArtifactContents(GetArtifactContentsRequest) returns (google.api.HttpBody) {
+    option (google.api.http) = {
+      get: "/v1/{name=projects/*/locations/*/artifacts/*}:getContents"
+      additional_bindings {
+        get: "/v1/{name=projects/*/locations/*/apis/*/artifacts/*}:getContents"
+      }
+      additional_bindings {
+        get: "/v1/{name=projects/*/locations/*/apis/*/versions/*/artifacts/*}:getContents"
+      }
+      additional_bindings {
+        get: "/v1/{name=projects/*/locations/*/apis/*/versions/*/specs/*/artifacts/*}:getContents"
+      }
+      additional_bindings {
+        get: "/v1/{name=projects/*/locations/*/apis/*/deployments/*/artifacts/*}:getContents"
+      }
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Creates a specified artifact.
+  rpc CreateArtifact(CreateArtifactRequest) returns (Artifact) {
+    option (google.api.http) = {
+      post: "/v1/{parent=projects/*/locations/*}/artifacts"
+      body: "artifact"
+      additional_bindings {
+        post: "/v1/{parent=projects/*/locations/*/apis/*}/artifacts"
+        body: "artifact"
+      }
+      additional_bindings {
+        post: "/v1/{parent=projects/*/locations/*/apis/*/versions/*}/artifacts"
+        body: "artifact"
+      }
+      additional_bindings {
+        post: "/v1/{parent=projects/*/locations/*/apis/*/versions/*/specs/*}/artifacts"
+        body: "artifact"
+      }
+      additional_bindings {
+        post: "/v1/{parent=projects/*/locations/*/apis/*/deployments/*}/artifacts"
+        body: "artifact"
+      }
+    };
+    option (google.api.method_signature) = "parent,artifact,artifact_id";
+  }
+
+  // Used to replace a specified artifact.
+  rpc ReplaceArtifact(ReplaceArtifactRequest) returns (Artifact) {
+    option (google.api.http) = {
+      put: "/v1/{artifact.name=projects/*/locations/*/artifacts/*}"
+      body: "artifact"
+      additional_bindings {
+        put: "/v1/{artifact.name=projects/*/locations/*/apis/*/artifacts/*}"
+        body: "artifact"
+      }
+      additional_bindings {
+        put: "/v1/{artifact.name=projects/*/locations/*/apis/*/versions/*/artifacts/*}"
+        body: "artifact"
+      }
+      additional_bindings {
+        put: "/v1/{artifact.name=projects/*/locations/*/apis/*/versions/*/specs/*/artifacts/*}"
+        body: "artifact"
+      }
+      additional_bindings {
+        put: "/v1/{artifact.name=projects/*/locations/*/apis/*/deployments/*/artifacts/*}"
+        body: "artifact"
+      }
+    };
+    option (google.api.method_signature) = "artifact";
+  }
+
+  // Removes a specified artifact.
+  rpc DeleteArtifact(DeleteArtifactRequest) returns (google.protobuf.Empty) {
+    option (google.api.http) = {
+      delete: "/v1/{name=projects/*/locations/*/artifacts/*}"
+      additional_bindings {
+        delete: "/v1/{name=projects/*/locations/*/apis/*/artifacts/*}"
+      }
+      additional_bindings {
+        delete: "/v1/{name=projects/*/locations/*/apis/*/versions/*/artifacts/*}"
+      }
+      additional_bindings {
+        delete: "/v1/{name=projects/*/locations/*/apis/*/versions/*/specs/*/artifacts/*}"
+      }
+      additional_bindings {
+        delete: "/v1/{name=projects/*/locations/*/apis/*/deployments/*/artifacts/*}"
+      }
+    };
+    option (google.api.method_signature) = "name";
+  }
+}
+
+// Request message for ListApis.
+message ListApisRequest {
+  // Required. The parent, which owns this collection of APIs.
+  // Format: `projects/*/locations/*`
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      child_type: "apigeeregistry.googleapis.com/Api"
+    }
+  ];
+
+  // The maximum number of APIs to return.
+  // The service may return fewer than this value.
+  // If unspecified, at most 50 values will be returned.
+  // The maximum is 1000; values above 1000 will be coerced to 1000.
+  int32 page_size = 2;
+
+  // A page token, received from a previous `ListApis` call.
+  // Provide this to retrieve the subsequent page.
+  //
+  // When paginating, all other parameters provided to `ListApis` must match
+  // the call that provided the page token.
+  string page_token = 3;
+
+  // An expression that can be used to filter the list. Filters use the Common
+  // Expression Language and can refer to all message fields.
+  string filter = 4;
+}
+
+// Response message for ListApis.
+message ListApisResponse {
+  // The APIs from the specified publisher.
+  repeated Api apis = 1;
+
+  // A token, which can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there are no subsequent pages.
+  string next_page_token = 2;
+}
+
+// Request message for GetApi.
+message GetApiRequest {
+  // Required. The name of the API to retrieve.
+  // Format: `projects/*/locations/*/apis/*`
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/Api"
+    }
+  ];
+}
+
+// Request message for CreateApi.
+message CreateApiRequest {
+  // Required. The parent, which owns this collection of APIs.
+  // Format: `projects/*/locations/*`
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      child_type: "apigeeregistry.googleapis.com/Api"
+    }
+  ];
+
+  // Required. The API to create.
+  Api api = 2 [(google.api.field_behavior) = REQUIRED];
+
+  // Required. The ID to use for the API, which will become the final component of
+  // the API's resource name.
+  //
+  // This value should be 4-63 characters, and valid characters
+  // are /[a-z][0-9]-/.
+  //
+  // Following AIP-162, IDs must not have the form of a UUID.
+  string api_id = 3 [(google.api.field_behavior) = REQUIRED];
+}
+
+// Request message for UpdateApi.
+message UpdateApiRequest {
+  // Required. The API to update.
+  //
+  // The `name` field is used to identify the API to update.
+  // Format: `projects/*/locations/*/apis/*`
+  Api api = 1 [(google.api.field_behavior) = REQUIRED];
+
+  // The list of fields to be updated. If omitted, all fields are updated that
+  // are set in the request message (fields set to default values are ignored).
+  // If an asterisk "*" is specified, all fields are updated, including fields
+  // that are unspecified/default in the request.
+  google.protobuf.FieldMask update_mask = 2;
+
+  // If set to true, and the API is not found, a new API will be created.
+  // In this situation, `update_mask` is ignored.
+  bool allow_missing = 3;
+}
+
+// Request message for DeleteApi.
+message DeleteApiRequest {
+  // Required. The name of the API to delete.
+  // Format: `projects/*/locations/*/apis/*`
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/Api"
+    }
+  ];
+
+  // If set to true, any child resources will also be deleted.
+  // (Otherwise, the request will only work if there are no child resources.)
+  bool force = 2;
+}
+
+// Request message for ListApiVersions.
+message ListApiVersionsRequest {
+  // Required. The parent, which owns this collection of versions.
+  // Format: `projects/*/locations/*/apis/*`
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      child_type: "apigeeregistry.googleapis.com/ApiVersion"
+    }
+  ];
+
+  // The maximum number of versions to return.
+  // The service may return fewer than this value.
+  // If unspecified, at most 50 values will be returned.
+  // The maximum is 1000; values above 1000 will be coerced to 1000.
+  int32 page_size = 2;
+
+  // A page token, received from a previous `ListApiVersions` call.
+  // Provide this to retrieve the subsequent page.
+  //
+  // When paginating, all other parameters provided to `ListApiVersions` must
+  // match the call that provided the page token.
+  string page_token = 3;
+
+  // An expression that can be used to filter the list. Filters use the Common
+  // Expression Language and can refer to all message fields.
+  string filter = 4;
+}
+
+// Response message for ListApiVersions.
+message ListApiVersionsResponse {
+  // The versions from the specified publisher.
+  repeated ApiVersion api_versions = 1;
+
+  // A token, which can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there are no subsequent pages.
+  string next_page_token = 2;
+}
+
+// Request message for GetApiVersion.
+message GetApiVersionRequest {
+  // Required. The name of the version to retrieve.
+  // Format: `projects/*/locations/*/apis/*/versions/*`
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiVersion"
+    }
+  ];
+}
+
+// Request message for CreateApiVersion.
+message CreateApiVersionRequest {
+  // Required. The parent, which owns this collection of versions.
+  // Format: `projects/*/locations/*/apis/*`
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      child_type: "apigeeregistry.googleapis.com/ApiVersion"
+    }
+  ];
+
+  // Required. The version to create.
+  ApiVersion api_version = 2 [(google.api.field_behavior) = REQUIRED];
+
+  // Required. The ID to use for the version, which will become the final component of
+  // the version's resource name.
+  //
+  // This value should be 1-63 characters, and valid characters
+  // are /[a-z][0-9]-/.
+  //
+  // Following AIP-162, IDs must not have the form of a UUID.
+  string api_version_id = 3 [(google.api.field_behavior) = REQUIRED];
+}
+
+// Request message for UpdateApiVersion.
+message UpdateApiVersionRequest {
+  // Required. The version to update.
+  //
+  // The `name` field is used to identify the version to update.
+  // Format: `projects/*/locations/*/apis/*/versions/*`
+  ApiVersion api_version = 1 [(google.api.field_behavior) = REQUIRED];
+
+  // The list of fields to be updated. If omitted, all fields are updated that
+  // are set in the request message (fields set to default values are ignored).
+  // If an asterisk "*" is specified, all fields are updated, including fields
+  // that are unspecified/default in the request.
+  google.protobuf.FieldMask update_mask = 2;
+
+  // If set to true, and the version is not found, a new version will be
+  // created. In this situation, `update_mask` is ignored.
+  bool allow_missing = 3;
+}
+
+// Request message for DeleteApiVersion.
+message DeleteApiVersionRequest {
+  // Required. The name of the version to delete.
+  // Format: `projects/*/locations/*/apis/*/versions/*`
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiVersion"
+    }
+  ];
+
+  // If set to true, any child resources will also be deleted.
+  // (Otherwise, the request will only work if there are no child resources.)
+  bool force = 2;
+}
+
+// Request message for ListApiSpecs.
+message ListApiSpecsRequest {
+  // Required. The parent, which owns this collection of specs.
+  // Format: `projects/*/locations/*/apis/*/versions/*`
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      child_type: "apigeeregistry.googleapis.com/ApiSpec"
+    }
+  ];
+
+  // The maximum number of specs to return.
+  // The service may return fewer than this value.
+  // If unspecified, at most 50 values will be returned.
+  // The maximum is 1000; values above 1000 will be coerced to 1000.
+  int32 page_size = 2;
+
+  // A page token, received from a previous `ListApiSpecs` call.
+  // Provide this to retrieve the subsequent page.
+  //
+  // When paginating, all other parameters provided to `ListApiSpecs` must match
+  // the call that provided the page token.
+  string page_token = 3;
+
+  // An expression that can be used to filter the list. Filters use the Common
+  // Expression Language and can refer to all message fields except contents.
+  string filter = 4;
+}
+
+// Response message for ListApiSpecs.
+message ListApiSpecsResponse {
+  // The specs from the specified publisher.
+  repeated ApiSpec api_specs = 1;
+
+  // A token, which can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there are no subsequent pages.
+  string next_page_token = 2;
+}
+
+// Request message for GetApiSpec.
+message GetApiSpecRequest {
+  // Required. The name of the spec to retrieve.
+  // Format: `projects/*/locations/*/apis/*/versions/*/specs/*`
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiSpec"
+    }
+  ];
+}
+
+// Request message for GetApiSpecContents.
+message GetApiSpecContentsRequest {
+  // Required. The name of the spec whose contents should be retrieved.
+  // Format: `projects/*/locations/*/apis/*/versions/*/specs/*`
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiSpec"
+    }
+  ];
+}
+
+// Request message for CreateApiSpec.
+message CreateApiSpecRequest {
+  // Required. The parent, which owns this collection of specs.
+  // Format: `projects/*/locations/*/apis/*/versions/*`
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      child_type: "apigeeregistry.googleapis.com/ApiSpec"
+    }
+  ];
+
+  // Required. The spec to create.
+  ApiSpec api_spec = 2 [(google.api.field_behavior) = REQUIRED];
+
+  // Required. The ID to use for the spec, which will become the final component of
+  // the spec's resource name.
+  //
+  // This value should be 4-63 characters, and valid characters
+  // are /[a-z][0-9]-/.
+  //
+  // Following AIP-162, IDs must not have the form of a UUID.
+  string api_spec_id = 3 [(google.api.field_behavior) = REQUIRED];
+}
+
+// Request message for UpdateApiSpec.
+message UpdateApiSpecRequest {
+  // Required. The spec to update.
+  //
+  // The `name` field is used to identify the spec to update.
+  // Format: `projects/*/locations/*/apis/*/versions/*/specs/*`
+  ApiSpec api_spec = 1 [(google.api.field_behavior) = REQUIRED];
+
+  // The list of fields to be updated. If omitted, all fields are updated that
+  // are set in the request message (fields set to default values are ignored).
+  // If an asterisk "*" is specified, all fields are updated, including fields
+  // that are unspecified/default in the request.
+  google.protobuf.FieldMask update_mask = 2;
+
+  // If set to true, and the spec is not found, a new spec will be created.
+  // In this situation, `update_mask` is ignored.
+  bool allow_missing = 3;
+}
+
+// Request message for DeleteApiSpec.
+message DeleteApiSpecRequest {
+  // Required. The name of the spec to delete.
+  // Format: `projects/*/locations/*/apis/*/versions/*/specs/*`
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiSpec"
+    }
+  ];
+
+  // If set to true, any child resources will also be deleted.
+  // (Otherwise, the request will only work if there are no child resources.)
+  bool force = 2;
+}
+
+// Request message for TagApiSpecRevision.
+message TagApiSpecRevisionRequest {
+  // Required. The name of the spec to be tagged, including the revision ID.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiSpec"
+    }
+  ];
+
+  // Required. The tag to apply.
+  // The tag should be at most 40 characters, and match `[a-z][a-z0-9-]{3,39}`.
+  string tag = 2 [(google.api.field_behavior) = REQUIRED];
+}
+
+// Request message for ListApiSpecRevisions.
+message ListApiSpecRevisionsRequest {
+  // Required. The name of the spec to list revisions for.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiSpec"
+    }
+  ];
+
+  // The maximum number of revisions to return per page.
+  int32 page_size = 2;
+
+  // The page token, received from a previous ListApiSpecRevisions call.
+  // Provide this to retrieve the subsequent page.
+  string page_token = 3;
+}
+
+// Response message for ListApiSpecRevisionsResponse.
+message ListApiSpecRevisionsResponse {
+  // The revisions of the spec.
+  repeated ApiSpec api_specs = 1;
+
+  // A token that can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there are no subsequent pages.
+  string next_page_token = 2;
+}
+
+// Request message for RollbackApiSpec.
+message RollbackApiSpecRequest {
+  // Required. The spec being rolled back.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiSpec"
+    }
+  ];
+
+  // Required. The revision ID to roll back to.
+  // It must be a revision of the same spec.
+  //
+  //   Example: `c7cfa2a8`
+  string revision_id = 2 [(google.api.field_behavior) = REQUIRED];
+}
+
+// Request message for DeleteApiSpecRevision.
+message DeleteApiSpecRevisionRequest {
+  // Required. The name of the spec revision to be deleted,
+  // with a revision ID explicitly included.
+  //
+  // Example:
+  // `projects/sample/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml@c7cfa2a8`
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiSpec"
+    }
+  ];
+}
+
+// Request message for ListApiDeployments.
+message ListApiDeploymentsRequest {
+  // Required. The parent, which owns this collection of deployments.
+  // Format: `projects/*/locations/*/apis/*`
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      child_type: "apigeeregistry.googleapis.com/ApiDeployment"
+    }
+  ];
+
+  // The maximum number of deployments to return.
+  // The service may return fewer than this value.
+  // If unspecified, at most 50 values will be returned.
+  // The maximum is 1000; values above 1000 will be coerced to 1000.
+  int32 page_size = 2;
+
+  // A page token, received from a previous `ListApiDeployments` call.
+  // Provide this to retrieve the subsequent page.
+  //
+  // When paginating, all other parameters provided to `ListApiDeployments` must
+  // match the call that provided the page token.
+  string page_token = 3;
+
+  // An expression that can be used to filter the list. Filters use the Common
+  // Expression Language and can refer to all message fields.
+  string filter = 4;
+}
+
+// Response message for ListApiDeployments.
+message ListApiDeploymentsResponse {
+  // The deployments from the specified publisher.
+  repeated ApiDeployment api_deployments = 1;
+
+  // A token, which can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there are no subsequent pages.
+  string next_page_token = 2;
+}
+
+// Request message for GetApiDeployment.
+message GetApiDeploymentRequest {
+  // Required. The name of the deployment to retrieve.
+  // Format: `projects/*/locations/*/apis/*/deployments/*`
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiDeployment"
+    }
+  ];
+}
+
+// Request message for CreateApiDeployment.
+message CreateApiDeploymentRequest {
+  // Required. The parent, which owns this collection of deployments.
+  // Format: `projects/*/locations/*/apis/*`
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      child_type: "apigeeregistry.googleapis.com/ApiDeployment"
+    }
+  ];
+
+  // Required. The deployment to create.
+  ApiDeployment api_deployment = 2 [(google.api.field_behavior) = REQUIRED];
+
+  // Required. The ID to use for the deployment, which will become the final component of
+  // the deployment's resource name.
+  //
+  // This value should be 4-63 characters, and valid characters
+  // are /[a-z][0-9]-/.
+  //
+  // Following AIP-162, IDs must not have the form of a UUID.
+  string api_deployment_id = 3 [(google.api.field_behavior) = REQUIRED];
+}
+
+// Request message for UpdateApiDeployment.
+message UpdateApiDeploymentRequest {
+  // Required. The deployment to update.
+  //
+  // The `name` field is used to identify the deployment to update.
+  // Format: `projects/*/locations/*/apis/*/deployments/*`
+  ApiDeployment api_deployment = 1 [(google.api.field_behavior) = REQUIRED];
+
+  // The list of fields to be updated. If omitted, all fields are updated that
+  // are set in the request message (fields set to default values are ignored).
+  // If an asterisk "*" is specified, all fields are updated, including fields
+  // that are unspecified/default in the request.
+  google.protobuf.FieldMask update_mask = 2;
+
+  // If set to true, and the deployment is not found, a new deployment will be
+  // created. In this situation, `update_mask` is ignored.
+  bool allow_missing = 3;
+}
+
+// Request message for DeleteApiDeployment.
+message DeleteApiDeploymentRequest {
+  // Required. The name of the deployment to delete.
+  // Format: `projects/*/locations/*/apis/*/deployments/*`
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiDeployment"
+    }
+  ];
+
+  // If set to true, any child resources will also be deleted.
+  // (Otherwise, the request will only work if there are no child resources.)
+  bool force = 2;
+}
+
+// Request message for TagApiDeploymentRevision.
+message TagApiDeploymentRevisionRequest {
+  // Required. The name of the deployment to be tagged, including the revision ID.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiDeployment"
+    }
+  ];
+
+  // Required. The tag to apply.
+  // The tag should be at most 40 characters, and match `[a-z][a-z0-9-]{3,39}`.
+  string tag = 2 [(google.api.field_behavior) = REQUIRED];
+}
+
+// Request message for ListApiDeploymentRevisions.
+message ListApiDeploymentRevisionsRequest {
+  // Required. The name of the deployment to list revisions for.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiDeployment"
+    }
+  ];
+
+  // The maximum number of revisions to return per page.
+  int32 page_size = 2;
+
+  // The page token, received from a previous ListApiDeploymentRevisions call.
+  // Provide this to retrieve the subsequent page.
+  string page_token = 3;
+}
+
+// Response message for ListApiDeploymentRevisionsResponse.
+message ListApiDeploymentRevisionsResponse {
+  // The revisions of the deployment.
+  repeated ApiDeployment api_deployments = 1;
+
+  // A token that can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there are no subsequent pages.
+  string next_page_token = 2;
+}
+
+// Request message for RollbackApiDeployment.
+message RollbackApiDeploymentRequest {
+  // Required. The deployment being rolled back.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiDeployment"
+    }
+  ];
+
+  // Required. The revision ID to roll back to.
+  // It must be a revision of the same deployment.
+  //
+  //   Example: `c7cfa2a8`
+  string revision_id = 2 [(google.api.field_behavior) = REQUIRED];
+}
+
+// Request message for DeleteApiDeploymentRevision.
+message DeleteApiDeploymentRevisionRequest {
+  // Required. The name of the deployment revision to be deleted,
+  // with a revision ID explicitly included.
+  //
+  // Example:
+  // `projects/sample/locations/global/apis/petstore/deployments/prod@c7cfa2a8`
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/ApiDeployment"
+    }
+  ];
+}
+
+// Request message for ListArtifacts.
+message ListArtifactsRequest {
+  // Required. The parent, which owns this collection of artifacts.
+  // Format: `{parent}`
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      child_type: "apigeeregistry.googleapis.com/Artifact"
+    }
+  ];
+
+  // The maximum number of artifacts to return.
+  // The service may return fewer than this value.
+  // If unspecified, at most 50 values will be returned.
+  // The maximum is 1000; values above 1000 will be coerced to 1000.
+  int32 page_size = 2;
+
+  // A page token, received from a previous `ListArtifacts` call.
+  // Provide this to retrieve the subsequent page.
+  //
+  // When paginating, all other parameters provided to `ListArtifacts` must
+  // match the call that provided the page token.
+  string page_token = 3;
+
+  // An expression that can be used to filter the list. Filters use the Common
+  // Expression Language and can refer to all message fields except contents.
+  string filter = 4;
+}
+
+// Response message for ListArtifacts.
+message ListArtifactsResponse {
+  // The artifacts from the specified publisher.
+  repeated Artifact artifacts = 1;
+
+  // A token, which can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there are no subsequent pages.
+  string next_page_token = 2;
+}
+
+// Request message for GetArtifact.
+message GetArtifactRequest {
+  // Required. The name of the artifact to retrieve.
+  // Format: `{parent}/artifacts/*`
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/Artifact"
+    }
+  ];
+}
+
+// Request message for GetArtifactContents.
+message GetArtifactContentsRequest {
+  // Required. The name of the artifact whose contents should be retrieved.
+  // Format: `{parent}/artifacts/*`
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/Artifact"
+    }
+  ];
+}
+
+// Request message for CreateArtifact.
+message CreateArtifactRequest {
+  // Required. The parent, which owns this collection of artifacts.
+  // Format: `{parent}`
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      child_type: "apigeeregistry.googleapis.com/Artifact"
+    }
+  ];
+
+  // Required. The artifact to create.
+  Artifact artifact = 2 [(google.api.field_behavior) = REQUIRED];
+
+  // Required. The ID to use for the artifact, which will become the final component of
+  // the artifact's resource name.
+  //
+  // This value should be 4-63 characters, and valid characters
+  // are /[a-z][0-9]-/.
+  //
+  // Following AIP-162, IDs must not have the form of a UUID.
+  string artifact_id = 3 [(google.api.field_behavior) = REQUIRED];
+}
+
+// Request message for ReplaceArtifact.
+message ReplaceArtifactRequest {
+  // Required. The artifact to replace.
+  //
+  // The `name` field is used to identify the artifact to replace.
+  // Format: `{parent}/artifacts/*`
+  Artifact artifact = 1 [(google.api.field_behavior) = REQUIRED];
+}
+
+// Request message for DeleteArtifact.
+message DeleteArtifactRequest {
+  // Required. The name of the artifact to delete.
+  // Format: `{parent}/artifacts/*`
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {
+      type: "apigeeregistry.googleapis.com/Artifact"
+    }
+  ];
+}
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/README.md b/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/README.md
new file mode 100644
index 0000000..ee3aa68
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/README.md
@@ -0,0 +1,4 @@
+# Introduction
+This is a Google example service representing a simple digital library.
+It manages a collection of shelf resources, and each shelf owns a collection
+of book resources.
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/BUILD.bazel b/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/BUILD.bazel
new file mode 100644
index 0000000..b3d45a5
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/BUILD.bazel
@@ -0,0 +1,328 @@
+# This file was automatically generated by BuildFileGenerator
+# https://github.com/googleapis/rules_gapic/tree/master/bazel
+
+# Most of the manual changes to this file will be overwritten.
+# It's **only** allowed to change the following rule attribute values:
+# - names of *_gapic_assembly_* rules
+# - certain parameters of *_gapic_library rules, including but not limited to:
+#    * extra_protoc_parameters
+#    * extra_protoc_file_parameters
+# The complete list of preserved parameters can be found in the source code.
+
+##############################################################################
+# Common
+##############################################################################
+load("@rules_proto//proto:defs.bzl", "proto_library")
+load(
+    "@com_google_googleapis_imports//:imports.bzl",
+    "csharp_gapic_assembly_pkg",
+    "csharp_gapic_library",
+    "csharp_grpc_library",
+    "csharp_proto_library",
+    "go_gapic_assembly_pkg",
+    "go_gapic_library",
+    "go_proto_library",
+    "go_test",
+    "java_gapic_assembly_gradle_pkg",
+    "java_gapic_library",
+    "java_gapic_test",
+    "java_grpc_library",
+    "java_proto_library",
+    "nodejs_gapic_assembly_pkg",
+    "nodejs_gapic_library",
+    "php_gapic_assembly_pkg",
+    "php_gapic_library",
+    "php_grpc_library",
+    "php_proto_library",
+    "proto_library_with_info",
+    "py_gapic_assembly_pkg",
+    "py_gapic_library",
+    "py_test",
+    "ruby_cloud_gapic_library",
+    "ruby_gapic_assembly_pkg",
+    "ruby_grpc_library",
+    "ruby_proto_library",
+)
+
+# This is an API workspace, having public visibility by default makes perfect sense.
+package(default_visibility = ["//visibility:public"])
+
+proto_library(
+    name = "library_proto",
+    srcs = [
+        "library.proto",
+    ],
+    deps = [
+        "//google/api:annotations_proto",
+        "//google/api:client_proto",
+        "//google/api:field_behavior_proto",
+        "//google/api:resource_proto",
+        "@com_google_protobuf//:empty_proto",
+        "@com_google_protobuf//:field_mask_proto",
+    ],
+)
+
+proto_library_with_info(
+    name = "library_proto_with_info",
+    deps = [
+        ":library_proto",
+        "//google/cloud:common_resources_proto",
+    ],
+)
+
+java_proto_library(
+    name = "library_java_proto",
+    deps = [":library_proto"],
+)
+
+java_grpc_library(
+    name = "library_java_grpc",
+    srcs = [":library_proto"],
+    deps = [":library_java_proto"],
+)
+
+java_gapic_library(
+    name = "library_java_gapic",
+    srcs = [":library_proto_with_info"],
+    gapic_yaml = "library_example_gapic.yaml",
+    grpc_service_config = "library_grpc_service_config.json",
+    test_deps = [
+        ":library_java_grpc",
+    ],
+    transport = "grpc+rest",
+    deps = [
+        ":library_java_proto",
+    ],
+)
+
+java_gapic_test(
+    name = "library_java_gapic_test_suite",
+    test_classes = [
+        "com.google.cloud.example.library.v1.LibraryServiceClientHttpJsonTest",
+        "com.google.cloud.example.library.v1.LibraryServiceClientTest",
+    ],
+    runtime_deps = [":library_java_gapic_test"],
+)
+
+# Open Source Packages
+java_gapic_assembly_gradle_pkg(
+    name = "google-cloud-example-library-v1-java",
+    transport = "grpc+rest",
+    deps = [
+        ":library_java_gapic",
+        ":library_java_grpc",
+        ":library_java_proto",
+        ":library_proto",
+    ],
+)
+
+go_proto_library(
+    name = "library_go_proto",
+    compilers = ["@io_bazel_rules_go//proto:go_grpc"],
+    importpath = "google.golang.org/genproto/googleapis/example/library/v1",
+    protos = [":library_proto"],
+    deps = [
+        "//google/api:annotations_go_proto",
+    ],
+)
+
+go_gapic_library(
+    name = "library_go_gapic",
+    srcs = [":library_proto_with_info"],
+    grpc_service_config = "library_grpc_service_config.json",
+    importpath = "google.golang.org/google/example/library/v1;library",
+    metadata = True,
+    service_yaml = "//google/example/library:library_example_v1.yaml",
+    transport = "grpc+rest",
+    deps = [
+        ":library_go_proto",
+    ],
+)
+
+go_test(
+    name = "library_go_gapic_test",
+    srcs = [":library_go_gapic_srcjar_test"],
+    embed = [":library_go_gapic"],
+    importpath = "google.golang.org/google/example/library/v1",
+)
+
+# Open Source Packages
+go_gapic_assembly_pkg(
+    name = "gapi-cloud-example-library-v1-go",
+    deps = [
+        ":library_go_gapic",
+        ":library_go_gapic_srcjar-metadata.srcjar",
+        ":library_go_gapic_srcjar-test.srcjar",
+        ":library_go_proto",
+    ],
+)
+
+py_gapic_library(
+    name = "library_py_gapic",
+    srcs = [":library_proto"],
+    grpc_service_config = "library_grpc_service_config.json",
+)
+
+py_test(
+    name = "library_py_gapic_test",
+    srcs = [
+        "library_py_gapic_pytest.py",
+        "library_py_gapic_test.py",
+    ],
+    legacy_create_init = False,
+    deps = [":library_py_gapic"],
+)
+
+# Open Source Packages
+py_gapic_assembly_pkg(
+    name = "example-library-v1-py",
+    deps = [
+        ":library_py_gapic",
+    ],
+)
+
+php_proto_library(
+    name = "library_php_proto",
+    deps = [":library_proto"],
+)
+
+php_grpc_library(
+    name = "library_php_grpc",
+    srcs = [":library_proto"],
+    deps = [":library_php_proto"],
+)
+
+php_gapic_library(
+    name = "library_php_gapic",
+    srcs = [":library_proto_with_info"],
+    grpc_service_config = "library_grpc_service_config.json",
+    service_yaml = "//google/example/library:library_example_v1.yaml",
+    deps = [
+        ":library_php_grpc",
+        ":library_php_proto",
+    ],
+)
+
+# Open Source Packages
+php_gapic_assembly_pkg(
+    name = "google-cloud-example-library-v1-php",
+    deps = [
+        ":library_php_gapic",
+        ":library_php_grpc",
+        ":library_php_proto",
+    ],
+)
+
+nodejs_gapic_library(
+    name = "library_nodejs_gapic",
+    package_name = "@google-cloud/library",
+    src = ":library_proto_with_info",
+    extra_protoc_parameters = ["metadata"],
+    grpc_service_config = "library_grpc_service_config.json",
+    package = "google.cloud.example.library.v1",
+    service_yaml = "//google/example/library:library_example_v1.yaml",
+    deps = [],
+)
+
+nodejs_gapic_assembly_pkg(
+    name = "example-library-v1-nodejs",
+    deps = [
+        ":library_nodejs_gapic",
+        ":library_proto",
+    ],
+)
+
+ruby_proto_library(
+    name = "library_ruby_proto",
+    deps = [":library_proto"],
+)
+
+ruby_grpc_library(
+    name = "library_ruby_grpc",
+    srcs = [":library_proto"],
+    deps = [":library_ruby_proto"],
+)
+
+# Uncomment the following once the space issue has been fixed.
+ruby_cloud_gapic_library(
+    name = "library_ruby_gapic",
+    srcs = [":library_proto_with_info"],
+    extra_protoc_parameters = [
+        "ruby-cloud-gem-name=google-cloud-example_library-v1",
+        "ruby-cloud-env-prefix=LIBRARY",
+        "ruby-cloud-product-url=https://cloud.google.com",
+        "ruby-cloud-api-id=library-example.googleapis.com",
+        "ruby-cloud-api-shortname=library",
+    ],
+    grpc_service_config = "library_grpc_service_config.json",
+    ruby_cloud_description = "This is a Google example service representing a simple digital library. It manages a collection of shelf resources, and each shelf owns a collection of book resources.",
+    ruby_cloud_title = "Example Library V1",
+    deps = [
+        ":library_ruby_grpc",
+        ":library_ruby_proto",
+    ],
+)
+
+# Open Source Packages
+ruby_gapic_assembly_pkg(
+    name = "google-cloud-example-library-v1-ruby",
+    deps = [
+        ":library_ruby_gapic",
+        ":library_ruby_grpc",
+        ":library_ruby_proto",
+    ],
+)
+
+csharp_proto_library(
+    name = "library_csharp_proto",
+    deps = [":library_proto"],
+)
+
+csharp_grpc_library(
+    name = "library_csharp_grpc",
+    srcs = [":library_proto"],
+    deps = [":library_csharp_proto"],
+)
+
+csharp_gapic_library(
+    name = "library_csharp_gapic",
+    srcs = [":library_proto_with_info"],
+    common_resources_config = "@gax_dotnet//:Google.Api.Gax/ResourceNames/CommonResourcesConfig.json",
+    grpc_service_config = "library_grpc_service_config.json",
+    service_yaml = "//google/example/library:library_example_v1.yaml",
+    deps = [
+        ":library_csharp_grpc",
+        ":library_csharp_proto",
+    ],
+)
+
+# Open Source Packages
+csharp_gapic_assembly_pkg(
+    name = "google-cloud-example-library-v1-csharp",
+    deps = [
+        ":library_csharp_gapic",
+        ":library_csharp_grpc",
+        ":library_csharp_proto",
+    ],
+)
+
+##############################################################################
+# C++
+##############################################################################
+load(
+    "@com_google_googleapis_imports//:imports.bzl",
+    "cc_grpc_library",
+    "cc_proto_library",
+)
+
+cc_proto_library(
+    name = "library_cc_proto",
+    deps = [":library_proto"],
+)
+
+cc_grpc_library(
+    name = "library_cc_grpc",
+    srcs = [":library_proto"],
+    grpc_only = True,
+    deps = [":library_cc_proto"],
+)
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/library.proto b/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/library.proto
new file mode 100644
index 0000000..4e2a374
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/library.proto
@@ -0,0 +1,346 @@
+// Copyright 2019 Google LLC.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+syntax = "proto3";
+
+package google.example.library.v1;
+
+import "google/api/annotations.proto";
+import "google/api/client.proto";
+import "google/api/field_behavior.proto";
+import "google/api/resource.proto";
+import "google/protobuf/empty.proto";
+import "google/protobuf/field_mask.proto";
+
+option go_package = "google.golang.org/genproto/googleapis/example/library/v1;library";
+option java_multiple_files = true;
+option java_outer_classname = "LibraryProto";
+option java_package = "com.google.example.library.v1";
+option php_namespace = "Google\\Cloud\\Example\\Library\\V1";
+
+// This API represents a simple digital library. It lets you manage Shelf
+// resources and Book resources in the library. It defines the following
+// resource model:
+//
+// - The API has a collection of [Shelf][google.example.library.v1.Shelf]
+//   resources, named `shelves/*`
+//
+// - Each Shelf has a collection of [Book][google.example.library.v1.Book]
+//   resources, named `shelves/*/books/*`
+service LibraryService {
+  option (google.api.default_host) = "library-example.googleapis.com";
+
+  // Creates a shelf, and returns the new Shelf.
+  rpc CreateShelf(CreateShelfRequest) returns (Shelf) {
+    option (google.api.http) = {
+      post: "/v1/shelves"
+      body: "shelf"
+    };
+    option (google.api.method_signature) = "shelf";
+  }
+
+  // Gets a shelf. Returns NOT_FOUND if the shelf does not exist.
+  rpc GetShelf(GetShelfRequest) returns (Shelf) {
+    option (google.api.http) = {
+      get: "/v1/{name=shelves/*}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Lists shelves. The order is unspecified but deterministic. Newly created
+  // shelves will not necessarily be added to the end of this list.
+  rpc ListShelves(ListShelvesRequest) returns (ListShelvesResponse) {
+    option (google.api.http) = {
+      get: "/v1/shelves"
+    };
+  }
+
+  // Deletes a shelf. Returns NOT_FOUND if the shelf does not exist.
+  rpc DeleteShelf(DeleteShelfRequest) returns (google.protobuf.Empty) {
+    option (google.api.http) = {
+      delete: "/v1/{name=shelves/*}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Merges two shelves by adding all books from the shelf named
+  // `other_shelf_name` to shelf `name`, and deletes
+  // `other_shelf_name`. Returns the updated shelf.
+  // The book ids of the moved books may not be the same as the original books.
+  //
+  // Returns NOT_FOUND if either shelf does not exist.
+  // This call is a no-op if the specified shelves are the same.
+  rpc MergeShelves(MergeShelvesRequest) returns (Shelf) {
+    option (google.api.http) = {
+      post: "/v1/{name=shelves/*}:merge"
+      body: "*"
+    };
+    option (google.api.method_signature) = "name,other_shelf";
+  }
+
+  // Creates a book, and returns the new Book.
+  rpc CreateBook(CreateBookRequest) returns (Book) {
+    option (google.api.http) = {
+      post: "/v1/{parent=shelves/*}/books"
+      body: "book"
+    };
+    option (google.api.method_signature) = "parent,book";
+  }
+
+  // Gets a book. Returns NOT_FOUND if the book does not exist.
+  rpc GetBook(GetBookRequest) returns (Book) {
+    option (google.api.http) = {
+      get: "/v1/{name=shelves/*/books/*}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Lists books in a shelf. The order is unspecified but deterministic. Newly
+  // created books will not necessarily be added to the end of this list.
+  // Returns NOT_FOUND if the shelf does not exist.
+  rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {
+    option (google.api.http) = {
+      get: "/v1/{parent=shelves/*}/books"
+    };
+    option (google.api.method_signature) = "parent";
+  }
+
+  // Deletes a book. Returns NOT_FOUND if the book does not exist.
+  rpc DeleteBook(DeleteBookRequest) returns (google.protobuf.Empty) {
+    option (google.api.http) = {
+      delete: "/v1/{name=shelves/*/books/*}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Updates a book. Returns INVALID_ARGUMENT if the name of the book
+  // is non-empty and does not equal the existing name.
+  rpc UpdateBook(UpdateBookRequest) returns (Book) {
+    option (google.api.http) = {
+      patch: "/v1/{book.name=shelves/*/books/*}"
+      body: "book"
+    };
+    option (google.api.method_signature) = "book,update_mask";
+  }
+
+  // Moves a book to another shelf, and returns the new book. The book
+  // id of the new book may not be the same as the original book.
+  rpc MoveBook(MoveBookRequest) returns (Book) {
+    option (google.api.http) = {
+      post: "/v1/{name=shelves/*/books/*}:move"
+      body: "*"
+    };
+    option (google.api.method_signature) = "name,other_shelf_name";
+  }
+}
+
+// A single book in the library.
+message Book {
+  option (google.api.resource) = {
+    type: "library-example.googleapis.com/Book",
+    pattern: "shelves/{shelf}/books/{book}"
+  };
+
+  // The resource name of the book.
+  // Book names have the form `shelves/{shelf_id}/books/{book_id}`.
+  // The name is ignored when creating a book.
+  string name = 1;
+
+  // The name of the book author.
+  string author = 2;
+
+  // The title of the book.
+  string title = 3;
+
+  // Value indicating whether the book has been read.
+  bool read = 4;
+}
+
+// A Shelf contains a collection of books with a theme.
+message Shelf {
+  option (google.api.resource) = {
+    type: "library-example.googleapis.com/Shelf",
+    pattern: "shelves/{shelf_id}"
+  };
+
+  // The resource name of the shelf.
+  // Shelf names have the form `shelves/{shelf_id}`.
+  // The name is ignored when creating a shelf.
+  string name = 1;
+
+  // The theme of the shelf
+  string theme = 2;
+}
+
+// Request message for LibraryService.CreateShelf.
+message CreateShelfRequest {
+  // The shelf to create.
+  Shelf shelf = 1 [(google.api.field_behavior) = REQUIRED];
+}
+
+// Request message for LibraryService.GetShelf.
+message GetShelfRequest {
+  // The name of the shelf to retrieve.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference).type =
+        "library-example.googleapis.com/Shelf"
+  ];
+}
+
+// Request message for LibraryService.ListShelves.
+message ListShelvesRequest {
+  // Requested page size. Server may return fewer shelves than requested.
+  // If unspecified, server will pick an appropriate default.
+  int32 page_size = 1;
+
+  // A token identifying a page of results the server should return.
+  // Typically, this is the value of
+  // [ListShelvesResponse.next_page_token][google.example.library.v1.ListShelvesResponse.next_page_token]
+  // returned from the previous call to `ListShelves` method.
+  string page_token = 2;
+}
+
+// Response message for LibraryService.ListShelves.
+message ListShelvesResponse {
+  // The list of shelves.
+  repeated Shelf shelves = 1;
+
+  // A token to retrieve next page of results.
+  // Pass this value in the
+  // [ListShelvesRequest.page_token][google.example.library.v1.ListShelvesRequest.page_token]
+  // field in the subsequent call to `ListShelves` method to retrieve the next
+  // page of results.
+  string next_page_token = 2;
+}
+
+// Request message for LibraryService.DeleteShelf.
+message DeleteShelfRequest {
+  // The name of the shelf to delete.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference).type =
+        "library-example.googleapis.com/Shelf"
+  ];
+}
+
+// Describes the shelf being removed (other_shelf_name) and updated
+// (name) in this merge.
+message MergeShelvesRequest {
+  // The name of the shelf we're adding books to.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference).type =
+        "library-example.googleapis.com/Shelf"
+  ];
+
+  // The name of the shelf we're removing books from and deleting.
+  string other_shelf = 2 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference).type =
+        "library-example.googleapis.com/Shelf"
+  ];
+}
+
+// Request message for LibraryService.CreateBook.
+message CreateBookRequest {
+  // The name of the shelf in which the book is created.
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference).type =
+        "library-example.googleapis.com/Shelf"
+  ];
+
+  // The book to create.
+  Book book = 2 [(google.api.field_behavior) = REQUIRED];
+}
+
+// Request message for LibraryService.GetBook.
+message GetBookRequest {
+  // The name of the book to retrieve.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference).type = "library-example.googleapis.com/Book"
+  ];
+}
+
+// Request message for LibraryService.ListBooks.
+message ListBooksRequest {
+  // The name of the shelf whose books we'd like to list.
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference).type =
+        "library-example.googleapis.com/Shelf"
+  ];
+
+  // Requested page size. Server may return fewer books than requested.
+  // If unspecified, server will pick an appropriate default.
+  int32 page_size = 2;
+
+  // A token identifying a page of results the server should return.
+  // Typically, this is the value of
+  // [ListBooksResponse.next_page_token][google.example.library.v1.ListBooksResponse.next_page_token].
+  // returned from the previous call to `ListBooks` method.
+  string page_token = 3;
+}
+
+// Response message for LibraryService.ListBooks.
+message ListBooksResponse {
+  // The list of books.
+  repeated Book books = 1;
+
+  // A token to retrieve next page of results.
+  // Pass this value in the
+  // [ListBooksRequest.page_token][google.example.library.v1.ListBooksRequest.page_token]
+  // field in the subsequent call to `ListBooks` method to retrieve the next
+  // page of results.
+  string next_page_token = 2;
+}
+
+// Request message for LibraryService.UpdateBook.
+message UpdateBookRequest {
+  // The name of the book to update.
+  Book book = 1 [(google.api.field_behavior) = REQUIRED];
+
+  // Required. Mask of fields to update.
+  google.protobuf.FieldMask update_mask = 2
+      [(google.api.field_behavior) = REQUIRED];
+}
+
+// Request message for LibraryService.DeleteBook.
+message DeleteBookRequest {
+  // The name of the book to delete.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference).type = "library-example.googleapis.com/Book"
+  ];
+}
+
+// Describes what book to move (name) and what shelf we're moving it
+// to (other_shelf_name).
+message MoveBookRequest {
+  // The name of the book to move.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference).type = "library-example.googleapis.com/Book"
+  ];
+
+  // The name of the destination shelf.
+  string other_shelf_name = 2 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference).type =
+        "library-example.googleapis.com/Shelf"
+  ];
+}
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/library_example_gapic.yaml b/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/library_example_gapic.yaml
new file mode 100644
index 0000000..af79caf
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/library_example_gapic.yaml
@@ -0,0 +1,6 @@
+type: com.google.api.codegen.ConfigProto
+config_schema_version: 2.0.0
+# The settings of generated code in a specific language.
+language_settings:
+  java:
+    package_name: com.google.cloud.example.library.v1
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/library_example_v1.yaml b/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/library_example_v1.yaml
new file mode 100644
index 0000000..a1297cf
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/library_example_v1.yaml
@@ -0,0 +1,41 @@
+type: google.api.Service
+config_version: 3
+name: library-example.googleapis.com
+title: Example Library API
+
+apis:
+- name: google.example.library.v1.LibraryService
+
+documentation:
+  summary: A simple Google Example Library API.
+  overview: |-
+    # Introduction
+
+    This is a Google example service representing a simple digital library. It
+    manages a collection of shelf resources, and each shelf owns a collection of
+    book resources.
+
+backend:
+  rules:
+  - selector: google.example.library.v1.LibraryService.CreateShelf
+    deadline: 10.0
+  - selector: google.example.library.v1.LibraryService.GetShelf
+    deadline: 10.0
+  - selector: google.example.library.v1.LibraryService.ListShelves
+    deadline: 10.0
+  - selector: google.example.library.v1.LibraryService.DeleteShelf
+    deadline: 10.0
+  - selector: google.example.library.v1.LibraryService.MergeShelves
+    deadline: 10.0
+  - selector: google.example.library.v1.LibraryService.CreateBook
+    deadline: 10.0
+  - selector: google.example.library.v1.LibraryService.GetBook
+    deadline: 10.0
+  - selector: google.example.library.v1.LibraryService.ListBooks
+    deadline: 10.0
+  - selector: google.example.library.v1.LibraryService.DeleteBook
+    deadline: 10.0
+  - selector: google.example.library.v1.LibraryService.UpdateBook
+    deadline: 10.0
+  - selector: google.example.library.v1.LibraryService.MoveBook
+    deadline: 10.0
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/library_grpc_service_config.json b/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/library_grpc_service_config.json
new file mode 100644
index 0000000..5404d66
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/example/library/v1/library_grpc_service_config.json
@@ -0,0 +1,75 @@
+{
+  "methodConfig": [
+    {
+      "name": [
+        {
+          "service": "google.example.library.v1.LibraryService",
+          "method": "GetShelf"
+        },
+        {
+          "service": "google.example.library.v1.LibraryService",
+          "method": "ListShelves"
+        },
+        {
+          "service": "google.example.library.v1.LibraryService",
+          "method": "DeleteShelf"
+        },
+        {
+          "service": "google.example.library.v1.LibraryService",
+          "method": "GetBook"
+        },
+        {
+          "service": "google.example.library.v1.LibraryService",
+          "method": "ListBooks"
+        },
+        {
+          "service": "google.example.library.v1.LibraryService",
+          "method": "DeleteBook"
+        },
+        {
+          "service": "google.example.library.v1.LibraryService",
+          "method": "UpdateBook"
+        }
+      ],
+      "timeout": "60s",
+      "retryPolicy": {
+        "maxAttempts": 5,
+        "initialBackoff": "0.100s",
+        "maxBackoff": "60s",
+        "backoffMultiplier": 1.3,
+        "retryableStatusCodes": [
+          "DEADLINE_EXCEEDED",
+          "UNAVAILABLE"
+        ]
+      }
+    },
+    {
+      "name": [
+        {
+          "service": "google.example.library.v1.LibraryService",
+          "method": "CreateShelf"
+        },
+        {
+          "service": "google.example.library.v1.LibraryService",
+          "method": "MergeShelves"
+        },
+        {
+          "service": "google.example.library.v1.LibraryService",
+          "method": "CreateBook"
+        },
+        {
+          "service": "google.example.library.v1.LibraryService",
+          "method": "MoveBook"
+        }
+      ],
+      "timeout": "60s",
+      "retryPolicy": {
+        "maxAttempts": 5,
+        "initialBackoff": "0.100s",
+        "maxBackoff": "60s",
+        "backoffMultiplier": 1.3,
+        "retryableStatusCodes": []
+      }
+    }
+  ]
+}
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/longrunning/operations.proto b/cmd/registry/cmd/upload/bulk/testdata/protos/google/longrunning/operations.proto
new file mode 100644
index 0000000..c1fdc6f
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/longrunning/operations.proto
@@ -0,0 +1,247 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package google.longrunning;
+
+import "google/api/annotations.proto";
+import "google/api/client.proto";
+import "google/protobuf/any.proto";
+import "google/protobuf/duration.proto";
+import "google/protobuf/empty.proto";
+import "google/rpc/status.proto";
+import "google/protobuf/descriptor.proto";
+
+option cc_enable_arenas = true;
+option csharp_namespace = "Google.LongRunning";
+option go_package = "google.golang.org/genproto/googleapis/longrunning;longrunning";
+option java_multiple_files = true;
+option java_outer_classname = "OperationsProto";
+option java_package = "com.google.longrunning";
+option php_namespace = "Google\\LongRunning";
+
+extend google.protobuf.MethodOptions {
+  // Additional information regarding long-running operations.
+  // In particular, this specifies the types that are returned from
+  // long-running operations.
+  //
+  // Required for methods that return `google.longrunning.Operation`; invalid
+  // otherwise.
+  google.longrunning.OperationInfo operation_info = 1049;
+}
+
+// Manages long-running operations with an API service.
+//
+// When an API method normally takes long time to complete, it can be designed
+// to return [Operation][google.longrunning.Operation] to the client, and the client can use this
+// interface to receive the real response asynchronously by polling the
+// operation resource, or pass the operation resource to another API (such as
+// Google Cloud Pub/Sub API) to receive the response.  Any API service that
+// returns long-running operations should implement the `Operations` interface
+// so developers can have a consistent client experience.
+service Operations {
+  option (google.api.default_host) = "longrunning.googleapis.com";
+
+  // Lists operations that match the specified filter in the request. If the
+  // server doesn't support this method, it returns `UNIMPLEMENTED`.
+  //
+  // NOTE: the `name` binding allows API services to override the binding
+  // to use different resource name schemes, such as `users/*/operations`. To
+  // override the binding, API services can add a binding such as
+  // `"/v1/{name=users/*}/operations"` to their service configuration.
+  // For backwards compatibility, the default name includes the operations
+  // collection id, however overriding users must ensure the name binding
+  // is the parent resource, without the operations collection id.
+  rpc ListOperations(ListOperationsRequest) returns (ListOperationsResponse) {
+    option (google.api.http) = {
+      get: "/v1/{name=operations}"
+    };
+    option (google.api.method_signature) = "name,filter";
+  }
+
+  // Gets the latest state of a long-running operation.  Clients can use this
+  // method to poll the operation result at intervals as recommended by the API
+  // service.
+  rpc GetOperation(GetOperationRequest) returns (Operation) {
+    option (google.api.http) = {
+      get: "/v1/{name=operations/**}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Deletes a long-running operation. This method indicates that the client is
+  // no longer interested in the operation result. It does not cancel the
+  // operation. If the server doesn't support this method, it returns
+  // `google.rpc.Code.UNIMPLEMENTED`.
+  rpc DeleteOperation(DeleteOperationRequest) returns (google.protobuf.Empty) {
+    option (google.api.http) = {
+      delete: "/v1/{name=operations/**}"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Starts asynchronous cancellation on a long-running operation.  The server
+  // makes a best effort to cancel the operation, but success is not
+  // guaranteed.  If the server doesn't support this method, it returns
+  // `google.rpc.Code.UNIMPLEMENTED`.  Clients can use
+  // [Operations.GetOperation][google.longrunning.Operations.GetOperation] or
+  // other methods to check whether the cancellation succeeded or whether the
+  // operation completed despite cancellation. On successful cancellation,
+  // the operation is not deleted; instead, it becomes an operation with
+  // an [Operation.error][google.longrunning.Operation.error] value with a [google.rpc.Status.code][google.rpc.Status.code] of 1,
+  // corresponding to `Code.CANCELLED`.
+  rpc CancelOperation(CancelOperationRequest) returns (google.protobuf.Empty) {
+    option (google.api.http) = {
+      post: "/v1/{name=operations/**}:cancel"
+      body: "*"
+    };
+    option (google.api.method_signature) = "name";
+  }
+
+  // Waits until the specified long-running operation is done or reaches at most
+  // a specified timeout, returning the latest state.  If the operation is
+  // already done, the latest state is immediately returned.  If the timeout
+  // specified is greater than the default HTTP/RPC timeout, the HTTP/RPC
+  // timeout is used.  If the server does not support this method, it returns
+  // `google.rpc.Code.UNIMPLEMENTED`.
+  // Note that this method is on a best-effort basis.  It may return the latest
+  // state before the specified timeout (including immediately), meaning even an
+  // immediate response is no guarantee that the operation is done.
+  rpc WaitOperation(WaitOperationRequest) returns (Operation) {
+  }
+}
+
+// This resource represents a long-running operation that is the result of a
+// network API call.
+message Operation {
+  // The server-assigned name, which is only unique within the same service that
+  // originally returns it. If you use the default HTTP mapping, the
+  // `name` should be a resource name ending with `operations/{unique_id}`.
+  string name = 1;
+
+  // Service-specific metadata associated with the operation.  It typically
+  // contains progress information and common metadata such as create time.
+  // Some services might not provide such metadata.  Any method that returns a
+  // long-running operation should document the metadata type, if any.
+  google.protobuf.Any metadata = 2;
+
+  // If the value is `false`, it means the operation is still in progress.
+  // If `true`, the operation is completed, and either `error` or `response` is
+  // available.
+  bool done = 3;
+
+  // The operation result, which can be either an `error` or a valid `response`.
+  // If `done` == `false`, neither `error` nor `response` is set.
+  // If `done` == `true`, exactly one of `error` or `response` is set.
+  oneof result {
+    // The error result of the operation in case of failure or cancellation.
+    google.rpc.Status error = 4;
+
+    // The normal response of the operation in case of success.  If the original
+    // method returns no data on success, such as `Delete`, the response is
+    // `google.protobuf.Empty`.  If the original method is standard
+    // `Get`/`Create`/`Update`, the response should be the resource.  For other
+    // methods, the response should have the type `XxxResponse`, where `Xxx`
+    // is the original method name.  For example, if the original method name
+    // is `TakeSnapshot()`, the inferred response type is
+    // `TakeSnapshotResponse`.
+    google.protobuf.Any response = 5;
+  }
+}
+
+// The request message for [Operations.GetOperation][google.longrunning.Operations.GetOperation].
+message GetOperationRequest {
+  // The name of the operation resource.
+  string name = 1;
+}
+
+// The request message for [Operations.ListOperations][google.longrunning.Operations.ListOperations].
+message ListOperationsRequest {
+  // The name of the operation's parent resource.
+  string name = 4;
+
+  // The standard list filter.
+  string filter = 1;
+
+  // The standard list page size.
+  int32 page_size = 2;
+
+  // The standard list page token.
+  string page_token = 3;
+}
+
+// The response message for [Operations.ListOperations][google.longrunning.Operations.ListOperations].
+message ListOperationsResponse {
+  // A list of operations that matches the specified filter in the request.
+  repeated Operation operations = 1;
+
+  // The standard List next-page token.
+  string next_page_token = 2;
+}
+
+// The request message for [Operations.CancelOperation][google.longrunning.Operations.CancelOperation].
+message CancelOperationRequest {
+  // The name of the operation resource to be cancelled.
+  string name = 1;
+}
+
+// The request message for [Operations.DeleteOperation][google.longrunning.Operations.DeleteOperation].
+message DeleteOperationRequest {
+  // The name of the operation resource to be deleted.
+  string name = 1;
+}
+
+// The request message for [Operations.WaitOperation][google.longrunning.Operations.WaitOperation].
+message WaitOperationRequest {
+  // The name of the operation resource to wait on.
+  string name = 1;
+
+  // The maximum duration to wait before timing out. If left blank, the wait
+  // will be at most the time permitted by the underlying HTTP/RPC protocol.
+  // If RPC context deadline is also specified, the shorter one will be used.
+  google.protobuf.Duration timeout = 2;
+}
+
+// A message representing the message types used by a long-running operation.
+//
+// Example:
+//
+//   rpc LongRunningRecognize(LongRunningRecognizeRequest)
+//       returns (google.longrunning.Operation) {
+//     option (google.longrunning.operation_info) = {
+//       response_type: "LongRunningRecognizeResponse"
+//       metadata_type: "LongRunningRecognizeMetadata"
+//     };
+//   }
+message OperationInfo {
+  // Required. The message name of the primary return type for this
+  // long-running operation.
+  // This type will be used to deserialize the LRO's response.
+  //
+  // If the response is in a different package from the rpc, a fully-qualified
+  // message name must be used (e.g. `google.protobuf.Struct`).
+  //
+  // Note: Altering this value constitutes a breaking change.
+  string response_type = 1;
+
+  // Required. The message name of the metadata type for this long-running
+  // operation.
+  //
+  // If the response is in a different package from the rpc, a fully-qualified
+  // message name must be used (e.g. `google.protobuf.Struct`).
+  //
+  // Note: Altering this value constitutes a breaking change.
+  string metadata_type = 2;
+}
diff --git a/cmd/registry/cmd/upload/bulk/testdata/protos/google/rpc/status.proto b/cmd/registry/cmd/upload/bulk/testdata/protos/google/rpc/status.proto
new file mode 100644
index 0000000..3b1f7a9
--- /dev/null
+++ b/cmd/registry/cmd/upload/bulk/testdata/protos/google/rpc/status.proto
@@ -0,0 +1,47 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package google.rpc;
+
+import "google/protobuf/any.proto";
+
+option cc_enable_arenas = true;
+option go_package = "google.golang.org/genproto/googleapis/rpc/status;status";
+option java_multiple_files = true;
+option java_outer_classname = "StatusProto";
+option java_package = "com.google.rpc";
+option objc_class_prefix = "RPC";
+
+// The `Status` type defines a logical error model that is suitable for
+// different programming environments, including REST APIs and RPC APIs. It is
+// used by [gRPC](https://github.com/grpc). Each `Status` message contains
+// three pieces of data: error code, error message, and error details.
+//
+// You can find out more about this error model and how to work with it in the
+// [API Design Guide](https://cloud.google.com/apis/design/errors).
+message Status {
+  // The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code].
+  int32 code = 1;
+
+  // A developer-facing error message, which should be in English. Any
+  // user-facing error message should be localized and sent in the
+  // [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client.
+  string message = 2;
+
+  // A list of messages that carry the error details.  There is a common set of
+  // message types for APIs to use.
+  repeated google.protobuf.Any details = 3;
+}