epok 0.2.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12d0e1ef79e2fd5ff522e79748f413cdb123a79c863bba24a85dce868a1c7127
4
- data.tar.gz: 0c44daa4fe8d10908c75301d9a363bacf4d03ff0edf0eb13a2ff307cf96fe286
3
+ metadata.gz: ee8d9d659f3f1da4d0e290c2fe57173ca577ccaf6ae8c9ab5d6fb0df5a41045d
4
+ data.tar.gz: d075af0af60603bfc513f65f26f6656cc9a43e3e3a6dd25da47245cd96a0ea05
5
5
  SHA512:
6
- metadata.gz: 8358374a58c28bd2cb5f10ec9ce94248b0a9ba2a2bb2a733f4cfb003a62fecf90e8e6ec46ffd8648b8ab4172df745976586fcc09b264d10c26f34438138af961
7
- data.tar.gz: bcc962b28eaf4a3d8bb62325a422709488a437338aad6c7eced0afe9bce2c27a5865f7d367674759888e7fe78c28f46d8fb4350b3424dab60fba894627050816
6
+ metadata.gz: d57ad60dcd6be25dcd1ad82039809fccc8d9039ed31439e3209fd2c85cdbe82cccf83a9e9c9846989f182c9290ccd60d440a69d3aa673f500f160cd31cf55ee2
7
+ data.tar.gz: 7b850241d36d95455e480cb26222233927447bab1cffe547388a53c5d0c92c6d61ae387c3c70db9aae7eb784623c38a5bf220f693f6fde06fc39a10cd01c83fb
data/CLAUDE.md CHANGED
@@ -14,18 +14,34 @@ bundle exec swarf lib/ # complexity vs. coverage report, worst first
14
14
  Cassettes are gitignored, so the first test run hits the live API.
15
15
  The API rejects curl's default User-Agent, but Ruby's Net::HTTP works fine.
16
16
 
