distributed-press-api-client 0.1.5 → 0.2.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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/lib/distributed_press/multipart.rb +51 -0
  3. data/lib/distributed_press/v1/client/admin.rb +53 -0
  4. data/lib/distributed_press/v1/client/auth.rb +62 -0
  5. data/lib/distributed_press/v1/client/publisher.rb +56 -0
  6. data/lib/distributed_press/v1/client/site.rb +110 -0
  7. data/lib/distributed_press/v1/client.rb +140 -0
  8. data/lib/distributed_press/v1/errors.rb +52 -0
  9. data/lib/distributed_press/v1/schemas/admin.rb +17 -0
  10. data/lib/distributed_press/v1/schemas/http_protocol.rb +18 -0
  11. data/lib/distributed_press/v1/schemas/hyper_protocol.rb +20 -0
  12. data/lib/distributed_press/v1/schemas/ipfs_protocol.rb +21 -0
  13. data/lib/distributed_press/v1/schemas/new_admin.rb +16 -0
  14. data/lib/distributed_press/v1/schemas/new_publisher.rb +16 -0
  15. data/lib/distributed_press/v1/schemas/new_site.rb +24 -0
  16. data/lib/distributed_press/v1/schemas/new_token_payload.rb +24 -0
  17. data/lib/distributed_press/v1/schemas/publisher.rb +18 -0
  18. data/lib/distributed_press/v1/schemas/publishing_site.rb +16 -0
  19. data/lib/distributed_press/v1/schemas/site.rb +34 -0
  20. data/lib/distributed_press/v1/schemas/token_header.rb +17 -0
  21. data/lib/distributed_press/v1/schemas/token_payload.rb +27 -0
  22. data/lib/distributed_press/v1/schemas/update_site.rb +23 -0
  23. data/lib/distributed_press/v1/schemas.rb +24 -0
  24. data/lib/distributed_press/v1/token.rb +124 -0
  25. data/lib/distributed_press/v1.rb +15 -0
  26. data/lib/distributed_press/version.rb +7 -0
  27. data/lib/distributed_press.rb +25 -53
  28. data/lib/{jekyll-distributed-press.rb → jekyll-distributed-press-v0.rb} +6 -8
  29. metadata +249 -4
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Schemas
8
+ # JWT payload for new tokens
9
+ class NewTokenPayload < Dry::Schema::JSON
10
+ # Capabilities supported by the APIv1
11
+ #
12
+ # @return [Array]
13
+ CAPABILITIES = %w[publisher admin refresh].freeze
14
+
15
+ define do
16
+ optional(:issuedTo).filled(:string)
17
+ required(:capabilities).filled(:array).each do
18
+ included_in? CAPABILITIES
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Schemas
8
+ # Schema for full publisher
9
+ class Publisher < Dry::Schema::JSON
10
+ define do
11
+ required(:id).filled(:string)
12
+ required(:name).filled(:string)
13
+ required(:ownedSites).array(:string)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Schemas
8
+ # Schema for publishing a site
9
+ class PublishingSite < Dry::Schema::JSON
10
+ define do
11
+ required(:id).filled(:string)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+ require_relative 'http_protocol'
5
+ require_relative 'hyper_protocol'
6
+ require_relative 'ipfs_protocol'
7
+
8
+ class DistributedPress
9
+ module V1
10
+ module Schemas
11
+ # Schema for full site, usually the APIv1 responds with this.
12
+ class Site < Dry::Schema::JSON
13
+ define do
14
+ required(:id).filled(:string)
15
+
16
+ # TODO: Validate domain name
17
+ required(:domain).filled(:string)
18
+
19
+ required(:protocols).hash do
20
+ required(:http).filled(:bool)
21
+ required(:ipfs).filled(:bool)
22
+ required(:hyper).filled(:bool)
23
+ end
24
+
25
+ required(:links).hash do
26
+ optional(:http).hash(HttpProtocol.new)
27
+ optional(:hyper).hash(HyperProtocol.new)
28
+ optional(:ipfs).hash(IpfsProtocol.new)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Schemas
8
+ # JWT header
9
+ class TokenHeader < Dry::Schema::JSON
10
+ define do
11
+ required(:alg).filled(:string)
12
+ optional(:typ).filled(:string)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Schemas
8
+ # JWT payload
9
+ class TokenPayload < Dry::Schema::JSON
10
+ # Capabilities supported by the APIv1
11
+ #
12
+ # @return [Array]
13
+ CAPABILITIES = %w[publisher admin refresh].freeze
14
+
15
+ define do
16
+ required(:issuedTo).filled(:string)
17
+ required(:tokenId).filled(:string)
18
+ required(:iat).filled(:integer, gt?: 0)
19
+ required(:expires).filled(:integer, gt?: -2)
20
+ required(:capabilities).filled(:array).each do
21
+ included_in? CAPABILITIES
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Schemas
8
+ # Schema for updating a website options. ID is required to
9
+ # generate URL.
10
+ class UpdateSite < Dry::Schema::JSON
11
+ define do
12
+ required(:id).filled(:string)
13
+
14
+ required(:protocols).hash do
15
+ optional(:http).filled(:bool)
16
+ optional(:ipfs).filled(:bool)
17
+ optional(:hyper).filled(:bool)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'schemas/admin'
4
+ require_relative 'schemas/http_protocol'
5
+ require_relative 'schemas/hyper_protocol'
6
+ require_relative 'schemas/ipfs_protocol'
7
+ require_relative 'schemas/new_admin'
8
+ require_relative 'schemas/new_publisher'
9
+ require_relative 'schemas/new_site'
10
+ require_relative 'schemas/new_token_payload'
11
+ require_relative 'schemas/publisher'
12
+ require_relative 'schemas/publishing_site'
13
+ require_relative 'schemas/site'
14
+ require_relative 'schemas/token_header'
15
+ require_relative 'schemas/token_payload'
16
+ require_relative 'schemas/update_site'
17
+
18
+ class DistributedPress
19
+ module V1
20
+ # Schema validation
21
+ module Schemas
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+ require_relative 'errors'
5
+ require_relative 'schemas/token_header'
6
+ require_relative 'schemas/token_payload'
7
+
8
+ class DistributedPress
9
+ module V1
10
+ # Authentication is done using a JWT with different capabilities.
11
+ # This class decodes and help us figure out what we can do
12
+ # client-side.
13
+ class Token
14
+ # Decoded JWT
15
+ #
16
+ # @return [JWT]
17
+ attr_reader :decoded
18
+
19
+ # Parsed and validated token header
20
+ #
21
+ # @see https://jwt.io
22
+ # @return [DistributedPress::V1::Schemas::TokenHeader]
23
+ attr_reader :header
24
+
25
+ # Parsed and validated token payload
26
+ #
27
+ # @return [DistributedPress::V1::Schemas::TokenPayload]
28
+ attr_reader :payload
29
+
30
+ # Decodes a JWT string
31
+ #
32
+ # @param token [String]
33
+ def initialize(token:)
34
+ @token = token
35
+ # XXX: We can't validate the token without its secret.
36
+ @decoded = JWT.decode(token, nil, false)
37
+
38
+ @header = Schemas::TokenHeader.new.call(decoded.find do |part|
39
+ part['alg']
40
+ end)
41
+
42
+ @payload = Schemas::TokenPayload.new.call(decoded.find do |part|
43
+ part['tokenId']
44
+ end)
45
+
46
+ raise TokenHeaderNotValidError unless header.success?
47
+ raise TokenPayloadNotValidError unless payload.success?
48
+ end
49
+
50
+ # Returns the original token when converted to String
51
+ #
52
+ # @return [String]
53
+ def to_s
54
+ @token
55
+ end
56
+
57
+ # Checks if the token expired. Returns false if expiration time
58
+ # is negative.
59
+ #
60
+ # @return [Boolean]
61
+ def expired?
62
+ return false if forever?
63
+
64
+ !expires_in_x_seconds.positive?
65
+ end
66
+
67
+ # Checks if the token never expires
68
+ #
69
+ # @return [Boolean]
70
+ def forever?
71
+ payload[:expires].negative?
72
+ end
73
+
74
+ # Return issuing time
75
+ #
76
+ # @return [Time]
77
+ def issued_at
78
+ Time.at(payload[:iat])
79
+ end
80
+
81
+ # Return expiration time
82
+ #
83
+ # @return [Time]
84
+ def expires_at
85
+ Time.at(0, payload[:expires], :millisecond)
86
+ end
87
+
88
+ # Returns expiration time in seconds
89
+ #
90
+ # @return [Integer]
91
+ def expires_in_x_seconds
92
+ expires_at.to_i - Time.now.to_i
93
+ end
94
+
95
+ # Checks if a token gives us publisher capabilities
96
+ #
97
+ # @return [Boolean]
98
+ def publisher?
99
+ payload[:capabilities].include? 'publisher'
100
+ end
101
+
102
+ # Checks if a token gives us admin capabilities
103
+ #
104
+ # @return [Boolean]
105
+ def admin?
106
+ payload[:capabilities].include? 'admin'
107
+ end
108
+
109
+ # Checks if a token gives us refresh capabilities
110
+ #
111
+ # @return [Boolean]
112
+ def refresh?
113
+ payload[:capabilities].include? 'refresh'
114
+ end
115
+
116
+ # Returns payload capabilities
117
+ #
118
+ # @return [Array]
119
+ def capabilities
120
+ payload[:capabilities]
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'v1/client'
4
+ require_relative 'v1/client/admin'
5
+ require_relative 'v1/client/auth'
6
+ require_relative 'v1/client/publisher'
7
+ require_relative 'v1/client/site'
8
+ require_relative 'v1/schemas'
9
+
10
+ # API Client
11
+ class DistributedPress
12
+ # APIv1
13
+ module V1
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # API client
4
+ class DistributedPress
5
+ # Version
6
+ VERSION = '0.2.0'
7
+ end
@@ -2,28 +2,27 @@
2
2
 
