distributed-press-api-client 0.1.5 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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