paprika_client 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: be90eff04cdca456a4aa2aa93e1f08b2819aea6aa4922be0242e92b924c4d721
4
+ data.tar.gz: 20f63a1e26726ac6961f5411f93642ee454f3c1821dc261e1be7db2d4920e547
5
+ SHA512:
6
+ metadata.gz: 31f2594519e1dae0a6b0f1b5385c9a275f67fc2610499ef5ae171151248e58c9a183da045855a7287c121648e7696f55b3b6ce2b0f298b9a5d5c800bf24a9b9e
7
+ data.tar.gz: 7eb404887b1de963c2e09bb9c579fd6940bffe782e0d738921a022fdc855c02d62ccbc8e1e24039f544d0330c81c3e8167897880e93f029f0e9f6bdae59aa37d
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-07-02
4
+
5
+ - Initial release.
6
+ - `PaprikaClient::Client`: list recipes, fetch a full recipe, save a recipe
7
+ (create/update) with automatic sync-hash recomputation, and notify.
8
+ - `PaprikaClient::Recipe`: attribute wrapper with `nutritional_info` accessors
9
+ and deterministic sync-hash calculation.
10
+ - `PaprikaClient::Hashing`: Python-`json.dumps`-compatible serialization used
11
+ for the recipe sync hash.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 James Klein
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # PaprikaClient
2
+
3
+ [![CI](https://github.com/kleinjm/paprika_client/actions/workflows/ci.yml/badge.svg)](https://github.com/kleinjm/paprika_client/actions/workflows/ci.yml)
4
+
5
+ A small, dependency-free Ruby client for the (unofficial) [Paprika Recipe
6
+ Manager](https://www.paprikaapp.com/) Cloud Sync API. Read your own recipes
7
+ and write changes — including `nutritional_info` — back to your Paprika account
8
+ so they sync to all your devices.
9
+
10
+ > This uses Paprika's private sync API, which is not officially documented or
11
+ > supported. It works today because it's the same API the Paprika apps use, but
12
+ > it could change without notice. Use it with your own account and data.
13
+
14
+ ## Installation
15
+
16
+ ```ruby
17
+ gem "paprika_client"
18
+ ```
19
+
20
+ Or install directly:
21
+
22
+ ```bash
23
+ gem install paprika_client
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```ruby
29
+ require "paprika_client"
30
+
31
+ client = PaprikaClient::Client.new(
32
+ email: "you@example.com",
33
+ password: "your-paprika-password"
34
+ )
35
+
36
+ # List every recipe (lightweight: uid + change-detection hash)
37
+ client.recipes
38
+ # => [{ "uid" => "00011ACD-...", "hash" => "B84E5D7D..." }, ...]
39
+
40
+ # Fetch a full recipe
41
+ recipe = client.recipe("00011ACD-...")
42
+ recipe.name # => "Low Fodmap Ground Pork Noodle Bowls"
43
+ recipe.nutritional_info # => "Fat: 22.6 g\nCalories: 487 calories\n..."
44
+
45
+ # Update nutrition and save it back (recomputes the sync hash and notifies apps)
46
+ recipe.nutritional_info = "Calories: 500 calories\nProtein: 30 g"
47
+ client.save_recipe(recipe)
48
+ ```
49
+
50
+ `save_recipe` accepts either a `PaprikaClient::Recipe` or a plain attributes
51
+ hash, always recomputes the sync `hash`, uploads the gzipped payload, and (by
52
+ default) calls `notify` so your devices refresh. Pass `notify: false` to batch
53
+ several writes and notify once at the end.
54
+
55
+ ### Sync hashes
56
+
57
+ Paprika uses a per-recipe `hash` to detect changes during sync. The server
58
+ stores whatever hash a client sends, so `PaprikaClient` recomputes a
59
+ deterministic SHA-256 (matching the community reference implementation) on every
60
+ write. You never manage it yourself.
61
+
62
+ ## Authentication
63
+
64
+ Authentication is HTTP Basic (your Paprika email/password) against the v1 sync
65
+ endpoints. Keep credentials out of source control — use environment variables
66
+ or an encrypted credential store.
67
+
68
+ ## Development
69
+
70
+ ```bash
71
+ bin/setup # install dependencies
72
+ bundle exec rake # run tests + RuboCop
73
+ ```
74
+
75
+ Tests use WebMock (no live API calls) and enforce 100% line and branch
76
+ coverage via SimpleCov.
77
+
78
+ ## License
79
+
80
+ Released under the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create(:test) do |t|
7
+ # Start SimpleCov before minitest/autorun so its at_exit (coverage check)
8
+ # runs after the tests, not before them.
9
+ t.test_prelude = %(require_relative "test/simplecov_env")
10
+ end
11
+
12
+ require "rubocop/rake_task"
13
+
14
+ RuboCop::RakeTask.new
15
+
16
+ task default: %i[test rubocop]
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "zlib"
7
+ require "stringio"
8
+ require "securerandom"
9
+
10
+ module PaprikaClient
11
+ # Client for the Paprika Recipe Manager Cloud Sync API.
12
+ #
13
+ # client = PaprikaClient::Client.new(email: "you@example.com", password: "secret")
14
+ # client.recipes # => [{ "uid" => "...", "hash" => "..." }, ...]
15
+ # recipe = client.recipe(uid) # => PaprikaClient::Recipe
16
+ # recipe.nutritional_info = "Calories: 200 calories"
17
+ # client.save_recipe(recipe) # recomputes hash, uploads, notifies
18
+ #
19
+ # Authentication is HTTP Basic (email/password) against the v1 sync
20
+ # endpoints, which is what the desktop/mobile apps use under the hood.
21
+ class Client
22
+ DEFAULT_BASE_URL = "https://www.paprikaapp.com/api"
23
+ USER_AGENT = "paprika_client (rubygems.org/gems/paprika_client)"
24
+
25
+ attr_reader :base_url
26
+
27
+ def initialize(email:, password:, base_url: DEFAULT_BASE_URL)
28
+ raise ArgumentError, "email is required" if email.nil? || email.empty?
29
+ raise ArgumentError, "password is required" if password.nil? || password.empty?
30
+
31
+ @email = email
32
+ @password = password
33
+ @base_url = base_url
34
+ end
35
+
36
+ # Lightweight list of every recipe as { "uid" => ..., "hash" => ... }.
37
+ def recipes
38
+ get("/v1/sync/recipes/")
39
+ end
40
+
41
+ # Fetch a single full recipe by uid.
42
+ def recipe(uid)
43
+ Recipe.new(get("/v1/sync/recipe/#{uid}/"))
44
+ end
45
+
46
+ # Create or update a recipe. Accepts a Recipe or a plain attributes hash.
47
+ # The sync hash is always recomputed before upload so other devices detect
48
+ # the change. Calls #notify afterwards unless notify: false.
49
+ def save_recipe(recipe, notify: true)
50
+ recipe = Recipe.new(recipe) unless recipe.is_a?(Recipe)
51
+ raise ArgumentError, "recipe must have a uid" if recipe.uid.nil? || recipe.uid.to_s.empty?
52
+
53
+ attributes = recipe.with_recomputed_hash
54
+ post_multipart("/v1/sync/recipe/#{recipe.uid}/", gzip(JSON.generate(attributes)))
55
+ self.notify if notify
56
+ Recipe.new(attributes)
57
+ end
58
+
59
+ # Ask the API to notify the recipe apps that changes have occurred.
60
+ def notify
61
+ post("/v1/sync/notify/")
62
+ true
63
+ end
64
+
65
+ private
66
+
67
+ def get(path)
68
+ request(Net::HTTP::Get.new(uri_for(path)))
69
+ end
70
+
71
+ def post(path)
72
+ request(Net::HTTP::Post.new(uri_for(path)))
73
+ end
74
+
75
+ def post_multipart(path, gzipped_body)
76
+ boundary = "----paprikaclient#{SecureRandom.hex(12)}"
77
+ req = Net::HTTP::Post.new(uri_for(path))
78
+ req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
79
+ req.body = multipart_body(boundary, gzipped_body)
80
+ request(req)
81
+ end
82
+
83
+ def multipart_body(boundary, gzipped_body)
84
+ body = +""
85
+ body << "--#{boundary}\r\n"
86
+ body << "Content-Disposition: form-data; name=\"data\"; filename=\"data\"\r\n"
87
+ body << "Content-Type: application/octet-stream\r\n\r\n"
88
+ body << gzipped_body
89
+ body << "\r\n--#{boundary}--\r\n"
90
+ body
91
+ end
92
+
93
+ def request(req)
94
+ req.basic_auth(@email, @password)
95
+ req["User-Agent"] = USER_AGENT
96
+ uri = req.uri
97
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
98
+ http.request(req)
99
+ end
100
+ parse(res)
101
+ end
102
+
103
+ def parse(res)
104
+ raise HTTPError.new(res.code.to_i, res.body.to_s[0, 500]) unless res.code.to_i == 200
105
+
106
+ json = JSON.parse(gunzip(res.body))
107
+ if json.is_a?(Hash) && json.key?("error")
108
+ message = json["error"].is_a?(Hash) ? json["error"]["message"] : json["error"]
109
+ raise APIError, message.to_s
110
+ end
111
+
112
+ json.is_a?(Hash) && json.key?("result") ? json["result"] : json
113
+ end
114
+
115
+ def uri_for(path)
116
+ URI.parse("#{@base_url}#{path}")
117
+ end
118
+
119
+ def gzip(string)
120
+ io = StringIO.new
121
+ gz = Zlib::GzipWriter.new(io)
122
+ gz.write(string)
123
+ gz.close
124
+ io.string
125
+ end
126
+
127
+ # Paprika sometimes gzip-encodes the response body at the app layer.
128
+ def gunzip(body)
129
+ Zlib::GzipReader.new(StringIO.new(body)).read
130
+ rescue Zlib::GzipFile::Error
131
+ body
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaprikaClient
4
+ # Base class for all errors raised by this gem.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the API responds with a non-success HTTP status.
8
+ class HTTPError < Error
9
+ attr_reader :status, :body
10
+
11
+ def initialize(status, body)
12
+ @status = status
13
+ @body = body
14
+ super("Paprika API returned HTTP #{status}: #{body}")
15
+ end
16
+ end
17
+
18
+ # Raised when the API responds 200 but the payload contains an error object,
19
+ # e.g. {"error" => {"message" => "Invalid or expired credentials."}}.
20
+ class APIError < Error; end
21
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module PaprikaClient
6
+ # Reproduces the recipe `hash` that Paprika clients compute for change
7
+ # detection during sync.
8
+ #
9
+ # The value is SHA-256 of the recipe serialized the way Python's
10
+ # `json.dumps(obj, sort_keys=True)` does it: keys sorted, ", " between
11
+ # items, ": " between key and value, and non-ASCII escaped as \uXXXX.
12
+ #
13
+ # The Paprika sync server stores whatever hash a client sends (it does not
14
+ # validate it against a secret algorithm), so any deterministic scheme works
15
+ # for round-tripping; matching the community reference implementation keeps
16
+ # us consistent with other tools.
17
+ module Hashing
18
+ module_function
19
+
20
+ # Compute the sync hash for a recipe attributes hash. The existing "hash"
21
+ # key (if any) is excluded from the computation.
22
+ def digest(attributes)
23
+ without_hash = attributes.reject { |k, _| k.to_s == "hash" }
24
+ Digest::SHA256.hexdigest(python_json_dumps(without_hash))
25
+ end
26
+
27
+ # Serialize a Ruby object the way Python's json.dumps(sort_keys=True) does.
28
+ def python_json_dumps(obj)
29
+ case obj
30
+ when Hash
31
+ pairs = obj.keys.sort_by(&:to_s).map do |key|
32
+ "#{python_json_dumps(key.to_s)}: #{python_json_dumps(obj[key])}"
33
+ end
34
+ "{#{pairs.join(", ")}}"
35
+ when Array
36
+ "[#{obj.map { |v| python_json_dumps(v) }.join(", ")}]"
37
+ when String
38
+ %("#{escape_string(obj)}")
39
+ when nil
40
+ "null"
41
+ when true
42
+ "true"
43
+ when false
44
+ "false"
45
+ else
46
+ obj.to_s
47
+ end
48
+ end
49
+
50
+ ESCAPES = {
51
+ "\\" => "\\\\",
52
+ '"' => '\\"',
53
+ "\n" => "\\n",
54
+ "\t" => "\\t",
55
+ "\r" => "\\r",
56
+ "\b" => "\\b",
57
+ "\f" => "\\f"
58
+ }.freeze
59
+ private_constant :ESCAPES
60
+
61
+ def escape_string(str)
62
+ str.gsub(/[\\"\x00-\x1f]|[^\x00-\x7f]/) do |ch|
63
+ ESCAPES[ch] || ch.each_char.map { |c| format('\\u%04x', c.ord) }.join
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaprikaClient
4
+ # A Paprika recipe. Thin wrapper around the raw attributes hash returned by
5
+ # (and sent to) the sync API. Keys are stored as strings.
6
+ class Recipe
7
+ attr_reader :attributes
8
+
9
+ def initialize(attributes = {})
10
+ @attributes = {}
11
+ attributes.each { |k, v| @attributes[k.to_s] = v }
12
+ end
13
+
14
+ def [](key)
15
+ @attributes[key.to_s]
16
+ end
17
+
18
+ def []=(key, value)
19
+ @attributes[key.to_s] = value
20
+ end
21
+
22
+ def uid
23
+ self["uid"]
24
+ end
25
+
26
+ def name
27
+ self["name"]
28
+ end
29
+
30
+ def nutritional_info
31
+ self["nutritional_info"]
32
+ end
33
+
34
+ def nutritional_info=(value)
35
+ self["nutritional_info"] = value
36
+ end
37
+
38
+ # The sync hash currently stored on the recipe (named to avoid clobbering
39
+ # Ruby's Object#hash).
40
+ def sync_hash
41
+ self["hash"]
42
+ end
43
+
44
+ # Recompute the sync hash from the current attributes (excluding "hash").
45
+ def calculate_hash
46
+ Hashing.digest(@attributes)
47
+ end
48
+
49
+ # Attributes with a freshly-computed "hash", ready for upload.
50
+ def with_recomputed_hash
51
+ @attributes.merge("hash" => calculate_hash)
52
+ end
53
+
54
+ def to_h
55
+ @attributes.dup
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaprikaClient
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "paprika_client/version"
4
+ require_relative "paprika_client/errors"
5
+ require_relative "paprika_client/hashing"
6
+ require_relative "paprika_client/recipe"
7
+ require_relative "paprika_client/client"
8
+
9
+ # Ruby client for the (unofficial) Paprika Recipe Manager Cloud Sync API.
10
+ module PaprikaClient
11
+ end
@@ -0,0 +1,4 @@
1
+ module PaprikaClient
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: paprika_client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - James Klein
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Read and write your own Paprika Recipe Manager data (recipes, nutritional
13
+ info, and more) via Paprika's cloud sync API. Supports listing recipes, fetching
14
+ full recipes, and writing changes back with correct sync hashing.
15
+ email:
16
+ - kleinjm007@gmail.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE.txt
23
+ - README.md
24
+ - Rakefile
25
+ - lib/paprika_client.rb
26
+ - lib/paprika_client/client.rb
27
+ - lib/paprika_client/errors.rb
28
+ - lib/paprika_client/hashing.rb
29
+ - lib/paprika_client/recipe.rb
30
+ - lib/paprika_client/version.rb
31
+ - sig/paprika_client.rbs
32
+ homepage: https://github.com/kleinjm/paprika_client
33
+ licenses:
34
+ - MIT
35
+ metadata:
36
+ allowed_push_host: https://rubygems.org
37
+ homepage_uri: https://github.com/kleinjm/paprika_client
38
+ source_code_uri: https://github.com/kleinjm/paprika_client
39
+ changelog_uri: https://github.com/kleinjm/paprika_client/blob/main/CHANGELOG.md
40
+ rubygems_mfa_required: 'true'
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: 3.2.0
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 3.6.9
56
+ specification_version: 4
57
+ summary: Ruby client for the Paprika Recipe Manager Cloud Sync API.
58
+ test_files: []