3
3
  require 'httparty'
4
4
  require 'open3'
5
+ require_relative 'distributed_press/multipart'
5
6
 
6
7
  # Distributed Press (https://distributed.press) API client
7
8
  class DistributedPress
8
9
  include ::HTTParty
9
10
 
11
+ # Default URL
10
12
  DEFAULT_URL = 'https://api.distributed.press'
11
- NEWLINE = "\r\n"
12
- BOUNDARY = '--'
13
13
 
14
14
  attr_reader :api_key, :project_domain, :configure_result, :publish_result
15
15
 
16
- # @param options [Hash]
17
- # @option options [String] :url API URL
18
- # @option options [String] :api_key API Key
19
- # @option options [String] :project_domain Domain name
16
+ # @param url [String] API URL
17
+ # @param api_key [String] API Key
18
+ # @param project_domain [String] Domain name
20
19
  def initialize(url: DEFAULT_URL, api_key: nil, project_domain: nil)
21
20
  url ||= DEFAULT_URL
22
21
  @api_key = api_key || ENV['DISTRIBUTED_PRESS_API_KEY']
23
22
  @project_domain = project_domain || ENV['DISTRIBUTED_PRESS_PROJECT_DOMAIN']
24
23
 
25
- raise ArgumentError, "API key is missing" unless @api_key
26
- raise ArgumentError, "Project domain is missing" unless @project_domain
24
+ raise ArgumentError, 'API key is missing' unless @api_key
25
+ raise ArgumentError, 'Project domain is missing' unless @project_domain
27
26
 
