ImportController should use processors

This commit is contained in:
Justin Edmund 2025-02-17 23:38:53 -08:00
parent ebb3218c29
commit 5b8fcdcfba
2 changed files with 144 additions and 237 deletions

View file

@ -2,6 +2,20 @@
module Api module Api
module V1 module V1
##
# ImportController is responsible for importing game data (e.g. deck data)
# and creating a new Party along with associated records (job, characters, weapons, summons, etc.).
#
# The controller expects a JSON payload whose top-level key is "import". If not wrapped,
# the controller will wrap the raw data automatically.
#
# @example Valid payload structure
# {
# "import": {
# "deck": { "name": "My Party", ... },
# "pc": { "job": { "master": { "name": "Warrior" } }, ... }
# }
# }
class ImportController < Api::V1::ApiController class ImportController < Api::V1::ApiController
ELEMENT_MAPPING = { ELEMENT_MAPPING = {
0 => nil, 0 => nil,
@ -13,263 +27,84 @@ module Api
6 => 5 6 => 5
}.freeze }.freeze
##
# Processes an import request.
#
# It reads and parses the raw JSON, wraps the data under the "import" key if necessary,
# transforms the deck data using BaseDeckTransformer, validates that the transformed data
# contains required fields, and then creates a new Party record (and its associated objects)
# inside a transaction.
#
# @return [void] Renders JSON response with a party shortcode or an error message.
def create def create
Rails.logger.info "[IMPORT] Starting import..." Rails.logger.info '[IMPORT] Checking input...'
# Parse JSON request body body = parse_request_body
raw_body = request.raw_post return unless body
begin
raw_params = JSON.parse(raw_body) if raw_body.present? raw_params = body['import']
Rails.logger.info "[IMPORT] Raw game data: #{raw_params.inspect}" unless raw_params.is_a?(Hash)
rescue JSON::ParserError => e Rails.logger.error "[IMPORT] 'import' key is missing or not a hash."
Rails.logger.error "[IMPORT] Invalid JSON in request body: #{e.message}" return render json: { error: 'Invalid JSON data' }, status: :unprocessable_content
render json: { error: 'Invalid JSON data' }, status: :bad_request
return
end end
if raw_params.nil? || !raw_params.is_a?(Hash) unless raw_params['deck'].is_a?(Hash) &&
Rails.logger.error "[IMPORT] Missing or invalid game data" raw_params['deck'].key?('pc') &&
render json: { error: 'Missing or invalid game data' }, status: :bad_request raw_params['deck'].key?('npc')
return Rails.logger.error "[IMPORT] Deck data incomplete or missing."
return render json: { error: 'Invalid deck data' }, status: :unprocessable_content
end end
# Transform game data Rails.logger.info '[IMPORT] Starting import...'
transformer = ::Granblue::Transformers::BaseDeckTransformer.new(raw_params)
transformed_data = transformer.transform
Rails.logger.info "[IMPORT] Transformed data: #{transformed_data.inspect}"
# Validate transformed data return if performed? # Rendered an error response already
unless transformed_data[:name].present? && transformed_data[:lang].present?
Rails.logger.error "[IMPORT] Missing required fields in transformed data"
render json: { error: 'Missing required fields name or lang' }, status: :unprocessable_entity
return
end
# Create party party = Party.create(user: current_user)
party = Party.new(user: current_user) deck_data = raw_params['import']
process_data(party, deck_data)
ActiveRecord::Base.transaction do
# Basic party data
party.name = transformed_data[:name]
party.extra = transformed_data[:extra]
party.save!
# Process job and skills
if transformed_data[:class].present?
process_job(party, transformed_data[:class], transformed_data[:subskills])
end
# Process characters
if transformed_data[:characters].present?
process_characters(party, transformed_data[:characters])
end
# Process weapons
if transformed_data[:weapons].present?
process_weapons(party, transformed_data[:weapons])
end
# Process summons
if transformed_data[:summons].present?
process_summons(party, transformed_data[:summons], transformed_data[:friend_summon])
end
# Process sub summons
if transformed_data[:sub_summons].present?
process_sub_summons(party, transformed_data[:sub_summons])
end
end
# Return shortcode for redirection
render json: { shortcode: party.shortcode }, status: :created render json: { shortcode: party.shortcode }, status: :created
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[IMPORT] Error processing import: #{e.message}" render json: { error: e.message }, status: :unprocessable_content
Rails.logger.error "[IMPORT] Backtrace: #{e.backtrace.join("\n")}"
render json: { error: 'Error processing import' }, status: :unprocessable_entity
end end
private private
def process_job(party, job_name, subskills) ##
return unless job_name # Reads and parses the raw JSON request body.
job = Job.find_by("name_en = ? OR name_jp = ?", job_name, job_name) #
unless job # @return [Hash] Parsed JSON data.
Rails.logger.warn "[IMPORT] Could not find job: #{job_name}" # @raise [JSON::ParserError] If the JSON is invalid.
return def parse_request_body
end raw_body = request.raw_post
JSON.parse(raw_body)
party.job = job rescue JSON::ParserError => e
party.save! Rails.logger.error "[IMPORT] Invalid JSON: #{e.message}"
Rails.logger.info "[IMPORT] Assigned job=#{job_name} to party_id=#{party.id}" render json: { error: 'Invalid JSON data' }, status: :bad_request and return
return unless subskills&.any?
subskills.each_with_index do |skill_name, idx|
next if skill_name.blank?
skill = JobSkill.find_by("(name_en = ? OR name_jp = ?) AND job_id = ?", skill_name, skill_name, job.id)
unless skill
Rails.logger.warn "[IMPORT] Could not find skill=#{skill_name} for job_id=#{job.id}"
next
end
party["skill#{idx + 1}_id"] = skill.id
Rails.logger.info "[IMPORT] Assigned skill=#{skill_name} at position #{idx + 1}"
end
end end
def process_characters(party, characters) ##
return unless characters&.any? # Ensures that the provided data is wrapped under an "import" key.
Rails.logger.info "[IMPORT] Processing #{characters.length} characters" #
# @param data [Hash] The parsed JSON data.
characters.each_with_index do |char_data, idx| # @return [Hash] Data wrapped under the "import" key.
character = Character.find_by(granblue_id: char_data[:id]) def wrap_import_data(data)
unless character data.key?('import') ? data : { 'import' => data }
Rails.logger.warn "[IMPORT] Character not found: #{char_data[:id]}"
next
end
GridCharacter.create!(
party: party,
character_id: character.id,
position: idx,
uncap_level: char_data[:uncap],
perpetuity: char_data[:ringed] || false,
transcendence_step: char_data[:transcend] || 0
)
Rails.logger.info "[IMPORT] Added character: #{character.name_en} at position #{idx}"
end
end end
def process_weapons(party, weapons) ##
return unless weapons&.any? # Processes the deck data using processors.
Rails.logger.info "[IMPORT] Processing #{weapons.length} weapons" #
# @param party [Party] The party to insert data into
# @param data [Hash] The wrapped data.
# @return [Hash] The transformed deck data.
def process_data(party, data)
Rails.logger.info '[IMPORT] Transforming deck data'
weapons.each_with_index do |weapon_data, idx| Processors::JobProcessor.new(party, data).process
weapon = Weapon.find_by(granblue_id: weapon_data[:id]) Processors::CharacterProcessor.new(party, data).process
unless weapon Processors::SummonProcessor.new(party, data).process
Rails.logger.warn "[IMPORT] Weapon not found: #{weapon_data[:id]}" Processors::WeaponProcessor.new(party, data).process
next
end
grid_weapon = GridWeapon.create!(
party: party,
weapon_id: weapon.id,
position: idx - 1,
mainhand: idx.zero?,
uncap_level: weapon_data[:uncap],
transcendence_step: weapon_data[:transcend] || 0,
element: weapon_data[:attr] ? ELEMENT_MAPPING[weapon_data[:attr]] : nil
)
process_weapon_keys(grid_weapon, weapon_data[:keys]) if weapon_data[:keys]
process_weapon_ax(grid_weapon, weapon_data[:ax]) if weapon_data[:ax]
Rails.logger.info "[IMPORT] Added weapon: #{weapon.name_en} at position #{idx - 1}"
end
end
def process_weapon_keys(grid_weapon, keys)
keys.each_with_index do |key_id, idx|
key = WeaponKey.find_by(granblue_id: key_id)
unless key
Rails.logger.warn "[IMPORT] WeaponKey not found: #{key_id}"
next
end
grid_weapon["weapon_key#{idx + 1}_id"] = key.id
grid_weapon.save!
end
end
def process_weapon_ax(grid_weapon, ax_skills)
ax_skills.each_with_index do |ax, idx|
grid_weapon["ax_modifier#{idx + 1}"] = ax[:id].to_i
grid_weapon["ax_strength#{idx + 1}"] = ax[:val].to_s.gsub(/[+%]/, '').to_i
end
grid_weapon.save!
end
def process_summons(party, summons, friend_summon = nil)
return unless summons&.any?
Rails.logger.info "[IMPORT] Processing #{summons.length} summons"
# Main and sub summons
summons.each_with_index do |summon_data, idx|
summon = Summon.find_by(granblue_id: summon_data[:id])
unless summon
Rails.logger.warn "[IMPORT] Summon not found: #{summon_data[:id]}"
next
end
grid_summon = GridSummon.new(
party: party,
summon_id: summon.id,
position: idx,
main: idx.zero?,
friend: false,
uncap_level: summon_data[:uncap],
transcendence_step: summon_data[:transcend] || 0,
quick_summon: summon_data[:qs] || false
)
if grid_summon.save
Rails.logger.info "[IMPORT] Added summon: #{summon.name_en} at position #{idx}"
else
Rails.logger.error "[IMPORT] Failed to save summon: #{grid_summon.errors.full_messages}"
end
end
# Friend summon if provided
process_friend_summon(party, friend_summon) if friend_summon.present?
end
def process_friend_summon(party, friend_summon)
friend = Summon.find_by("name_en = ? OR name_jp = ?", friend_summon, friend_summon)
unless friend
Rails.logger.warn "[IMPORT] Friend summon not found: #{friend_summon}"
return
end
grid_summon = GridSummon.new(
party: party,
summon_id: friend.id,
position: 6,
main: false,
friend: true,
uncap_level: friend.ulb ? 5 : (friend.flb ? 4 : 3)
)
if grid_summon.save
Rails.logger.info "[IMPORT] Added friend summon: #{friend.name_en}"
else
Rails.logger.error "[IMPORT] Failed to save friend summon: #{grid_summon.errors.full_messages}"
end
end
def process_sub_summons(party, sub_summons)
return unless sub_summons&.any?
Rails.logger.info "[IMPORT] Processing #{sub_summons.length} sub summons"
sub_summons.each_with_index do |summon_data, idx|
summon = Summon.find_by(granblue_id: summon_data[:id])
unless summon
Rails.logger.warn "[IMPORT] Sub summon not found: #{summon_data[:id]}"
next
end
grid_summon = GridSummon.new(
party: party,
summon_id: summon.id,
position: idx + 5,
main: false,
friend: false,
uncap_level: summon_data[:uncap],
transcendence_step: summon_data[:transcend] || 0
)
if grid_summon.save
Rails.logger.info "[IMPORT] Added sub summon: #{summon.name_en} at position #{idx + 5}"
else
Rails.logger.error "[IMPORT] Failed to save sub summon: #{grid_summon.errors.full_messages}"
end
end
end end
end end
end end