17
- ## Releasing
18
-
19
- 1. Make sure `main` is clean and pushed, and `bundle exec rake test` passes.
20
- 2. Bump `Epok::VERSION` in `lib/epok/version.rb`. Follow semver:
21
- patch for fixes, minor for new behaviour, major for breaking changes.
22
- 3. Commit it as `Bump version to X.Y.Z`.
23
- 4. Run `bundle exec rake release`. This builds the gem, creates and pushes
24
- the `vX.Y.Z` tag, and pushes the gem to rubygems.org. It needs rubygems
25
- credentials (`gem signin`) and may prompt for an OTP if the account has
26
- MFA enabled. If it prompts, the user has to run it themselves.
27
- 5. Create a GitHub release from the tag with a short summary of what changed:
28
- `gh release create vX.Y.Z --generate-notes`.
29
-
30
- Version numbers on rubygems.org cannot be reused, so check the diff before
31
- running step 4.
17
+ ## API notes
18
+
19
+ Official docs are Swagger specs on the city's 3scale portal. The HTML at
20
+ https://datosabiertos-apis.buenosaires.gob.ar/BA_Root/Documentacion is
21
+ JavaScript-rendered; fetch the JSON directly:
22
+
23
+ - Index: https://datosabiertos-apis.buenosaires.gob.ar/api_docs/services.json
24
+ - EPOk ("Busquedas de Lugares"): .../api_docs/services/10.json
25
+ - USIG Geocoder: .../api_docs/services/8.json
26
+ - Datos Útiles: .../api_docs/services/9.json
27
+
28
+ The specs list `datosabiertos-*-apis.buenosaires.gob.ar` hosts that return
29
+ 404. The live hosts are `epok.buenosaires.gob.ar` for EPOk and
30
+ `ws.usig.buenosaires.gob.ar` for USIG (REST index with docs at
31
+ https://ws.usig.buenosaires.gob.ar/rest/).
32
+
33
+ - `/buscar/` also takes `categoria` (comma-separated), `clase`, `bbox`,
34
+ `start`, `limit` and `totalFull`.
35
+ - `/reverseGeocoderLugares/` takes `srid` (default 4326) and `radio`
36
+ (default 1 metre, hence our 500 default).
37
+ - Unknown ids return `200 {}`, unknown categories return an empty list.
38
+ - Object coordinates are projected (Gauss-Krüger Buenos Aires). USIG's
39
+ `/rest/convertir_coordenadas` turns them into longitude and latitude.
40
+ - `ws.usig.buenosaires.gob.ar/datos_utiles?x=&y=` answers with empty strings
41
+ outside the city; `Epok.datos_utiles` turns those into nil.
42
+ - `servicios.usig.buenosaires.gob.ar/normalizar/?direccion=&geocodificar=true`
43
+ matches across the whole AMBA, CABA first. Coordinates come back as
44
+ strings for CABA and floats elsewhere. No match is `200` with an empty
45
+ list and an `errorMessage`; `Epok.geocode` raises NotFound with it.
46
+ - A December 2025 city report says "API USIG" is being replaced by
47
+ "API Servicios Geo" during 2026. The USIG hosts above may move.
data/Gemfile CHANGED
@@ -4,4 +4,5 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
4
4
 
5
5
  gemspec
6
6
 
7
+ gem "irb", group: :development
7
8
  gem "swarf", group: :development
data/Gemfile.lock CHANGED
@@ -1,31 +1,47 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- epok (0.2.0)
4
+ epok (0.5.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
8
8
  specs:
9
9
  addressable (2.9.0)
10
10
  public_suffix (>= 2.0.2, < 8.0)
11
- ansi (1.6.0)
12
11
  bigdecimal (4.1.3)
13
- builder (3.3.0)
14
12
  crack (1.0.1)
15
13
  bigdecimal
16
14
  rexml
15
+ erb (6.0.7)
17
16
  hashdiff (1.2.1)
17
+ io-console (0.9.3)
18
+ irb (1.18.0)
19
+ pp (>= 0.6.0)
20
+ prism (>= 1.3.0)
21
+ rdoc (>= 4.0.0)
22
+ reline (>= 0.4.2)
23
+ logger (1.7.0)
18
24
  minitest (5.27.0)
19
- minitest-reporters (1.8.0)
20
- ansi
21
- builder
22
- minitest (>= 5.0, < 7)
23
- ruby-progressbar
25
+ pp (0.6.4)
26
+ prettyprint
27
+ prettyprint (0.2.0)
28
+ prism (1.9.0)
24
29
  public_suffix (7.0.5)
25
30
  rake (13.4.2)
31
+ rbs (4.2.0)
32
+ logger
33
+ prism (>= 1.6.0)
34
+ tsort
35
+ rdoc (8.0.0)
36
+ erb
37
+ prism (>= 1.6.0)
38
+ rbs (>= 4.0.0)
39
+ tsort
40
+ reline (0.7.0)
41
+ io-console (~> 0.5)
26
42
  rexml (3.4.4)
27
- ruby-progressbar (1.13.0)
28
43
  swarf (0.2.0)
44
+ tsort (0.2.0)
29
45
  vcr (6.4.0)
30
46
  webmock (3.26.4)
31
47
  addressable (>= 2.8.0)
@@ -38,8 +54,8 @@ PLATFORMS
38
54
 
39
55
  DEPENDENCIES
40
56
  epok!
57
+ irb
41
58
  minitest (~> 5.0)
42
- minitest-reporters (~> 1.3, >= 1.3.6)
43
59
  rake (~> 13.0)
44
60
  swarf
45
61
  vcr (~> 6.0)
@@ -47,19 +63,26 @@ DEPENDENCIES
47
63
 
48
64
  CHECKSUMS
49
65
  addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
50
- ansi (1.6.0) sha256=ac9ea0c0ea8d32fb4e271348e609963ac78882f34b73836c2a02b3622e666658
51
66
  bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
52
- builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
53
67
  crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
54
- epok (0.2.0)
68
+ epok (0.5.0)
69
+ erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
55
70
  hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
71
+ io-console (0.9.3) sha256=f555049461b0afb78a5448b5be8e7c7b48371347cd48abfc22814764160000dd
72
+ irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
73
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
56
74
  minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
57
- minitest-reporters (1.8.0) sha256=8ce5280fb73ad3178ae525454df169b6f28c1b38b1d088ea91815d3a370ba384
75
+ pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
76
+ prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
77
+ prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
58
78
  public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
59
79
  rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
80
+ rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
81
+ rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
82
+ reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
60
83
  rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
61
- ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
62
84
  swarf (0.2.0) sha256=b4306a244fe4b096cfe263e452e666156856eac2467ae5d770639ef11388b375
85
+ tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
63
86
  vcr (6.4.0) sha256=077ac92cc16efc5904eb90492a18153b5e6ca5398046d8a249a7c96a9ea24ae6
64
87
  webmock (3.26.4) sha256=8d8da206d217ebe6968cfb09c77f4533c23074e1432bad865f3994eacbaad50d
65
88
 
data/README.md CHANGED
@@ -20,29 +20,73 @@ Or install it yourself as:
20
20
 
21
21
  ## Usage
22
22
 
23
+ "I'm at Callao y Corrientes, what's around me?" in five requests:
24
+
23
25
  ```ruby
24
- >> obelisco = Epok::Location.new(x: -58.381570, y: -34.603738)
25
- => #<struct Epok::Location x=-58.38157, y=-34.603738>
26
+ require "epok"
27
+
28
+ # 1. Turn a typed address into coordinates.
29
+ here = Epok.geocode("callao y corrientes").first
30
+ puts "You are at #{here.address}"
31
+
32
+ # 2. Which neighbourhood and comuna is that?
33
+ area = Epok.datos_utiles(here.location)
34
+ puts "That's #{area.barrio}, #{area.comuna}"
35
+
36
+ # 3. What's within 300 metres, by category?
37
+ nearby = Epok.geocoder(here.location, %w[estaciones_de_subte farmacias], radius: 300)
38
+ nearby.sort_by(&:distance).first(5).each do |place|
39
+ puts " #{place.distance.round} m #{place.name} (#{place.kind})"
40
+ end
41
+
42
+ # 4. Drill into one: full record and coordinates for a map pin.
43
+ closest = nearby.min_by(&:distance)
44
+ puts "Pin: #{closest.location.y}, #{closest.location.x}"
45
+ closest.content.each { |key, value| puts " #{key}: #{value}" }
46
+ ```
47
+
48
+ ```
49
+ You are at CALLAO AV. y CORRIENTES AV., CABA
50
+ That's San Nicolas, Comuna 3
51
+ 3 m CALLAO - MAESTRO ALFREDO BRAVO (Línea B) (Estación de Subte (Metro))
52
+ 42 m Farmacia en CORRIENTES AV. 1820 (Farmacia)
53
+ 73 m Farmacia en CORRIENTES AV. 1835 (Farmacia)
54
+ 98 m Farmacia en CALLAO AV. 477 (Farmacia)
55
+ 111 m Farmacia en CORRIENTES AV. 1880 (Farmacia)
56
+ Pin: -34.604419, -58.392318
57
+ Nombre: CALLAO - MAESTRO ALFREDO BRAVO (Línea B)
58
+ Línea: B
59
+ Estado: Estación Malabia cerrada por obras de renovación integral.
60
+ Cabeceras: J. M. de Rosas - L. N. Alem
61
+ Tiempo: 27 min.
62
+ ```
26
63
 
27
- >> pharmacies_around_obelisco = Epok.geocoder(obelisco, "farmacias")
28
- => #<Epok::Geocoder:0x00007f8719ab4f08 @x=-58.38157, @y=-34.603738, @categories="farmacias">
64
+ Steps 1 to 3 are one request each; step 4 is two (the record, then the coordinate conversion). The content hash is whatever the city publishes for that category: subway stations carry line, status and travel time, pharmacies carry phone and delivery details.
29
65
 
30
- >> pharmacies_around_obelisco.first.content
31
- => {"Nombre"=>"Farmacia en PELLEGRINI, CARLOS 765", "Teléfono"=>"43223198 43220298"}
66
+ ### The rest
32
67
 
33
- >> cgp = Epok.search("cgp")
34
- => #<Epok::Search:0x00007f871a09ac60 @query="cgp">
68
+ **Search by text**, optionally filtered by category and capped:
35
69
 
36
- >> sede_4 = cgp.first
37
- => #<Epok::Object:0x00007f8719b36788 @id="sedes_de_comunas|4">
70
+ ```ruby
71
+ Epok.search("cgp", categories: "sedes_de_comunas", limit: 3).map(&:name)
72
+ # => ["Sede Comunal 4", "Sede Comunal 9", "Sede Comunal 10"]
73
+ ```
38
74
 
39
- >> sede_4.name
40
- => "Sede Comunal 4"
75
+ **Categories** are what the geocoder and the search filter take. There are 167, each with an id, a display name and a description:
41
76
 
42
- >> sede_4.content
43
- => {"Nombre"=>"Sede Comunal 4", "Dirección"=>"BARCO CENTENERA del 2906", "Teléfonos"=>"4918-2243/1815/8920-4949 9024 Int. 105", "Trámites y Servicios"=>"<a href=\"http://www.buenosaires.gob.ar/comuna-4/sede-comunal-4\" target=\"_blank\">link</a>"}
77
+ ```ruby
78
+ Epok.categories.find { |c| c.id == "farmacias" }
79
+ # => #<struct Epok::Category id="farmacias", name="Farmacias", description="">
44
80
  ```
45
81
 
82
+ **Collections** from `search` and `geocoder` are `Enumerable` and lazy: nothing is requested until you iterate.
83
+
84
+ **Objects** know their `id`, `name`, `kind`, `category` and, from the geocoder, `distance` in metres straight from the listing. `content`, `normalized_address` and `location` fetch the full record on first use; `location` also converts the city's projected coordinates through USIG. `Epok::Object.new("farmacias|1082")` looks one up by id.
85
+
86
+ **Locations** are `Epok::Location.new(x: longitude, y: latitude)`. `geocode` returns every match across the metro area, city first, as `Address` structs with `address`, `partido`, `localidad` and `location`. `datos_utiles` returns nil for the city fields outside CABA and fills `partido_amba` and `localidad_amba` instead.
87
+
88
+ **Errors**: every request failure raises `Epok::Error`. An unknown id or address raises `Epok::NotFound`.
89
+
46
90
  ## Development
47
91
 
48
92
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/bin/console CHANGED
@@ -4,6 +4,4 @@ require "bundler/setup"
4
4
  require "epok"
5
5
  require "irb"
6
6
 
7
- @obelisco = Epok::Location.new(x: -58.381570, y: -34.603738)
8
-
9
7
  IRB.start(__FILE__)
data/epok.gemspec CHANGED
@@ -18,7 +18,6 @@ Gem::Specification.new do |spec|
18
18
  spec.require_paths = ["lib"]
19
19
 
20
20
  spec.add_development_dependency "minitest", "~> 5.0"
21
- spec.add_development_dependency "minitest-reporters", "~> 1.3", ">= 1.3.6"
22
21
  spec.add_development_dependency "rake", "~> 13.0"
23
22
  spec.add_development_dependency "vcr", "~> 6.0"
24
23
  spec.add_development_dependency "webmock", "~> 3.5", ">= 3.5.1"
data/lib/epok/api.rb CHANGED
@@ -2,25 +2,74 @@ require "net/http"
2
2
  require "json"
3
3
 
4
4
  module Epok
5
+ class Error < StandardError; end
6
+ class NotFound < Error; end
7
+
5
8
  class API
6
- BASE_URL = "https://epok.buenosaires.gob.ar".freeze
9
+ EPOK_URL = "https://epok.buenosaires.gob.ar".freeze
10
+ USIG_URL = "https://ws.usig.buenosaires.gob.ar".freeze
11
+ USIG_SERVICES_URL = "https://servicios.usig.buenosaires.gob.ar".freeze
12
+ TIMEOUT = 10
7
13
 
8
14
  def self.object(id)
9
- JSON.parse(get("/getObjectContent/?id=#{id}"))
15
+ object = get(EPOK_URL, "/getObjectContent/", id: id)
16
+ raise NotFound, "no object with id #{id}" if object.empty?
17
+
18
+ object
19
+ end
20
+
21
+ def self.search(query, categories = nil, limit = nil)
22
+ params = { texto: query, categoria: categories, limit: limit }.compact
23
+
24
+ get(EPOK_URL, "/buscar/", params)["instancias"]
25
+ end
26
+
27
+ def self.geocoder(x, y, categories, radius)
28
+ get(EPOK_URL, "/reverseGeocoderLugares/",
29
+ x: x, y: y, categorias: categories, radio: radius)["instancias"]
30
+ end
31
+
32
+ def self.categories
33
+ get(EPOK_URL, "/getCategorias/", {})["categorias"]
34
+ end
35
+
36
+ def self.geocode(text)
37
+ response = get(USIG_SERVICES_URL, "/normalizar/", direccion: text, geocodificar: true)
38
+ addresses = response["direccionesNormalizadas"]
39
+ raise NotFound, response["errorMessage"] if addresses.empty?
40
+
41
+ addresses
10
42
  end
11
43
 
12
- def self.search(query)
13
- JSON.parse(get("/buscar/?texto=#{query}"))["instancias"]
44
+ def self.datos_utiles(x, y)
45
+ get(USIG_URL, "/datos_utiles", x: x, y: y)
14
46
  end
15
47
 
16
- def self.geocoder(x, y, categories)
17
- JSON.parse(get(
18
- "/reverseGeocoderLugares/?x=#{x}&y=#{y}&categorias=#{categories}&radio=500"
19
- ))["instancias"]
48
+ # Converts EPOk's projected coordinates (Gauss-Krüger Buenos Aires) to
49
+ # longitude and latitude through the city's USIG service.
50
+ def self.to_lonlat(x, y)
51
+ response = get(USIG_URL, "/rest/convertir_coordenadas", x: x, y: y, output: "lonlat")
52
+ raise Error, "USIG could not convert (#{x}, #{y})" unless response["tipo_resultado"] == "Ok"
53
+
54
+ response["resultado"].values_at("x", "y").map(&:to_f)
20
55
  end
21
56
 
22
- def self.get(path)
23
- Net::HTTP.get_response(URI("#{BASE_URL}#{path}")).body
57
+ def self.get(host, path, params)
58
+ uri = URI("#{host}#{path}")
59
+ uri.query = URI.encode_www_form(params)
60
+
61
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
62
+ open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
63
+ http.get(uri.request_uri)
64
+ end
65
+
66
+ unless response.is_a?(Net::HTTPSuccess)
67
+ raise Error, "EPOk responded #{response.code} to #{uri}"
68
+ end
69
+
70
+ JSON.parse(response.body)
71
+ rescue SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, JSON::ParserError => e
72
+ raise Error, "#{e.class}: #{e.message}"
24
73
  end
25
74
  private_class_method :get
26
75
  end
@@ -2,22 +2,18 @@ module Epok
2
2
  class Collection
3
3
  include Enumerable
4
4
 
5
- attr_reader :data
6
-
7
- def initialize(data, id = "id")
8
- @data = collection(data, id)
5
+ def initialize(&fetch)
6
+ @fetch = fetch
9
7
  end
10
8
 
11
9
  def each(&block)
12
- data.each(&block)
10
+ objects.each(&block)
13
11
  end
14
12
 
15
13
  private
16
14
 
17
- def collection(data, id)
18
- data.map do |item|
19
- Object.new(item[id])
20
- end
15
+ def objects
16
+ @objects ||= @fetch.call.map { |item| Object.new(item) }
21
17
  end
22
18
  end
23
19
  end
data/lib/epok/object.rb CHANGED
@@ -1,19 +1,48 @@
1
1
  module Epok
2
2
  class Object
3
- attr_reader :id
3
+ attr_reader :id, :distance
4
4
 
5
- def initialize(id)
6
- @id = id
5
+ # Accepts either an id ("farmacias|1082") or a result hash from a search
6
+ # or geocoder listing. Listing attributes are available immediately; the
7
+ # full content is fetched on first use.
8
+ def initialize(attributes)
9
+ attributes = { "id" => attributes } if attributes.is_a?(String)
10
+
11
+ @id = attributes.fetch("id")
12
+ @name = attributes["nombre"]&.strip
13
+ @kind = attributes["clase"]
14
+ @distance = attributes["distancia"]&.to_f
7
15
  end
8
16
 
9
17
  def name
10
- content["Nombre"]
18
+ @name ||= content["Nombre"]
19
+ end
20
+
21
+ def kind
22
+ @kind ||= object["clase"]
23
+ end
24
+
25
+ def category
26
+ id.split("|").first
11
27
  end
12
28
 
13
29
  def normalized_address
14
30
  object["direccionNormalizada"]
15
31
  end
16
32
 
33
+ # Longitude and latitude as a Location, or nil when EPOk has no
34
+ # centroid for the object. Costs one extra request the first time.
35
+ def location
36
+ return @location if defined?(@location)
37
+
38
+ centroid = object.dig("ubicacion", "centroide")
39
+ @location = centroid && begin
40
+ x, y = centroid[/\(([^)]*)\)/, 1].split.map(&:to_f)
41
+ lon, lat = API.to_lonlat(x, y)
42
+ Location.new(x: lon, y: lat)
43
+ end
44
+ end
45
+
17
46
  def content
18
47
  object["contenido"].each_with_object({}) do |entry, hash|
19
48
  name, value = entry.values_at("nombre", "valor")
data/lib/epok/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Epok
2
- VERSION = "0.2.0"
2
+ VERSION = "0.5.0"
3
3
  end
data/lib/epok.rb CHANGED
@@ -1,17 +1,65 @@
1
1
  require "epok/api"
2
- require "epok/collectable"
3
2
  require "epok/collection"
4
- require "epok/geocoder"
5
3
  require "epok/object"
6
- require "epok/search"
7
4
  require "epok/version"
8
5
 
9
6
  module Epok
10
- def self.search(query)
11
- Search.new(query)
7
+ Location = Struct.new(:x, :y, keyword_init: true)
8
+ Category = Struct.new(:id, :name, :description, keyword_init: true)
9
+ DatosUtiles = Struct.new(
10
+ :barrio, :comuna, :comisaria, :comisaria_vecinal,
11
+ :area_hospitalaria, :region_sanitaria, :distrito_escolar,
12
+ :partido_amba, :localidad_amba,
13
+ keyword_init: true
14
+ )
15
+
16
+ Address = Struct.new(:address, :partido, :localidad, :location, keyword_init: true)
17
+
18
+ DEFAULT_RADIUS = 500
19
+
20
+ def self.search(query, categories: nil, limit: nil)
21
+ categories = Array(categories).join(",") if categories
22
+
23
+ Collection.new { API.search(query, categories, limit) }
24
+ end
25
+
26
+ def self.geocoder(location, categories, radius: DEFAULT_RADIUS)
27
+ categories = Array(categories).join(",")
28
+
29
+ Collection.new { API.geocoder(location.x, location.y, categories, radius) }
30
+ end
31
+
32
+ def self.geocode(text)
33
+ API.geocode(text).map do |address|
34
+ x, y = address["coordenadas"].values_at("x", "y").map(&:to_f)
35
+
36
+ Address.new(
37
+ address: address["direccion"],
38
+ partido: address["nombre_partido"],
39
+ localidad: address["nombre_localidad"],
40
+ location: Location.new(x: x, y: y)
41
+ )
42
+ end
43
+ end
44
+
45
+ def self.datos_utiles(location)
46
+ datos = API.datos_utiles(location.x, location.y)
47
+
48
+ DatosUtiles.new(
49
+ DatosUtiles.members.to_h do |member|
50
+ value = datos[member.to_s].to_s.strip.squeeze(" ")
51
+ [member, value.empty? ? nil : value]
52
+ end
53
+ )
12
54
  end
13
55
 
14
- def self.geocoder(location, categories)
15
- Geocoder.new(location, categories)
56
+ def self.categories
57
+ API.categories.map do |category|
58
+ Category.new(
59
+ id: category["nombre_normalizado"],
60
+ name: category["nombre"],
61
+ description: category["descripcion"]
62
+ )
63
+ end
16
64
  end
17
65
  end
@@ -0,0 +1,65 @@
1
+ require "test_helper"
2
+
3
+ module Epok
4
+ class APITest < Minitest::Test
5
+ include WebMock::API
6
+
7
+ def setup
8
+ VCR.eject_cassette
9
+ VCR.turn_off!
10
+ end
11
+
12
+ def teardown
13
+ WebMock.reset!
14
+ VCR.turn_on!
15
+ end
16
+
17
+ def test_that_a_server_error_raises
18
+ stub_request(:get, /epok/).to_return(status: 500, body: "boom")
19
+
20
+ error = assert_raises(Error) { Epok.search("x").first }
21
+ assert_match "500", error.message
22
+ end
23
+
24
+ def test_that_a_timeout_raises
25
+ stub_request(:get, /epok/).to_timeout
26
+
27
+ assert_raises(Error) { Epok.categories }
28
+ end
29
+
30
+ def test_that_a_non_json_body_raises
31
+ stub_request(:get, /epok/).to_return(status: 200, body: "<html>")
32
+
33
+ assert_raises(Error) { Epok.search("x").first }
34
+ end
35
+
36
+ def test_that_a_missing_object_raises_not_found
37
+ stub_request(:get, /getObjectContent/).to_return(status: 200, body: "{}")
38
+
39
+ assert_raises(NotFound) { Object.new("nope|1").content }
40
+ end
41
+
42
+ def test_that_an_object_without_a_centroid_has_no_location
43
+ stub_request(:get, /getObjectContent/)
44
+ .to_return(status: 200, body: '{"contenido": [], "ubicacion": null}')
45
+
46
+ object = Object.new("espacios_verdes_publicos|1")
47
+
48
+ assert_nil object.location
49
+ assert_nil object.location
50
+ end
51
+
52
+ def test_that_a_failed_coordinate_conversion_raises
53
+ stub_request(:get, /getObjectContent/)
54
+ .to_return(status: 200, body: '{"ubicacion": {"centroide": "POINT (1 2)"}}')
55
+ stub_request(:get, /usig/)
56
+ .to_return(status: 200, body: '{"tipo_resultado":"Error","resultado":{"x":"","y":""}}')
57
+
58
+ assert_raises(Error) { Object.new("x|1").location }
59
+ end
60
+
61
+ def test_that_not_found_is_an_error
62
+ assert_operator NotFound, :<, Error
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,26 @@
1
+ require "test_helper"
2
+
3
+ module Epok
4
+ class CategoriesTest < Minitest::Test
5
+ def setup
6
+ VCR.insert_cassette("categories")
7
+ end
8
+
9
+ def teardown
10
+ VCR.eject_cassette("categories")
11
+ end
12
+
13
+ def test_that_categories_are_listed
14
+ categories = Epok.categories
15
+
16
+ assert_operator categories.size, :>, 100
17
+ assert categories.all? { |category| category.is_a?(Category) }
18
+ end
19
+
20
+ def test_that_a_category_has_an_id_and_a_name
21
+ pharmacies = Epok.categories.find { |category| category.id == "farmacias" }
22
+
23
+ assert_equal "Farmacias", pharmacies.name
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,22 @@
1
+ require "test_helper"
2
+
3
+ module Epok
4
+ class CollectionTest < Minitest::Test
5
+ def test_that_a_collection_is_enumerable
6
+ collection = Collection.new { [{"id" => "a|1"}, {"id" => "a|2"}] }
7
+
8
+ assert_equal ["a|1", "a|2"], collection.map(&:id)
9
+ assert_equal 2, collection.count
10
+ end
11
+
12
+ def test_that_a_collection_fetches_lazily_and_once
13
+ calls = 0
14
+ collection = Collection.new { calls += 1; [{"id" => "a|1"}] }
15
+
16
+ assert_equal 0, calls
17
+ collection.first
18
+ collection.to_a
19
+ assert_equal 1, calls
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,39 @@
1
+ require "test_helper"
2
+
3
+ module Epok
4
+ class DatosUtilesTest < Minitest::Test
5
+ def setup
6
+ VCR.insert_cassette("datos_utiles")
7
+ end
8
+
9
+ def teardown
10
+ VCR.eject_cassette("datos_utiles")
11
+ end
12
+
13
+ def test_that_datos_utiles_returns_the_barrio_and_comuna
14
+ datos = Epok.datos_utiles(Location.new(x: -58.381570, y: -34.603738))
15
+
16
+ assert_equal "San Nicolas", datos.barrio
17
+ assert_equal "Comuna 1", datos.comuna
18
+ end
19
+
20
+ def test_that_datos_utiles_returns_the_other_jurisdictions
21
+ datos = Epok.datos_utiles(Location.new(x: -58.381570, y: -34.603738))
22
+
23
+ assert_equal "3", datos.comisaria
24
+ assert_equal "1B", datos.comisaria_vecinal
25
+ assert_equal "HTAL. DR. J.M. RAMOS MEJÍA", datos.area_hospitalaria
26
+ assert_equal "I (Este)", datos.region_sanitaria
27
+ assert_equal "Distrito Escolar I", datos.distrito_escolar
28
+ end
29
+
30
+ def test_that_outside_the_city_fields_are_nil_and_amba_is_filled
31
+ datos = Epok.datos_utiles(Location.new(x: -58.60, y: -34.60))
32
+
33
+ assert_nil datos.barrio
34
+ assert_nil datos.comuna
35
+ assert_equal "Tres de Febrero", datos.partido_amba
36
+ assert_equal "Ciudad Jardín Lomas de El Palomar", datos.localidad_amba
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,36 @@
1
+ require "test_helper"
2
+
3
+ module Epok
4
+ class GeocodeTest < Minitest::Test
5
+ def setup
6
+ VCR.insert_cassette("geocode")
7
+ end
8
+
9
+ def teardown
10
+ VCR.eject_cassette("geocode")
11
+ end
12
+
13
+ def test_that_an_address_is_normalized_and_located
14
+ address = Epok.geocode("cabildo 1234").first
15
+
16
+ assert_equal "CABILDO AV. 1234, CABA", address.address
17
+ assert_in_delta(-58.448466, address.location.x)
18
+ assert_in_delta(-34.568290, address.location.y)
19
+ end
20
+
21
+ def test_that_an_address_has_a_partido_and_localidad
22
+ addresses = Epok.geocode("cabildo 1234")
23
+
24
+ assert_equal "CABA", addresses.first.partido
25
+ assert_equal "CABA", addresses.first.localidad
26
+ assert_equal "La Matanza", addresses[1].partido
27
+ assert_equal "Villa Madero", addresses[1].localidad
28
+ end
29
+
30
+ def test_that_an_unknown_address_raises_not_found
31
+ error = assert_raises(NotFound) { Epok.geocode("xyzzy 999") }
32
+
33
+ assert_equal "Calle inexistente: xyzzy 999", error.message
34
+ end
35
+ end
36
+ end
@@ -3,7 +3,7 @@ require "test_helper"
3
3
  module Epok
4
4
  class GeocoderTest < Minitest::Test
5
5
  def setup
6
- VCR.insert_cassette("geocoder", record: :new_episodes)
6
+ VCR.insert_cassette("geocoder")
7
7
  end
8
8
 
9
9
  def teardown
@@ -18,12 +18,32 @@ module Epok
18
18
  assert_match "Línea D", result.name
19
19
  end
20
20
 
21
+ def test_that_geocoder_results_have_a_distance
22
+ assert_operator result.distance, :<, 500
23
+ end
24
+
25
+ def test_that_geocoder_accepts_a_radius
26
+ near = Epok.geocoder(obelisco, "estaciones_de_subte", radius: 100)
27
+ far = Epok.geocoder(obelisco, "estaciones_de_subte", radius: 1000)
28
+
29
+ assert_operator near.count, :<, far.count
30
+ assert near.all? { |station| station.distance <= 100 }
31
+ end
32
+
33
+ def test_that_geocoder_accepts_several_categories
34
+ results = Epok.geocoder(obelisco, %w[estaciones_de_subte farmacias])
35
+
36
+ assert_equal %w[estaciones_de_subte farmacias].sort, results.map(&:category).uniq.sort
37
+ end
38
+
21
39
  private
22
40
 
23
41
  def result
24
- obelisco = Location.new(x: -58.381570, y: -34.603738)
25
- results = Geocoder.new(obelisco, "estaciones_de_subte")
26
- results.first
42
+ Epok.geocoder(obelisco, "estaciones_de_subte").first
43
+ end
44
+
45
+ def obelisco
46
+ Location.new(x: -58.381570, y: -34.603738)
27
47
  end
28
48
  end
29
49
  end
@@ -14,10 +14,26 @@ module Epok
14
14
  assert_equal "BIBLIOTECA ANMAT", object.name
15
15
  end
16
16
 
17
+ def test_that_an_object_has_a_kind
18
+ assert_equal "Dependencias Culturales", object.kind
19
+ end
20
+
21
+ def test_that_an_object_has_a_category
22
+ assert_equal "dependencias_culturales", object.category
23
+ end
24
+
17
25
  def test_that_an_object_has_a_normalized_address
18
26
  assert_equal "DE MAYO AV. 869", object.normalized_address
19
27
  end
20
28
 
29
+ def test_that_an_object_has_a_location
30
+ location = object.location
31
+
32
+ assert_instance_of Location, location
33
+ assert_in_delta(-58.38, location.x, 0.01)
34
+ assert_in_delta(-34.61, location.y, 0.01)
35
+ end
36
+
21
37
  def test_that_an_object_has_content
22
38
  expected_content = {
23
39
  "Nombre" => "BIBLIOTECA ANMAT",
@@ -36,6 +52,27 @@ module Epok
36
52
  assert_equal expected_content, object.content
37
53
  end
38
54
 
55
+ def test_that_listing_attributes_do_not_fetch
56
+ object = Object.new(
57
+ "id" => "farmacias|1082",
58
+ "nombre" => "Farmacia en CERRITO 342 ",
59
+ "clase" => "Farmacia",
60
+ "distancia" => "112.31"
61
+ )
62
+
63
+ VCR.eject_cassette
64
+ VCR.turned_off do
65
+ assert_equal "Farmacia en CERRITO 342", object.name
66
+ assert_equal "Farmacia", object.kind
67
+ assert_equal "farmacias", object.category
68
+ assert_in_delta 112.31, object.distance
69
+ end
70
+ end
71
+
72
+ def test_that_an_object_built_from_an_id_has_no_distance
73
+ assert_nil object.distance
74
+ end
75
+
39
76
  private
40
77
 
41
78
  def object
@@ -18,11 +18,30 @@ module Epok
18
18
  assert_match "San Miguel de Garicoits", result.name
19
19
  end
20
20
 
21
+ def test_that_search_encodes_the_query
22
+ assert_match "Peña", Epok.search("plaza peña").first.name
23
+ end
24
+
25
+ def test_that_search_filters_by_category
26
+ results = Epok.search("san martin", categories: "estaciones_de_subte")
27
+
28
+ assert_equal ["estaciones_de_subte"], results.map(&:category).uniq
29
+ end
30
+
31
+ def test_that_search_filters_by_several_categories
32
+ results = Epok.search("san martin", categories: %w[estaciones_de_subte estaciones_de_ferrocarril])
33
+
34
+ assert_equal %w[estaciones_de_ferrocarril estaciones_de_subte], results.map(&:category).uniq.sort
35
+ end
36
+
37
+ def test_that_search_limits_results
38
+ assert_equal 2, Epok.search("san martin", limit: 2).count
39
+ end
40
+
21
41
  private
22
42
 
23
43
  def result
24
- results = Search.new("garicoits")
25
- results.first
44
+ Epok.search("garicoits").first
26
45
  end
27
46
  end
28
47
  end
data/test/epok_test.rb CHANGED
@@ -1,20 +1,13 @@
1
1
  require "test_helper"
2
2
 
3
3
  class EpokTest < Minitest::Test
4
- def test_that_search_builds_a_search
5
- search = Epok.search("garicoits")
6
-
7
- assert_instance_of Epok::Search, search
8
- assert_equal "garicoits", search.query
4
+ def test_that_search_builds_a_collection
5
+ assert_instance_of Epok::Collection, Epok.search("garicoits")
9
6
  end
10
7
 
11
- def test_that_geocoder_builds_a_geocoder
8
+ def test_that_geocoder_builds_a_collection
12
9
  obelisco = Epok::Location.new(x: -58.381570, y: -34.603738)
13
- geocoder = Epok.geocoder(obelisco, "farmacias")
14
10
 
15
- assert_instance_of Epok::Geocoder, geocoder
16
- assert_equal(-58.381570, geocoder.x)
17
- assert_equal(-34.603738, geocoder.y)
18
- assert_equal "farmacias", geocoder.categories
11
+ assert_instance_of Epok::Collection, Epok.geocoder(obelisco, "farmacias")
19
12
  end
20
13
  end
data/test/test_helper.rb CHANGED
@@ -4,12 +4,10 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
4
 
5
5
  require "epok"
6
6
  require "minitest/autorun"
7
- require "minitest/reporters"
8
7
  require "vcr"
9
8
 
10
- Minitest::Reporters.use!
11
-
12
9
  VCR.configure do |config|
13
10
  config.cassette_library_dir = "test/fixtures"
14
11
  config.hook_into :webmock
12
+ config.default_cassette_options = { record: :new_episodes }
15
13
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: epok
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ariel Rzezak
@@ -23,26 +23,6 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '5.0'
26
- - !ruby/object:Gem::Dependency
27
- name: minitest-reporters
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '1.3'
33
- - - ">="
34
- - !ruby/object:Gem::Version
35
- version: 1.3.6
36
- type: :development
37
- prerelease: false
38
- version_requirements: !ruby/object:Gem::Requirement
39
- requirements:
40
- - - "~>"
41
- - !ruby/object:Gem::Version
42
- version: '1.3'
43
- - - ">="
44
- - !ruby/object:Gem::Version
45
- version: 1.3.6
46
26
  - !ruby/object:Gem::Dependency
47
27
  name: rake
48
28
  requirement: !ruby/object:Gem::Requirement
@@ -112,12 +92,14 @@ files:
112
92
  - epok.gemspec
113
93
  - lib/epok.rb
114
94
  - lib/epok/api.rb
115
- - lib/epok/collectable.rb
116
95
  - lib/epok/collection.rb
117
- - lib/epok/geocoder.rb
118
96
  - lib/epok/object.rb
119
- - lib/epok/search.rb
120
97
  - lib/epok/version.rb
98
+ - test/epok/api_test.rb
99
+ - test/epok/categories_test.rb
100
+ - test/epok/collection_test.rb
101
+ - test/epok/datos_utiles_test.rb
102
+ - test/epok/geocode_test.rb
121
103
  - test/epok/geocoder_test.rb
122
104
  - test/epok/object_test.rb
123
105
  - test/epok/search_test.rb
@@ -1,17 +0,0 @@
1
- require "forwardable"
2
-
3
- module Epok
4
- module Collectable
5
- def self.included(base)
6
- base.class_eval do
7
- extend Forwardable
8
-
9
- def_delegators :results, :entries, :first
10
- end
11
- end
12
-
13
- def collection(object)
14
- Collection.new object
15
- end
16
- end
17
- end
data/lib/epok/geocoder.rb DELETED
@@ -1,21 +0,0 @@
1
- module Epok
2
- Location = Struct.new(:x, :y, keyword_init: true)
3
-
4
- class Geocoder
5
- include Collectable
6
-
7
- attr_reader :x, :y, :categories
8
-
9
- def initialize(location, categories)
10
- @x = location.x
11
- @y = location.y
12
- @categories = categories
13
- end
14
-
15
- private
16
-
17
- def results
18
- collection API.geocoder(x, y, categories)
19
- end
20
- end
21
- end
data/lib/epok/search.rb DELETED
@@ -1,17 +0,0 @@
1
- module Epok
2
- class Search
3
- include Collectable
4
-
5
- attr_reader :query
6
-
7
- def initialize(query)
8
- @query = query
9
- end
10
-
11
- private
12
-
13
- def results
14
- collection API.search(query)
15
- end
16
- end
17
- end