28
27
  self.class.default_options[:base_uri] = HTTParty.normalize_base_uri(url)
29
28
  end
@@ -33,42 +32,46 @@ class DistributedPress
33
32
  # @return [Boolean] Configuration was successful
34
33
  def configure
35
34
  stream, config = IO.pipe
35
+ multipart = Multipart.new
36
36
 
37
37
  Thread.new do
38
- write_multipart_header config, 'config.json'
38
+ multipart.write_header config, 'config.json'
39
39
  IO.copy_stream StringIO.new({ 'domain' => project_domain }.to_json), config
40
- write_multipart_footer config
40
+ multipart.write_footer config
41
41
 
42
42
  config.close
43
43
  end
44
44
 
45
- @configure_result = upload_io('/v0/publication/configure', stream)
45
+ @configure_result = upload_io('/v0/publication/configure', stream, multipart.boundary)
46
46
 
47
47
  configure_result['errorCode'].zero?
48
48
  end
49
49
 
50
50
  # Publishes a directory by tarballing it on the fly.
51
51
  #
52
- # @param [String] Directory path
52
+ # @param path [String] Directory path
53
53
  # @return [Boolean] Upload was successful
54
54
  def publish(path)
55
55
  raise ArgumentError, "#{path} is not a directory" unless File.directory? path
