From 46de804a6350222e0cd735efcb0dfdada49347ba Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Wed, 6 May 2026 20:11:59 +0200 Subject: [PATCH] dtos, new services, serializers --- apps/api/Gemfile | 1 + apps/api/Gemfile.lock | 2 + .../app/controllers/api/events_controller.rb | 2 +- .../app/controllers/api/images_controller.rb | 2 +- .../app/controllers/api/members_controller.rb | 2 +- .../controllers/api/software_controller.rb | 2 +- .../api/software_highlighted_controller.rb | 2 +- apps/api/app/controllers/files_controller.rb | 19 +--- apps/api/app/controllers/update_controller.rb | 24 ++--- apps/api/app/dtos/file_result.rb | 13 +++ apps/api/app/dtos/file_show_input.rb | 1 + apps/api/app/dtos/image_show_input.rb | 1 + apps/api/app/dtos/update_input.rb | 1 + apps/api/app/serializers/event_serializer.rb | 7 +- .../serializers/external_link_serializer.rb | 12 +++ apps/api/app/serializers/member_serializer.rb | 16 +-- .../api/app/serializers/release_serializer.rb | 17 ++++ .../app/serializers/software_serializer.rb | 99 ++++--------------- apps/api/app/services/event_service.rb | 5 + apps/api/app/services/file_service.rb | 19 ++++ apps/api/app/services/image_service.rb | 5 + apps/api/app/services/member_service.rb | 5 + .../services/software_highlighted_service.rb | 19 +++- .../api/app/services/software_list_service.rb | 9 -- apps/api/app/services/software_service.rb | 21 ++++ apps/api/app/services/update_service.rb | 13 +++ 26 files changed, 176 insertions(+), 143 deletions(-) create mode 100644 apps/api/app/dtos/file_result.rb create mode 100644 apps/api/app/dtos/file_show_input.rb create mode 100644 apps/api/app/dtos/image_show_input.rb create mode 100644 apps/api/app/dtos/update_input.rb create mode 100644 apps/api/app/serializers/external_link_serializer.rb create mode 100644 apps/api/app/serializers/release_serializer.rb create mode 100644 apps/api/app/services/event_service.rb create mode 100644 apps/api/app/services/file_service.rb create mode 100644 apps/api/app/services/image_service.rb create mode 100644 apps/api/app/services/member_service.rb delete mode 100644 apps/api/app/services/software_list_service.rb create mode 100644 apps/api/app/services/software_service.rb create mode 100644 apps/api/app/services/update_service.rb diff --git a/apps/api/Gemfile b/apps/api/Gemfile index ea3dcde..8e74e45 100644 --- a/apps/api/Gemfile +++ b/apps/api/Gemfile @@ -4,6 +4,7 @@ gem "rails", "~> 8.0" gem "mysql2", "~> 0.5" gem "puma", ">= 5.0" gem "rubyzip", "~> 2.3" +gem "blueprinter" gem "bootsnap", require: false gem "activeadmin" diff --git a/apps/api/Gemfile.lock b/apps/api/Gemfile.lock index d865378..cc0af29 100644 --- a/apps/api/Gemfile.lock +++ b/apps/api/Gemfile.lock @@ -91,6 +91,7 @@ GEM base64 (0.3.0) bcrypt (3.1.22) bigdecimal (4.1.2) + blueprinter (1.3.0) bootsnap (1.24.3) msgpack (~> 1.2) builder (3.3.0) @@ -284,6 +285,7 @@ PLATFORMS DEPENDENCIES activeadmin + blueprinter bootsnap debug devise diff --git a/apps/api/app/controllers/api/events_controller.rb b/apps/api/app/controllers/api/events_controller.rb index 8cd03dd..c57ba64 100644 --- a/apps/api/app/controllers/api/events_controller.rb +++ b/apps/api/app/controllers/api/events_controller.rb @@ -1,5 +1,5 @@ class Api::EventsController < ApiController def index - render json: EventSerializer.serialize_all(Event.upcoming) + render json: EventService.new.index end end diff --git a/apps/api/app/controllers/api/images_controller.rb b/apps/api/app/controllers/api/images_controller.rb index 12a0525..3a1e71a 100644 --- a/apps/api/app/controllers/api/images_controller.rb +++ b/apps/api/app/controllers/api/images_controller.rb @@ -1,6 +1,6 @@ class Api::ImagesController < ApplicationController def show - image = Image.find(params[:id]) + image = ImageService.new.show(ImageShowInput.new(id: params[:id])) send_file image.file_path, type: image.content_type, disposition: "inline" rescue ActiveRecord::RecordNotFound render plain: "Not Found", status: :not_found diff --git a/apps/api/app/controllers/api/members_controller.rb b/apps/api/app/controllers/api/members_controller.rb index 56e5853..a2f43db 100644 --- a/apps/api/app/controllers/api/members_controller.rb +++ b/apps/api/app/controllers/api/members_controller.rb @@ -1,5 +1,5 @@ class Api::MembersController < ApiController def index - render json: MemberSerializer.serialize_all(Member.includes(:image).order(:id)) + render json: MemberService.new.index end end diff --git a/apps/api/app/controllers/api/software_controller.rb b/apps/api/app/controllers/api/software_controller.rb index 668b417..01b8d15 100644 --- a/apps/api/app/controllers/api/software_controller.rb +++ b/apps/api/app/controllers/api/software_controller.rb @@ -1,5 +1,5 @@ class Api::SoftwareController < ApiController def index - render json: SoftwareListService.new.call + render json: SoftwareService.new.index end end diff --git a/apps/api/app/controllers/api/software_highlighted_controller.rb b/apps/api/app/controllers/api/software_highlighted_controller.rb index cafaf0b..94f2ac6 100644 --- a/apps/api/app/controllers/api/software_highlighted_controller.rb +++ b/apps/api/app/controllers/api/software_highlighted_controller.rb @@ -1,6 +1,6 @@ class Api::SoftwareHighlightedController < ApiController def index - result = SoftwareHighlightedService.new.call + result = SoftwareHighlightedService.new.index if result render json: result else diff --git a/apps/api/app/controllers/files_controller.rb b/apps/api/app/controllers/files_controller.rb index 8d557d7..0ba718f 100644 --- a/apps/api/app/controllers/files_controller.rb +++ b/apps/api/app/controllers/files_controller.rb @@ -1,21 +1,12 @@ class FilesController < ApplicationController skip_forgery_protection - BASE_PATH = Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares")).realpath def show - requested = params[:path].to_s - full_path = BASE_PATH.join(requested) - - if File.directory?(full_path) - index_path = full_path.join('index.html') - return head(:not_found) unless File.file?(index_path) - return redirect_to("/file/#{requested.chomp('/')}/index.html", status: :moved_permanently) + result = FileService.new.show(FileShowInput.new(path: params[:path])) + case result.type + when :redirect then redirect_to result.url, status: :moved_permanently + when :file then send_file result.path, disposition: "inline" + when :not_found then head :not_found end - - if File.file?(full_path) - send_file full_path, disposition: 'inline' - else - head :not_found - end end end diff --git a/apps/api/app/controllers/update_controller.rb b/apps/api/app/controllers/update_controller.rb index b7c3e18..6b50dd4 100644 --- a/apps/api/app/controllers/update_controller.rb +++ b/apps/api/app/controllers/update_controller.rb @@ -1,24 +1,18 @@ class UpdateController < ApiController - PLATFORM_SERVICES = { - "tic80" => "SoftwareUpdater::Tic80Service", - "ebitengine" => "SoftwareUpdater::EbitengineService", - "love" => "SoftwareUpdater::LoveService" - }.freeze - def update return render plain: "Unauthorized", status: :unauthorized unless authorized? + return render plain: "Version not provided", status: :bad_request if params[:version].blank? - platform = params[:platform] - name = params[:name] - version = params[:version] + input = UpdateInput.new( + platform: params[:platform], + name: params[:name], + version: params[:version] + ) - return render plain: "Version not provided", status: :bad_request if version.blank? - - service_class_name = PLATFORM_SERVICES[platform] - return render plain: "Unsupported platform: #{platform}", status: :bad_request unless service_class_name - - service_class_name.constantize.new.update(name, version) + UpdateService.new.update(input) render plain: "Updated" + rescue ArgumentError => e + render plain: e.message, status: :bad_request rescue => e Rails.logger.error("[UpdateController] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}") render plain: e.message, status: :internal_server_error diff --git a/apps/api/app/dtos/file_result.rb b/apps/api/app/dtos/file_result.rb new file mode 100644 index 0000000..f221f64 --- /dev/null +++ b/apps/api/app/dtos/file_result.rb @@ -0,0 +1,13 @@ +class FileResult + attr_reader :type, :path, :url + + def initialize(type:, path: nil, url: nil) + @type = type + @path = path + @url = url + end + + def self.file(path) = new(type: :file, path: path) + def self.redirect(url) = new(type: :redirect, url: url) + def self.not_found = new(type: :not_found) +end diff --git a/apps/api/app/dtos/file_show_input.rb b/apps/api/app/dtos/file_show_input.rb new file mode 100644 index 0000000..ce15e6d --- /dev/null +++ b/apps/api/app/dtos/file_show_input.rb @@ -0,0 +1 @@ +FileShowInput = Struct.new(:path, keyword_init: true) diff --git a/apps/api/app/dtos/image_show_input.rb b/apps/api/app/dtos/image_show_input.rb new file mode 100644 index 0000000..d6ae0dc --- /dev/null +++ b/apps/api/app/dtos/image_show_input.rb @@ -0,0 +1 @@ +ImageShowInput = Struct.new(:id, keyword_init: true) diff --git a/apps/api/app/dtos/update_input.rb b/apps/api/app/dtos/update_input.rb new file mode 100644 index 0000000..5216733 --- /dev/null +++ b/apps/api/app/dtos/update_input.rb @@ -0,0 +1 @@ +UpdateInput = Struct.new(:platform, :name, :version, keyword_init: true) diff --git a/apps/api/app/serializers/event_serializer.rb b/apps/api/app/serializers/event_serializer.rb index 1f06bdd..082ea8e 100644 --- a/apps/api/app/serializers/event_serializer.rb +++ b/apps/api/app/serializers/event_serializer.rb @@ -1,7 +1,6 @@ -class EventSerializer +class EventSerializer < Blueprinter::Base DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" - def self.serialize_all(events) - events.map { |e| { name: e.name, date: e.date.utc.strftime(DATE_FORMAT) } } - end + field :name + field(:date) { |event| event.date.utc.strftime(DATE_FORMAT) } end diff --git a/apps/api/app/serializers/external_link_serializer.rb b/apps/api/app/serializers/external_link_serializer.rb new file mode 100644 index 0000000..242cf47 --- /dev/null +++ b/apps/api/app/serializers/external_link_serializer.rb @@ -0,0 +1,12 @@ +class ExternalLinkSerializer < Blueprinter::Base + GO_ZERO_TIME = "0001-01-01T00:00:00Z" + TS_FORMAT = "%Y-%m-%dT%H:%M:%S.%3NZ" + + field(:ID) { |el| el.id } + field(:CreatedAt) { |el| el.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME } + field(:UpdatedAt) { |el| el.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME } + field(:DeletedAt) { |el| el.deleted_at&.utc&.strftime(TS_FORMAT) } + field(:softwareId) { |el| el.software_id } + field :label + field :url +end diff --git a/apps/api/app/serializers/member_serializer.rb b/apps/api/app/serializers/member_serializer.rb index 9ea67eb..8260c2a 100644 --- a/apps/api/app/serializers/member_serializer.rb +++ b/apps/api/app/serializers/member_serializer.rb @@ -1,15 +1,5 @@ -class MemberSerializer - def self.serialize_all(members) - members.map { |m| serialize(m) } - end +class MemberSerializer < Blueprinter::Base + fields :nick, :real_nick, :motto, :avatar_filename - def self.serialize(m) - { - nick: m.nick, - real_nick: m.real_nick, - motto: m.motto, - avatar_filename: m.avatar_filename, - image_url: m.image_id ? "/api/image/#{m.image_id}" : nil - } - end + field(:image_url) { |member| member.image_id ? "/api/image/#{member.image_id}" : nil } end diff --git a/apps/api/app/serializers/release_serializer.rb b/apps/api/app/serializers/release_serializer.rb new file mode 100644 index 0000000..1231d39 --- /dev/null +++ b/apps/api/app/serializers/release_serializer.rb @@ -0,0 +1,17 @@ +class ReleaseSerializer < Blueprinter::Base + GO_ZERO_TIME = "0001-01-01T00:00:00Z" + TS_FORMAT = "%Y-%m-%dT%H:%M:%S.%3NZ" + FILE_PATH_FROM = "/softwares/" + FILE_PATH_TO = "/file/" + + field(:ID) { |r| r.id } + field(:CreatedAt) { |r| r.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME } + field(:UpdatedAt) { |r| r.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME } + field(:DeletedAt) { |r| r.deleted_at&.utc&.strftime(TS_FORMAT) } + field(:softwareId) { |r| r.software_id } + field :version + field(:cartridgePath) { |r| r.cartridge_path.blank? ? "" : r.cartridge_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) } + field(:sourcePath) { |r| r.source_path.blank? ? "" : r.source_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) } + field(:htmlFolderPath) { |r| r.html_folder_path.blank? ? "" : r.html_folder_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) } + field(:docsFolderPath) { |r| r.docs_folder_path.blank? ? "" : r.docs_folder_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) } +end diff --git a/apps/api/app/serializers/software_serializer.rb b/apps/api/app/serializers/software_serializer.rb index 32bda62..2af8fb6 100644 --- a/apps/api/app/serializers/software_serializer.rb +++ b/apps/api/app/serializers/software_serializer.rb @@ -1,83 +1,20 @@ -class SoftwareSerializer - GO_ZERO_TIME = "0001-01-01T00:00:00Z" - FILE_PATH_FROM = "/softwares/" - FILE_PATH_TO = "/file/" +class SoftwareSerializer < Blueprinter::Base + GO_ZERO_TIME = "0001-01-01T00:00:00Z" + TS_FORMAT = "%Y-%m-%dT%H:%M:%S.%3NZ" - def self.show_data(software, releases) - sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse - - latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first - web_playable = sorted.find { |r| r.html_folder_path.present? } - - { - software: serialize_software(software), - releases: sorted.map { |r| serialize_release(r) }, - latestRelease: latest ? serialize_release(latest) : nil, - webPlayableRelease: web_playable ? serialize_release(web_playable) : nil - } - end - - def self.serialize_software(sw) - { - "ID" => sw.id, - "CreatedAt" => fmt(sw.created_at), - "UpdatedAt" => fmt(sw.updated_at), - "DeletedAt" => fmt_nullable(sw.deleted_at), - "name" => sw.name, - "title" => sw.title, - "author" => sw.author, - "desc" => sw.desc.to_s, - "story" => sw.story.to_s, - "license" => sw.license.to_s, - "platform" => sw.platform, - "status" => sw.status, - "highlighted" => sw.highlighted ? true : false, - "externalLinks" => sw.external_links.map { |el| serialize_external_link(el) }, - "imageUrl" => sw.image_id ? "/api/image/#{sw.image_id}" : nil - } - end - - def self.serialize_release(r) - { - "ID" => r.id, - "CreatedAt" => fmt(r.created_at), - "UpdatedAt" => fmt(r.updated_at), - "DeletedAt" => fmt_nullable(r.deleted_at), - "softwareId" => r.software_id, - "version" => r.version, - "cartridgePath" => rewrite(r.cartridge_path), - "sourcePath" => rewrite(r.source_path), - "htmlFolderPath" => rewrite(r.html_folder_path), - "docsFolderPath" => rewrite(r.docs_folder_path) - } - end - - def self.serialize_external_link(el) - { - "ID" => el.id, - "CreatedAt" => fmt(el.created_at), - "UpdatedAt" => fmt(el.updated_at), - "DeletedAt" => fmt_nullable(el.deleted_at), - "softwareId" => el.software_id, - "label" => el.label, - "url" => el.url - } - end - - # null → Go zero-time string (matches GORM behavior) - def self.fmt(t) - return GO_ZERO_TIME if t.nil? - t.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ") - end - - # null → JSON null (matches gorm.DeletedAt behavior) - def self.fmt_nullable(t) - return nil if t.nil? - t.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ") - end - - def self.rewrite(path) - return "" if path.blank? - path.gsub(FILE_PATH_FROM, FILE_PATH_TO) - end + field(:ID) { |sw| sw.id } + field(:CreatedAt) { |sw| sw.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME } + field(:UpdatedAt) { |sw| sw.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME } + field(:DeletedAt) { |sw| sw.deleted_at&.utc&.strftime(TS_FORMAT) } + field :name + field :title + field :author + field(:desc) { |sw| sw.desc.to_s } + field(:story) { |sw| sw.story.to_s } + field(:license) { |sw| sw.license.to_s } + field :platform + field :status + field(:highlighted) { |sw| sw.highlighted ? true : false } + field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) } + field(:imageUrl) { |sw| sw.image_id ? "/api/image/#{sw.image_id}" : nil } end diff --git a/apps/api/app/services/event_service.rb b/apps/api/app/services/event_service.rb new file mode 100644 index 0000000..f69441b --- /dev/null +++ b/apps/api/app/services/event_service.rb @@ -0,0 +1,5 @@ +class EventService + def index + EventSerializer.render_as_hash(Event.upcoming) + end +end diff --git a/apps/api/app/services/file_service.rb b/apps/api/app/services/file_service.rb new file mode 100644 index 0000000..9144a11 --- /dev/null +++ b/apps/api/app/services/file_service.rb @@ -0,0 +1,19 @@ +class FileService + BASE_PATH = Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares")).realpath + + def show(input) + full_path = BASE_PATH.join(input.path.to_s) + + if File.directory?(full_path) + index_path = full_path.join("index.html") + return FileResult.not_found unless File.file?(index_path) + return FileResult.redirect("/file/#{input.path.to_s.chomp("/")}/index.html") + end + + if File.file?(full_path) + FileResult.file(full_path) + else + FileResult.not_found + end + end +end diff --git a/apps/api/app/services/image_service.rb b/apps/api/app/services/image_service.rb new file mode 100644 index 0000000..f2209f2 --- /dev/null +++ b/apps/api/app/services/image_service.rb @@ -0,0 +1,5 @@ +class ImageService + def show(input) + Image.find(input.id) + end +end diff --git a/apps/api/app/services/member_service.rb b/apps/api/app/services/member_service.rb new file mode 100644 index 0000000..71a8d96 --- /dev/null +++ b/apps/api/app/services/member_service.rb @@ -0,0 +1,5 @@ +class MemberService + def index + MemberSerializer.render_as_hash(Member.includes(:image).order(:id)) + end +end diff --git a/apps/api/app/services/software_highlighted_service.rb b/apps/api/app/services/software_highlighted_service.rb index a87a184..acc162b 100644 --- a/apps/api/app/services/software_highlighted_service.rb +++ b/apps/api/app/services/software_highlighted_service.rb @@ -1,5 +1,5 @@ class SoftwareHighlightedService - def call + def index software = Software.includes(:external_links, :image) .where(highlighted: true) .order(id: :desc) @@ -7,6 +7,21 @@ class SoftwareHighlightedService return nil unless software releases = Release.where(software_id: software.id).to_a - SoftwareSerializer.show_data(software, releases) + build_response(software, releases) + end + + private + + def build_response(software, releases) + sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse + latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first + web_playable = sorted.find { |r| r.html_folder_path.present? } + + { + software: SoftwareSerializer.render_as_hash(software), + releases: ReleaseSerializer.render_as_hash(sorted), + latestRelease: latest ? ReleaseSerializer.render_as_hash(latest) : nil, + webPlayableRelease: web_playable ? ReleaseSerializer.render_as_hash(web_playable) : nil + } end end diff --git a/apps/api/app/services/software_list_service.rb b/apps/api/app/services/software_list_service.rb deleted file mode 100644 index 34cd875..0000000 --- a/apps/api/app/services/software_list_service.rb +++ /dev/null @@ -1,9 +0,0 @@ -class SoftwareListService - def call - softwares = Software.includes(:releases, :external_links, :image).all - items = softwares.map do |sw| - SoftwareSerializer.show_data(sw, sw.releases.to_a) - end - { softwares: items } - end -end diff --git a/apps/api/app/services/software_service.rb b/apps/api/app/services/software_service.rb new file mode 100644 index 0000000..9f6438b --- /dev/null +++ b/apps/api/app/services/software_service.rb @@ -0,0 +1,21 @@ +class SoftwareService + def index + softwares = Software.includes(:releases, :external_links, :image).all + { softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a) } } + end + + private + + def build_response(software, releases) + sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse + latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first + web_playable = sorted.find { |r| r.html_folder_path.present? } + + { + software: SoftwareSerializer.render_as_hash(software), + releases: ReleaseSerializer.render_as_hash(sorted), + latestRelease: latest ? ReleaseSerializer.render_as_hash(latest) : nil, + webPlayableRelease: web_playable ? ReleaseSerializer.render_as_hash(web_playable) : nil + } + end +end diff --git a/apps/api/app/services/update_service.rb b/apps/api/app/services/update_service.rb new file mode 100644 index 0000000..c6f6b11 --- /dev/null +++ b/apps/api/app/services/update_service.rb @@ -0,0 +1,13 @@ +class UpdateService + PLATFORM_SERVICES = { + "tic80" => "SoftwareUpdater::Tic80Service", + "ebitengine" => "SoftwareUpdater::EbitengineService", + "love" => "SoftwareUpdater::LoveService" + }.freeze + + def update(input) + service_class = PLATFORM_SERVICES[input.platform] + raise ArgumentError, "Unsupported platform: #{input.platform}" unless service_class + service_class.constantize.new.update(input.name, input.version) + end +end