alpr-sdk 0.1.1

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.
data/lib/alpr/page.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alpr
4
+ # One page of a list: its items, and the cursor of the next page.
5
+ class Page
6
+ # @return [Array] the items of the page
7
+ attr_reader :items
8
+ # @return [String, nil] the cursor to ask the next page with; nil on the last page
9
+ attr_reader :next_cursor
10
+
11
+ def initialize(items:, next_cursor:)
12
+ @items = items.freeze
13
+ @next_cursor = next_cursor.nil? || next_cursor.to_s.empty? ? nil : next_cursor.to_s
14
+ freeze
15
+ end
16
+
17
+ # Builds a page from the API's hash ({"data": [...], "next": cursor}), each
18
+ # item through the block.
19
+ def self.from_hash(data, next_key = "next", &item)
20
+ new(items: (data["data"] || []).map(&item), next_cursor: data[next_key])
21
+ end
22
+
23
+ # Walks pages from the cursor +first+ (the newest page when nil) until the
24
+ # last one, yielding every item and ending at the first error; +fetch+
25
+ # answers the Page of the cursor it is given. A cursor that does not move
26
+ # would ask for the same page forever, so it ends the walk too.
27
+ def self.iterate(first, &fetch)
28
+ Enumerator.new do |yielder|
29
+ cursor = first.nil? || first.to_s.empty? ? nil : first.to_s
30
+ loop do
31
+ result = fetch.call(cursor)
32
+ result.items.each { |item| yielder << item }
33
+ break if result.next_cursor.nil? || result.next_cursor == cursor
34
+
35
+ cursor = result.next_cursor
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Code generated by sdk/generator. DO NOT EDIT.
4
+
5
+ module Alpr
6
+ # The body of photos:detect.
7
+ class PhotosDetectParams < Params
8
+ MEMBERS = %i[image image_mode lpr_confidence min_ocr_confidence].freeze
9
+
10
+ # A foto, JPEG ou PNG.
11
+ # @return [String (the bytes)]
12
+ attr_reader :image
13
+
14
+ # Só NONE é aceito.
15
+ # @return [String ("NONE"), nil]
16
+ attr_reader :image_mode
17
+
18
+ # Piso de confiança do detector de placas.
19
+ # @return [Float, nil]
20
+ attr_reader :lpr_confidence
21
+
22
+ # Piso de confiança da leitura dos caracteres.
23
+ # @return [Float, nil]
24
+ attr_reader :min_ocr_confidence
25
+
26
+ def initialize(image:, image_mode: nil, lpr_confidence: nil, min_ocr_confidence: nil)
27
+ @image = image
28
+ @image_mode = image_mode
29
+ @lpr_confidence = lpr_confidence
30
+ @min_ocr_confidence = min_ocr_confidence
31
+ freeze
32
+ end
33
+
34
+ # The multipart/form-data body as the API reads it: the photo as a file,
35
+ # the rest as text fields, an optional one left unset left out.
36
+ def to_form
37
+ form = Form.new
38
+ form.file("image", @image)
39
+ form.text("image_mode", @image_mode) unless @image_mode.nil?
40
+ form.text("lpr_confidence", @lpr_confidence) unless @lpr_confidence.nil?
41
+ form.text("min_ocr_confidence", @min_ocr_confidence) unless @min_ocr_confidence.nil?
42
+ form
43
+ end
44
+ end
45
+
46
+ # The body of photos:ocr.
47
+ class PhotosOCRParams < Params
48
+ MEMBERS = %i[image].freeze
49
+
50
+ # A imagem da placa já recortada, JPEG ou PNG.
51
+ # @return [String (the bytes)]
52
+ attr_reader :image
53
+
54
+ def initialize(image:)
55
+ @image = image
56
+ freeze
57
+ end
58
+
59
+ # The multipart/form-data body as the API reads it: the photo as a file,
60
+ # the rest as text fields, an optional one left unset left out.
61
+ def to_form
62
+ form = Form.new
63
+ form.file("image", @image)
64
+ form
65
+ end
66
+ end
67
+
68
+ # The body of photos:recognize.
69
+ class PhotosRecognizeParams < Params
70
+ MEMBERS = %i[image image_mode lpr_confidence min_ocr_confidence].freeze
71
+
72
+ # A foto, JPEG ou PNG.
73
+ # @return [String (the bytes)]
74
+ attr_reader :image
75
+
76
+ # Só NONE é aceito.
77
+ # @return [String ("NONE"), nil]
78
+ attr_reader :image_mode
79
+
80
+ # Piso de confiança do detector de placas.
81
+ # @return [Float, nil]
82
+ attr_reader :lpr_confidence
83
+
84
+ # Piso de confiança da leitura dos caracteres.
85
+ # @return [Float, nil]
86
+ attr_reader :min_ocr_confidence
87
+
88
+ def initialize(image:, image_mode: nil, lpr_confidence: nil, min_ocr_confidence: nil)
89
+ @image = image
90
+ @image_mode = image_mode
91
+ @lpr_confidence = lpr_confidence
92
+ @min_ocr_confidence = min_ocr_confidence
93
+ freeze
94
+ end
95
+
96
+ # The multipart/form-data body as the API reads it: the photo as a file,
97
+ # the rest as text fields, an optional one left unset left out.
98
+ def to_form
99
+ form = Form.new
100
+ form.file("image", @image)
101
+ form.text("image_mode", @image_mode) unless @image_mode.nil?
102
+ form.text("lpr_confidence", @lpr_confidence) unless @lpr_confidence.nil?
103
+ form.text("min_ocr_confidence", @min_ocr_confidence) unless @min_ocr_confidence.nil?
104
+ form
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Code generated by sdk/generator. DO NOT EDIT.
4
+
5
+ module Alpr
6
+ # The API's cameras.
7
+ class CamerasService
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ # Listar as câmeras.
13
+ #
14
+ # @return [Array<Camera>]
15
+ def list(api_key: nil)
16
+ data = @client.request("GET", "/api/v1/cameras", api_key: api_key)
17
+ (data["data"] || []).map { |item| Camera.from_hash(item) }
18
+ end
19
+ end
20
+
21
+ # The API's photos.
22
+ class PhotosService
23
+ def initialize(client)
24
+ @client = client
25
+ end
26
+
27
+ # Localizar as placas de uma foto.
28
+ #
29
+ # @param params [PhotosDetectParams, Hash]
30
+ # @return [PhotoDetection]
31
+ def detect(params, api_key: nil)
32
+ data = @client.request("POST", "/api/v1/detect", form: PhotosDetectParams.coerce(params).to_form, api_key: api_key)
33
+ PhotoDetection.from_hash(data)
34
+ end
35
+
36
+ # Ler uma placa já recortada.
37
+ #
38
+ # @param params [PhotosOCRParams, Hash]
39
+ # @return [PlateReading]
40
+ def ocr(params, api_key: nil)
41
+ data = @client.request("POST", "/api/v1/ocr", form: PhotosOCRParams.coerce(params).to_form, api_key: api_key)
42
+ PlateReading.from_hash(data)
43
+ end
44
+
45
+ # Ler as placas de uma foto.
46
+ #
47
+ # @param params [PhotosRecognizeParams, Hash]
48
+ # @return [PhotoRecognition]
49
+ def recognize(params, api_key: nil)
50
+ data = @client.request("POST", "/api/v1/recognize", form: PhotosRecognizeParams.coerce(params).to_form, api_key: api_key)
51
+ PhotoRecognition.from_hash(data)
52
+ end
53
+ end
54
+
55
+ # The API's recognitions.
56
+ class RecognitionsService
57
+ def initialize(client)
58
+ @client = client
59
+ end
60
+
61
+ # Imagem de uma leitura.
62
+ #
63
+ # @return [DetectionImageLink]
64
+ def image(id, api_key: nil)
65
+ data = @client.request("GET", "/api/v1/detections/#{Alpr.segment(id)}/image", api_key: api_key)
66
+ DetectionImageLink.from_hash(data)
67
+ end
68
+
69
+ # Listar as leituras. One page; +list_iter+ walks every page. +before+ is the
70
+ # cursor the previous page answered as +next_cursor+; nil asks for the newest page.
71
+ #
72
+ # @return [Page<Recognition>]
73
+ def list(before: nil, plate: nil, camera: nil, source: nil, api_key: nil)
74
+ query = { "before" => before, "plate" => plate, "camera" => camera, "source" => source }
75
+ data = @client.request("GET", "/api/v1/recognitions", query: query, api_key: api_key)
76
+ Page.from_hash(data, "next") { |item| Recognition.from_hash(item) }
77
+ end
78
+
79
+ # Walks every page of recognitions:list from the cursor +before+ (the newest when
80
+ # nil), item by item, until the last page; it ends at the first error.
81
+ #
82
+ # @return [Enumerator<Recognition>]
83
+ def list_iter(before: nil, plate: nil, camera: nil, source: nil, api_key: nil, &block)
84
+ items = Page.iterate(before) do |at|
85
+ list(before: at, plate: plate, camera: camera, source: source, api_key: api_key)
86
+ end
87
+ block ? items.each(&block) : items
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Code generated by sdk/generator. DO NOT EDIT.
4
+
5
+ module Alpr
6
+ # This SDK's version.
7
+ VERSION = "0.1.1"
8
+
9
+ # The contract this SDK was generated from.
10
+ API_VERSION = "1.0.0"
11
+
12
+ # Alpr.sh's API. The workspace is the API key's: alpr_live_….
13
+ DEFAULT_BASE_URL = "https://alpr.sh"
14
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Code generated by sdk/generator. DO NOT EDIT.
4
+
5
+ require "json"
6
+ require "openssl"
7
+
8
+ # Verifying the webhooks Alpr.sh sends.
9
+ module Alpr
10
+ # How far from now a webhook's timestamp may be, in seconds.
11
+ WEBHOOK_TOLERANCE = 300
12
+
13
+ # How many signatures a request may list: one for the secret in use and one
14
+ # for each secret still signing during a rotation.
15
+ MAX_WEBHOOK_SIGNATURES = 6
16
+
17
+ # A request Alpr.sh did not sign, or signed too long ago.
18
+ class WebhookVerificationError < StandardError
19
+ def initialize(message = "alpr: webhook signature does not verify")
20
+ super
21
+ end
22
+ end
23
+
24
+ # A verified body that is not an event, or not the event its name says.
25
+ class WebhookParseError < StandardError; end
26
+
27
+ # What every webhook event answers. +event_name+ is the event's name:
28
+ # recognition.created.
29
+ module WebhookEvent
30
+ # @return [String]
31
+ def event_name
32
+ @event
33
+ end
34
+ end
35
+
36
+ # The payload of
37
+ # * alert.triggered
38
+ class AlertTriggeredWebhookEvent < WebhookAlertTriggered
39
+ include WebhookEvent
40
+
41
+ TYPES = %w[alert.triggered].freeze
42
+ end
43
+
44
+ # The payload of
45
+ # * recognition.created
46
+ class RecognitionCreatedWebhookEvent < WebhookRecognitionCreated
47
+ include WebhookEvent
48
+
49
+ TYPES = %w[recognition.created].freeze
50
+ end
51
+
52
+ # The payload of
53
+ # * webhook.test
54
+ class TestWebhookEvent < WebhookTest
55
+ include WebhookEvent
56
+
57
+ TYPES = %w[webhook.test].freeze
58
+ end
59
+
60
+ # An event of a name this SDK does not know yet; its body is kept as it was
61
+ # sent.
62
+ class UnknownWebhookEvent < Model
63
+ include WebhookEvent
64
+
65
+ MEMBERS = %i[event id body].freeze
66
+
67
+ # @return [String] the event's name
68
+ attr_reader :event
69
+ # @return [String] the id the body carries, or an empty string
70
+ attr_reader :id
71
+ # @return [Hash] the whole body, decoded
72
+ attr_reader :body
73
+
74
+ def initialize(event:, id:, body:)
75
+ @event = event
76
+ @id = id
77
+ @body = body
78
+ freeze
79
+ end
80
+ end
81
+
82
+ # Checks a webhook Alpr.sh sent and answers its event.
83
+ #
84
+ # Alpr.sh signs the headers X-Alprsh-Timestamp and X-Alprsh-Delivery-Id and
85
+ # the body: X-Alprsh-Signature carries "sha256=" and the hex HMAC-SHA256 of
86
+ # "{timestamp}.{delivery-id}.{body}" under the endpoint's secret (the whole
87
+ # whsec_… text, as it is), and more than one signature while a secret is
88
+ # rotated -- one matching is enough. Pass the body exactly as it arrived,
89
+ # before parsing it, and deduplicate by the X-Alprsh-Delivery-Id header.
90
+ # +headers+ is a hash of the request's headers, in any case (a value may be a
91
+ # list, one a header line), or a Rack env. +now+ is for tests.
92
+ #
93
+ # @return [AlertTriggeredWebhookEvent, RecognitionCreatedWebhookEvent, TestWebhookEvent, UnknownWebhookEvent]
94
+ # @raise [WebhookVerificationError] the signature does not verify
95
+ # @raise [WebhookParseError] the body is not an event
96
+ # @raise [ArgumentError] the secret is empty
97
+ def self.verify_webhook(secret, headers, body, now: Time.now)
98
+ verify_webhook_signature(secret, headers, body, now: now)
99
+ webhook_event(body.to_s.b.force_encoding(Encoding::UTF_8))
100
+ end
101
+
102
+ # Checks a webhook's signature as verify_webhook does, without reading its
103
+ # body.
104
+ #
105
+ # @return [nil]
106
+ # @raise [WebhookVerificationError] the signature does not verify
107
+ # @raise [ArgumentError] the secret is empty
108
+ def self.verify_webhook_signature(secret, headers, body, now: Time.now)
109
+ raise ArgumentError, "alpr: the webhook secret is empty" if secret.to_s.empty?
110
+
111
+ delivery_id = webhook_header(headers, "x-alprsh-delivery-id").first.to_s
112
+ stamp = webhook_header(headers, "x-alprsh-timestamp").first.to_s
113
+ # Only ASCII digits: the signer never sends a sign, a space or other digits.
114
+ raise WebhookVerificationError if delivery_id.empty? || !stamp.match?(/\A[0-9]+\z/)
115
+ raise WebhookVerificationError if (now.to_r - stamp.to_i).abs > WEBHOOK_TOLERANCE
116
+
117
+ listed = webhook_header(headers, "x-alprsh-signature").join(",").split(",", -1)
118
+ raise WebhookVerificationError if listed.empty? || listed.size > MAX_WEBHOOK_SIGNATURES
119
+
120
+ expected = OpenSSL::HMAC.digest("SHA256", secret.to_s.b, "#{stamp}.#{delivery_id}.".b + body.to_s.b)
121
+ matched = false
122
+ listed.each do |signature|
123
+ # One malformed signature refuses the request. Every listed signature is
124
+ # compared, in constant time.
125
+ encoded = signature.strip[/\Asha256=([0-9a-fA-F]{64})\z/, 1]
126
+ raise WebhookVerificationError if encoded.nil?
127
+
128
+ matched = true if OpenSSL.fixed_length_secure_compare([encoded].pack("H*"), expected)
129
+ end
130
+ raise WebhookVerificationError unless matched
131
+
132
+ nil
133
+ end
134
+
135
+ # A verified body as the event its name says.
136
+ def self.webhook_event(raw)
137
+ body = begin
138
+ JSON.parse(raw)
139
+ rescue JSON::ParserError => e
140
+ raise WebhookParseError, "alpr: the webhook body is not an event: #{e.message}"
141
+ end
142
+ raise WebhookParseError, "alpr: the webhook body is not an event" unless body.is_a?(Hash) && body["event"].is_a?(String) && !body["event"].empty?
143
+
144
+ name = body["event"]
145
+ begin
146
+ case name
147
+ when *AlertTriggeredWebhookEvent::TYPES then AlertTriggeredWebhookEvent.from_hash(body)
148
+ when *RecognitionCreatedWebhookEvent::TYPES then RecognitionCreatedWebhookEvent.from_hash(body)
149
+ when *TestWebhookEvent::TYPES then TestWebhookEvent.from_hash(body)
150
+ else UnknownWebhookEvent.new(event: name, id: body["id"].is_a?(String) ? body["id"] : "", body: body)
151
+ end
152
+ rescue KeyError, TypeError, ArgumentError => e
153
+ raise WebhookParseError, "alpr: the webhook body is not a #{name} event: #{e.message}"
154
+ end
155
+ end
156
+ private_class_method :webhook_event
157
+
158
+ # The values of a header, looked up in any case; a Rack env's
159
+ # HTTP_X_ALPRSH_DELIVERY_ID names x-alprsh-delivery-id. A header sent as
160
+ # several lines has several values.
161
+ def self.webhook_header(headers, name)
162
+ values = []
163
+ headers.each do |key, value|
164
+ values.concat(Array(value).map(&:to_s)) if key.to_s.downcase.tr("_", "-").delete_prefix("http-") == name
165
+ end
166
+ values
167
+ end
168
+ private_class_method :webhook_header
169
+ end
data/lib/alpr.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Code generated by sdk/generator. DO NOT EDIT.
4
+
5
+ # Alpr.sh's SDK for Ruby, generated from Alpr.sh's API contract.
6
+ #
7
+ # alpr = Alpr::Client.new(ENV.fetch("ALPR_API_KEY"))
8
+ # result = alpr.photos.recognize({ image: File.binread("photo.jpg") })
9
+ # alpr.recognitions.list_iter(plate: "BRA2E19").each { |reading| puts reading.captured_at }
10
+ module Alpr
11
+ # A path parameter, escaped.
12
+ def self.segment(value)
13
+ value.to_s.b.gsub(/[^A-Za-z0-9\-._~]/) { |char| format("%%%02X", char.ord) }
14
+ end
15
+ end
16
+
17
+ require_relative "alpr/version"
18
+ require_relative "alpr/error"
19
+ require_relative "alpr/model"
20
+ require_relative "alpr/multipart"
21
+ require_relative "alpr/models"
22
+ require_relative "alpr/params"
23
+ require_relative "alpr/page"
24
+ require_relative "alpr/services"
25
+ require_relative "alpr/client"
26
+ require_relative "alpr/webhooks"
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: alpr-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Alpr.sh
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ executables: []
13
+ extensions: []
14
+ extra_rdoc_files: []
15
+ files:
16
+ - README.md
17
+ - lib/alpr.rb
18
+ - lib/alpr/client.rb
19
+ - lib/alpr/error.rb
20
+ - lib/alpr/model.rb
21
+ - lib/alpr/models.rb
22
+ - lib/alpr/multipart.rb
23
+ - lib/alpr/page.rb
24
+ - lib/alpr/params.rb
25
+ - lib/alpr/services.rb
26
+ - lib/alpr/version.rb
27
+ - lib/alpr/webhooks.rb
28
+ homepage: https://alpr.sh
29
+ licenses:
30
+ - MIT
31
+ metadata:
32
+ homepage_uri: https://alpr.sh
33
+ source_code_uri: https://github.com/alpr-sh/sdk
34
+ rubygems_mfa_required: 'true'
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '3.1'
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubygems_version: 4.0.16
50
+ specification_version: 4
51
+ summary: 'Alpr.sh''s SDK for Ruby: license-plate recognition from photos, readings,
52
+ cameras and webhooks.'
53
+ test_files: []