56
56
 
57
57
  filename = File.basename path
58
58
 
59
- Open3.popen2('tar', '--to-stdout', '--create', '--gzip', '--dereference', '--directory', path, '.') do |stdin, stdout, thread|
59
+ Open3.popen2('tar', '--to-stdout', '--create', '--gzip', '--dereference', '--directory', path,
60
+ '.') do |_stdin, stdout, thread|
60
61
  stream, body = IO.pipe
61
62
 
63
+ multipart = Multipart.new
64
+
62
65
  # Generate a multipart body and copy contents to it.
63
66
  Thread.new do
64
- write_multipart_header body, filename
67
+ multipart.write_header body, "#{filename}.tar.gz"
65
68
  IO.copy_stream stdout, body
66
- write_multipart_footer body
69
+ multipart.write_footer body
67
70
 
68
71
  body.close
69
72
  end
70
73
 
71
- @publish_result = upload_io '/v0/publication/publish', stream
74
+ @publish_result = upload_io '/v0/publication/publish', stream, multipart.boundary
72
75
 
73
76
  # wait
74
77
  thread.value
@@ -78,36 +81,12 @@ class DistributedPress
78
81
 
79
82
  private
80
83
 
81
- # Write the multipart header
82
- def write_multipart_header(io, filename)
83
- io.write BOUNDARY
84
- io.write boundary
85
- io.write NEWLINE
86
- io.write "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}.tar.gz\""
87
- io.write NEWLINE
88
- io.write 'Content-Type: application/octet-stream'
89
- io.write NEWLINE
90
- io.write NEWLINE
91
-
92
- nil
93
- end
94
-
95
- # Write the multipart footer
96
- def write_multipart_footer(io)
97
- io.write NEWLINE
98
- io.write BOUNDARY
99
- io.write boundary
100
- io.write BOUNDARY
101
- io.write NEWLINE
102
-
103
- nil
104
- end
105
-
106
84
  # Upload an IO object
107
85
  #
108
- # @param [String]
109
- # @param [IO]
110
- def upload_io(endpoint, io)
86
+ # @param endpoint [String]
87
+ # @param io [IO]
88
+ # @param boundary [String]
89
+ def upload_io(endpoint, io, boundary)
111
90
  self.class.post(endpoint, body_stream: io, headers: headers(
112
91
  'Accept' => 'application/json',
113
92
  'Content-Type' => "multipart/form-data; boundary=#{boundary}",
@@ -115,18 +94,11 @@ class DistributedPress
115
94
  ))
116
95
  end
117
96
 
118
- # Generate a MultiPart boundary and reuse it
119
- #
120
- # @return [String]
121
- def boundary
122
- @boundary ||= HTTParty::Request::MultipartBoundary.generate
123
- end
124
-
125
97
  # Default request headers
126
98
  #
127
- # @param [Hash] Extra headers
99
+ # @param extra [Hash] Extra headers
128
100
  # @return [Hash]
129
101
  def headers(extra = {})
130
- extra.merge('Accept' => 'application/json', 'Authorization' => 'Bearer ' + api_key)
102
+ extra.merge('Accept' => 'application/json', 'Authorization' => "Bearer #{api_key}")
131
103
  end
132
104
  end
@@ -28,14 +28,12 @@ Jekyll::Hooks.register :site, :post_write, priority: :low do |site|
28
28
  dest = site.dest
29
29
  locales_enabled = site.config['plugins'].include?('jekyll-locales')
30
30
 
31
- if locales_enabled
32
- if site.locales?
33
- if site.locale != site.locales.last
34
- info "Ignoring #{site.locale}"
35
- next
36
- else
37
- dest = File.realpath(File.join(site.dest, '..'))
38
- end
31
+ if locales_enabled && site.locales?
32
+ if site.locale != site.locales.last
33
+ info "Ignoring #{site.locale}"
34
+ next
35
+ else
36
+ dest = File.realpath(File.join(site.dest, '..'))
39
37
  end
40
38
  end
41
39