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 +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +24 -0
- data/README.md +135 -0
- data/lib/coworkingview/client.rb +794 -0
- data/lib/coworkingview/version.rb +5 -0
- data/lib/coworkingview.rb +4 -0
- metadata +101 -0
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
|
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: []
|