coarnotify 0.1.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.
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coarnotify
4
+ # Base class for all exceptions in the coarnotifyrb library
5
+ class NotifyException < StandardError
6
+ end
7
+
8
+ # Exception class for validation errors.
9
+ #
10
+ # This class is designed to be thrown and caught and to collect validation errors
11
+ # as it passes through the validation pipeline.
12
+ #
13
+ # For example an object validator may do something like this:
14
+ #
15
+ # def validate
16
+ # ve = ValidationError.new
17
+ # ve.add_error(prop_name, "#{prop_name} is a required field")
18
+ # raise ve if ve.has_errors?
19
+ # true
20
+ # end
21
+ #
22
+ # If this is called by a subclass which is also validating, then this may be used
23
+ # like this:
24
+ #
25
+ # def validate
26
+ # ve = ValidationError.new
27
+ # begin
28
+ # super
29
+ # rescue ValidationError => superve
30
+ # ve = superve
31
+ # end
32
+ #
33
+ # ve.add_error(prop_name, "#{prop_name} is a required field")
34
+ # raise ve if ve.has_errors?
35
+ # true
36
+ # end
37
+ #
38
+ # By the time the ValidationError is finally raised to the top, it will contain
39
+ # all the validation errors from the various levels of validation that have been
40
+ # performed.
41
+ #
42
+ # The errors are stored as a multi-level hash with the keys at the top level
43
+ # being the fields in the data structure which have errors, and within the value
44
+ # for each key there are two possible keys:
45
+ #
46
+ # * errors: an array of error messages for this field
47
+ # * nested: a hash of further errors for nested fields
48
+ #
49
+ # {
50
+ # "key1" => {
51
+ # "errors" => ["error1", "error2"],
52
+ # "nested" => {
53
+ # "key2" => {
54
+ # "errors" => ["error3"]
55
+ # }
56
+ # }
57
+ # }
58
+ # }
59
+ class ValidationError < NotifyException
60
+ attr_reader :errors
61
+
62
+ # Create a new ValidationError with the given errors hash
63
+ #
64
+ # @param errors [Hash] The errors hash to initialize with
65
+ def initialize(errors = {})
66
+ super()
67
+ @errors = errors
68
+ end
69
+
70
+ # Record an error on the supplied key with the message value
71
+ #
72
+ # @param key [String] the key for which an error is to be recorded
73
+ # @param value [String] the error message
74
+ def add_error(key, value)
75
+ @errors[key] ||= { "errors" => [] }
76
+ @errors[key]["errors"] << value
77
+ end
78
+
79
+ # Take an existing ValidationError and add it as a nested set of errors under the supplied key
80
+ #
81
+ # @param key [String] the key under which all the nested validation errors should go
82
+ # @param subve [ValidationError] the existing ValidationError object
83
+ def add_nested_errors(key, subve)
84
+ @errors[key] ||= { "errors" => [] }
85
+ @errors[key]["nested"] ||= {}
86
+
87
+ subve.errors.each do |k, v|
88
+ @errors[key]["nested"][k] = v
89
+ end
90
+ end
91
+
92
+ # Are there any errors registered
93
+ #
94
+ # @return [Boolean] true if there are errors, false otherwise
95
+ def has_errors?
96
+ !@errors.empty?
97
+ end
98
+
99
+ # String representation of the errors
100
+ #
101
+ # @return [String] string representation of the errors hash
102
+ def to_s
103
+ @errors.to_s
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require_relative 'core/activity_streams2'
5
+ require_relative 'core/notify'
6
+ require_relative 'patterns/accept'
7
+ require_relative 'patterns/announce_endorsement'
8
+ require_relative 'patterns/announce_relationship'
9
+ require_relative 'patterns/announce_review'
10
+ require_relative 'patterns/announce_service_result'
11
+ require_relative 'patterns/reject'
12
+ require_relative 'patterns/request_endorsement'
13
+ require_relative 'patterns/request_review'
14
+ require_relative 'patterns/tentatively_accept'
15
+ require_relative 'patterns/tentatively_reject'
16
+ require_relative 'patterns/unprocessable_notification'
17
+ require_relative 'patterns/undo_offer'
18
+ require_relative 'exceptions'
19
+
20
+ module Coarnotify
21
+ # Factory for producing the correct model based on the type or data within a payload
22
+ module Factory
23
+ # Factory for producing the correct model based on the type or data within a payload
24
+ class COARNotifyFactory
25
+ # The list of model classes recognised by this factory
26
+ MODELS = [
27
+ Patterns::Accept,
28
+ Patterns::AnnounceEndorsement,
29
+ Patterns::AnnounceRelationship,
30
+ Patterns::AnnounceReview,
31
+ Patterns::AnnounceServiceResult,
32
+ Patterns::Reject,
33
+ Patterns::RequestEndorsement,
34
+ Patterns::RequestReview,
35
+ Patterns::TentativelyAccept,
36
+ Patterns::TentativelyReject,
37
+ Patterns::UnprocessableNotification,
38
+ Patterns::UndoOffer
39
+ ]
40
+
41
+ # Get the model class based on the supplied types. The returned value is the class, not an instance.
42
+ #
43
+ # This is achieved by inspecting all of the known types in MODELS, and performing the following
44
+ # calculation:
45
+ #
46
+ # 1. If the supplied types are a subset of the model types, then this is a candidate, keep a reference to it
47
+ # 2. If the candidate fit is exact (supplied types and model types are the same), return the class
48
+ # 3. If the class is a better fit than the last candidate, update the candidate. If the fit is exact, return the class
49
+ # 4. Once we have run out of models to check, return the best candidate (or nil if none found)
50
+ #
51
+ # @param incoming_types [String, Array<String>] a single type or array of types
52
+ # @return [Class, nil] A class representing the best fit for the supplied types, or nil if no match
53
+ def self.get_by_types(incoming_types)
54
+ incoming_types = [incoming_types] unless incoming_types.is_a?(Array)
55
+
56
+ candidate = nil
57
+ candidate_fit = nil
58
+
59
+ MODELS.each do |m|
60
+ document_types = m.type_constant
61
+ document_types = [document_types] unless document_types.is_a?(Array)
62
+
63
+ if document_types.to_set.subset?(incoming_types.to_set)
64
+ if candidate_fit.nil?
65
+ candidate = m
66
+ candidate_fit = incoming_types.length - document_types.length
67
+ return candidate if candidate_fit == 0
68
+ else
69
+ fit = incoming_types.length - document_types.length
70
+ return m if fit == 0
71
+ if fit.abs < candidate_fit.abs
72
+ candidate = m
73
+ candidate_fit = fit
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ candidate
80
+ end
81
+
82
+ # Get an instance of a model based on the data provided.
83
+ #
84
+ # Internally this calls get_by_types to determine the class to instantiate, and then creates an instance of that
85
+ # using the supplied options.
86
+ #
87
+ # If a model cannot be found that matches the data, a NotifyException is raised.
88
+ #
89
+ # @param data [Hash] The raw stream data to parse and instantiate around
90
+ # @param options [Hash] any options to pass to the object constructor
91
+ # @return [Core::Notify::NotifyPattern] A NotifyPattern of the correct type, wrapping the data
92
+ def self.get_by_object(data, **options)
93
+ stream = Core::ActivityStreams2::ActivityStream.new(data)
94
+
95
+ types = stream.get_property(Core::ActivityStreams2::Properties::TYPE)
96
+ raise NotifyException, "No type found in object" if types.nil?
97
+
98
+ klazz = get_by_types(types)
99
+ raise NotifyException, "No matching pattern found for types: #{types}" if klazz.nil?
100
+
101
+ klazz.new(stream: data, **options)
102
+ end
103
+
104
+ # Register a new model with the factory
105
+ #
106
+ # @param model [Class] the model class to register
107
+ def self.register(model)
108
+ existing = get_by_types(model.type_constant)
109
+ MODELS.delete(existing) if existing
110
+ MODELS << model
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+
6
+ module Coarnotify
7
+ # HTTP layer interface and default implementation using Net::HTTP
8
+ module Http
9
+ # Interface for the HTTP layer
10
+ #
11
+ # This defines the methods which need to be implemented in order for the client to fully operate
12
+ class HttpLayer
13
+ # Make an HTTP POST request to the supplied URL with the given body data, and headers
14
+ #
15
+ # args and kwargs can be used to pass implementation-specific parameters
16
+ #
17
+ # @param url [String] the request URL
18
+ # @param data [String] the body data
19
+ # @param headers [Hash] HTTP headers as a hash to include in the request
20
+ # @param args [Array] argument list to pass on to the implementation
21
+ # @param kwargs [Hash] keyword arguments to pass on to the implementation
22
+ # @return [HttpResponse] the HTTP response
23
+ def post(url, data, headers = {}, *args, **kwargs)
24
+ raise NotImplementedError
25
+ end
26
+
27
+ # Make an HTTP GET request to the supplied URL with the given headers
28
+ #
29
+ # args and kwargs can be used to pass implementation-specific parameters
30
+ #
31
+ # @param url [String] the request URL
32
+ # @param headers [Hash] HTTP headers as a hash to include in the request
33
+ # @param args [Array] argument list to pass on to the implementation
34
+ # @param kwargs [Hash] keyword arguments to pass on to the implementation
35
+ # @return [HttpResponse] the HTTP response
36
+ def get(url, headers = {}, *args, **kwargs)
37
+ raise NotImplementedError
38
+ end
39
+ end
40
+
41
+ # Interface for the HTTP response object
42
+ #
43
+ # This defines the methods which need to be implemented in order for the client to fully operate
44
+ class HttpResponse
45
+ # Get the value of a header from the response
46
+ #
47
+ # @param header_name [String] the name of the header
48
+ # @return [String] the header value
49
+ def header(header_name)
50
+ raise NotImplementedError
51
+ end
52
+
53
+ # Get the status code of the response
54
+ #
55
+ # @return [Integer] the status code
56
+ def status_code
57
+ raise NotImplementedError
58
+ end
59
+ end
60
+
61
+ #######################################
62
+ ## Implementations using Net::HTTP
63
+
64
+ # Implementation of the HTTP layer using Net::HTTP. This is the default implementation
65
+ # used when no other implementation is supplied
66
+ class NetHttpLayer < HttpLayer
67
+ # Make an HTTP POST request to the supplied URL with the given body data, and headers
68
+ #
69
+ # args and kwargs can be used to pass additional parameters to the Net::HTTP request,
70
+ # such as authentication credentials, etc.
71
+ #
72
+ # @param url [String] the request URL
73
+ # @param data [String] the body data
74
+ # @param headers [Hash] HTTP headers as a hash to include in the request
75
+ # @param args [Array] argument list (unused in this implementation)
76
+ # @param kwargs [Hash] keyword arguments (unused in this implementation)
77
+ # @return [NetHttpResponse] the HTTP response
78
+ def post(url, data, headers = {}, *args, **kwargs)
79
+ uri = URI.parse(url)
80
+ http = Net::HTTP.new(uri.host, uri.port)
81
+ http.use_ssl = (uri.scheme == 'https')
82
+
83
+ request = Net::HTTP::Post.new(uri.request_uri)
84
+ headers.each { |key, value| request[key] = value }
85
+ request.body = data
86
+
87
+ response = http.request(request)
88
+ NetHttpResponse.new(response)
89
+ end
90
+
91
+ # Make an HTTP GET request to the supplied URL with the given headers
92
+ #
93
+ # args and kwargs can be used to pass additional parameters to the Net::HTTP request,
94
+ # such as authentication credentials, etc.
95
+ #
96
+ # @param url [String] the request URL
97
+ # @param headers [Hash] HTTP headers as a hash to include in the request
98
+ # @param args [Array] argument list (unused in this implementation)
99
+ # @param kwargs [Hash] keyword arguments (unused in this implementation)
100
+ # @return [NetHttpResponse] the HTTP response
101
+ def get(url, headers = {}, *args, **kwargs)
102
+ uri = URI.parse(url)
103
+ http = Net::HTTP.new(uri.host, uri.port)
104
+ http.use_ssl = (uri.scheme == 'https')
105
+
106
+ request = Net::HTTP::Get.new(uri.request_uri)
107
+ headers.each { |key, value| request[key] = value }
108
+
109
+ response = http.request(request)
110
+ NetHttpResponse.new(response)
111
+ end
112
+ end
113
+
114
+ # Implementation of the HTTP response object using Net::HTTP
115
+ #
116
+ # This wraps the Net::HTTP response object and provides the interface required by the client
117
+ class NetHttpResponse < HttpResponse
118
+ # Construct the object as a wrapper around the original Net::HTTP response object
119
+ #
120
+ # @param resp [Net::HTTPResponse] response object from Net::HTTP
121
+ def initialize(resp)
122
+ @resp = resp
123
+ end
124
+
125
+ # Get the value of a header from the response
126
+ #
127
+ # @param header_name [String] the name of the header
128
+ # @return [String] the header value
129
+ def header(header_name)
130
+ @resp[header_name]
131
+ end
132
+
133
+ # Get the status code of the response
134
+ #
135
+ # @return [Integer] the status code
136
+ def status_code
137
+ @resp.code.to_i
138
+ end
139
+
140
+ # Get the original Net::HTTP response object
141
+ #
142
+ # @return [Net::HTTPResponse] the original response object
143
+ def net_http_response
144
+ @resp
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/notify'
4
+ require_relative '../core/activity_streams2'
5
+ require_relative '../exceptions'
6
+
7
+ module Coarnotify
8
+ module Patterns
9
+ # Pattern to represent an Accept notification
10
+ # https://coar-notify.net/specification/1.0.0/accept/
11
+ class Accept < Core::Notify::NotifyPattern
12
+ include Core::Notify::NestedPatternObjectMixin
13
+
14
+ # The Accept type
15
+ def self.type_constant
16
+ Core::ActivityStreams2::ActivityStreamsTypes::ACCEPT
17
+ end
18
+
19
+ # Validate the Accept pattern.
20
+ #
21
+ # In addition to the base validation, this:
22
+ #
23
+ # * Makes inReplyTo required
24
+ # * Requires the inReplyTo value to be the same as the object.id value
25
+ #
26
+ # @return [Boolean] true if valid, otherwise raises ValidationError
27
+ def validate
28
+ ve = ValidationError.new
29
+ begin
30
+ super
31
+ rescue ValidationError => superve
32
+ ve = superve
33
+ end
34
+
35
+ # Technically, no need to validate the value, as this is handled by the superclass,
36
+ # but leaving it in for completeness
37
+ required_and_validate(ve, Core::ActivityStreams2::Properties::IN_REPLY_TO, in_reply_to)
38
+
39
+ objid = object&.id
40
+ if in_reply_to != objid
41
+ ve.add_error(Core::ActivityStreams2::Properties::IN_REPLY_TO,
42
+ "Expected inReplyTo id to be the same as the nested object id. inReplyTo: #{in_reply_to}, object.id: #{objid}")
43
+ end
44
+
45
+ raise ve if ve.has_errors?
46
+ true
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/notify'
4
+ require_relative '../core/activity_streams2'
5
+
6
+ module Coarnotify
7
+ module Patterns
8
+ # Pattern to represent an AnnounceEndorsement notification
9
+ class AnnounceEndorsement < Core::Notify::NotifyPattern
10
+ def self.type_constant
11
+ [Core::ActivityStreams2::ActivityStreamsTypes::ANNOUNCE, Core::Notify::NotifyTypes::ENDORSEMENT_ACTION]
12
+ end
13
+
14
+ # Get a context specific to Announce Endorsement
15
+ #
16
+ # @return [AnnounceEndorsementContext, nil] The Announce Endorsement context object
17
+ def context
18
+ c = get_property(Core::ActivityStreams2::Properties::CONTEXT)
19
+ if c
20
+ AnnounceEndorsementContext.new(stream: c, validate_stream_on_construct: false,
21
+ validate_properties: @validate_properties, validators: @validators,
22
+ validation_context: Core::ActivityStreams2::Properties::CONTEXT,
23
+ properties_by_reference: @properties_by_reference)
24
+ end
25
+ end
26
+
27
+ # Set the context property of the notification
28
+ #
29
+ # @param value [AnnounceEndorsementContext] the context to set
30
+ def context=(value)
31
+ set_property(Core::ActivityStreams2::Properties::CONTEXT, value.doc)
32
+ end
33
+
34
+ # Extends the base validation to make `context` required
35
+ #
36
+ # @return [Boolean] true if valid, otherwise raises ValidationError
37
+ def validate
38
+ ve = Core::Notify::ValidationError.new
39
+
40
+ begin
41
+ super
42
+ rescue Core::Notify::ValidationError => superve
43
+ ve = superve
44
+ end
45
+
46
+ required_and_validate(ve, Core::ActivityStreams2::Properties::CONTEXT, context)
47
+
48
+ raise ve if ve.has_errors?
49
+ true
50
+ end
51
+ end
52
+
53
+ # Announce Endorsement context object, which extends the base NotifyObject
54
+ # to allow us to pass back a custom AnnounceEndorsementItem
55
+ class AnnounceEndorsementContext < Core::Notify::NotifyObject
56
+ # Get a custom AnnounceEndorsementItem
57
+ #
58
+ # @return [AnnounceEndorsementItem, nil] the Announce Endorsement Item
59
+ def item
60
+ i = get_property(Core::Notify::NotifyProperties::ITEM)
61
+ if i
62
+ AnnounceEndorsementItem.new(stream: i, validate_stream_on_construct: false,
63
+ validate_properties: @validate_properties, validators: @validators,
64
+ validation_context: Core::Notify::NotifyProperties::ITEM,
65
+ properties_by_reference: @properties_by_reference)
66
+ end
67
+ end
68
+
69
+ # Set the item property
70
+ #
71
+ # @param value [AnnounceEndorsementItem] the item to set
72
+ def item=(value)
73
+ set_property(Core::Notify::NotifyProperties::ITEM, value.doc)
74
+ end
75
+ end
76
+
77
+ # Announce Endorsement Item, which extends the base NotifyItem to provide
78
+ # additional validation
79
+ class AnnounceEndorsementItem < Core::Notify::NotifyItem
80
+ # Extends the base validation with validation custom to Announce Endorsement notifications
81
+ #
82
+ # * Adds type validation, which the base NotifyItem does not apply
83
+ # * Requires the mediaType value
84
+ #
85
+ # @return [Boolean] true if valid, otherwise raises a ValidationError
86
+ def validate
87
+ ve = Core::Notify::ValidationError.new
88
+
89
+ begin
90
+ super
91
+ rescue Core::Notify::ValidationError => superve
92
+ ve = superve
93
+ end
94
+
95
+ required_and_validate(ve, Core::ActivityStreams2::Properties::TYPE, type)
96
+ required(ve, Core::Notify::NotifyProperties::MEDIA_TYPE, media_type)
97
+
98
+ raise ve if ve.has_errors?
99
+ true
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/notify'
4
+ require_relative '../core/activity_streams2'
5
+
6
+ module Coarnotify
7
+ module Patterns
8
+ # Pattern to represent an AnnounceRelationship notification
9
+ class AnnounceRelationship < Core::Notify::NotifyPattern
10
+ def self.type_constant
11
+ [Core::ActivityStreams2::ActivityStreamsTypes::ANNOUNCE, Core::Notify::NotifyTypes::RELATIONSHIP_ACTION]
12
+ end
13
+
14
+ # Custom getter to retrieve the object property as an AnnounceRelationshipObject
15
+ #
16
+ # @return [AnnounceRelationshipObject, nil] the object
17
+ def object
18
+ o = get_property(Core::ActivityStreams2::Properties::OBJECT)
19
+ if o
20
+ AnnounceRelationshipObject.new(stream: o, validate_stream_on_construct: false,
21
+ validate_properties: @validate_properties, validators: @validators,
22
+ validation_context: Core::ActivityStreams2::Properties::OBJECT,
23
+ properties_by_reference: @properties_by_reference)
24
+ end
25
+ end
26
+
27
+ # Set the object property of the notification
28
+ #
29
+ # @param value [AnnounceRelationshipObject] the object to set
30
+ def object=(value)
31
+ set_property(Core::ActivityStreams2::Properties::OBJECT, value.doc)
32
+ end
33
+
34
+ # Extends the base validation to make `context` required
35
+ #
36
+ # @return [Boolean] true if valid, otherwise raises ValidationError
37
+ def validate
38
+ ve = Core::Notify::ValidationError.new
39
+
40
+ begin
41
+ super
42
+ rescue Core::Notify::ValidationError => superve
43
+ ve = superve
44
+ end
45
+
46
+ required_and_validate(ve, Core::ActivityStreams2::Properties::CONTEXT, context)
47
+
48
+ raise ve if ve.has_errors?
49
+ true
50
+ end
51
+ end
52
+
53
+ # Custom object class for Announce Relationship to apply the custom validation
54
+ class AnnounceRelationshipObject < Core::Notify::NotifyObject
55
+ # Extend the base validation to include the following constraints:
56
+ #
57
+ # * The object type is required and must validate
58
+ # * The as:subject property is required
59
+ # * The as:object property is required
60
+ # * The as:relationship property is required
61
+ #
62
+ # @return [Boolean] true if validation passes, otherwise raise a ValidationError
63
+ def validate
64
+ ve = Core::Notify::ValidationError.new
65
+
66
+ begin
67
+ super
68
+ rescue Core::Notify::ValidationError => superve
69
+ ve = superve
70
+ end
71
+
72
+ required_and_validate(ve, Core::ActivityStreams2::Properties::TYPE, type)
73
+ required_and_validate(ve, Core::ActivityStreams2::Properties::SUBJECT_TRIPLE, subject)
74
+ required_and_validate(ve, Core::ActivityStreams2::Properties::OBJECT_TRIPLE, object_triple)
75
+ required_and_validate(ve, Core::ActivityStreams2::Properties::RELATIONSHIP_TRIPLE, relationship)
76
+
77
+ raise ve if ve.has_errors?
78
+ true
79
+ end
80
+ end
81
+ end
82
+ end