jpzip 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/LICENSE +21 -0
- data/README.md +90 -0
- data/jpzip.gemspec +39 -0
- data/lib/jpzip/cache.rb +78 -0
- data/lib/jpzip/client.rb +240 -0
- data/lib/jpzip/http.rb +82 -0
- data/lib/jpzip/types.rb +84 -0
- data/lib/jpzip/version.rb +11 -0
- data/lib/jpzip.rb +60 -0
- metadata +100 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1f683058e4526a22c00c308de577f22b564c6f7f061453503198e85c4b0db51f
|
|
4
|
+
data.tar.gz: ec819d45358f48dbf25e0270ce7bde1b5d02b9aa0bdf7072ad83a1f639e3849a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6651b29e75ad6b1c7530048e8a051c5851161ff35c65850cc24823dd9dc1190af7c70ceeaf13b996a48dff252cd68e6e773855619fa67b961a6a57d72d604934
|
|
7
|
+
data.tar.gz: b93fc8e3edf07e60c1f3f647331be79d304d96e7f8c3f32a2469cf6f9b3f9a12c1a8241fd2ec6eedb064692d130010b8d9d82844a8eda8256b4ef5db4f881117
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nadai
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# jpzip — Ruby SDK
|
|
2
|
+
|
|
3
|
+
> 日本の郵便番号を CDN 配信の JSON データから引く Ruby SDK。
|
|
4
|
+
|
|
5
|
+
- 配信ドメイン: `https://jpzip.nadai.dev`
|
|
6
|
+
- プロトコル仕様: [`jpzip/spec`](https://github.com/jpzip/spec)
|
|
7
|
+
- データ ETL: [`jpzip/data`](https://github.com/jpzip/data)
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
gem "jpzip"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
または直接:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
gem install jpzip
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Ruby 3.2 以上が必要(`Data.define` を使用)。
|
|
21
|
+
|
|
22
|
+
## 使い方
|
|
23
|
+
|
|
24
|
+
### モジュール関数 API
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
require "jpzip"
|
|
28
|
+
|
|
29
|
+
entry = Jpzip.lookup("2310017")
|
|
30
|
+
# entry == nil なら見つからなかった
|
|
31
|
+
# entry.prefecture #=> "神奈川県"
|
|
32
|
+
# entry.city #=> "横浜市中区"
|
|
33
|
+
# entry.towns.first.town #=> "本町"
|
|
34
|
+
|
|
35
|
+
dict = Jpzip.lookup_group("23") # 2 桁は 10 並列 fetch
|
|
36
|
+
all = Jpzip.lookup_all
|
|
37
|
+
meta = Jpzip.meta
|
|
38
|
+
|
|
39
|
+
Jpzip.valid_zipcode?("2310017") #=> true
|
|
40
|
+
Jpzip.valid_zipcode?("231-0017") #=> false
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### クライアント API (L2 キャッシュ・複数インスタンス用)
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
client = Jpzip::Client.new(
|
|
47
|
+
base_url: "https://jpzip.nadai.dev",
|
|
48
|
+
memory_cache_size: 200,
|
|
49
|
+
cache: my_cache, # Jpzip::Cache サブクラス
|
|
50
|
+
on_spec_mismatch: ->(expected, got) {
|
|
51
|
+
warn "jpzip spec mismatch: expected=#{expected} got=#{got}"
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
client.preload("all")
|
|
56
|
+
entry = client.lookup("2310017")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Cache インターフェース
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
class MyFileCache < Jpzip::Cache
|
|
63
|
+
def get(key); ...; end # => String or nil
|
|
64
|
+
def set(key, value); ...; end
|
|
65
|
+
def delete(key); ...; end
|
|
66
|
+
def clear; ...; end
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
ファイル / Redis / Memcached 等の任意の実装を渡せる。L2 は明示的に有効化した場合のみ使われ、デフォルトは L1 (メモリ LRU) のみ。
|
|
71
|
+
|
|
72
|
+
## 入力検証
|
|
73
|
+
|
|
74
|
+
`Jpzip.lookup` は `\A\d{7}\z` にマッチしない入力に対して fetch せず `nil` を返す。
|
|
75
|
+
|
|
76
|
+
## バージョン整合性
|
|
77
|
+
|
|
78
|
+
`Jpzip.meta` 取得時、`spec_version` が SDK 対応バージョンと異なる場合 `on_spec_mismatch` コールバックが 1 度だけ呼ばれる。データバージョンが変わったら L1/L2 を自動 invalidate する。
|
|
79
|
+
|
|
80
|
+
## 並列性とスレッドセーフ
|
|
81
|
+
|
|
82
|
+
`Jpzip::Client` はスレッドセーフ。複数スレッドから同一インスタンスを共有しても安全。`lookup_group("23")` や `lookup_all` は内部で `Thread` を使って並列 fetch する。
|
|
83
|
+
|
|
84
|
+
## 依存
|
|
85
|
+
|
|
86
|
+
なし。Ruby 標準ライブラリ (`net/http`, `json`, `monitor`) のみ使用。
|
|
87
|
+
|
|
88
|
+
## ライセンス
|
|
89
|
+
|
|
90
|
+
[MIT](./LICENSE)
|
data/jpzip.gemspec
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/jpzip/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "jpzip"
|
|
7
|
+
spec.version = Jpzip::VERSION
|
|
8
|
+
spec.authors = ["nadai"]
|
|
9
|
+
spec.email = ["noreply@nadai.dev"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Ruby SDK for the jpzip Japanese postal-code dataset"
|
|
12
|
+
spec.description = "jpzip は日本の郵便番号を CDN 配信の JSON データから引く Ruby SDK。" \
|
|
13
|
+
"L1 LRU メモリキャッシュを内蔵し、任意の L2 永続キャッシュを差し込める。"
|
|
14
|
+
spec.homepage = "https://github.com/jpzip/ruby"
|
|
15
|
+
spec.license = "MIT"
|
|
16
|
+
spec.required_ruby_version = ">= 3.2"
|
|
17
|
+
|
|
18
|
+
spec.metadata = {
|
|
19
|
+
"homepage_uri" => spec.homepage,
|
|
20
|
+
"source_code_uri" => "https://github.com/jpzip/ruby",
|
|
21
|
+
"bug_tracker_uri" => "https://github.com/jpzip/ruby/issues",
|
|
22
|
+
"documentation_uri" => "https://github.com/jpzip/ruby",
|
|
23
|
+
"rubygems_mfa_required" => "true"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
spec.files = Dir[
|
|
27
|
+
"lib/**/*.rb",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"jpzip.gemspec"
|
|
31
|
+
]
|
|
32
|
+
spec.require_paths = ["lib"]
|
|
33
|
+
|
|
34
|
+
# No runtime dependencies — net/http and json ship with Ruby.
|
|
35
|
+
|
|
36
|
+
spec.add_development_dependency "minitest", "~> 5.20"
|
|
37
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
38
|
+
spec.add_development_dependency "webmock", "~> 3.20"
|
|
39
|
+
end
|
data/lib/jpzip/cache.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Jpzip
|
|
6
|
+
# Cache is the abstract interface a user-supplied L2 persistent cache must
|
|
7
|
+
# satisfy. Implementations are free to add TTLs, eviction, or backends
|
|
8
|
+
# (file, Redis, IndexedDB-equivalent, etc.).
|
|
9
|
+
#
|
|
10
|
+
# Subclasses MUST override every method below.
|
|
11
|
+
class Cache
|
|
12
|
+
# Get the bytes stored under +key+, or nil if absent.
|
|
13
|
+
def get(key)
|
|
14
|
+
raise NotImplementedError, "#{self.class}#get must be implemented"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Set +value+ (a String of bytes) under +key+.
|
|
18
|
+
def set(key, value)
|
|
19
|
+
raise NotImplementedError, "#{self.class}#set must be implemented"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Delete the entry stored under +key+ (no-op if absent).
|
|
23
|
+
def delete(key)
|
|
24
|
+
raise NotImplementedError, "#{self.class}#delete must be implemented"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Clear every entry in the cache.
|
|
28
|
+
def clear
|
|
29
|
+
raise NotImplementedError, "#{self.class}#clear must be implemented"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# MemoryLRU is the L1 in-memory cache, bounded by a fixed number of prefix
|
|
34
|
+
# entries. It is safe for concurrent use.
|
|
35
|
+
class MemoryLRU
|
|
36
|
+
DEFAULT_CAPACITY = 100
|
|
37
|
+
|
|
38
|
+
def initialize(capacity = DEFAULT_CAPACITY)
|
|
39
|
+
@capacity = capacity < 1 ? 1 : capacity
|
|
40
|
+
# Ruby's Hash preserves insertion order, so it doubles as an LRU index:
|
|
41
|
+
# touch on read by deleting + re-inserting at the tail.
|
|
42
|
+
@items = {}
|
|
43
|
+
@mu = Monitor.new
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def get(key)
|
|
47
|
+
@mu.synchronize do
|
|
48
|
+
return nil unless @items.key?(key)
|
|
49
|
+
|
|
50
|
+
value = @items.delete(key)
|
|
51
|
+
@items[key] = value
|
|
52
|
+
value
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def set(key, value)
|
|
57
|
+
@mu.synchronize do
|
|
58
|
+
@items.delete(key) if @items.key?(key)
|
|
59
|
+
@items[key] = value
|
|
60
|
+
@items.shift while @items.size > @capacity
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def delete(key)
|
|
66
|
+
@mu.synchronize { @items.delete(key) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def clear
|
|
70
|
+
@mu.synchronize { @items.clear }
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def size
|
|
75
|
+
@mu.synchronize { @items.size }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/jpzip/client.rb
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "monitor"
|
|
5
|
+
|
|
6
|
+
require_relative "cache"
|
|
7
|
+
require_relative "http"
|
|
8
|
+
require_relative "types"
|
|
9
|
+
require_relative "version"
|
|
10
|
+
|
|
11
|
+
module Jpzip
|
|
12
|
+
ZIP_REGEX = /\A\d{7}\z/.freeze
|
|
13
|
+
PREFIX_REGEX = /\A\d{1,3}\z/.freeze
|
|
14
|
+
|
|
15
|
+
# InvalidPrefixError is raised when LookupGroup/Preload receive a prefix
|
|
16
|
+
# that is not 1-3 digits.
|
|
17
|
+
class InvalidPrefixError < ArgumentError; end
|
|
18
|
+
|
|
19
|
+
# Client is the jpzip SDK entry point. Construct it once and reuse it
|
|
20
|
+
# across threads — it is safe for concurrent use.
|
|
21
|
+
class Client
|
|
22
|
+
# @param base_url [String] override the CDN origin
|
|
23
|
+
# @param cache [Jpzip::Cache, nil] optional L2 persistent cache
|
|
24
|
+
# @param memory_cache_size [Integer] L1 LRU capacity in prefix entries
|
|
25
|
+
# @param on_spec_mismatch [Proc, nil] hook invoked once when /meta.json's
|
|
26
|
+
# spec_version differs from {Jpzip::SPEC_VERSION}
|
|
27
|
+
# @param http_client [Proc, nil] testing hook receiving a URI, returning
|
|
28
|
+
# a Net::HTTPResponse-like object
|
|
29
|
+
def initialize(base_url: DEFAULT_BASE_URL,
|
|
30
|
+
cache: nil,
|
|
31
|
+
memory_cache_size: MemoryLRU::DEFAULT_CAPACITY,
|
|
32
|
+
on_spec_mismatch: nil,
|
|
33
|
+
http_client: nil)
|
|
34
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
35
|
+
@cache = cache
|
|
36
|
+
@mem = MemoryLRU.new(memory_cache_size)
|
|
37
|
+
@on_spec_mismatch = on_spec_mismatch
|
|
38
|
+
@http_client = http_client
|
|
39
|
+
@meta_mu = Monitor.new
|
|
40
|
+
@meta_cached = nil
|
|
41
|
+
@meta_resolved = false
|
|
42
|
+
@known_version = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Lookup returns the ZipcodeEntry for +zipcode+ or nil if not found.
|
|
46
|
+
# Malformed input returns nil without contacting the network.
|
|
47
|
+
def lookup(zipcode)
|
|
48
|
+
return nil unless ZIP_REGEX.match?(zipcode.to_s)
|
|
49
|
+
|
|
50
|
+
dict = fetch_prefix_dict(zipcode[0, 3])
|
|
51
|
+
return nil if dict.nil?
|
|
52
|
+
|
|
53
|
+
dict[zipcode]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# LookupGroup fetches all entries under a 1-, 2-, or 3-digit prefix.
|
|
57
|
+
# A 2-digit prefix fans out into 10 parallel prefix-3 fetches.
|
|
58
|
+
#
|
|
59
|
+
# @return [Hash{String => Jpzip::ZipcodeEntry}]
|
|
60
|
+
def lookup_group(prefix)
|
|
61
|
+
prefix = prefix.to_s
|
|
62
|
+
raise InvalidPrefixError, "jpzip: prefix must be 1-3 digits, got #{prefix.inspect}" unless PREFIX_REGEX.match?(prefix)
|
|
63
|
+
|
|
64
|
+
case prefix.length
|
|
65
|
+
when 3
|
|
66
|
+
fetch_prefix_dict(prefix) || {}
|
|
67
|
+
when 1
|
|
68
|
+
fetch_url(group_url(prefix)) || {}
|
|
69
|
+
when 2
|
|
70
|
+
parallel_merge(0.upto(9).map { |i| "#{prefix}#{i}" }) { |p3| fetch_prefix_dict(p3) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# LookupAll fans out across /g/0..9.json in parallel and merges. The CDN
|
|
75
|
+
# does not publish a single /all.json because the combined file exceeds
|
|
76
|
+
# Cloudflare Pages' 25 MiB per-file limit.
|
|
77
|
+
def lookup_all
|
|
78
|
+
parallel_merge(0.upto(9).map(&:to_s)) { |p1| fetch_url(group_url(p1)) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Meta returns the cached /meta.json. First call hits the network; later
|
|
82
|
+
# calls return the cached value until {#refresh} is invoked.
|
|
83
|
+
def meta
|
|
84
|
+
@meta_mu.synchronize do
|
|
85
|
+
return @meta_cached if @meta_resolved
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
result = Http.get("#{@base_url}/meta.json", http_client: @http_client)
|
|
89
|
+
|
|
90
|
+
@meta_mu.synchronize do
|
|
91
|
+
if result.status == 404
|
|
92
|
+
@meta_resolved = true
|
|
93
|
+
@meta_cached = nil
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
parsed = JSON.parse(result.body)
|
|
98
|
+
m = Meta.from_hash(parsed)
|
|
99
|
+
|
|
100
|
+
if m.spec_version != SPEC_VERSION && @on_spec_mismatch
|
|
101
|
+
@on_spec_mismatch.call(SPEC_VERSION, m.spec_version)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
if @known_version && @known_version != m.version
|
|
105
|
+
@mem.clear
|
|
106
|
+
@cache&.clear
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
@known_version = m.version
|
|
110
|
+
@meta_cached = m
|
|
111
|
+
@meta_resolved = true
|
|
112
|
+
m
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Preload pulls the requested scope into L1 (and L2 when configured).
|
|
117
|
+
# +scope+ is either the string "all" or a 1-3 digit prefix.
|
|
118
|
+
def preload(scope)
|
|
119
|
+
scope = scope.to_s
|
|
120
|
+
if scope == "all"
|
|
121
|
+
dict = lookup_all
|
|
122
|
+
buckets = Hash.new { |h, k| h[k] = {} }
|
|
123
|
+
dict.each { |zip, entry| buckets[zip[0, 3]][zip] = entry }
|
|
124
|
+
buckets.each do |p, b|
|
|
125
|
+
url = prefix_url(p)
|
|
126
|
+
@mem.set(url, b)
|
|
127
|
+
write_l2(url, b)
|
|
128
|
+
end
|
|
129
|
+
return nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
raise InvalidPrefixError, "jpzip: prefix must be 1-3 digits, got #{scope.inspect}" unless PREFIX_REGEX.match?(scope)
|
|
133
|
+
|
|
134
|
+
lookup_group(scope)
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Refresh wipes L1 (and L2 when configured) and forgets cached meta.
|
|
139
|
+
def refresh
|
|
140
|
+
@mem.clear
|
|
141
|
+
@meta_mu.synchronize do
|
|
142
|
+
@meta_cached = nil
|
|
143
|
+
@meta_resolved = false
|
|
144
|
+
@known_version = nil
|
|
145
|
+
end
|
|
146
|
+
@cache&.clear
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @api private
|
|
151
|
+
def memory_cache_size
|
|
152
|
+
@mem.size
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def prefix_url(prefix3)
|
|
158
|
+
"#{@base_url}/p/#{prefix3}.json"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def group_url(prefix1)
|
|
162
|
+
"#{@base_url}/g/#{prefix1}.json"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def fetch_prefix_dict(prefix3)
|
|
166
|
+
url = prefix_url(prefix3)
|
|
167
|
+
if (cached = @mem.get(url))
|
|
168
|
+
return cached
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
if (from_l2 = read_l2(url))
|
|
172
|
+
@mem.set(url, from_l2)
|
|
173
|
+
return from_l2
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
dict = fetch_url(url)
|
|
177
|
+
if dict
|
|
178
|
+
@mem.set(url, dict)
|
|
179
|
+
write_l2(url, dict)
|
|
180
|
+
end
|
|
181
|
+
dict
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def fetch_url(url)
|
|
185
|
+
result = Http.get(url, http_client: @http_client)
|
|
186
|
+
return nil if result.status == 404
|
|
187
|
+
|
|
188
|
+
parsed = JSON.parse(result.body)
|
|
189
|
+
parsed.each_with_object({}) do |(zip, raw), out|
|
|
190
|
+
out[zip] = ZipcodeEntry.from_hash(raw)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def read_l2(url)
|
|
195
|
+
return nil unless @cache
|
|
196
|
+
|
|
197
|
+
bytes = @cache.get(url)
|
|
198
|
+
return nil if bytes.nil? || bytes.empty?
|
|
199
|
+
|
|
200
|
+
begin
|
|
201
|
+
parsed = JSON.parse(bytes)
|
|
202
|
+
rescue JSON::ParserError
|
|
203
|
+
@cache.delete(url)
|
|
204
|
+
return nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
parsed.each_with_object({}) do |(zip, raw), out|
|
|
208
|
+
out[zip] = ZipcodeEntry.from_hash(raw)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def write_l2(url, dict)
|
|
213
|
+
return unless @cache
|
|
214
|
+
|
|
215
|
+
payload = dict.each_with_object({}) do |(zip, entry), h|
|
|
216
|
+
h[zip] = entry.to_h
|
|
217
|
+
end
|
|
218
|
+
@cache.set(url, JSON.generate(payload))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# parallel_merge runs +block+ for each value in +items+ across up to 10
|
|
222
|
+
# threads, then merges the resulting hashes (nils skipped). Raises the
|
|
223
|
+
# first error if any thread fails.
|
|
224
|
+
def parallel_merge(items)
|
|
225
|
+
threads = items.map do |item|
|
|
226
|
+
Thread.new do
|
|
227
|
+
Thread.current.report_on_exception = false
|
|
228
|
+
yield(item)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
results = threads.map(&:value)
|
|
233
|
+
results.each_with_object({}) do |dict, out|
|
|
234
|
+
next if dict.nil?
|
|
235
|
+
|
|
236
|
+
out.merge!(dict)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
data/lib/jpzip/http.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Jpzip
|
|
7
|
+
# Internal HTTP helpers. Pinned to the Ruby stdlib (net/http) so the gem
|
|
8
|
+
# has zero runtime dependencies.
|
|
9
|
+
module Http
|
|
10
|
+
MAX_ATTEMPTS = 3
|
|
11
|
+
BASE_BACKOFF = 0.2
|
|
12
|
+
DEFAULT_TIMEOUT = 30 # seconds
|
|
13
|
+
|
|
14
|
+
# Result wraps an HTTP response: +body+ (String or nil) and +status+ (Integer).
|
|
15
|
+
Result = Struct.new(:body, :status, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
# Get fetches +url+ with bounded retries on 5xx / network failures.
|
|
18
|
+
#
|
|
19
|
+
# Returns a Result where +status+ is the HTTP status code. On 404 +body+
|
|
20
|
+
# is nil so callers can distinguish "absent" from "fetch error". On
|
|
21
|
+
# repeated failure this raises the last error encountered.
|
|
22
|
+
def self.get(url, http_client: nil, sleeper: nil)
|
|
23
|
+
uri = URI(url)
|
|
24
|
+
last_error = nil
|
|
25
|
+
|
|
26
|
+
MAX_ATTEMPTS.times do |attempt|
|
|
27
|
+
if attempt.positive?
|
|
28
|
+
delay = BASE_BACKOFF * (2**attempt)
|
|
29
|
+
(sleeper || method(:sleep)).call(delay)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
response = perform_request(uri, http_client)
|
|
34
|
+
status = response.code.to_i
|
|
35
|
+
|
|
36
|
+
return Result.new(body: nil, status: 404) if status == 404
|
|
37
|
+
|
|
38
|
+
if status >= 500
|
|
39
|
+
last_error = HttpError.new("jpzip: #{url} returned #{status}")
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if status >= 400
|
|
44
|
+
raise HttpError, "jpzip: #{url} returned #{status}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
return Result.new(body: response.body, status: status)
|
|
48
|
+
rescue HttpError
|
|
49
|
+
raise
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
last_error = e
|
|
52
|
+
next
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
raise(last_error || HttpError.new("jpzip: #{url} failed after #{MAX_ATTEMPTS} attempts"))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.perform_request(uri, http_client)
|
|
60
|
+
if http_client
|
|
61
|
+
return http_client.call(uri)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Net::HTTP.start(
|
|
65
|
+
uri.host,
|
|
66
|
+
uri.port,
|
|
67
|
+
use_ssl: uri.scheme == "https",
|
|
68
|
+
open_timeout: DEFAULT_TIMEOUT,
|
|
69
|
+
read_timeout: DEFAULT_TIMEOUT
|
|
70
|
+
) do |http|
|
|
71
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
72
|
+
req["Accept"] = "application/json"
|
|
73
|
+
req["Accept-Encoding"] = "gzip"
|
|
74
|
+
http.request(req)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# HttpError signals a non-retryable HTTP failure (4xx other than 404, or
|
|
79
|
+
# exhausted retries on 5xx).
|
|
80
|
+
class HttpError < StandardError; end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/jpzip/types.rb
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jpzip
|
|
4
|
+
# Town corresponds to one element of ZipcodeEntry#towns.
|
|
5
|
+
#
|
|
6
|
+
# Fields match the JSON shape served by the CDN (snake_case).
|
|
7
|
+
Town = Data.define(:town, :kana, :roma, :note) do
|
|
8
|
+
# Build a Town from a parsed JSON hash. Unknown keys are ignored so the
|
|
9
|
+
# SDK keeps working when the protocol grows new optional fields.
|
|
10
|
+
def self.from_hash(h)
|
|
11
|
+
new(
|
|
12
|
+
town: h["town"] || "",
|
|
13
|
+
kana: h["kana"] || "",
|
|
14
|
+
roma: h["roma"] || "",
|
|
15
|
+
note: h["note"]
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_h
|
|
20
|
+
base = { town: town, kana: kana, roma: roma }
|
|
21
|
+
base[:note] = note if note
|
|
22
|
+
base
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# ZipcodeEntry is one logical entry as published by the CDN.
|
|
27
|
+
ZipcodeEntry = Data.define(
|
|
28
|
+
:prefecture,
|
|
29
|
+
:prefecture_kana,
|
|
30
|
+
:prefecture_roma,
|
|
31
|
+
:prefecture_code,
|
|
32
|
+
:city,
|
|
33
|
+
:city_kana,
|
|
34
|
+
:city_roma,
|
|
35
|
+
:city_code,
|
|
36
|
+
:towns
|
|
37
|
+
) do
|
|
38
|
+
def self.from_hash(h)
|
|
39
|
+
new(
|
|
40
|
+
prefecture: h["prefecture"] || "",
|
|
41
|
+
prefecture_kana: h["prefecture_kana"] || "",
|
|
42
|
+
prefecture_roma: h["prefecture_roma"] || "",
|
|
43
|
+
prefecture_code: h["prefecture_code"] || "",
|
|
44
|
+
city: h["city"] || "",
|
|
45
|
+
city_kana: h["city_kana"] || "",
|
|
46
|
+
city_roma: h["city_roma"] || "",
|
|
47
|
+
city_code: h["city_code"] || "",
|
|
48
|
+
towns: (h["towns"] || []).map { |t| Town.from_hash(t) }
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Endpoints is part of /meta.json.
|
|
54
|
+
Endpoints = Data.define(:group, :prefix) do
|
|
55
|
+
def self.from_hash(h)
|
|
56
|
+
new(group: h["group"] || "", prefix: h["prefix"] || "")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Meta is /meta.json.
|
|
61
|
+
Meta = Data.define(
|
|
62
|
+
:version,
|
|
63
|
+
:generated_at,
|
|
64
|
+
:spec_version,
|
|
65
|
+
:total_zipcodes,
|
|
66
|
+
:prefix_count,
|
|
67
|
+
:by_pref,
|
|
68
|
+
:data_source,
|
|
69
|
+
:endpoints
|
|
70
|
+
) do
|
|
71
|
+
def self.from_hash(h)
|
|
72
|
+
new(
|
|
73
|
+
version: h["version"] || "",
|
|
74
|
+
generated_at: h["generated_at"] || "",
|
|
75
|
+
spec_version: h["spec_version"] || "",
|
|
76
|
+
total_zipcodes: h["total_zipcodes"] || 0,
|
|
77
|
+
prefix_count: h["prefix_count"] || 0,
|
|
78
|
+
by_pref: h["by_pref"] || {},
|
|
79
|
+
data_source: h["data_source"] || "",
|
|
80
|
+
endpoints: Endpoints.from_hash(h["endpoints"] || {})
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jpzip
|
|
4
|
+
VERSION = "0.1.0"
|
|
5
|
+
|
|
6
|
+
# SpecVersion is the jpzip protocol version this SDK targets.
|
|
7
|
+
SPEC_VERSION = "1.0"
|
|
8
|
+
|
|
9
|
+
# DefaultBaseURL is the production CDN origin.
|
|
10
|
+
DEFAULT_BASE_URL = "https://jpzip.nadai.dev"
|
|
11
|
+
end
|
data/lib/jpzip.rb
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "jpzip/version"
|
|
4
|
+
require_relative "jpzip/types"
|
|
5
|
+
require_relative "jpzip/cache"
|
|
6
|
+
require_relative "jpzip/http"
|
|
7
|
+
require_relative "jpzip/client"
|
|
8
|
+
|
|
9
|
+
# Jpzip is the Ruby SDK for the jpzip postal-code dataset
|
|
10
|
+
# (https://jpzip.nadai.dev). The SDK fetches normalized JSON from the CDN,
|
|
11
|
+
# keeps a per-prefix in-memory LRU, and optionally backs that with a
|
|
12
|
+
# user-supplied persistent cache.
|
|
13
|
+
module Jpzip
|
|
14
|
+
class << self
|
|
15
|
+
# Returns true iff +str+ is a syntactically valid 7-digit zipcode
|
|
16
|
+
# (no network call).
|
|
17
|
+
def valid_zipcode?(str)
|
|
18
|
+
ZIP_REGEX.match?(str.to_s)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Convenience shortcuts delegating to a process-wide default Client.
|
|
22
|
+
# The singleton uses L1 only — for L2 caches construct your own Client.
|
|
23
|
+
def lookup(zipcode)
|
|
24
|
+
default_client.lookup(zipcode)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def lookup_group(prefix)
|
|
28
|
+
default_client.lookup_group(prefix)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def lookup_all
|
|
32
|
+
default_client.lookup_all
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def preload(scope)
|
|
36
|
+
default_client.preload(scope)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def meta
|
|
40
|
+
default_client.meta
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Replace the singleton — mainly for tests.
|
|
44
|
+
def reset_default_client!
|
|
45
|
+
@default_client = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Override the singleton with a configured Client. Useful when the app
|
|
49
|
+
# wants to share an L2 cache through the module-level helpers.
|
|
50
|
+
def configure(**opts)
|
|
51
|
+
@default_client = Client.new(**opts)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def default_client
|
|
57
|
+
@default_client ||= Client.new
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: jpzip
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- nadai
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-16 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.20'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.20'
|
|
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.20'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.20'
|
|
55
|
+
description: jpzip は日本の郵便番号を CDN 配信の JSON データから引く Ruby SDK。L1 LRU メモリキャッシュを内蔵し、任意の
|
|
56
|
+
L2 永続キャッシュを差し込める。
|
|
57
|
+
email:
|
|
58
|
+
- noreply@nadai.dev
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- LICENSE
|
|
64
|
+
- README.md
|
|
65
|
+
- jpzip.gemspec
|
|
66
|
+
- lib/jpzip.rb
|
|
67
|
+
- lib/jpzip/cache.rb
|
|
68
|
+
- lib/jpzip/client.rb
|
|
69
|
+
- lib/jpzip/http.rb
|
|
70
|
+
- lib/jpzip/types.rb
|
|
71
|
+
- lib/jpzip/version.rb
|
|
72
|
+
homepage: https://github.com/jpzip/ruby
|
|
73
|
+
licenses:
|
|
74
|
+
- MIT
|
|
75
|
+
metadata:
|
|
76
|
+
homepage_uri: https://github.com/jpzip/ruby
|
|
77
|
+
source_code_uri: https://github.com/jpzip/ruby
|
|
78
|
+
bug_tracker_uri: https://github.com/jpzip/ruby/issues
|
|
79
|
+
documentation_uri: https://github.com/jpzip/ruby
|
|
80
|
+
rubygems_mfa_required: 'true'
|
|
81
|
+
post_install_message:
|
|
82
|
+
rdoc_options: []
|
|
83
|
+
require_paths:
|
|
84
|
+
- lib
|
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.2'
|
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '0'
|
|
95
|
+
requirements: []
|
|
96
|
+
rubygems_version: 3.5.22
|
|
97
|
+
signing_key:
|
|
98
|
+
specification_version: 4
|
|
99
|
+
summary: Ruby SDK for the jpzip Japanese postal-code dataset
|
|
100
|
+
test_files: []
|