distributed-press-api-client 0.1.5 → 0.2.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 +4 -4
- data/lib/distributed_press/multipart.rb +51 -0
- data/lib/distributed_press/v1/client/admin.rb +53 -0
- data/lib/distributed_press/v1/client/auth.rb +62 -0
- data/lib/distributed_press/v1/client/publisher.rb +56 -0
- data/lib/distributed_press/v1/client/site.rb +110 -0
- data/lib/distributed_press/v1/client.rb +140 -0
- data/lib/distributed_press/v1/errors.rb +52 -0
- data/lib/distributed_press/v1/schemas/admin.rb +17 -0
- data/lib/distributed_press/v1/schemas/http_protocol.rb +18 -0
- data/lib/distributed_press/v1/schemas/hyper_protocol.rb +20 -0
- data/lib/distributed_press/v1/schemas/ipfs_protocol.rb +21 -0
- data/lib/distributed_press/v1/schemas/new_admin.rb +16 -0
- data/lib/distributed_press/v1/schemas/new_publisher.rb +16 -0
- data/lib/distributed_press/v1/schemas/new_site.rb +24 -0
- data/lib/distributed_press/v1/schemas/new_token_payload.rb +24 -0
- data/lib/distributed_press/v1/schemas/publisher.rb +18 -0
- data/lib/distributed_press/v1/schemas/publishing_site.rb +16 -0
- data/lib/distributed_press/v1/schemas/site.rb +34 -0
- data/lib/distributed_press/v1/schemas/token_header.rb +17 -0
- data/lib/distributed_press/v1/schemas/token_payload.rb +27 -0
- data/lib/distributed_press/v1/schemas/update_site.rb +23 -0
- data/lib/distributed_press/v1/schemas.rb +24 -0
- data/lib/distributed_press/v1/token.rb +124 -0
- data/lib/distributed_press/v1.rb +15 -0
- data/lib/distributed_press/version.rb +7 -0
- data/lib/distributed_press.rb +25 -53
- data/lib/{jekyll-distributed-press.rb → jekyll-distributed-press-v0.rb} +6 -8
- 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
|
data/lib/distributed_press.rb
CHANGED
@@ -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
|
17
|
-
# @
|
18
|
-
# @
|
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,
|
26
|
-
raise ArgumentError,
|
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
|
-
|
38
|
+
multipart.write_header config, 'config.json'
|
39
39
|
IO.copy_stream StringIO.new({ 'domain' => project_domain }.to_json), config
|
40
|
-
|
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,
|
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
|
-
|
67
|
+
multipart.write_header body, "#{filename}.tar.gz"
|
65
68
|
IO.copy_stream stdout, body
|
66
|
-
|
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
|
-
|
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' =>
|
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
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
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
|
|