weapons: add validate, create, download endpoints

Add weapon entity creation API following the established pattern from
characters and summons controllers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-12-01 03:04:15 -08:00
parent d70683ea1f
commit 29cb276a2a
5 changed files with 369 additions and 2 deletions

View file

@ -5,18 +5,115 @@ module Api
class WeaponsController < Api::V1::ApiController class WeaponsController < Api::V1::ApiController
include IdResolvable include IdResolvable
before_action :set before_action :set, only: %i[show download_images download_status]
before_action :ensure_editor_role, only: %i[create validate download_images]
# GET /weapons/:id
def show def show
render json: WeaponBlueprint.render(@weapon, view: :full) render json: WeaponBlueprint.render(@weapon, view: :full)
end end
# POST /weapons
# Creates a new weapon record
def create
weapon = Weapon.new(weapon_params)
if weapon.save
render json: WeaponBlueprint.render(weapon, view: :full), status: :created
else
render_validation_error_response(weapon)
end
end
# GET /weapons/validate/:granblue_id
# Validates that a granblue_id has accessible images on Granblue servers
def validate
granblue_id = params[:granblue_id]
validator = WeaponImageValidator.new(granblue_id)
response_data = {
granblue_id: granblue_id,
exists_in_db: validator.exists_in_db?
}
if validator.valid?
render json: response_data.merge(
valid: true,
image_urls: validator.image_urls
)
else
render json: response_data.merge(
valid: false,
error: validator.error_message
)
end
end
# POST /weapons/:id/download_images
# Triggers async image download for a weapon
def download_images
# Queue the download job
DownloadWeaponImagesJob.perform_later(
@weapon.id,
force: params.dig(:options, :force) == true,
size: params.dig(:options, :size) || 'all'
)
# Set initial status
DownloadWeaponImagesJob.update_status(
@weapon.id,
'queued',
progress: 0,
images_downloaded: 0
)
render json: {
status: 'queued',
weapon_id: @weapon.id,
granblue_id: @weapon.granblue_id,
message: 'Image download job has been queued'
}, status: :accepted
end
# GET /weapons/:id/download_status
# Returns the status of an image download job
def download_status
status = DownloadWeaponImagesJob.status(@weapon.id)
render json: status.merge(
weapon_id: @weapon.id,
granblue_id: @weapon.granblue_id
)
end
private private
def set def set
@weapon = find_by_any_id(Weapon, params[:id]) @weapon = find_by_any_id(Weapon, params[:id])
render_not_found_response('weapon') unless @weapon render_not_found_response('weapon') unless @weapon
end end
# Ensures the current user has editor role (role >= 7)
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[WEAPONS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def weapon_params
params.require(:weapon).permit(
:granblue_id, :name_en, :name_jp, :rarity, :element, :proficiency, :series, :new_series,
:flb, :ulb, :transcendence, :extra, :limit, :ax,
:min_hp, :max_hp, :max_hp_flb, :max_hp_ulb,
:min_atk, :max_atk, :max_atk_flb, :max_atk_ulb,
:max_level, :max_skill_level, :max_awakening_level,
:release_date, :flb_date, :ulb_date, :transcendence_date,
:wiki_en, :wiki_ja, :gamewith, :kamigame,
:recruits,
nicknames_en: [], nicknames_jp: []
)
end
end end
end end
end end

View file

@ -0,0 +1,87 @@
# frozen_string_literal: true
# Background job for downloading weapon images from Granblue servers to S3.
# Stores progress in Redis for status polling.
#
# @example Enqueue a download job
# job = DownloadWeaponImagesJob.perform_later(weapon.id)
# # Poll status with: DownloadWeaponImagesJob.status(weapon.id)
class DownloadWeaponImagesJob < ApplicationJob
queue_as :downloads
retry_on StandardError, wait: :exponentially_longer, attempts: 3
discard_on ActiveRecord::RecordNotFound do |job, _error|
weapon_id = job.arguments.first
Rails.logger.error "[DownloadWeaponImages] Weapon #{weapon_id} not found"
update_status(weapon_id, 'failed', error: 'Weapon not found')
end
# Status keys for Redis storage
REDIS_KEY_PREFIX = 'weapon_image_download'
STATUS_TTL = 1.hour.to_i
class << self
# Get the current status of a download job for a weapon
#
# @param weapon_id [String] UUID of the weapon
# @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error
def status(weapon_id)
data = redis.get(redis_key(weapon_id))
return { status: 'not_found' } unless data
JSON.parse(data, symbolize_names: true)
end
def redis_key(weapon_id)
"#{REDIS_KEY_PREFIX}:#{weapon_id}"
end
def redis
@redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
end
def update_status(weapon_id, status, **attrs)
data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs)
redis.setex(redis_key(weapon_id), STATUS_TTL, data.to_json)
end
end
def perform(weapon_id, force: false, size: 'all')
Rails.logger.info "[DownloadWeaponImages] Starting download for weapon #{weapon_id}"
weapon = Weapon.find(weapon_id)
update_status(weapon_id, 'processing', progress: 0, images_downloaded: 0)
service = WeaponImageDownloadService.new(
weapon,
force: force,
size: size,
storage: :s3
)
result = service.download
if result.success?
Rails.logger.info "[DownloadWeaponImages] Completed for weapon #{weapon_id}"
update_status(
weapon_id,
'completed',
progress: 100,
images_downloaded: result.total,
images_total: result.total,
images: result.images
)
else
Rails.logger.error "[DownloadWeaponImages] Failed for weapon #{weapon_id}: #{result.error}"
update_status(weapon_id, 'failed', error: result.error)
raise StandardError, result.error # Trigger retry
end
end
private
def update_status(weapon_id, status, **attrs)
self.class.update_status(weapon_id, status, **attrs)
end
end

