coworkingview 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '0989348b7aeb3b87bdaf8cd62329c5289e2eb0caa7b0c59671b418fad5f80ebe'
4
+ data.tar.gz: 67b2cc39acf7a06630d4ce22c58076b24dd19eefc0b2a5c4f023abb48de8570f
5
+ SHA512:
6
+ metadata.gz: 5c423a30beb3882c8c0b9b52871cf258571893d571b5dbad0818eb0eb070d003181cc151f91168ca76132e90ce5483e227430d97c28421f3fd429f82276a6434
7
+ data.tar.gz: 7f815d5e56b5977e9f537dd03c25fd35958df9007a51dc48cb3775a9580b1471e03211fb4b6b75de8749d08ab011294f067c2db0ad025f5dcd2021ff94d33fcb
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release of the Ruby client for the public CoworkingView API.
6
+
7
+ - `rates`, `cities`, `spaces` and `space` call `GET /v1/market/rates`, `GET /v1/properties` and `GET /v1/properties/{slug}`.
8
+ - Same columns, ordering and HTTP behaviour as the Python, R, Java and Rust packages.
9
+ - Standard library only (`net/http` and `json`). Ruby 3.0 or newer.
data/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CoworkingView
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ The data returned by the API is not covered by this licence. The CoworkingView
24
+ Price Index is licensed under CC BY 4.0; see README.md.
data/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # coworkingview
2
+
3
+ Ruby client for the public API of [CoworkingView](https://coworkingview.com).
4
+
5
+ It reads:
6
+
7
+ - **The CoworkingView Price Index** (`GET /v1/market/rates`): median, 25th and 75th percentile by city and product, with the sample size next to every figure. Open data under **CC BY 4.0**. The attribution string comes back on every result and has to travel with the figures.
8
+ - **Catalogue search** (`GET /v1/properties`) and **one space** (`GET /v1/properties/{slug}`): coworking spaces and other flexible offices, filtered by city, neighbourhood, operator, product, capacity and price.
9
+
10
+ No API key. Nothing is downloaded when the library is loaded. The client uses `net/http` and `json` from the standard library and has no runtime dependencies.
11
+
12
+ The same three endpoints, columns and HTTP behaviour are implemented by the Python, R, Java and Rust packages in this repository. Numbers are checked against the shared fixtures in `spec/`.
13
+
14
+ Maintained by [CoworkingView](https://coworkingview.com).
15
+
16
+ ## Install
17
+
18
+ Ruby 3.0 or newer. This gem is not published to rubygems.org. Build it from this directory:
19
+
20
+ ```bash
21
+ cd integrations/ruby
22
+ gem build coworkingview.gemspec
23
+ gem install ./coworkingview-0.1.0.gem
24
+ ```
25
+
26
+ Or point Bundler at a checkout:
27
+
28
+ ```ruby
29
+ gem "coworkingview", git: "https://github.com/coworkingview/flex-office-data", glob: "integrations/ruby/*.gemspec"
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```ruby
35
+ require "coworkingview"
36
+
37
+ client = CoworkingView::Client.new
38
+
39
+ berlin = client.rates(city: "berlin")
40
+ berlin.data.each do |row|
41
+ puts "#{row[:product]} #{row[:unit]} median #{row[:median]} #{row[:currency]} n=#{row[:sample_size]}"
42
+ end
43
+ # Print this next to any figure you republish. The index is CC BY 4.0.
44
+ puts berlin.attribution
45
+
46
+ rooms = client.spaces(city: "madrid", product: "meeting_room", limit: 20)
47
+ rooms.data.each { |row| puts "#{row[:title]} #{row[:url]}" }
48
+
49
+ space = client.space("sankt-oberholz-rosenthaler-berlin")
50
+ space.pricing.each do |offer|
51
+ # price is nil when the operator quotes on request. It is not zero.
52
+ price = offer[:price_on_request] ? "on request" : "#{offer[:price]} #{offer[:currency]}"
53
+ puts "#{offer[:product]} #{price}"
54
+ end
55
+ ```
56
+
57
+ `CoworkingView::Client.new` talks to `https://api.coworkingview.com`. Pass `base_url:` to point at another origin, or set `COWORKINGVIEW_API_URL`. A trailing slash is removed. The default user agent is `coworkingview-ruby/0.1.0 (+https://github.com/coworkingview/flex-office-data)`.
58
+
59
+ ```ruby
60
+ client = CoworkingView::Client.new(
61
+ timeout: 5,
62
+ user_agent: "my-research-project/1.0 (me@example.org)"
63
+ )
64
+ ```
65
+
66
+ `locale` defaults to `"en"` and is always sent. The API requires one of `en`, `de` or `es`.
67
+
68
+ ### Price Index
69
+
70
+ ```ruby
71
+ client.rates(city: "berlin", currency: "EUR")
72
+ client.cities(country: "germany")
73
+ ```
74
+
75
+ `rates` returns one row per published city and product. `cities` is the same request, one row per city, published cities first. Both carry `attribution`, `license` (the response's `licenseUrl`) and `as_of`.
76
+
77
+ | Argument | Values |
78
+ |---|---|
79
+ | `locale` | `en` (default), `es`, `de` |
80
+ | `currency` | `EUR`, `GBP`, `USD`, `CHF`, `AED`, `VES` (any case). `VES` is sent through; the API answers 400 where it cannot convert |
81
+ | `city` | City slug, for example `berlin` |
82
+ | `country` | Country slug, for example `germany` |
83
+
84
+ A product the API adds later shows up in `rates` without a library update. Products this version does not name a unit for come back with `unit: nil`. `low_confidence` is true when `sample_size` is below 10.
85
+
86
+ ### Catalogue
87
+
88
+ ```ruby
89
+ client.spaces(
90
+ city: "berlin",
91
+ zone: "mitte",
92
+ operator: "regus",
93
+ product: "flex_desk",
94
+ min_capacity: 1,
95
+ min_price: 0,
96
+ max_price: 400,
97
+ currency: "EUR",
98
+ sort: "price-asc",
99
+ limit: 50
100
+ )
101
+ ```
102
+
103
+ `spaces` calls `GET /v1/properties` and follows pages until `limit` rows (default 50, maximum 200). Each request asks for at most 50 rows. `product` is sent as `pricingOptionType`. `sort` is sent as `sortBy`: `random` (default), `price-asc`, `date-desc` or `date-asc`. When the first page of a random sort returns `meta.seed`, later pages send it back. Pass `seed:` to supply one yourself.
104
+
105
+ `space` calls `GET /v1/properties/{slug}`. The slug is checked against `^[A-Za-z0-9][A-Za-z0-9._-]*$` before it is placed in the path.
106
+
107
+ `Dataset#attribution` on a search is `meta.citation.text`. `Dataset#notice` is `meta.catalogueNotice`. `Dataset#total` is `pagination.total` from the first page. A space uses `webUrl` when the API sends one, otherwise `https://coworkingview.com/{locale}/{workspace|oficina|buero}/{slug}`.
108
+
109
+ Descriptions, photos and logos are not returned. Link to `url` instead of copying the catalogue.
110
+
111
+ ### Errors
112
+
113
+ `CoworkingView::APIError` is raised for a non-2xx response and for a body that is not a JSON object. `status` is the HTTP status. `code`, `title`, `detail` and `errors` come from the problem document. When the body has no `code`, `code` is `HTTP_<status>`. A non-JSON success body uses `BAD_RESPONSE`.
114
+
115
+ On HTTP 429 or 5xx the client waits `Retry-After` seconds (else `RateLimit-Reset`, else 2 seconds, never more than 10) and retries once. The per-request timeout is 20 seconds. DNS failures, refused connections and timeouts raise `CoworkingView::Error` with `status` 0 and `code` `CONNECTION` or `TIMEOUT`.
116
+
117
+ Wrong arguments raise `ArgumentError` and do not send a request.
118
+
119
+ ## Tests
120
+
121
+ ```bash
122
+ bundle install
123
+ bundle exec rake test
124
+ gem build coworkingview.gemspec
125
+ ```
126
+
127
+ HTTP is stubbed with webmock. The tests replay `spec/fixtures/` and compare rows with `spec/golden/`.
128
+
129
+ ## Licence and attribution
130
+
131
+ - **Code:** MIT, see [LICENSE](LICENSE).
132
+ - **Price Index data:** [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Credit *CoworkingView Price Index* with a link to <https://coworkingview.com/en/tools/price-index>. Use the `attribution` field on the result.
133
+ - **Catalogue:** names, prices and links are for search and reference. Descriptions, photos and logos belong to the operators and are not included. A search that follows pages stops at 200 listings.
134
+
135
+ Changes are in [CHANGELOG.md](CHANGELOG.md). Issues: <https://github.com/coworkingview/flex-office-data/issues>.
@@ -0,0 +1,794 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "time"
7
+ require "uri"
8
+
9
+ require_relative "version"
10
+
11
+ module CoworkingView
12
+ # Failure before an HTTP response arrived (DNS, connection refused, timeout).
13
+ class Error < StandardError
14
+ attr_reader :status, :code
15
+
16
+ def initialize(message, status: 0, code: "CONNECTION")
17
+ @status = status
18
+ @code = code
19
+ super(message)
20
+ end
21
+ end
22
+
23
+ # The API answered with an error, or with a body that is not usable JSON.
24
+ #
25
+ # +status+ is the HTTP status, never the status copied out of a problem body.
26
+ # +code+ is the problem document's +code+, or +HTTP_<status>+ / +BAD_RESPONSE+.
27
+ class APIError < Error
28
+ attr_reader :title, :detail, :errors
29
+
30
+ def initialize(status:, code:, title:, detail: nil, errors: nil)
31
+ @title = title
32
+ @detail = detail
33
+ @errors = errors.is_a?(Array) ? errors.select { |item| item.is_a?(Hash) } : []
34
+ message = "#{status} #{code}: #{title}"
35
+ message = "#{message} (#{detail})" if detail
36
+ super(message, status: status, code: code)
37
+ end
38
+ end
39
+
40
+ # Rows from one call, plus the attribution that has to travel with them.
41
+ class Dataset
42
+ attr_reader :data, :attribution, :license, :as_of, :notice, :total
43
+
44
+ def initialize(data:, attribution: nil, license: nil, as_of: nil, notice: nil, total: nil)
45
+ @data = data
46
+ @attribution = attribution
47
+ @license = license
48
+ @as_of = as_of
49
+ @notice = notice
50
+ @total = total
51
+ end
52
+ end
53
+
54
+ # One catalogue detail: the listing fields, a pricing table, and citation.
55
+ Space = Struct.new(
56
+ :slug,
57
+ :title,
58
+ :city_slug,
59
+ :zone_slug,
60
+ :operator_slug,
61
+ :operator_name,
62
+ :country_code,
63
+ :lat,
64
+ :lng,
65
+ :featured,
66
+ :market_verdict,
67
+ :market_median,
68
+ :market_sample_size,
69
+ :url,
70
+ :updated_at,
71
+ :address,
72
+ :capacity,
73
+ :amenities,
74
+ :pricing,
75
+ :attribution,
76
+ :notice,
77
+ keyword_init: true
78
+ )
79
+
80
+ # Client for the public CoworkingView API.
81
+ #
82
+ # The API needs no key. Creating a client does not touch the network. Calls
83
+ # use three endpoints only:
84
+ #
85
+ # - +GET /v1/market/rates+ — Price Index (+rates+, +cities+)
86
+ # - +GET /v1/properties+ — catalogue search (+spaces+)
87
+ # - +GET /v1/properties/{slug}+ — one space (+space+)
88
+ #
89
+ # On HTTP 429 or 5xx the client waits +Retry-After+ seconds, else
90
+ # +RateLimit-Reset+, else 2 seconds, and never more than 10, then tries once
91
+ # more.
92
+ class Client
93
+ DEFAULT_BASE_URL = "https://api.coworkingview.com"
94
+ ENV_BASE_URL = "COWORKINGVIEW_API_URL"
95
+ DEFAULT_TIMEOUT = 20
96
+ DEFAULT_USER_AGENT = "coworkingview-ruby/#{VERSION} (+https://github.com/coworkingview/flex-office-data)".freeze
97
+ FALLBACK_WAIT = 2.0
98
+ MAX_WAIT = 10.0
99
+ MAX_LIMIT = 200
100
+ PAGE_SIZE = 50
101
+ LOW_CONFIDENCE_BELOW = 10
102
+
103
+ LOCALES = %w[en es de].freeze
104
+ CURRENCIES = %w[EUR GBP USD CHF AED VES].freeze
105
+ PRODUCTS = %w[
106
+ flex_desk fix_desk private_office exclusive_bereich day_pass
107
+ meeting_room event_space virtual_office membership
108
+ ].freeze
109
+ SORTS = %w[random price-asc date-desc date-asc].freeze
110
+ PRODUCT_ORDER = PRODUCTS
111
+ STAT_UNITS = {
112
+ "day_pass" => "day",
113
+ "meeting_room" => "hour",
114
+ "event_space" => "hour",
115
+ "virtual_office" => "month"
116
+ }.freeze
117
+ WORKSPACE_PATH = { "en" => "workspace", "es" => "oficina", "de" => "buero" }.freeze
118
+ SITE_URL = "https://coworkingview.com"
119
+ SLUG_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._-]*\z/
120
+
121
+ RATE_COLUMNS = %i[
122
+ city_slug city_name country_code currency product unit median p25 p75
123
+ sample_size property_count operator_count low_confidence as_of
124
+ ].freeze
125
+ CITY_COLUMNS = %i[
126
+ city_slug city_name country_code currency published property_count
127
+ operator_count entry_median entry_min entry_max p25 p75 sample_size as_of
128
+ ].freeze
129
+
130
+ attr_reader :base_url, :timeout, :user_agent
131
+
132
+ def initialize(base_url: nil, timeout: DEFAULT_TIMEOUT, user_agent: nil, sleeper: nil)
133
+ @base_url = resolve_base_url(base_url)
134
+ @timeout = check_timeout(timeout)
135
+ @user_agent = check_user_agent(user_agent)
136
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
137
+ end
138
+
139
+ def inspect
140
+ "#<#{self.class.name} base_url=#{@base_url.inspect} timeout=#{@timeout.inspect}>"
141
+ end
142
+
143
+ # Price Index figures, one row per published city and product.
144
+ #
145
+ # +GET /v1/market/rates+. Unpublished cities produce no rows. A product the
146
+ # API adds later is kept, so a new aggregate does not need a new release.
147
+ def rates(city: nil, country: nil, currency: nil, locale: "en")
148
+ path = "/v1/market/rates"
149
+ payload = get(path, market_query(city, country, currency, locale))
150
+ data = object_data(payload, path)
151
+ rows = guarded(path) { rate_rows(data) }
152
+ index_dataset(rows, data)
153
+ end
154
+
155
+ # Cities of the Price Index, published cities first.
156
+ #
157
+ # Same request as #rates.
158
+ def cities(city: nil, country: nil, currency: nil, locale: "en")
159
+ path = "/v1/market/rates"
160
+ payload = get(path, market_query(city, country, currency, locale))
161
+ data = object_data(payload, path)
162
+ rows = guarded(path) { city_rows(data) }
163
+ index_dataset(rows, data)
164
+ end
165
+
166
+ # Catalogue search. One row per space, at most 200.
167
+ #
168
+ # +GET /v1/properties+. Pages are followed until +limit+ rows or the last
169
+ # page. Page size is +min(50, limit)+. With +sort+ +random+, the seed of
170
+ # the first page is sent again on the following pages.
171
+ def spaces(
172
+ city: nil,
173
+ country: nil,
174
+ zone: nil,
175
+ operator: nil,
176
+ product: nil,
177
+ min_capacity: nil,
178
+ min_price: nil,
179
+ max_price: nil,
180
+ currency: nil,
181
+ sort: "random",
182
+ limit: 50,
183
+ locale: "en",
184
+ seed: nil
185
+ )
186
+ path = "/v1/properties"
187
+ request_locale = check_locale(locale)
188
+ request_sort = check_sort(sort)
189
+ request_limit = check_limit(limit)
190
+ low = check_price("min_price", min_price)
191
+ high = check_price("max_price", max_price)
192
+ check_price_range(low, high)
193
+ user_seed = check_seed(seed)
194
+ params = {
195
+ "locale" => request_locale,
196
+ "city" => check_text("city", city),
197
+ "country" => check_text("country", country),
198
+ "zone" => check_text("zone", zone),
199
+ "operator" => check_text("operator", operator),
200
+ "pricingOptionType" => check_product(product),
201
+ "minCapacity" => check_count("min_capacity", min_capacity),
202
+ "minPrice" => low,
203
+ "maxPrice" => high,
204
+ "currency" => check_currency(currency),
205
+ "sortBy" => request_sort,
206
+ "pageSize" => [PAGE_SIZE, request_limit].min
207
+ }
208
+ params["seed"] = user_seed unless user_seed.nil?
209
+
210
+ cards = []
211
+ first_meta = {}
212
+ total = nil
213
+ page = 1
214
+ loop do
215
+ payload = get(path, params.merge("page" => page))
216
+ data = list_data(payload, path)
217
+ meta = meta_of(payload)
218
+ pagination = meta["pagination"]
219
+ pagination = {} unless pagination.is_a?(Hash)
220
+ if page == 1
221
+ first_meta = meta
222
+ total = pagination["total"]
223
+ if request_sort == "random" && user_seed.nil? && !meta["seed"].nil?
224
+ params["seed"] = meta["seed"]
225
+ end
226
+ end
227
+ data.each { |card| cards << card if card.is_a?(Hash) }
228
+ page_count = pagination["pageCount"]
229
+ break if cards.length >= request_limit || data.empty? || !page_count.is_a?(Integer) || page >= page_count
230
+
231
+ page += 1
232
+ end
233
+
234
+ rows = guarded(path) { cards.take(request_limit).map { |card| space_row(card, request_locale) } }
235
+ notice = first_meta["catalogueNotice"]
236
+ Dataset.new(
237
+ data: rows,
238
+ attribution: citation_text(first_meta),
239
+ notice: notice.is_a?(String) ? notice : nil,
240
+ total: total
241
+ )
242
+ end
243
+
244
+ # One space and its pricing table.
245
+ #
246
+ # +GET /v1/properties/{slug}+. The slug is checked before it is put in the path.
247
+ def space(slug, locale: "en")
248
+ request_locale = check_locale(locale)
249
+ path = "/v1/properties/#{check_slug(slug)}"
250
+ payload = get(path, { "locale" => request_locale })
251
+ detail = object_data(payload, path)
252
+ meta = meta_of(payload)
253
+ notice = meta["catalogueNotice"]
254
+ guarded(path) do
255
+ fields = space_row(detail, request_locale)
256
+ web_url = detail["webUrl"]
257
+ fields[:url] = web_url if web_url.is_a?(String) && !web_url.empty?
258
+ fields[:address] = detail["address"]
259
+ capacity = detail["capacity"]
260
+ fields[:capacity] = capacity.is_a?(Integer) ? capacity : nil
261
+ amenities = detail["amenities"]
262
+ fields[:amenities] = amenities.is_a?(Array) ? amenities.map(&:to_s) : []
263
+ fields[:pricing] = pricing_rows(detail)
264
+ fields[:attribution] = citation_text(meta)
265
+ fields[:notice] = notice.is_a?(String) ? notice : nil
266
+ Space.new(**fields)
267
+ end
268
+ end
269
+
270
+ def self.retry_wait(headers, now: Time.now)
271
+ wait = delay_seconds(header_value(headers, "Retry-After"), now: now)
272
+ wait = delay_seconds(header_value(headers, "RateLimit-Reset"), now: now) if wait.nil?
273
+ wait = FALLBACK_WAIT if wait.nil?
274
+ [wait, MAX_WAIT].min
275
+ end
276
+
277
+ def self.header_value(headers, name)
278
+ return nil if headers.nil?
279
+
280
+ headers.each do |key, value|
281
+ return value if key.to_s.casecmp?(name)
282
+ end
283
+ nil
284
+ end
285
+
286
+ def self.delay_seconds(value, now:)
287
+ return nil if value.nil?
288
+
289
+ text = value.to_s.strip
290
+ return nil if text.empty?
291
+ return [0.0, text.to_f].max if /\A-?\d+(?:\.\d+)?\z/.match?(text)
292
+
293
+ begin
294
+ moment = Time.httpdate(text)
295
+ rescue ArgumentError
296
+ return nil
297
+ end
298
+ delta = moment - now
299
+ delta.negative? ? 0.0 : delta.to_f
300
+ end
301
+ private_class_method :delay_seconds
302
+
303
+ private
304
+
305
+ def resolve_base_url(base_url)
306
+ raw = blank?(base_url) ? ENV.fetch(ENV_BASE_URL, nil) : base_url
307
+ raw = DEFAULT_BASE_URL if blank?(raw)
308
+ unless raw.is_a?(String)
309
+ raise ArgumentError, "base_url must be an http(s) URL, got #{raw.inspect}."
310
+ end
311
+
312
+ stripped = raw.strip.sub(%r{/+\z}, "")
313
+ uri = URI.parse(stripped)
314
+ unless %w[http https].include?(uri.scheme) && uri.host && uri.userinfo.nil?
315
+ raise ArgumentError, "base_url must be an http(s) origin without userinfo."
316
+ end
317
+ unless uri.path.nil? || uri.path.empty?
318
+ raise ArgumentError, "base_url must be an origin without a path."
319
+ end
320
+ unless uri.query.nil? && uri.fragment.nil?
321
+ raise ArgumentError, "base_url must be an origin without a query or fragment."
322
+ end
323
+
324
+ stripped
325
+ rescue URI::InvalidURIError
326
+ raise ArgumentError, "base_url must be an http(s) origin without userinfo."
327
+ end
328
+
329
+ def blank?(value)
330
+ value.nil? || (value.is_a?(String) && value.strip.empty?)
331
+ end
332
+
333
+ def check_timeout(value)
334
+ unless (value.is_a?(Integer) || value.is_a?(Float)) && value.positive?
335
+ raise ArgumentError, "timeout must be a positive number of seconds, got #{value.inspect}."
336
+ end
337
+
338
+ value.to_f
339
+ end
340
+
341
+ def check_user_agent(value)
342
+ return DEFAULT_USER_AGENT if blank?(value)
343
+ unless value.is_a?(String)
344
+ raise ArgumentError, "user_agent must be a string, got #{value.inspect}."
345
+ end
346
+ if /[\r\n]/.match?(value)
347
+ raise ArgumentError, "user_agent must be a single line."
348
+ end
349
+
350
+ value.strip
351
+ end
352
+
353
+ def market_query(city, country, currency, locale)
354
+ {
355
+ "locale" => check_locale(locale),
356
+ "city" => check_text("city", city),
357
+ "country" => check_text("country", country),
358
+ "currency" => check_currency(currency)
359
+ }
360
+ end
361
+
362
+ def get(path, params)
363
+ uri = build_uri(path, params)
364
+ attempt = 0
365
+ loop do
366
+ attempt += 1
367
+ status, headers, body, reason = perform(uri)
368
+ return parse_object(status, body, path) if status.between?(200, 299)
369
+ raise problem(status, reason, body) unless attempt == 1 && retryable?(status)
370
+
371
+ @sleeper.call(self.class.retry_wait(headers))
372
+ end
373
+ end
374
+
375
+ def build_uri(path, params)
376
+ cleaned = []
377
+ params.each do |key, value|
378
+ next if value.nil?
379
+
380
+ cleaned << [key.to_s, value]
381
+ end
382
+ query = URI.encode_www_form(cleaned)
383
+ url = +"#{@base_url}#{path}"
384
+ url << "?#{query}" unless query.empty?
385
+ URI.parse(url)
386
+ end
387
+
388
+ def perform(uri)
389
+ http = Net::HTTP.new(uri.host, uri.port)
390
+ http.use_ssl = uri.scheme == "https"
391
+ http.open_timeout = @timeout
392
+ http.read_timeout = @timeout
393
+ http.write_timeout = @timeout
394
+ http.max_retries = 0 if http.respond_to?(:max_retries=)
395
+ request = Net::HTTP::Get.new(uri)
396
+ request["Accept"] = "application/json"
397
+ request["User-Agent"] = @user_agent
398
+ response = http.request(request)
399
+ [response.code.to_i, header_map(response), response.body.to_s, response.message.to_s]
400
+ rescue Timeout::Error => e
401
+ raise Error.new("Could not reach #{@base_url}: #{e.message}", status: 0, code: "TIMEOUT")
402
+ rescue SocketError, IOError, SystemCallError, OpenSSL::SSL::SSLError => e
403
+ raise Error.new("Could not reach #{@base_url}: #{e.message}", status: 0, code: "CONNECTION")
404
+ end
405
+
406
+ def header_map(response)
407
+ headers = {}
408
+ response.each_header { |key, value| headers[key] = value }
409
+ headers
410
+ end
411
+
412
+ def retryable?(status)
413
+ status == 429 || status.between?(500, 599)
414
+ end
415
+
416
+ def parse_json(raw)
417
+ text = raw.to_s.dup
418
+ text.force_encoding(Encoding::UTF_8)
419
+ raise JSON::ParserError, "invalid encoding" unless text.valid_encoding?
420
+
421
+ JSON.parse(text)
422
+ end
423
+
424
+ def parse_object(status, body, path)
425
+ begin
426
+ payload = parse_json(body)
427
+ rescue JSON::ParserError
428
+ raise bad_response(status, "The API returned a body that is not valid JSON", "GET #{path}")
429
+ end
430
+ unless payload.is_a?(Hash)
431
+ raise bad_response(status, "The API returned JSON that is not an object", "GET #{path}")
432
+ end
433
+
434
+ payload
435
+ end
436
+
437
+ def problem(status, reason, raw)
438
+ body = {}
439
+ begin
440
+ parsed = parse_json(raw)
441
+ body = parsed if parsed.is_a?(Hash)
442
+ rescue JSON::ParserError
443
+ body = {}
444
+ end
445
+ title = body["title"]
446
+ detail = body["detail"]
447
+ code = body["code"]
448
+ APIError.new(
449
+ status: status,
450
+ code: code.is_a?(String) && !code.empty? ? code : "HTTP_#{status}",
451
+ title: title.is_a?(String) && !title.empty? ? title : (reason.empty? ? "HTTP #{status}" : reason),
452
+ detail: detail.is_a?(String) ? detail : nil,
453
+ errors: body["errors"]
454
+ )
455
+ end
456
+
457
+ def bad_response(status, title, detail)
458
+ APIError.new(status: status, code: "BAD_RESPONSE", title: title, detail: detail)
459
+ end
460
+
461
+ def object_data(payload, path)
462
+ unless payload.key?("data")
463
+ raise bad_response(200, "The API response has no 'data' member", "GET #{path}")
464
+ end
465
+ data = payload["data"]
466
+ return data if data.is_a?(Hash)
467
+
468
+ raise bad_response(200, "Unexpected shape for GET #{path}", "data is not an object")
469
+ end
470
+
471
+ def list_data(payload, path)
472
+ unless payload.key?("data")
473
+ raise bad_response(200, "The API response has no 'data' member", "GET #{path}")
474
+ end
475
+ data = payload["data"]
476
+ return data if data.is_a?(Array)
477
+
478
+ raise bad_response(200, "Unexpected shape for GET #{path}", "data is not an array")
479
+ end
480
+
481
+ def guarded(path)
482
+ yield
483
+ rescue KeyError, NoMethodError, TypeError => e
484
+ raise bad_response(200, "Unexpected shape for GET #{path}", e.message)
485
+ end
486
+
487
+ def meta_of(payload)
488
+ meta = payload["meta"]
489
+ meta.is_a?(Hash) ? meta : {}
490
+ end
491
+
492
+ def citation_text(meta)
493
+ citation = meta["citation"]
494
+ return nil unless citation.is_a?(Hash)
495
+
496
+ text = citation["text"]
497
+ text.is_a?(String) ? text : nil
498
+ end
499
+
500
+ def index_dataset(rows, data)
501
+ attribution = data["attribution"]
502
+ license = data["licenseUrl"]
503
+ as_of = data["asOf"]
504
+ Dataset.new(
505
+ data: rows,
506
+ attribution: attribution.is_a?(String) ? attribution : nil,
507
+ license: license.is_a?(String) ? license : nil,
508
+ as_of: as_of.is_a?(String) ? as_of : nil
509
+ )
510
+ end
511
+
512
+ def money_amount(money)
513
+ return nil if money.nil?
514
+
515
+ money.fetch("amount")
516
+ end
517
+
518
+ def sub(object, key)
519
+ return nil unless object.is_a?(Hash)
520
+
521
+ object[key]
522
+ end
523
+
524
+ def snake(key)
525
+ key.to_s.gsub(/(?<!^)(?=[A-Z])/, "_").downcase
526
+ end
527
+
528
+ def stat?(value)
529
+ value.is_a?(Hash) && value.key?("median") && value.key?("sampleSize")
530
+ end
531
+
532
+ def product_sort_key(product)
533
+ index = PRODUCT_ORDER.index(product)
534
+ index ? [0, index, ""] : [1, 0, product.to_s]
535
+ end
536
+
537
+ def rate_rows(data)
538
+ rows = []
539
+ cities = data["cities"]
540
+ cities = [] unless cities.is_a?(Array)
541
+ cities.each do |city|
542
+ next unless city.is_a?(Hash)
543
+
544
+ place = city.fetch("city")
545
+ base = {
546
+ city_slug: place.fetch("slug"),
547
+ city_name: place.fetch("name"),
548
+ country_code: city["countryCode"],
549
+ currency: city.fetch("currency")
550
+ }
551
+ figures = city["figures"]
552
+ by_type = city["byType"]
553
+ items = if figures.is_a?(Array) && !figures.empty?
554
+ figures
555
+ elsif by_type.is_a?(Array)
556
+ by_type
557
+ else
558
+ []
559
+ end
560
+ items.each do |item|
561
+ next unless item.is_a?(Hash)
562
+
563
+ rows << base.merge(
564
+ product: item.fetch("optionType"),
565
+ unit: "month",
566
+ median: money_amount(item.fetch("median")),
567
+ p25: money_amount(item["p25"]),
568
+ p75: money_amount(item["p75"]),
569
+ sample_size: item.fetch("sampleSize"),
570
+ property_count: item["propertyCount"],
571
+ operator_count: item["operatorCount"]
572
+ )
573
+ end
574
+ city.each do |key, value|
575
+ next unless stat?(value)
576
+
577
+ product = snake(key)
578
+ rows << base.merge(
579
+ product: product,
580
+ unit: STAT_UNITS[product],
581
+ median: money_amount(value.fetch("median")),
582
+ p25: money_amount(value["p25"]),
583
+ p75: money_amount(value["p75"]),
584
+ sample_size: value.fetch("sampleSize"),
585
+ property_count: value["propertyCount"],
586
+ operator_count: value["operatorCount"]
587
+ )
588
+ end
589
+ end
590
+ as_of = data["asOf"]
591
+ rows.each do |row|
592
+ row[:low_confidence] = row[:sample_size] < LOW_CONFIDENCE_BELOW
593
+ row[:as_of] = as_of
594
+ end
595
+ rows.sort_by! { |row| [row[:city_slug].to_s, product_sort_key(row[:product])] }
596
+ rows.map { |row| RATE_COLUMNS.to_h { |column| [column, row[column]] } }
597
+ end
598
+
599
+ def city_rows(data)
600
+ as_of = data["asOf"]
601
+ rows = []
602
+ cities = data["cities"]
603
+ cities = [] unless cities.is_a?(Array)
604
+ cities.each do |city|
605
+ next unless city.is_a?(Hash)
606
+
607
+ place = city.fetch("city")
608
+ rows << {
609
+ city_slug: place.fetch("slug"),
610
+ city_name: place.fetch("name"),
611
+ country_code: city["countryCode"],
612
+ currency: city["currency"],
613
+ published: true,
614
+ property_count: city["propertyCount"],
615
+ operator_count: city["operatorCount"],
616
+ entry_median: money_amount(city["entryMedian"]),
617
+ entry_min: money_amount(city["entryMin"]),
618
+ entry_max: money_amount(city["entryMax"]),
619
+ p25: money_amount(city["p25"]),
620
+ p75: money_amount(city["p75"]),
621
+ sample_size: city["sampleSize"],
622
+ as_of: as_of
623
+ }
624
+ end
625
+ unpublished = data["unpublished"]
626
+ unpublished = [] unless unpublished.is_a?(Array)
627
+ unpublished.each do |city|
628
+ next unless city.is_a?(Hash)
629
+
630
+ place = city.fetch("city")
631
+ rows << {
632
+ city_slug: place.fetch("slug"),
633
+ city_name: place.fetch("name"),
634
+ country_code: city["countryCode"],
635
+ currency: city["currency"],
636
+ published: false,
637
+ property_count: nil,
638
+ operator_count: nil,
639
+ entry_median: nil,
640
+ entry_min: nil,
641
+ entry_max: nil,
642
+ p25: nil,
643
+ p75: nil,
644
+ sample_size: nil,
645
+ as_of: as_of
646
+ }
647
+ end
648
+ rows.sort_by! { |row| [row[:published] ? 0 : 1, row[:city_slug].to_s] }
649
+ rows.map { |row| CITY_COLUMNS.to_h { |column| [column, row[column]] } }
650
+ end
651
+
652
+ def listing_url(slug, locale)
653
+ "#{SITE_URL}/#{locale}/#{WORKSPACE_PATH.fetch(locale)}/#{slug}"
654
+ end
655
+
656
+ def space_row(card, locale)
657
+ market = card["marketPosition"]
658
+ market = {} unless market.is_a?(Hash)
659
+ {
660
+ slug: card.fetch("slug"),
661
+ title: card.fetch("title"),
662
+ city_slug: sub(card["city"], "slug"),
663
+ zone_slug: sub(card["zone"], "slug"),
664
+ operator_slug: sub(card["operator"], "slug"),
665
+ operator_name: sub(card["operator"], "name"),
666
+ country_code: card["countryCode"],
667
+ lat: sub(card["coordinates"], "lat"),
668
+ lng: sub(card["coordinates"], "lng"),
669
+ featured: card["featured"],
670
+ market_verdict: market["verdict"],
671
+ market_median: money_amount(market["median"]),
672
+ market_sample_size: market["sampleSize"],
673
+ url: listing_url(card.fetch("slug"), locale),
674
+ updated_at: card["updatedAt"]
675
+ }
676
+ end
677
+
678
+ def pricing_rows(detail)
679
+ options = detail["pricingOptions"]
680
+ options = [] unless options.is_a?(Array)
681
+ options.filter_map do |option|
682
+ next unless option.is_a?(Hash)
683
+
684
+ price = option["price"]
685
+ {
686
+ product: option.fetch("optionType"),
687
+ title: option.fetch("title"),
688
+ price: money_amount(price),
689
+ currency: price.nil? ? nil : price.fetch("currency"),
690
+ billing_period: option.fetch("billingPeriod"),
691
+ price_on_request: option.fetch("priceOnRequest"),
692
+ min_capacity: option["minCapacity"],
693
+ max_capacity: option["maxCapacity"]
694
+ }
695
+ end
696
+ end
697
+
698
+ def check_one_of(name, value, valid)
699
+ unless value.is_a?(String) && valid.include?(value)
700
+ choices = valid.map(&:inspect).join(", ")
701
+ raise ArgumentError, "#{name}=#{value.inspect} is not supported; use one of #{choices}."
702
+ end
703
+
704
+ value
705
+ end
706
+
707
+ def check_locale(value)
708
+ check_one_of("locale", value, LOCALES)
709
+ end
710
+
711
+ def check_currency(value)
712
+ return nil if value.nil?
713
+
714
+ normalised = value.is_a?(String) ? value.upcase : value
715
+ check_one_of("currency", normalised, CURRENCIES)
716
+ end
717
+
718
+ def check_product(value)
719
+ return nil if value.nil?
720
+
721
+ check_one_of("product", value, PRODUCTS)
722
+ end
723
+
724
+ def check_sort(value)
725
+ check_one_of("sort", value, SORTS)
726
+ end
727
+
728
+ def check_text(name, value)
729
+ return nil if value.nil?
730
+ unless value.is_a?(String) && !value.strip.empty?
731
+ raise ArgumentError, "#{name} must be a non-empty string, got #{value.inspect}."
732
+ end
733
+
734
+ value.strip
735
+ end
736
+
737
+ def check_limit(value)
738
+ unless value.is_a?(Integer) && value >= 1
739
+ raise ArgumentError, "limit must be a positive integer, got #{value.inspect}."
740
+ end
741
+ if value > MAX_LIMIT
742
+ raise ArgumentError,
743
+ "limit=#{value} exceeds the maximum of #{MAX_LIMIT}. The catalogue may be " \
744
+ "searched and linked to, not bulk-copied: its content belongs to the operators " \
745
+ "and the API's catalogue notice forbids systematic extraction. Narrow the " \
746
+ "search (city, zone, operator, product, price) instead."
747
+ end
748
+
749
+ value
750
+ end
751
+
752
+ def check_count(name, value)
753
+ return nil if value.nil?
754
+ unless value.is_a?(Integer) && value >= 0
755
+ raise ArgumentError, "#{name} must be a non-negative integer, got #{value.inspect}."
756
+ end
757
+
758
+ value
759
+ end
760
+
761
+ def check_price(name, value)
762
+ return nil if value.nil?
763
+ unless (value.is_a?(Integer) || value.is_a?(Float)) && value >= 0
764
+ raise ArgumentError, "#{name} must be a non-negative number, got #{value.inspect}."
765
+ end
766
+
767
+ value
768
+ end
769
+
770
+ def check_price_range(min_price, max_price)
771
+ return if min_price.nil? || max_price.nil? || min_price <= max_price
772
+
773
+ raise ArgumentError, "min_price (#{min_price}) is greater than max_price (#{max_price})."
774
+ end
775
+
776
+ def check_slug(value)
777
+ unless value.is_a?(String) && SLUG_PATTERN.match?(value)
778
+ raise ArgumentError,
779
+ "slug=#{value.inspect} is not a valid slug; slugs are letters, digits, '.', '_' " \
780
+ "and '-', starting with a letter or digit (e.g. 'sankt-oberholz-rosenthaler-berlin')."
781
+ end
782
+
783
+ value
784
+ end
785
+
786
+ def check_seed(value)
787
+ return nil if value.nil?
788
+ return value if value.is_a?(Integer) && value >= 0
789
+ return value.strip if value.is_a?(String) && !value.strip.empty?
790
+
791
+ raise ArgumentError, "seed must be a non-negative integer or a non-empty string, got #{value.inspect}."
792
+ end
793
+ end
794
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoworkingView
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "coworkingview/version"
4
+ require_relative "coworkingview/client"
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: coworkingview
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - CoworkingView
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-11-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 5.25.0
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 5.25.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: webmock
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.24'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.24'
55
+ description: |
56
+ Ruby client for the public CoworkingView API (https://api.coworkingview.com).
57
+ It reads the CoworkingView Price Index — median, 25th and 75th percentile
58
+ prices by city and product, with the sample size beside every figure — and
59
+ searches the coworking catalogue. No API key. The Price Index is CC BY 4.0
60
+ and the attribution string is returned with every index result. The only
61
+ runtime dependencies are net/http and json from the Ruby standard library.
62
+ email:
63
+ - info@coworkingview.com
64
+ executables: []
65
+ extensions: []
66
+ extra_rdoc_files: []
67
+ files:
68
+ - CHANGELOG.md
69
+ - LICENSE
70
+ - README.md
71
+ - lib/coworkingview.rb
72
+ - lib/coworkingview/client.rb
73
+ - lib/coworkingview/version.rb
74
+ homepage: https://coworkingview.com
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ homepage_uri: https://coworkingview.com
79
+ source_code_uri: https://github.com/coworkingview/flex-office-data/tree/main/integrations/ruby
80
+ changelog_uri: https://github.com/coworkingview/flex-office-data/blob/main/integrations/ruby/CHANGELOG.md
81
+ rubygems_mfa_required: 'true'
82
+ post_install_message:
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '3.0'
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 3.5.22
98
+ signing_key:
99
+ specification_version: 4
100
+ summary: Ruby client for the CoworkingView Price Index and catalogue.
101
+ test_files: []