From a27bdecde01ac9801decfa0640f79c6645b88f0e Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Mon, 1 Dec 2025 02:41:16 -0800 Subject: [PATCH] summons: add validate, create, download endpoints --- app/controllers/api/v1/summons_controller.rb | 98 ++++++++++++++++++- app/jobs/download_summon_images_job.rb | 87 ++++++++++++++++ app/services/summon_image_download_service.rb | 80 +++++++++++++++ app/services/summon_image_validator.rb | 96 ++++++++++++++++++ config/routes.rb | 10 +- 5 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 app/jobs/download_summon_images_job.rb create mode 100644 app/services/summon_image_download_service.rb create mode 100644 app/services/summon_image_validator.rb diff --git a/app/controllers/api/v1/summons_controller.rb b/app/controllers/api/v1/summons_controller.rb index 7f53e4d..036cc8f 100644 --- a/app/controllers/api/v1/summons_controller.rb +++ b/app/controllers/api/v1/summons_controller.rb @@ -5,18 +5,114 @@ module Api class SummonsController < Api::V1::ApiController 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 /summons/:id def show render json: SummonBlueprint.render(@summon, view: :full) end + # POST /summons + # Creates a new summon record + def create + summon = Summon.new(summon_params) + + if summon.save + render json: SummonBlueprint.render(summon, view: :full), status: :created + else + render_validation_error_response(summon) + end + end + + # GET /summons/validate/:granblue_id + # Validates that a granblue_id has accessible images on Granblue servers + def validate + granblue_id = params[:granblue_id] + validator = SummonImageValidator.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 /summons/:id/download_images + # Triggers async image download for a summon + def download_images + # Queue the download job + DownloadSummonImagesJob.perform_later( + @summon.id, + force: params.dig(:options, :force) == true, + size: params.dig(:options, :size) || 'all' + ) + + # Set initial status + DownloadSummonImagesJob.update_status( + @summon.id, + 'queued', + progress: 0, + images_downloaded: 0 + ) + + render json: { + status: 'queued', + summon_id: @summon.id, + granblue_id: @summon.granblue_id, + message: 'Image download job has been queued' + }, status: :accepted + end + + # GET /summons/:id/download_status + # Returns the status of an image download job + def download_status + status = DownloadSummonImagesJob.status(@summon.id) + + render json: status.merge( + summon_id: @summon.id, + granblue_id: @summon.granblue_id + ) + end + private def set @summon = find_by_any_id(Summon, params[:id]) render_not_found_response('summon') unless @summon 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 "[SUMMONS] Unauthorized access attempt by user #{current_user&.id}" + render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized + end + + def summon_params + params.require(:summon).permit( + :granblue_id, :name_en, :name_jp, :summon_id, :rarity, :element, :series, + :flb, :ulb, :transcendence, :subaura, :limit, + :min_hp, :max_hp, :max_hp_flb, :max_hp_ulb, :max_hp_xlb, + :min_atk, :max_atk, :max_atk_flb, :max_atk_ulb, :max_atk_xlb, + :max_level, + :release_date, :flb_date, :ulb_date, :transcendence_date, + :wiki_en, :wiki_ja, :gamewith, :kamigame, + nicknames_en: [], nicknames_jp: [] + ) + end end end end diff --git a/app/jobs/download_summon_images_job.rb b/app/jobs/download_summon_images_job.rb new file mode 100644 index 0000000..6f5ec9f --- /dev/null +++ b/app/jobs/download_summon_images_job.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Background job for downloading summon images from Granblue servers to S3. +# Stores progress in Redis for status polling. +# +# @example Enqueue a download job +# job = DownloadSummonImagesJob.perform_later(summon.id) +# # Poll status with: DownloadSummonImagesJob.status(summon.id) +class DownloadSummonImagesJob < ApplicationJob + queue_as :downloads + + retry_on StandardError, wait: :exponentially_longer, attempts: 3 + + discard_on ActiveRecord::RecordNotFound do |job, _error| + summon_id = job.arguments.first + Rails.logger.error "[DownloadSummonImages] Summon #{summon_id} not found" + update_status(summon_id, 'failed', error: 'Summon not found') + end + + # Status keys for Redis storage + REDIS_KEY_PREFIX = 'summon_image_download' + STATUS_TTL = 1.hour.to_i + + class << self + # Get the current status of a download job for a summon + # + # @param summon_id [String] UUID of the summon + # @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error + def status(summon_id) + data = redis.get(redis_key(summon_id)) + return { status: 'not_found' } unless data + + JSON.parse(data, symbolize_names: true) + end + + def redis_key(summon_id) + "#{REDIS_KEY_PREFIX}:#{summon_id}" + end + + def redis + @redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')) + end + + def update_status(summon_id, status, **attrs) + data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs) + redis.setex(redis_key(summon_id), STATUS_TTL, data.to_json) + end + end + + def perform(summon_id, force: false, size: 'all') + Rails.logger.info "[DownloadSummonImages] Starting download for summon #{summon_id}" + + summon = Summon.find(summon_id) + update_status(summon_id, 'processing', progress: 0, images_downloaded: 0) + + service = SummonImageDownloadService.new( + summon, + force: force, + size: size, + storage: :s3 + ) + + result = service.download + + if result.success? + Rails.logger.info "[DownloadSummonImages] Completed for summon #{summon_id}" + update_status( + summon_id, + 'completed', + progress: 100, + images_downloaded: result.total, + images_total: result.total, + images: result.images + ) + else + Rails.logger.error "[DownloadSummonImages] Failed for summon #{summon_id}: #{result.error}" + update_status(summon_id, 'failed', error: result.error) + raise StandardError, result.error # Trigger retry + end + end + + private + + def update_status(summon_id, status, **attrs) + self.class.update_status(summon_id, status, **attrs) + end +end diff --git a/app/services/summon_image_download_service.rb b/app/services/summon_image_download_service.rb new file mode 100644 index 0000000..736a0f2 --- /dev/null +++ b/app/services/summon_image_download_service.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# Service wrapper for downloading summon images from Granblue servers to S3. +# Uses the existing SummonDownloader but provides a cleaner interface for controllers. +# +# @example Download images for a summon +# service = SummonImageDownloadService.new(summon) +# result = service.download +# if result.success? +# puts result.images +# else +# puts result.error +# end +class SummonImageDownloadService + Result = Struct.new(:success?, :images, :error, :total, keyword_init: true) + + def initialize(summon, options = {}) + @summon = summon + @force = options[:force] || false + @size = options[:size] || 'all' + @storage = options[:storage] || :s3 + end + + # Downloads images for the summon + # + # @return [Result] Struct with success status, images manifest, and any errors + def download + downloader = Granblue::Downloaders::SummonDownloader.new( + @summon.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 "[SummonImageDownload] Failed for #{@summon.granblue_id}: #{e.message}" + Result.new( + success?: false, + error: e.message + ) + end + + private + + def build_image_manifest + sizes = Granblue::Downloaders::SummonDownloader::SIZES + variants = build_variants + + sizes.each_with_object({}) do |size, manifest| + manifest[size] = variants.map do |variant| + extension = size == 'detail' ? 'png' : 'jpg' + "#{variant}.#{extension}" + end + end + end + + def build_variants + # Summons use the raw granblue_id for base variant (no _01 suffix) + variants = [@summon.granblue_id] + variants << "#{@summon.granblue_id}_02" if @summon.ulb + if @summon.transcendence + variants << "#{@summon.granblue_id}_03" + variants << "#{@summon.granblue_id}_04" + end + variants + end + + def count_total_images(manifest) + manifest.values.sum(&:size) + end +end diff --git a/app/services/summon_image_validator.rb b/app/services/summon_image_validator.rb new file mode 100644 index 0000000..1634414 --- /dev/null +++ b/app/services/summon_image_validator.rb @@ -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 summon ID is valid before creating a database record. +# +# @example Validate a summon ID +# validator = SummonImageValidator.new("2040001000") +# if validator.valid? +# puts validator.image_urls +# else +# puts validator.error_message +# end +class SummonImageValidator + BASE_URL = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/summon' + + SIZES = { + main: 'ls', + grid: 'party_sub', + 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 summon with this granblue_id already exists in the database. + # + # @return [Boolean] true if exists, false otherwise + def exists_in_db? + Summon.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 + # Summons 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 diff --git a/config/routes.rb b/config/routes.rb index 4a18324..5f3d3fc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,7 +22,15 @@ Rails.application.routes.draw do get 'download_status' end end - resources :summons, only: :show + resources :summons, 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 :favorites, only: [:create] get 'version', to: 'api#version'