View file

@ -0,0 +1,79 @@
# frozen_string_literal: true
# Service wrapper for downloading weapon images from Granblue servers to S3.
# Uses the existing WeaponDownloader but provides a cleaner interface for controllers.
#
# @example Download images for a weapon
# service = WeaponImageDownloadService.new(weapon)
# result = service.download
# if result.success?
# puts result.images
# else
# puts result.error
# end
class WeaponImageDownloadService
Result = Struct.new(:success?, :images, :error, :total, keyword_init: true)
def initialize(weapon, options = {})
@weapon = weapon
@force = options[:force] || false
@size = options[:size] || 'all'
@storage = options[:storage] || :s3
end
# Downloads images for the weapon
#
# @return [Result] Struct with success status, images manifest, and any errors
def download
downloader = Granblue::Downloaders::WeaponDownloader.new(
@weapon.granblue_id,
storage: @storage,
verbose: Rails.env.development?
)
selected_size = @size == 'all' ? nil : @size
downloader.download(selected_size)
manifest = build_image_manifest
Result.new(
success?: true,
images: manifest,
total: count_total_images(manifest)
)
rescue StandardError => e
Rails.logger.error "[WeaponImageDownload] Failed for #{@weapon.granblue_id}: #{e.message}"
Result.new(
success?: false,
error: e.message
)
end
private
def build_image_manifest
sizes = Granblue::Downloaders::WeaponDownloader::SIZES
variants = build_variants
sizes.each_with_object({}) do |size, manifest|
manifest[size] = variants.map do |variant|
extension = size == 'base' ? 'png' : 'jpg'
"#{variant}.#{extension}"
end
end
end
def build_variants
# Weapons use the raw granblue_id for base variant
variants = [@weapon.granblue_id]
if @weapon.transcendence
variants << "#{@weapon.granblue_id}_02"
variants << "#{@weapon.granblue_id}_03"
end
variants
end
def count_total_images(manifest)
manifest.values.sum(&:size)
end
end

View file

@ -0,0 +1,96 @@
# frozen_string_literal: true
# Validates that a granblue_id has accessible images on the Granblue Fantasy game server.
# Used to verify that a weapon ID is valid before creating a database record.
#
# @example Validate a weapon ID
# validator = WeaponImageValidator.new("1040001000")
# if validator.valid?
# puts validator.image_urls
# else
# puts validator.error_message
# end
class WeaponImageValidator
BASE_URL = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/weapon'
SIZES = {
main: 'ls',
grid: 'm',
square: 's'
}.freeze
attr_reader :granblue_id, :error_message, :image_urls
def initialize(granblue_id)
@granblue_id = granblue_id.to_s
@error_message = nil
@image_urls = {}
end
# Validates the granblue_id by checking if the main image is accessible.
#
# @return [Boolean] true if valid, false otherwise
def valid?
return invalid_format unless valid_format?
check_image_accessibility
end
# Checks if a weapon with this granblue_id already exists in the database.
#
# @return [Boolean] true if exists, false otherwise
def exists_in_db?
Weapon.exists?(granblue_id: @granblue_id)
end
private
def valid_format?
@granblue_id.present? && @granblue_id.match?(/^\d{10}$/)
end
def invalid_format
@error_message = 'Invalid granblue_id format. Must be a 10-digit number.'
false
end
def check_image_accessibility
# Weapons use the raw granblue_id without variant suffix for base image
variant_id = @granblue_id
# Build image URLs for all sizes
SIZES.each do |size_name, directory|
url = "#{BASE_URL}/#{directory}/#{variant_id}.jpg"
@image_urls[size_name] = url
end
# Check if the main image is accessible via HEAD request
main_url = @image_urls[:main]
begin
uri = URI.parse(main_url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = 5
http.read_timeout = 5
# Skip CRL verification in development (Akamai CDN can have CRL issues locally)
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if Rails.env.development?
request = Net::HTTP::Head.new(uri.request_uri)
response = http.request(request)
if response.code == '200'
true
else
@error_message = "No images found for this granblue_id (HTTP #{response.code})"
false
end
rescue Net::OpenTimeout, Net::ReadTimeout => e
@error_message = "Request timed out while validating image URL: #{e.message}"
false
rescue StandardError => e
@error_message = "Failed to validate image URL: #{e.message}"
false
end
end
end

View file

@ -12,7 +12,15 @@ Rails.application.routes.draw do
resources :grid_weapons, only: %i[create update destroy] resources :grid_weapons, only: %i[create update destroy]
resources :grid_characters, only: %i[create update destroy] resources :grid_characters, only: %i[create update destroy]
resources :grid_summons, only: %i[create update destroy] resources :grid_summons, only: %i[create update destroy]
resources :weapons, only: :show resources :weapons, only: %i[show create] do
collection do
get 'validate/:granblue_id', action: :validate, as: :validate
end
member do
post 'download_images'
get 'download_status'
end
end
resources :characters, only: %i[show create] do resources :characters, only: %i[show create] do
collection do collection do
get 'validate/:granblue_id', action: :validate, as: :validate get 'validate/:granblue_id', action: :validate, as: :validate