distributed-press-api-client 0.1.4 → 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 +28 -54
  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,26 +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
 
10
- NEWLINE = "\r\n"
11
- BOUNDARY = '--'
11
+ # Default URL
12
+ DEFAULT_URL = 'https://api.distributed.press'
12
13
 
13
14
  attr_reader :api_key, :project_domain, :configure_result, :publish_result
14
15
 
15
- # @param options [Hash]
16
- # @option options [String] :url API URL
17
- # @option options [String] :api_key API Key
18
- # @option options [String] :project_domain Domain name
19
- def initialize(url: 'https://api.distributed.press', api_key: nil, project_domain: nil)
16
+ # @param url [String] API URL
17
+ # @param api_key [String] API Key
18
+ # @param project_domain [String] Domain name
19
+ def initialize(url: DEFAULT_URL, api_key: nil, project_domain: nil)
20
+ url ||= DEFAULT_URL
20
21
  @api_key = api_key || ENV['DISTRIBUTED_PRESS_API_KEY']
21
22
  @project_domain = project_domain || ENV['DISTRIBUTED_PRESS_PROJECT_DOMAIN']
22
23
 
23
- raise ArgumentError, "API key is missing" unless @api_key
24
- 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
25
26
 
26
27
  self.class.default_options[:base_uri] = HTTParty.normalize_base_uri(url)
27
28
  end
@@ -31,42 +32,46 @@ class DistributedPress
31
32
  # @return [Boolean] Configuration was successful
32
33
  def configure
33
34
  stream, config = IO.pipe
35
+ multipart = Multipart.new
34
36
 
35
37
  Thread.new do
36
- write_multipart_header config, 'config.json'
38
+ multipart.write_header config, 'config.json'
37
39
  IO.copy_stream StringIO.new({ 'domain' => project_domain }.to_json), config
38
- write_multipart_footer config
40
+ multipart.write_footer config
39
41
 
40
42
  config.close
41
43
  end
42
44
 
43
- @configure_result = upload_io('/v0/publication/configure', stream)
45
+ @configure_result = upload_io('/v0/publication/configure', stream, multipart.boundary)
44
46
 
45
47
  configure_result['errorCode'].zero?
46
48
  end
47
49
 
48
50
  # Publishes a directory by tarballing it on the fly.
49
51
  #
50
- # @param [String] Directory path
52
+ # @param path [String] Directory path
51
53
  # @return [Boolean] Upload was successful
52
54
  def publish(path)
53
55
  raise ArgumentError, "#{path} is not a directory" unless File.directory? path
54
56
 
55
57
  filename = File.basename path
56
58
 
57
- 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|
58
61
  stream, body = IO.pipe
59
62
 
63
+ multipart = Multipart.new
64
+
60
65
  # Generate a multipart body and copy contents to it.
61
66
  Thread.new do
62
- write_multipart_header body, filename
67
+ multipart.write_header body, "#{filename}.tar.gz"
63
68
  IO.copy_stream stdout, body
64
- write_multipart_footer body
69
+ multipart.write_footer body
65
70
 
66
71
  body.close
67
72
  end
68
73
 
69
- @publish_result = upload_io '/v0/publication/publish', stream
74
+ @publish_result = upload_io '/v0/publication/publish', stream, multipart.boundary
70
75
 
71
76
  # wait
72
77
  thread.value
@@ -76,36 +81,12 @@ class DistributedPress
76
81
 
77
82
  private
78
83
 
79
- # Write the multipart header
80
- def write_multipart_header(io, filename)
81
- io.write BOUNDARY
82
- io.write boundary
83
- io.write NEWLINE
84
- io.write "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}.tar.gz\""
85
- io.write NEWLINE
86
- io.write 'Content-Type: application/octet-stream'
87
- io.write NEWLINE
88
- io.write NEWLINE
89
-
90
- nil
91
- end
92
-
93
- # Write the multipart footer
94
- def write_multipart_footer(io)
95
- io.write NEWLINE
96
- io.write BOUNDARY
97
- io.write boundary
98
- io.write BOUNDARY
99
- io.write NEWLINE
100
-
101
- nil
102
- end
103
-
104
84
  # Upload an IO object
105
85
  #
106
- # @param [String]
107
- # @param [IO]
108
- def upload_io(endpoint, io)
86
+ # @param endpoint [String]
87
+ # @param io [IO]
88
+ # @param boundary [String]
89
+ def upload_io(endpoint, io, boundary)
109
90
  self.class.post(endpoint, body_stream: io, headers: headers(
110
91
  'Accept' => 'application/json',
111
92
  'Content-Type' => "multipart/form-data; boundary=#{boundary}",
@@ -113,18 +94,11 @@ class DistributedPress
113
94
  ))
114
95
  end
115
96
 
116
- # Generate a MultiPart boundary and reuse it
117
- #
118
- # @return [String]
119
- def boundary
120
- @boundary ||= HTTParty::Request::MultipartBoundary.generate
121
- end
122
-
123
97
  # Default request headers
124
98
  #
125
- # @param [Hash] Extra headers
99
+ # @param extra [Hash] Extra headers
126
100
  # @return [Hash]
127
101
  def headers(extra = {})
128
- extra.merge('Accept' => 'application/json', 'Authorization' => 'Bearer ' + api_key)
102
+ extra.merge('Accept' => 'application/json', 'Authorization' => "Bearer #{api_key}")
129
103
  end
130
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