Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def generate
files << g("service/credentials", "lib/#{service.credentials_file_path}", service: service)
files << g("service/paths", "lib/#{service.paths_file_path}", service: service) if service.paths?
files << g("service/operations", "lib/#{service.operations_file_path}", service: service) if service.lro?
files << g("service/resumable_upload_stub", "lib/#{service.resumable_upload_stub_file_path}", service: service) if service.resumable_upload?
end
end

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<%- assert_locals method -%>
->(e) { ::Google::Cloud::Error.from_error e }
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<%- assert_locals method -%>
->(e) { ::Google::Cloud::Error.from_error e }
6 changes: 6 additions & 0 deletions gapic-generator/lib/gapic/generators/default_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,16 @@ def generate gem_presenter: nil
# Rest-only `service.stub` file
files << g("service/rest/service_stub", "lib/#{service.rest.service_stub_file_path}", service: service) if should_generate_rest

# Resumable upload stub, shared by both transports because uploads always travel over REST
files << g("service/resumable_upload_stub", "lib/#{service.resumable_upload_stub_file_path}", service: service) if service.resumable_upload?

# Unit tests for `client.rb`
files << g("service/test/client", "test/#{service.test_client_file_path}", service: service) if should_generate_grpc
files << g("service/rest/test/client", "test/#{service.rest.test_client_file_path}", service: service) if should_generate_rest

# Unit tests for resumable upload RPCs, which the client tests above skip
files << g("service/test/resumable_upload", "test/#{service.test_resumable_upload_file_path}", service: service) if service.resumable_upload?

# Unit tests for `paths.rb`
files << g("service/test/client_paths", "test/#{service.test_paths_file_path}", service: service) if service.paths? && should_generate_grpc

Expand Down
143 changes: 143 additions & 0 deletions gapic-generator/lib/gapic/model/method/resumable_upload.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# frozen_string_literal: true

# Copyright 2026 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
#
# https://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.

require "gapic/model/model_error"

module Gapic
module Model
module Method
##
# Resumable upload method-level model.
#
# A resumable upload RPC does not send its payload in the initiation request. The request it
# describes only creates an upload session; the bytes travel afterwards, over REST, in chunks
# addressed to a URL the server hands back. Generated clients therefore return an upload handle
# from such a method rather than a response message.
#
# Until the upload annotation is published, the set of such RPCs is carried here as a table, and
# so is the URL prefix each one's initiation request is sent under. When the annotation lands,
# {.url_prefix_for} keeps the table and detection moves to `http.media_upload.enabled`.
#
# @!attribute [r] url_prefix
# @return [String] The path prefix prepended to the transcoded initiation URL, without
# surrounding slashes, e.g. `resumable/upload`.
#
class ResumableUpload
# @return [String]
attr_reader :url_prefix

##
# @param url_prefix [String] The upload URL prefix for the matched RPC.
#
def initialize url_prefix
@url_prefix = url_prefix
end

##
# Exact matches, keyed by the full gRPC name of the RPC.
#
EXACT_PREFIXES = {
"google.showcase.v1beta1.ResumableUploadService.UploadMedia" => "resumable/upload"
}.freeze

##
# Version-family matches, for protos that are republished under a new version regularly.
# Anchored on the left at the package and on the right at the service and method, with the
# intervening segments (e.g. `.services.`) unconstrained.
#
VERSIONED_PREFIXES = [
{
left: /\Agoogle\.ads\.googleads\.v[0-9_]+\./,
right: ".YouTubeVideoUploadService.CreateYouTubeVideoUpload",
prefix: "resumable/upload"
}
].freeze

class << self
##
# Inspects a method and returns its resumable upload model, or `nil` if it does not perform
# resumable uploads.
#
# @param method [Gapic::Presenters::MethodPresenter]
#
# @raise [Gapic::Model::ModelError] if the method is a resumable upload RPC that the
# generator cannot generate an upload surface for.
#
# @return [Gapic::Model::Method::ResumableUpload, nil]
#
def create method
prefix = url_prefix_for method.grpc_full_name
return nil if prefix.nil?
validate! method
new prefix
end

