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.
- checksums.yaml +7 -0
- data/README.md +119 -0
- data/lib/alpr/client.rb +99 -0
- data/lib/alpr/error.rb +46 -0
- data/lib/alpr/model.rb +45 -0
- data/lib/alpr/models.rb +622 -0
- data/lib/alpr/multipart.rb +90 -0
- data/lib/alpr/page.rb +40 -0
- data/lib/alpr/params.rb +107 -0
- data/lib/alpr/services.rb +90 -0
- data/lib/alpr/version.rb +14 -0
- data/lib/alpr/webhooks.rb +169 -0
- data/lib/alpr.rb +26 -0
- metadata +53 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4fe27e01fe43c79174d04f2bb769b5cd3f487e3a54278a68e4ea626727de4f79
|
|
4
|
+
data.tar.gz: 35388b593ab3f741564794a6d63110c6a1d64441b0d9b785b445e7f52c8323f5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 80c64ff501bbdde4d6eaacdd13a4ffc9b5e64c62e1cabf288c8af6dfde159170787feb5e88f97ea4018b970b5df950d4a690b3ba9f31a267222766f3c891b15c
|
|
7
|
+
data.tar.gz: 45928fcb7090f97919284f67b96f4f3f6132905e65e76920379daa270acc44ce1afe91ae28b95b15206376e7c5f8aee6b82e9956b8b150aa27bf9e3b01d74548
|
data/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Alpr.sh SDK para Ruby
|
|
2
|
+
|
|
3
|
+
SDK oficial da API da Alpr.sh para Ruby 3.1+, sem dependências: só `net/http`, `json` e `openssl`. É gerado a partir do contrato da API (`openapi.json`, na raiz de [alpr-sh/sdk](https://github.com/alpr-sh/sdk)) por `generator/`. Não edite os arquivos desta pasta: regenere.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
gem install alpr-sdk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Começando
|
|
10
|
+
|
|
11
|
+
A chave de API (`alpr_live_…`) é a do espaço de trabalho e diz quais escopos as chamadas podem usar. Não há ambiente de teste.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
require "alpr"
|
|
15
|
+
|
|
16
|
+
alpr = Alpr::Client.new(ENV.fetch("ALPR_API_KEY")) # alpr_live_…
|
|
17
|
+
|
|
18
|
+
# Ler as placas de uma foto.
|
|
19
|
+
result = alpr.photos.recognize({ image: File.binread("foto.jpg"), lpr_confidence: 0.5 })
|
|
20
|
+
result.plates.each { |plate| puts "#{plate.plate} (#{plate.conf})" }
|
|
21
|
+
|
|
22
|
+
# Percorrer as leituras, da mais nova para a mais antiga, página a página.
|
|
23
|
+
alpr.recognitions.list_iter(plate: "BRA2E19").each do |reading|
|
|
24
|
+
puts "#{reading.captured_at} #{reading.plate}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Listar as câmeras do espaço.
|
|
28
|
+
alpr.cameras.list.each { |camera| puts camera.name }
|
|
29
|
+
|
|
30
|
+
# O link temporário da imagem de uma leitura.
|
|
31
|
+
image = alpr.recognitions.image("det_123")
|
|
32
|
+
puts image.image_url, image.expires_in
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## A foto
|
|
36
|
+
|
|
37
|
+
A foto é uma `String` com os **bytes** do arquivo (`File.binread`), nunca o caminho. O SDK envia `multipart/form-data` e dá nome e tipo ao arquivo pelos primeiros bytes: JPEG (`FF D8 FF`) vai como `photo.jpg` (`image/jpeg`), PNG como `photo.png` (`image/png`) e qualquer outra coisa como `photo` (`application/octet-stream`), para a API recusar. O corpo é a classe de parâmetros do método (`Alpr::PhotosRecognizeParams.new(image: bytes, lpr_confidence: 0.5)`) ou um `Hash` com os mesmos nomes; um campo opcional não informado não é enviado.
|
|
38
|
+
|
|
39
|
+
`lpr_confidence` e `min_ocr_confidence` são números de 0 a 1 (`ocr` só aceita `image`).
|
|
40
|
+
|
|
41
|
+
## Leituras e páginas
|
|
42
|
+
|
|
43
|
+
`alpr.recognitions.list(before: nil, plate: nil, camera: nil, source: nil)` devolve uma página (`Alpr::Page`, com `items` e `next_cursor`, que é `nil` na última). `list_iter` aceita os mesmos argumentos, devolve um `Enumerator` (ou recebe um bloco) e percorre todas as páginas a partir do cursor `before:` dado (sem ele, da leitura mais nova), seguindo `next_cursor` até o fim. Ele para se o cursor não avançar e no primeiro erro.
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
page = alpr.recognitions.list(camera: "cam_1")
|
|
47
|
+
page = alpr.recognitions.list(camera: "cam_1", before: page.next_cursor) if page.next_cursor
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Recursos
|
|
51
|
+
|
|
52
|
+
| Serviço | Métodos |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `alpr.cameras` | `list` |
|
|
55
|
+
| `alpr.photos` | `detect`, `ocr`, `recognize` |
|
|
56
|
+
| `alpr.recognitions` | `image`, `list`, `list_iter` |
|
|
57
|
+
|
|
58
|
+
Os modelos são objetos imutáveis com leitores em snake_case (`result.credits_remaining`, `plate.coordinate_left`). Um campo opcional que a API não enviou, ou que veio `null`, é `nil`. Todo método aceita `api_key:` para usar outra chave numa chamada. `Alpr::Client.new` aceita `base_url:`, `timeout:` (30 segundos) e `transport:`.
|
|
59
|
+
|
|
60
|
+
O `base_url` precisa ser `https`; `http` só vale para `localhost`, `127.0.0.0/8` e `::1`, e qualquer outro endereço levanta `ArgumentError`, para a chave nunca viajar em texto puro. Redirecionamentos nunca são seguidos: um 3xx é um `Alpr::Error`. Não há repetição automática de chamadas.
|
|
61
|
+
|
|
62
|
+
## Erros
|
|
63
|
+
|
|
64
|
+
Uma recusa da API levanta `Alpr::Error`, com `status`, `code`, `message`, `fields` (vazio, a não ser que a resposta traga o objeto `fields`) e, num 429, `retry_after` em segundos (do cabeçalho `Retry-After`). Uma resposta de erro que não é JSON também é um erro, só com o `status`.
|
|
65
|
+
|
|
66
|
+
| Status | `code` |
|
|
67
|
+
|---|---|
|
|
68
|
+
| 401 | `invalid_api_key` |
|
|
69
|
+
| 403 | `insufficient_scope` |
|
|
70
|
+
| 402 | `insufficient_credits` |
|
|
71
|
+
| 400 | `missing image`, `invalid_image`, `invalid_option` |
|
|
72
|
+
| 413 | `image_too_large` |
|
|
73
|
+
| 422 | `no_plate` |
|
|
74
|
+
| 404 | `not_found`, `image_unavailable` |
|
|
75
|
+
| 429 | `rate_limited` (com `retry_after`) |
|
|
76
|
+
| 503 | `engine_unavailable`, `storage_unavailable` |
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
begin
|
|
80
|
+
alpr.photos.recognize({ image: bytes })
|
|
81
|
+
rescue Alpr::Error => e
|
|
82
|
+
sleep e.retry_after if e.code == "rate_limited"
|
|
83
|
+
warn "#{e.status} #{e.code}: #{e.message}"
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Webhooks
|
|
88
|
+
|
|
89
|
+
A Alpr.sh assina cada entrega com três cabeçalhos (a busca ignora maiúsculas):
|
|
90
|
+
|
|
91
|
+
- `X-Alprsh-Delivery-Id`: o id da entrega;
|
|
92
|
+
- `X-Alprsh-Timestamp`: segundos Unix do envio;
|
|
93
|
+
- `X-Alprsh-Signature`: `sha256=` e o HMAC-SHA256 em hexadecimal (64 caracteres) de `timestamp + "." + delivery-id + "." + corpo`, com o segredo do endpoint (`whsec_…`) **inteiro, como está**, sem decodificar.
|
|
94
|
+
|
|
95
|
+
Durante a rotação do segredo o cabeçalho traz mais de uma assinatura, numa linha separada por vírgulas ou em várias linhas; basta uma conferir (no máximo 6; uma assinatura malformada recusa a requisição). O timestamp precisa ter só dígitos ASCII e estar a no máximo 300 segundos (`Alpr::WEBHOOK_TOLERANCE`) de agora. Passe o corpo cru, exatamente como chegou, e verifique antes de interpretar; os cabeçalhos podem vir num `Hash` (um valor pode ser uma lista, uma linha de cabeçalho cada) ou no env do Rack.
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
begin
|
|
99
|
+
event = Alpr.verify_webhook(ENV.fetch("ALPR_WEBHOOK_SECRET"), request.headers, request.raw_post)
|
|
100
|
+
rescue Alpr::WebhookVerificationError
|
|
101
|
+
head :bad_request
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
case event
|
|
106
|
+
when Alpr::RecognitionCreatedWebhookEvent then puts event.plate, event.captured_at
|
|
107
|
+
when Alpr::AlertTriggeredWebhookEvent then puts event.rule_name, event.recognition_id
|
|
108
|
+
when Alpr::TestWebhookEvent then puts event.sent_at
|
|
109
|
+
when Alpr::UnknownWebhookEvent then puts event.event_name # um evento que esta versão ainda não conhece: event.body é o JSON
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`Alpr.verify_webhook` confere a assinatura e interpreta o corpo; `Alpr.verify_webhook_signature` só confere a assinatura. Um segredo vazio levanta `ArgumentError` (erro de configuração, não de assinatura); uma assinatura que não confere levanta `Alpr::WebhookVerificationError`; um corpo assinado que não é um evento (não é um objeto, ou não tem `event` em texto) levanta `Alpr::WebhookParseError`.
|
|
114
|
+
|
|
115
|
+
O evento é tipado e traz os campos da carga direto, sem `data` nem `type`: `event_name`, `id`, `tenant_id`, `plate`… Um evento de nome desconhecido é um `Alpr::UnknownWebhookEvent` (com `event`, `id` e o corpo todo em `body`), não um erro.
|
|
116
|
+
|
|
117
|
+
**Deduplique as entregas pelo cabeçalho `X-Alprsh-Delivery-Id`.** O `id` do corpo é o da leitura ou do alerta, não o da entrega.
|
|
118
|
+
|
|
119
|
+
Versão do SDK: 0.1.1 · contrato da API: 1.0.0
|
data/lib/alpr/client.rb
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Code generated by sdk/generator. DO NOT EDIT.
|
|
4
|
+
|
|
5
|
+
require "ipaddr"
|
|
6
|
+
require "json"
|
|
7
|
+
require "net/http"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module Alpr
|
|
11
|
+
# The standard library's HTTP, so the SDK depends on nothing. A transport is
|
|
12
|
+
# anything that answers +call(method, url, headers, body, timeout)+ with
|
|
13
|
+
# +[status, headers in lower case, body]+. Redirects are never followed: the
|
|
14
|
+
# API key must not travel, and a 3xx is an Alpr::Error.
|
|
15
|
+
class NetHTTPTransport
|
|
16
|
+
def call(method, url, headers, body, timeout)
|
|
17
|
+
uri = URI(url)
|
|
18
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
19
|
+
open_timeout: timeout, read_timeout: timeout, write_timeout: timeout) do |http|
|
|
20
|
+
response = http.send_request(method, uri.request_uri, body, headers)
|
|
21
|
+
[response.code.to_i, response.each_header.to_h, response.body.to_s]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Alpr.sh's API with one workspace's API key.
|
|
27
|
+
#
|
|
28
|
+
# The key (+alpr_live_…+) says the workspace and the scopes the calls may use.
|
|
29
|
+
#
|
|
30
|
+
# alpr = Alpr::Client.new(ENV.fetch("ALPR_API_KEY"))
|
|
31
|
+
# result = alpr.photos.recognize({ image: File.binread("photo.jpg") })
|
|
32
|
+
# alpr.recognitions.list_iter { |reading| puts reading.plate }
|
|
33
|
+
class Client
|
|
34
|
+
# @return [CamerasService]
|
|
35
|
+
attr_reader :cameras
|
|
36
|
+
# @return [PhotosService]
|
|
37
|
+
attr_reader :photos
|
|
38
|
+
# @return [RecognitionsService]
|
|
39
|
+
attr_reader :recognitions
|
|
40
|
+
|
|
41
|
+
# The base URL must be https; http is accepted only for localhost,
|
|
42
|
+
# 127.0.0.0/8 and ::1, and any other raises ArgumentError.
|
|
43
|
+
def initialize(api_key, base_url: DEFAULT_BASE_URL, timeout: 30, transport: NetHTTPTransport.new)
|
|
44
|
+
@api_key = api_key
|
|
45
|
+
@base_url = base_url.chomp("/")
|
|
46
|
+
check_base_url!(@base_url)
|
|
47
|
+
@timeout = timeout
|
|
48
|
+
@transport = transport
|
|
49
|
+
@cameras = CamerasService.new(self)
|
|
50
|
+
@photos = PhotosService.new(self)
|
|
51
|
+
@recognitions = RecognitionsService.new(self)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Calls the API and answers the JSON it sent, parsed (nil for a 204); a
|
|
55
|
+
# refusal raises Alpr::Error. +form+ is the multipart body of a call that
|
|
56
|
+
# sends a photo, +api_key+ another key for this call.
|
|
57
|
+
def request(method, path, query: nil, form: nil, api_key: nil)
|
|
58
|
+
url = @base_url + path
|
|
59
|
+
values = (query || {}).reject { |_, value| value.nil? || value == "" }
|
|
60
|
+
url += "?#{URI.encode_www_form(values)}" unless values.empty?
|
|
61
|
+
headers = {
|
|
62
|
+
"Authorization" => "Bearer #{api_key.nil? || api_key.empty? ? @api_key : api_key}",
|
|
63
|
+
"Accept" => "application/json",
|
|
64
|
+
"User-Agent" => "alpr-ruby/#{VERSION}"
|
|
65
|
+
}
|
|
66
|
+
data = nil
|
|
67
|
+
unless form.nil?
|
|
68
|
+
data = form.body
|
|
69
|
+
headers["Content-Type"] = form.content_type
|
|
70
|
+
end
|
|
71
|
+
status, response_headers, raw = @transport.call(method, url, headers, data, @timeout)
|
|
72
|
+
raise Error.from_response(status, response_headers, raw) if status >= 300
|
|
73
|
+
|
|
74
|
+
return nil if status == 204 || raw.to_s.empty?
|
|
75
|
+
|
|
76
|
+
JSON.parse(raw.to_s.dup.force_encoding(Encoding::UTF_8))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def check_base_url!(base_url)
|
|
82
|
+
uri = begin
|
|
83
|
+
URI.parse(base_url)
|
|
84
|
+
rescue URI::InvalidURIError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
scheme = uri&.scheme.to_s.downcase
|
|
88
|
+
host = uri&.hostname.to_s.downcase
|
|
89
|
+
loopback = host == "localhost" || begin
|
|
90
|
+
IPAddr.new(host).loopback?
|
|
91
|
+
rescue IPAddr::Error
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
return if scheme == "https" || (scheme == "http" && loopback)
|
|
95
|
+
|
|
96
|
+
raise ArgumentError, "alpr: the base URL must be https (http is accepted only for localhost, 127.0.0.0/8 and ::1), so the API key is never sent in clear text"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/alpr/error.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Alpr
|
|
6
|
+
# A request the API refused.
|
|
7
|
+
#
|
|
8
|
+
# +code+ is the API's: invalid_api_key, insufficient_scope,
|
|
9
|
+
# insufficient_credits, missing image, invalid_image, invalid_option,
|
|
10
|
+
# image_too_large, no_plate, not_found, image_unavailable, rate_limited,
|
|
11
|
+
# engine_unavailable or storage_unavailable. +message+ is the API's
|
|
12
|
+
# explanation, +fields+ says, when the API names them, what is wrong with
|
|
13
|
+
# each field (usually empty), and +retry_after+ is how many seconds to wait
|
|
14
|
+
# on a 429.
|
|
15
|
+
class Error < StandardError
|
|
16
|
+
attr_reader :status, :code, :message, :fields, :retry_after
|
|
17
|
+
|
|
18
|
+
def initialize(status, code, message = "", fields = {}, retry_after = nil)
|
|
19
|
+
@status = status
|
|
20
|
+
@code = code
|
|
21
|
+
@message = message
|
|
22
|
+
@fields = fields
|
|
23
|
+
@retry_after = retry_after
|
|
24
|
+
super(message.empty? ? "alpr: #{status} #{code}" : "alpr: #{status} #{code}: #{message}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# What Ruby prints for an error nobody rescued: the status and code too.
|
|
28
|
+
def detailed_message(highlight: false, **)
|
|
29
|
+
highlight ? "\e[1m#{self} (#{self.class.name})\e[m" : "#{self} (#{self.class.name})"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The error a response with a status of 300 or more is.
|
|
33
|
+
def self.from_response(status, headers, body)
|
|
34
|
+
data = begin
|
|
35
|
+
loaded = JSON.parse(body.to_s.empty? ? "{}" : body.to_s.dup.force_encoding(Encoding::UTF_8))
|
|
36
|
+
loaded.is_a?(Hash) ? loaded : {}
|
|
37
|
+
rescue JSON::ParserError
|
|
38
|
+
{} # A refusal without a JSON body is still a refusal.
|
|
39
|
+
end
|
|
40
|
+
retry_after = headers["retry-after"].to_s
|
|
41
|
+
fields = data["fields"].is_a?(Hash) ? data["fields"] : {}
|
|
42
|
+
new(status, data["error"].to_s, data["message"].to_s, fields,
|
|
43
|
+
retry_after.match?(/\A[0-9]+\z/) ? retry_after.to_i : nil)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/alpr/model.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Alpr
|
|
4
|
+
# What every resource and every body shares: its members, read as a hash and
|
|
5
|
+
# compared by value.
|
|
6
|
+
module Value
|
|
7
|
+
def to_h
|
|
8
|
+
self.class::MEMBERS.to_h { |name| [name, instance_variable_get(:"@#{name}")] }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def ==(other)
|
|
12
|
+
other.class == self.class && other.to_h == to_h
|
|
13
|
+
end
|
|
14
|
+
alias eql? ==
|
|
15
|
+
|
|
16
|
+
def hash
|
|
17
|
+
[self.class, to_h].hash
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def inspect
|
|
21
|
+
"#<#{self.class.name} #{to_h.map { |name, value| "#{name}=#{value.inspect}" }.join(", ")}>"
|
|
22
|
+
end
|
|
23
|
+
alias to_s inspect
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A resource the API answers, built from its JSON and frozen.
|
|
27
|
+
class Model
|
|
28
|
+
include Value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The body of a write: built with keyword arguments, sent with the API's
|
|
32
|
+
# names, an optional field left unset left out.
|
|
33
|
+
class Params
|
|
34
|
+
include Value
|
|
35
|
+
|
|
36
|
+
# The params as they are, or built from a hash of their names.
|
|
37
|
+
def self.coerce(params)
|
|
38
|
+
case params
|
|
39
|
+
when self then params
|
|
40
|
+
when Hash then new(**params.transform_keys(&:to_sym))
|
|
41
|
+
else raise ArgumentError, "alpr: #{name} is built from a Hash, not #{params.class}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|