The build endpoints state their input rules once

`POST /build/publish` opened with three guard clauses and `POST /build/upload`
with eight, each one a `return render json: { error: ... }` — name present,
version present, name format, version format, file present, filename prefix,
size, digest. That is a validation layer written by hand, in a place where it
cannot be unit tested: exercising it needs a request.

`PublishInputDto` was already there and was a bare `Struct` with no rules at
all, so the controller carried them. It is an `ActiveModel::Model` now, with
the presence and platform-inclusion validations on it, and `UploadInputDto`
joins it with the name and version formats and the `<name>-<version>` filename
convention. Each controller reads:

    return render json: { error: input.error_message }, status: :bad_request unless input.valid?

Size and digest keep their own explicit checks, because they are not the same
answer: 413 tells a caller to stop, 422 tells it to retry a truncated upload,
and a single error bag cannot say which. Every status code the endpoints
answered before, they answer now — build_publish_controller_spec and
build_uploads_controller_spec pin all of them, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 09:01:10 +02:00
co-authored by Claude Opus 5
parent f1ad80ed0c
commit 7c9f2988be
4 changed files with 69 additions and 35 deletions
@@ -10,7 +10,7 @@ module WarpEngine
api :POST, "/build/publish", "Register an uploaded build as a release"
header "X-Update-Secret", "Shared secret or application token (update scope)", required: true
param :name, String, required: true, desc: "Software name"
param :platform, String, required: true, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
param :platform, String, required: true, desc: "Platform (one of WarpEngine::Platform::NAMES)"
param :version, String, required: true, desc: "Version string"
returns code: 200, desc: "JSON with the published name/platform/version"
error code: 401, desc: "Invalid secret"
@@ -21,24 +21,21 @@ module WarpEngine
return render json: { error: "Unauthorized" }, status: :unauthorized
end
%i[name platform version].each do |key|
return render json: { error: "#{key.to_s.capitalize} not provided" }, status: :bad_request if params[key].blank?
end
unless software_ownership_authorized?(params[:name])
return render json: { error: "Forbidden" }, status: :forbidden
end
input = WarpEngine::PublishInputDto.new(
platform: params[:platform],
name: params[:name],
version: params[:version]
)
return render json: { error: input.error_message }, status: :bad_request unless input.valid?
unless software_ownership_authorized?(input.name)
return render json: { error: "Forbidden" }, status: :forbidden
end
WarpEngine::PublishService.new.publish(input)
claim_software_ownership(params[:name])
claim_software_ownership(input.name)
render json: { published: true, name: params[:name], platform: params[:platform], version: params[:version] }
render json: { published: true, name: input.name, platform: input.platform, version: input.version }
end
end
end
@@ -9,8 +9,6 @@ module WarpEngine
short "Build artifact upload"
end
NAME_FORMAT = /\A[A-Za-z0-9._-]+\z/
api :POST, "/build/upload", "Upload a build artifact into the artifact directory"
header "X-Update-Secret", "Shared secret or application token (upload scope)", required: true
param :name, String, required: true, desc: "Software name (filename must be prefixed with <name>-<version>)"
@@ -28,35 +26,30 @@ module WarpEngine
return render json: { error: "Unauthorized" }, status: :unauthorized
end
name = params[:name].to_s
version = params[:version].to_s
file = params[:file]
input = WarpEngine::UploadInputDto.new(
name: params[:name].to_s,
version: params[:version].to_s,
file: params[:file],
sha256: params[:sha256]
)
return render json: { error: input.error_message }, status: :bad_request unless input.valid?
return render json: { error: "Invalid name" }, status: :bad_request unless name.match?(NAME_FORMAT)
return render json: { error: "Invalid version" }, status: :bad_request unless version.match?(NAME_FORMAT)
return render json: { error: "File not provided" }, status: :bad_request unless file.respond_to?(:original_filename)
unless software_ownership_authorized?(name)
unless software_ownership_authorized?(input.name)
return render json: { error: "Forbidden" }, status: :forbidden
end
filename = File.basename(file.original_filename.to_s)
unless filename.start_with?("#{name}-#{version}.", "#{name}-#{version}-")
return render json: { error: "Filename must be prefixed with #{name}-#{version}" }, status: :bad_request
end
max = WarpEngine.config.max_upload_size
if file.size > max
if input.file.size > max
return render json: { error: "File too large (max #{max / (1024 * 1024)}MB)" }, status: :payload_too_large
end
digest = Digest::SHA256.file(file.tempfile.path).hexdigest
if params[:sha256].present? && !ActiveSupport::SecurityUtils.secure_compare(params[:sha256].downcase, digest)
digest = Digest::SHA256.file(input.file.tempfile.path).hexdigest
if input.sha256.present? && !ActiveSupport::SecurityUtils.secure_compare(input.sha256.downcase, digest)
return render json: { error: "SHA256 mismatch" }, status: :unprocessable_entity
end
stored = WarpEngine::FileManagerService.new.upload("", file)
render json: { file: stored, size: file.size, sha256: digest }
stored = WarpEngine::FileManagerService.new.upload("", input.file)
render json: { file: stored, size: input.file.size, sha256: digest }
end
end
end