##
# The upload URL prefix for an RPC, or `nil` if the RPC does not perform resumable uploads.
#
# @param full_name [String] The full gRPC name of the RPC,
# e.g. `google.showcase.v1beta1.ResumableUploadService.UploadMedia`.
#
# @return [String, nil]
#
def url_prefix_for full_name
EXACT_PREFIXES[full_name] ||
VERSIONED_PREFIXES.find do |match|
match[:left].match?(full_name) && full_name.end_with?(match[:right])
end&.fetch(:prefix)
end

##
# Verifies that an upload surface can be generated for the given method. A resumable upload
# is a single unary POST that carries a body, and anything else in the table is a
# misconfiguration that must fail the build rather than generate code that cannot work.
#
# @param method [Gapic::Presenters::MethodPresenter]
#
# @raise [Gapic::Model::ModelError]
#
# @return [void]
#
def validate! method
reason = unsupported_reason method
return if reason.nil?
raise ModelError, "The method #{method.grpc_full_name} performs resumable uploads, " \
"which the generator supports only for #{reason}."
end

private

##
# @param method [Gapic::Presenters::MethodPresenter]
# @return [String, nil] What the method would have had to be, or `nil` if it is supported.
#
def unsupported_reason method
return "non-streaming methods" if method.client_streaming? || method.server_streaming?
return "non-paginated methods" if method.paged?
return "methods that are not long-running operations" if method.lro? || method.nonstandard_lro?

binding = method.http_bindings.first
return "methods with an HTTP binding" if binding.nil?
return "methods bound to POST" unless binding.verb == :post
return "methods whose HTTP binding has a body" unless binding.body?

nil
end
end
end
end
end
end
35 changes: 35 additions & 0 deletions gapic-generator/lib/gapic/presenters/method_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
require "active_support/inflector"
require "gapic/ruby_info"
require "gapic/helpers/namespace_helper"
require "gapic/model/method/resumable_upload"

module Gapic
module Presenters
Expand Down Expand Up @@ -71,6 +72,10 @@ def initialize service_presenter, api, method
@lro = Gapic::Model::Method.parse_lro @method, @api

@rest = MethodRestPresenter.new self, @api

# Built last: detection is cheap but its validation reads the LRO model, the HTTP bindings
# and the pagination check, all of which have to exist first.
@resumable_upload = Gapic::Model::Method::ResumableUpload.create self
end

##
Expand Down Expand Up @@ -279,6 +284,36 @@ def nonstandard_lro_client
service.nonstandard_lros.find { |model| model.service == @lro.service_full_name }
end

##
# Whether this method performs a resumable upload. Such a method returns an upload handle
# rather than a response, and its payload travels over REST in chunks after the request this
# method describes has created the upload session.
#
# @return [Boolean]
#
def resumable_upload?
!@resumable_upload.nil?
end

##
# The path prefix prepended to this method's transcoded initiation URL, without surrounding
# slashes, e.g. `resumable/upload`. `nil` unless this method performs a resumable upload.
#
# @return [String, nil]
#
def upload_url_prefix
@resumable_upload&.url_prefix
end

##
# The name of the constant the generated upload stub holds this method's URL prefix in.
#
# @return [String]
#
def upload_url_prefix_const_name
"#{name.upcase}_URL_PREFIX"
end

def client_streaming?
@method.client_streaming
end
Expand Down
65 changes: 65 additions & 0 deletions gapic-generator/lib/gapic/presenters/service_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,15 @@ def test_client_operations_file_path
service_file_path.sub ".rb", "_operations_test.rb"
end

##
# Path of the generated tests covering this service's resumable upload RPCs. Those RPCs are
# excluded from the ordinary client tests, which assume a call returns a response.
#
# @return [String]
def test_resumable_upload_file_path
service_file_path.sub ".rb", "_resumable_upload_test.rb"
end

def stub_name
"#{ActiveSupport::Inflector.underscore name}_stub"
end
Expand Down Expand Up @@ -489,6 +498,62 @@ def lro_service
ServicePresenter.new @gem_presenter, @api, lro.services.first, parent_service: self unless lro.nil?
end