View file

@ -0,0 +1,72 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'ImportController', type: :request do
let(:user) { create(:user) }
let(:access_token) do
Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: 30.days, scopes: 'public')
end
let(:headers) do
{ 'Authorization' => "Bearer #{access_token.token}", 'Content-Type' => 'application/json' }
end
# Load raw deck JSON from fixture and wrap it under the "import" key.
let(:raw_deck_data) do
file_path = Rails.root.join('spec', 'fixtures', 'deck_sample.json')
JSON.parse(File.read(file_path))
end
let(:valid_deck_json) { { 'import' => raw_deck_data }.to_json }
describe 'POST /api/v1/import' do
context 'with valid deck data' do
it 'creates a new party and returns a shortcode' do
expect {
post '/api/v1/import', params: valid_deck_json, headers: headers
}.to change(Party, :count).by(1)
expect(response).to have_http_status(:created)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('shortcode')
end
end
context 'with invalid JSON' do
it 'returns a bad request error' do
post '/api/v1/import', params: 'this is not json', headers: headers
expect(response).to have_http_status(:bad_request)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Invalid JSON data')
end
end
context 'with missing required fields in transformed data' do
it 'returns unprocessable entity error' do
# Here we simulate missing required fields by sending an import hash
# where the 'deck' key is present but missing required subkeys.
invalid_data = { 'import' => { 'deck' => { 'name' => '', 'pc' => nil } } }.to_json
post '/api/v1/import', params: invalid_data, headers: headers
expect(response).to have_http_status(:unprocessable_entity)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Invalid deck data')
end
end
context 'when an error occurs during processing' do
it 'returns unprocessable entity status with error details' do
# Stub the transformer to raise an error when transform is called.
allow_any_instance_of(Processors::CharacterProcessor)
.to receive(:process).and_raise(StandardError.new('Error processing import'))
allow_any_instance_of(Processors::WeaponProcessor)
.to receive(:process).and_raise(StandardError.new('Error processing import'))
allow_any_instance_of(Processors::SummonProcessor)
.to receive(:process).and_raise(StandardError.new('Error processing import'))
allow_any_instance_of(Processors::JobProcessor)
.to receive(:process).and_raise(StandardError.new('Error processing import'))
post '/api/v1/import', params: valid_deck_json, headers: headers
expect(response).to have_http_status(:unprocessable_entity)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Error processing import')
end
end
end
end