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 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
@@ -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
@@ -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
@@ -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: []