##
# Whether any of this service's RPCs perform resumable uploads, and therefore whether an upload
# stub has to be generated for it and built by its clients.
#
# @return [Boolean]
def resumable_upload?
methods.any?(&:resumable_upload?)
end

##
# Presenters for the RPCs of this service that perform resumable uploads.
#
# @return [Enumerable<Gapic::Presenters::MethodPresenter>]
def resumable_upload_methods
methods.select(&:resumable_upload?)
end

##
# The class name of the generated upload stub. One per service, shared by both transports, and
# deliberately not nested under `Rest::`: the gRPC client builds it too, because the upload
# itself always travels over REST.
#
# @return [String]
def resumable_upload_stub_name
"ResumableUploadStub"
end

# @return [String]
def resumable_upload_stub_name_full
fix_namespace @api, "#{service_name_full}::#{resumable_upload_stub_name}"
end

# @return [String]
def resumable_upload_stub_require
ruby_file_path @api, resumable_upload_stub_name_full
end

# @return [String]
def resumable_upload_stub_file_path
"#{resumable_upload_stub_require}.rb"
end

# @return [String]
def resumable_upload_stub_file_name
resumable_upload_stub_file_path.split("/").last
end

##
# An instance variable name used for the generated upload stub. The clients keep the stub here
# and expose no reader for it.
#
# @return [String]
def resumable_upload_stub_ivar
"@resumable_upload_stub"
end

def config_channel_args
{ "grpc.service_config_disable_resolution" => 1 }
end
Expand Down
12 changes: 12 additions & 0 deletions gapic-generator/lib/gapic/presenters/service_rest_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,18 @@ def methods
main_service.methods.select(&:can_generate_rest?)
end

##
# Presenters for methods that the REST service stub carries an implementation for. An upload
# RPC has no ordinary REST path: the REST client delegates to the upload handle exactly as the
# gRPC client does, so a plain call method and transcoder here would be dead code that also
# happens to be wrong — a non-resumable POST of the whole payload.
#
# @return [Enumerable<Gapic::Presenters::MethodPresenter>]
#
def service_stub_methods
methods.reject(&:resumable_upload?)
end

##
# Require string for the helpers file
#
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,66 @@ def initialize proto, _json, response_type:, phase1:
attr_reader :response_name
end

##
# Presentation information about resumable upload response handling.
#
# A resumable upload RPC returns a {::Gapic::ResumableUpload} handle rather than a
# response message, so the snippet names the call result `upload` and goes on to
# start the upload, which is what actually produces the response message.
#
class ResumableUploadResponseHandlingPresenter
include ResponseHandlingPresenterCommon

##
# Create a resumable upload response handling presenter
#
# @param proto [Google::Cloud::Tools::SnippetGen::ConfigLanguage::V1::Snippet::SimpleResponseHandling]
# The protobuf representation
# @param json [String]
# The JSON representation
# @param response_type [String] The fully qualified response message class
# @param phase1 [Boolean] True if this is a phase 1 snippet without config
#
def initialize proto, _json, response_type:, phase1:
@response_name = phase1 ? "upload" : compute_response_name(proto, phase1)
@render_lines = phase1 ? upload_lines(response_type) : []
@render = @render_lines.join "\n"
end

##
# The lines of rendered code
# @return [Array<String>]
#
attr_reader :render_lines

##
# The rendered code as a single string, possibly with line breaks
# @return [String]
#
attr_reader :render

##
# The name of the response variable, or nil for no response handling
# @return [String,nil]
#
attr_reader :response_name

private

def upload_lines response_type
[
"# The returned object is a handle for a resumable upload. Nothing has been",
"# uploaded yet, and the timeout and retry policy of the call above cover only",
"# the request that creates the upload session, not the upload as a whole.",
"stream = File.open \"input.bin\", \"rb\"",
"result = #{@response_name}.start stream: stream, content_type: \"application/octet-stream\"",
"",
"# The returned object is of type #{response_type}.",
"p result"
]
end
end

##
# Presentation information about LRO response handling
#
Expand Down
Loading
Loading