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 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
@@ -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