dtos, new services, serializers

This commit is contained in:
2026-05-06 20:11:59 +02:00
parent a7201788ee
commit 46de804a63
26 changed files with 176 additions and 143 deletions
+1
View File
@@ -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"
+2
View File
@@ -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
@@ -1,5 +1,5 @@
class Api::EventsController < ApiController
def index
render json: EventSerializer.serialize_all(Event.upcoming)
render json: EventService.new.index
end
end
@@ -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
@@ -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
@@ -1,5 +1,5 @@
class Api::SoftwareController < ApiController
def index
render json: SoftwareListService.new.call
render json: SoftwareService.new.index
end
end
@@ -1,6 +1,6 @@
class Api::SoftwareHighlightedController < ApiController
def index
result = SoftwareHighlightedService.new.call
result = SoftwareHighlightedService.new.index
if result
render json: result
else
+5 -14
View File
@@ -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
+9 -15
View File
@@ -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
+13
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
FileShowInput = Struct.new(:path, keyword_init: true)
+1
View File
@@ -0,0 +1 @@
ImageShowInput = Struct.new(:id, keyword_init: true)
+1
View File
@@ -0,0 +1 @@
UpdateInput = Struct.new(:platform, :name, :version, keyword_init: true)
+3 -4
View File
@@ -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
@@ -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
+3 -13
View File
@@ -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
@@ -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
+18 -81
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
class EventService
def index
EventSerializer.render_as_hash(Event.upcoming)
end
end
+19
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
class ImageService
def show(input)
Image.find(input.id)
end
end
+5
View File
@@ -0,0 +1,5 @@
class MemberService
def index
MemberSerializer.render_as_hash(Member.includes(:image).order(:id))
end
end
@@ -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
@@ -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
+21
View File
@@ -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
+13
View File
@@ -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