Store resource

This commit is contained in:
2026-08-18 13:04:21 +02:00
parent c85148d238
commit 4dccb9a815
12 changed files with 226 additions and 1 deletions
+53
View File
@@ -0,0 +1,53 @@
ActiveAdmin.register Store do
permit_params :name, :catalog_url, :store_repository_url
menu priority: 5, label: "🛒 Stores"
index do
selectable_column
id_column
column :name
column :catalog_url do |store|
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
end
column :store_repository_url do |store|
link_to store.store_repository_url, store.store_repository_url, target: "_blank", rel: "noopener"
end
column :updated_at
actions
end
filter :name
filter :catalog_url
filter :store_repository_url
show do
attributes_table do
row :id
row :name
row :catalog_url do |store|
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
end
row :store_repository_url do |store|
link_to store.store_repository_url, store.store_repository_url, target: "_blank", rel: "noopener"
end
row :created_at
row :updated_at
end
para do
"Listed by GET /api/stores, which the desktop client reads on first run: it " \
"takes the config from the store repository and points it at the catalog above."
end
end
form do |f|
f.inputs do
f.input :name, hint: "What the client shows in its store picker"
f.input :catalog_url, hint: "Base URL of the WarpEngine catalog, e.g. https://teletypegames.org"
f.input :store_repository_url,
hint: "Repository holding the store's config.json, e.g. " \
"https://git.teletypegames.org/stores/ttg-desktop-store"
end
f.actions
end
end
@@ -0,0 +1,20 @@
class Api::StoresController < ApiController
resource_description do
short "Stores"
end
api :GET, "/api/stores", "List the stores a client can install from"
desc <<~DESC
The registry the graphical desktop client reads on first run: which catalogs
exist, and where each one's store configuration lives. Public on purpose —
a client has nobody to log in as.
DESC
returns code: 200, desc: "Array of stores" do
property :name, String, desc: "Display name of the store"
property :catalogUrl, String, desc: "Base URL of the WarpEngine catalog it serves"
property :storeRepositoryUrl, String, desc: "Repository holding the store's config.json"
end
def index
render json: StoreService.new.index
end
end
+21
View File
@@ -0,0 +1,21 @@
# A store a client can install from: a WarpEngine catalog, and the repository
# holding the store's own configuration.
#
# This is deliberately not part of WarpEngine. The engine serves one catalog and
# has no business knowing which stores exist for it; the registry is a property of
# this site, which is what the graphical client asks.
class Store < ApplicationRecord
URL = %r{\Ahttps?://\S+\z}
validates :name, presence: true
validates :catalog_url, presence: true, format: { with: URL, message: "must be an http(s) URL" }
validates :store_repository_url, presence: true, format: { with: URL, message: "must be an http(s) URL" }
default_scope { where(deleted_at: nil) }
scope :ordered, -> { order(:name) }
def self.ransackable_attributes(auth_object = nil)
%w[id name catalog_url store_repository_url created_at updated_at]
end
end
@@ -0,0 +1,9 @@
class StoreSerializer < Blueprinter::Base
include WarpEngine::TimestampFields
field :name
# camelCase, as the catalog's own payloads use — one convention for a client
# that reads both.
field(:catalogUrl) { |store| store.catalog_url }
field(:storeRepositoryUrl) { |store| store.store_repository_url }
end
+5
View File
@@ -0,0 +1,5 @@
class StoreService
def index
StoreSerializer.render_as_hash(Store.ordered)
end
end
+1
View File
@@ -7,6 +7,7 @@ Rails.application.routes.draw do
get "swagger", to: "swagger#index"
get "events", to: "events#index"
get "members", to: "members#index"
get "stores", to: "stores#index"
get "wiki/pages", to: "wiki#index"
get "rss/blog", to: "rss#blog"
get "rss/releases", to: "rss#releases"
@@ -0,0 +1,19 @@
class CreateStores < ActiveRecord::Migration[8.1]
def change
create_table :stores, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci",
if_not_exists: true do |t|
# A store is a WarpEngine catalog plus the repository that configures a
# client for it. The registry lives here rather than in the engine: the
# engine serves one catalog and knows nothing about who ships stores for it.
t.string :name, null: false
t.string :catalog_url, null: false
t.string :store_repository_url, null: false
t.datetime :deleted_at, precision: 3
t.timestamps precision: 3
t.index :deleted_at, name: "idx_stores_deleted_at"
t.index :name, name: "idx_stores_name"
end
end
end
+12 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
ActiveRecord::Schema[8.1].define(version: 2026_08_18_000001) do
create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "deleted_at", precision: 3
@@ -281,6 +281,17 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
t.index ["owner_type", "owner_id"], name: "idx_softwares_owner"
end
create_table "stores", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "catalog_url", null: false
t.datetime "created_at", precision: 3, null: false
t.datetime "deleted_at", precision: 3
t.string "name", null: false
t.string "store_repository_url", null: false
t.datetime "updated_at", precision: 3, null: false
t.index ["deleted_at"], name: "idx_stores_deleted_at"
t.index ["name"], name: "idx_stores_name"
end
add_foreign_key "admin_users", "members"
add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify
add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links", on_delete: :cascade
+11
View File
@@ -15,3 +15,14 @@ end
m.avatar_filename = attrs[:avatar_filename]
end
end
# The store registry the graphical desktop client reads. Our own catalog is the
# first record; anyone running this site would add their own the same way, from
# the admin panel or here.
Store.find_or_create_by!(name: "Teletype Games") do |store|
store.catalog_url = ENV.fetch("STORE_CATALOG_URL", "https://teletypegames.org")
store.store_repository_url = ENV.fetch(
"STORE_REPOSITORY_URL",
"https://git.teletypegames.org/stores/ttg-desktop-store"
)
end
@@ -0,0 +1,35 @@
require "rails_helper"
RSpec.describe Api::StoresController, type: :request do
describe "GET /api/stores" do
it "lists stores without a login, in name order" do
create(:store, name: "Zed Games", catalog_url: "https://zed.example")
create(:store, name: "Apex Games", catalog_url: "https://apex.example")
get "/api/stores"
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json.map { |s| s["name"] }).to eq(["Apex Games", "Zed Games"])
end
it "answers with the fields a client needs, camelCased" do
create(:store)
get "/api/stores"
store = JSON.parse(response.body).first
expect(store).to include("name", "catalogUrl", "storeRepositoryUrl")
expect(store["catalogUrl"]).to eq("https://teletypegames.org")
expect(store["storeRepositoryUrl"]).to end_with("/stores/ttg-desktop-store")
end
it "leaves out soft-deleted stores" do
create(:store, name: "Gone").update!(deleted_at: Time.current)
get "/api/stores"
expect(JSON.parse(response.body)).to be_empty
end
end
end
+7
View File
@@ -0,0 +1,7 @@
FactoryBot.define do
factory :store do
name { "Teletype Games" }
catalog_url { "https://teletypegames.org" }
store_repository_url { "https://git.teletypegames.org/stores/ttg-desktop-store" }
end
end
+33
View File
@@ -0,0 +1,33 @@
require "rails_helper"
RSpec.describe Store, type: :model do
it { should validate_presence_of(:name) }
it { should validate_presence_of(:catalog_url) }
it { should validate_presence_of(:store_repository_url) }
it "rejects a catalog url that is not http(s)" do
store = build(:store, catalog_url: "git@example.org:thing.git")
expect(store).not_to be_valid
expect(store.errors[:catalog_url]).to include("must be an http(s) URL")
end
it "rejects a repository url that is not http(s)" do
expect(build(:store, store_repository_url: "not a url")).not_to be_valid
end
describe ".ordered" do
it "lists stores by name" do
later = create(:store, name: "Zed Games")
first = create(:store, name: "Apex Games")
expect(Store.ordered).to eq([first, later])
end
end
it "hides soft-deleted stores" do
store = create(:store)
store.update!(deleted_at: Time.current)
expect(Store.all).to be_empty
end
end