Showroom
A Ruby gem wrapping Shopify's public, unauthenticated JSON endpoints — no app, no token, no session required.
Showroom.configure { |c| c.store = 'acme.myshopify.com' }
Showroom::Product.where(limit: 5).each { |p| puts "#{p.title} — #{p.variants.first.price}" }
Showroom::Product.find('my-bike').available?
Showroom::Product.all.first(100)
Installation
gem install showroom
Or in your Gemfile:
gem 'showroom'
Configuration
Single-store (module-level)
Showroom.configure do |c|
c.store = 'acme.myshopify.com' # required
c.per_page = 50 # default, max 250
c.pagination_depth = 50 # max pages for .all / each_page
c.timeout = 30 # seconds
c.open_timeout = 10 # seconds
c.user_agent = 'MyApp/1.0' # optional override
end
All options can also be set via environment variables:
| Variable | Key |
|---|---|
SHOWROOM_STORE |
store |
SHOWROOM_USER_AGENT |
user_agent |
SHOWROOM_PER_PAGE |
per_page |
SHOWROOM_TIMEOUT |
timeout |
SHOWROOM_OPEN_TIMEOUT |
open_timeout |
Multi-store (per-client instances)
acme = Showroom::Client.new(store: 'acme.myshopify.com')
globo = Showroom::Client.new(store: 'globo.myshopify.com')
acme.products(limit: 5).map(&:title)
globo.product('road-bike').price
# Models remember which client fetched them
product = acme.product('lorem-road-bike')
product.url # => "https://acme.myshopify.com/products/lorem-road-bike"
# base_url is available on any client
acme.base_url # => "https://acme.myshopify.com"
Products
# List (single page)
Showroom::Product.where(limit: 10, product_type: 'Road Bike')
# Single
product = Showroom::Product.find('lorem-road-bike')
product.title # => "Lorem Road Bike"
product.handle # => "lorem-road-bike"
product.vendor # => "Lorem Cycles"
product.available? # => true (any variant in stock)
product.price # => "749.00" (lowest variant price)
product.price_range # => "749.00–899.00"
product.prices # => ["749.00", "899.00"] (unique prices from all variants)
product.url # => "https://acme.myshopify.com/products/lorem-road-bike"
product.featured_image # => #<Showroom::ProductImage ...> (first image)
product.main_image # => #<Showroom::ProductImage ...> (image with position 1)
# Variants
product.variants.each do |v|
puts "#{v.title} #{v.price} #{v.compare_at_price} on_sale=#{v.on_sale?} available=#{v.available?}"
end
# All pages — returns an Enumerator
Showroom::Product.all.each { |p| puts p.title }
# Explicit page iteration
Showroom::Product.each_page(limit: 250) do |batch, page|
puts "Page #{page}: #{batch.size} products"
end
Module-level shortcuts
Showroom.products(limit: 5) # => Array<Product>
Showroom.product('lorem-road-bike') # => Product
Collections
# List
Showroom::Collection.where(limit: 10)
# Single
collection = Showroom::Collection.find('lorem-helmets')
collection.title # => "Lorem Helmets"
collection.handle # => "lorem-helmets"
collection.products_count # => 12
collection.url # => "https://acme.myshopify.com/collections/lorem-helmets"
# Products in a collection
collection.products(limit: 5).each { |p| puts p.title }
Module-level shortcuts
Showroom.collections(limit: 5) # => Array<Collection>
Showroom.collection('lorem-helmets') # => Collection
Search
Showroom wraps Shopify's /search/suggest.json endpoint via Showroom.search.
result = Showroom.search('road bike', types: [:product, :collection], limit: 5)
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
q (first arg) |
String | — | Search query |
types: |
Array<Symbol> | [:product, :collection] |
Resource types to include |
limit: |
Integer | per_page config |
Max results per type |
Available types: values: :product, :collection, :page, :article, :query.
Accessing results
result = Showroom.search('lorem', types: [:product, :collection, :page, :article, :query])
result.products # => Array<Search::ProductSuggestion>
result.collections # => Array<Search::CollectionSuggestion>
result.pages # => Array<Search::PageSuggestion>
result.articles # => Array<Search::ArticleSuggestion>
result.queries # => Array<Search::QuerySuggestion>
result.products.first.title # => "Lorem Road Bike"
result.queries.first.text # => "lorem road bike"
Loading full models
Product and collection suggestions expose a #load method that fetches the complete model record:
suggestion = result.products.first
product = suggestion.load # => Showroom::Product (full record, makes one HTTP request)
suggestion = result.collections.first
collection = suggestion.load # => Showroom::Collection
# Page, article, and query suggestions do not support #load
result.pages.first.load # => NoMethodError
Error handling
begin
Showroom::Product.find('does-not-exist')
rescue Showroom::NotFound => e
puts "404: #{e.}"
rescue Showroom::TooManyRequests
puts "Rate limited — back off and retry"
rescue Showroom::InvalidResponse
puts "Store may be password-protected or blocking requests"
rescue Showroom::ConnectionError
puts "Network error"
rescue Showroom::Error => e
puts "Other Showroom error: #{e}"
end
Error hierarchy
Showroom::Error
├── ConfigurationError bad or missing store URL
├── ConnectionError network failure, timeout
├── InvalidResponse 200 OK but body is HTML (password-protected store)
└── ResponseError HTTP status >= 400
├── ClientError 4xx
│ ├── BadRequest 400
│ ├── NotFound 404
│ ├── UnprocessableEntity 422
│ └── TooManyRequests 429
└── ServerError 5xx
Custom middleware
Showroom.configure do |c|
c.store = 'acme.myshopify.com'
c.middleware = ->(conn) {
conn.response :logger
}
end
Caveats
- Password-protected stores return
200 OKwith an HTML body. Showroom raisesInvalidResponsein this case. - Rate limits — Shopify's public endpoints allow roughly 2 req/s per IP. Showroom raises
TooManyRequests(429) but does not retry automatically. Add your own back-off logic. /products.jsonmay be disabled on some stores. You'll receive aNotFoundorServerError.- User-Agent — some stores block the default Faraday UA. Showroom sets its own identifying header by default; override via
c.user_agentif needed. - Search result ordering is not stable —
/search/suggest.jsondoes not guarantee a consistent order across requests. Results with equal relevance scores may alternate non-deterministically. Do not rely onresult.products.firstbeing the same between calls.