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.
- 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
|
|