Skip to content

Commit 3916f76

Browse files
committed
(#917) Upload envelope graphs to S3 during publishing
1 parent faf4eb2 commit 3916f76

File tree

9 files changed

+169
-21
lines changed

9 files changed

+169
-21
lines changed

.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ AWS_REGION=us-east-2
22

33
ENVELOPE_DOWNLOADS_BUCKET=envelope-downloads
44

5+
ENVELOPE_GRAPHS_BUCKET=
6+
57
POSTGRESQL_ADDRESS=localhost
68
POSTGRESQL_USERNAME=metadataregistry
79
POSTGRESQL_PASSWORD=metadataregistry

app/api/v1/publish.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require 'policies/envelope_policy'
22
require 'services/publish_interactor'
3+
require 'services/sync_envelope_graph_with_s3'
34

45
module API
56
module V1

app/models/envelope.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ class Envelope < ActiveRecord::Base
4646
before_validation :process_resource, :process_headers
4747
before_save :assign_last_verified_on
4848
after_save :update_headers
49+
after_save :upload_to_s3
4950
before_destroy :delete_description_sets, prepend: true
5051
after_destroy :delete_from_ocn
52+
after_destroy :delete_from_s3
5153
after_commit :export_to_ocn
5254

5355
validates :envelope_community, :envelope_type, :envelope_version,
@@ -260,4 +262,12 @@ def export_to_ocn
260262

261263
ExportToOCNJob.perform_later(id)
262264
end
265+
266+
def upload_to_s3
267+
SyncEnvelopeGraphWithS3.upload(self)
268+
end
269+
270+
def delete_from_s3
271+
SyncEnvelopeGraphWithS3.remove(self)
272+
end
263273
end
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Uploads or deletes an envelope graph from the S3 bucket
2+
class SyncEnvelopeGraphWithS3
3+
attr_reader :envelope
4+
5+
delegate :envelope_community, :envelope_ceterms_ctid, to: :envelope
6+
7+
def initialize(envelope)
8+
@envelope = envelope
9+
end
10+
11+
class << self
12+
def upload(envelope)
13+
new(envelope).upload
14+
end
15+
16+
def remove(envelope)
17+
new(envelope).remove
18+
end
19+
end
20+
21+
def upload
22+
return unless s3_bucket_name
23+
24+
s3_object.put(
25+
body: envelope.processed_resource.to_json,
26+
content_type: 'application/json'
27+
)
28+
29+
envelope.update_column(:s3_url, s3_object.public_url)
30+
end
31+
32+
def remove
33+
return unless s3_bucket_name
34+
35+
s3_object.delete
36+
end
37+
38+
def s3_bucket
39+
@s3_bucket ||= s3_resource.bucket(s3_bucket_name)
40+
end
41+
42+
def s3_bucket_name
43+
ENV['ENVELOPE_GRAPHS_BUCKET'].presence
44+
end
45+
46+
def s3_key
47+
"#{envelope_community.name}/#{envelope_ceterms_ctid}.json"
48+
end
49+
50+
def s3_object
51+
@s3_object ||= s3_bucket.object(s3_key)
52+
end
53+
54+
def s3_resource
55+
@s3_resource ||= Aws::S3::Resource.new(region: ENV['AWS_REGION'].presence)
56+
end
57+
end
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class AddS3UrlToEnvelopes < ActiveRecord::Migration[8.0]
2+
def change
3+
add_column :envelopes, :s3_url, :string
4+
add_index :envelopes, :s3_url, unique: true
5+
end
6+
end

db/structure.sql

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,8 @@ CREATE TABLE public.envelopes (
432432
publishing_organization_id uuid,
433433
resource_publish_type character varying,
434434
last_verified_on date,
435-
publication_status integer DEFAULT 0 NOT NULL
435+
publication_status integer DEFAULT 0 NOT NULL,
436+
s3_url character varying
436437
);
437438

438439

@@ -1480,6 +1481,13 @@ CREATE INDEX index_envelopes_on_purged_at ON public.envelopes USING btree (purge
14801481
CREATE INDEX index_envelopes_on_resource_type ON public.envelopes USING btree (resource_type);
14811482

14821483

1484+
--
1485+
-- Name: index_envelopes_on_s3_url; Type: INDEX; Schema: public; Owner: -
1486+
--
1487+
1488+
CREATE UNIQUE INDEX index_envelopes_on_s3_url ON public.envelopes USING btree (s3_url);
1489+
1490+
14831491
--
14841492
-- Name: index_envelopes_on_top_level_object_ids; Type: INDEX; Schema: public; Owner: -
14851493
--
@@ -1889,6 +1897,7 @@ ALTER TABLE ONLY public.envelopes
18891897
SET search_path TO "$user", public;
18901898

18911899
INSERT INTO "schema_migrations" (version) VALUES
1900+
('20251022205617'),
18921901
('20250925025616'),
18931902
('20250922224518'),
18941903
('20250921174021'),

spec/factories/envelopes.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
FactoryBot.define do
22
factory :envelope do
3-
envelope_ceterms_ctid { Envelope.generate_ctid }
3+
envelope_ceterms_ctid { processed_resource[:'ceterms:ctid'] || Envelope.generate_ctid }
44
envelope_ctdl_type { 'ceterms:CredentialOrganization' }
55
envelope_type { :resource_data }
66
envelope_version { '0.52.0' }

spec/factories/resources.rb

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
FactoryBot.define do
22
factory :base_resource, class: 'Hashie::Mash' do
33
transient do
4+
ctid { Envelope.generate_ctid }
45
provisional { false }
56
end
67

78
add_attribute(:'adms:status') do
89
'graphPublicationStatus:Provisional' if provisional
910
end
11+
12+
add_attribute(:'ceterms:ctid') { ctid }
1013
end
1114

1215
factory :resource, parent: :base_resource do
@@ -19,11 +22,9 @@
1922
factory :cer_org, parent: :base_resource do
2023
add_attribute(:@type) { 'ceterms:CredentialOrganization' }
2124
add_attribute(:@context) { 'http://credreg.net/ctdl/schema/context/json' }
22-
transient { ctid { Envelope.generate_ctid } }
2325
add_attribute(:@id) do
2426
"http://credentialengineregistry.org/resources/#{ctid}"
2527
end
26-
add_attribute(:'ceterms:ctid') { ctid }
2728
add_attribute(:'ceterms:name') { 'Test Org' }
2829
add_attribute(:'ceterms:description') { 'Org Description' }
2930
add_attribute(:'ceterms:subjectWebpage') { 'http://example.com/test-org' }
@@ -51,8 +52,6 @@
5152
end
5253
add_attribute(:@type) { 'ceterms:Certificate' }
5354
add_attribute(:@context) { 'http://credreg.net/ctdl/schema/context/json' }
54-
transient { ctid { Envelope.generate_ctid } }
55-
add_attribute(:'ceterms:ctid') { ctid }
5655
add_attribute(:'ceterms:name') { 'Test Cred' }
5756
add_attribute(:'ceterms:description') { 'Test Cred Description' }
5857
add_attribute(:'ceterms:subjectWebpage') { 'http://example.com/test-cred' }
@@ -69,34 +68,28 @@
6968
factory :cer_ass_prof, parent: :base_resource do
7069
add_attribute(:@type) { 'ceterms:AssessmentProfile' }
7170
add_attribute(:@context) { 'http://credreg.net/ctdl/schema/context/json' }
72-
transient { ctid { Envelope.generate_ctid } }
7371
add_attribute(:@id) do
7472
"http://credentialengineregistry.org/resources/#{ctid}"
7573
end
76-
add_attribute(:'ceterms:ctid') { ctid }
7774
add_attribute(:'ceterms:name') { 'Test Assessment Profile' }
7875
end
7976

8077
factory :cer_cond_man, parent: :base_resource do
8178
add_attribute(:@type) { 'ceterms:ConditionManifest' }
8279
add_attribute(:@context) { 'http://credreg.net/ctdl/schema/context/json' }
83-
transient { ctid { Envelope.generate_ctid } }
8480
add_attribute(:@id) do
8581
"http://credentialengineregistry.org/resources/#{ctid}"
8682
end
87-
add_attribute(:'ceterms:ctid') { ctid }
8883
add_attribute(:'ceterms:name') { 'Test Cond Man' }
8984
add_attribute(:'ceterms:conditionManifestOf') { [{ '@id' => 'AgentID' }] }
9085
end
9186

9287
factory :cer_cost_man, parent: :base_resource do
9388
add_attribute(:@type) { 'ceterms:CostManifest' }
9489
add_attribute(:@context) { 'http://credreg.net/ctdl/schema/context/json' }
95-
transient { ctid { Envelope.generate_ctid } }
9690
add_attribute(:@id) do
9791
"http://credentialengineregistry.org/resources/#{ctid}"
9892
end
99-
add_attribute(:'ceterms:ctid') { ctid }
10093
add_attribute(:'ceterms:name') { 'Test Cost Man' }
10194
add_attribute(:'ceterms:costDetails') { 'CostDetails' }
10295
add_attribute(:'ceterms:costManifestOf') { [{ '@id' => 'AgentID' }] }
@@ -105,11 +98,9 @@
10598
factory :cer_lrn_opp_prof, parent: :base_resource do
10699
add_attribute(:@type) { 'ceterms:CostManifest' }
107100
add_attribute(:@context) { 'http://credreg.net/ctdl/schema/context/json' }
108-
transient { ctid { Envelope.generate_ctid } }
109101
add_attribute(:@id) do
110102
"http://credentialengineregistry.org/resources/#{ctid}"
111103
end
112-
add_attribute(:'ceterms:ctid') { ctid }
113104
add_attribute(:'ceterms:name') { 'Test Lrn Opp Prof' }
114105
add_attribute(:'ceterms:costDetails') { 'CostDetails' }
115106
add_attribute(:'ceterms:costManifestOf') { [{ '@id' => 'AgentID' }] }
@@ -141,37 +132,31 @@
141132
add_attribute(:@id) { ctid }
142133
add_attribute(:@type) { 'ceterms:AssessmentProfile' }
143134
add_attribute(:@context) { 'http://credreg.net/ctdl/schema/context/json' }
144-
add_attribute(:'ceterms:ctid') { ctid }
145135
add_attribute(:'ceterms:name') { 'Test Assessment Profile' }
146136
add_attribute(:'ceasn:isPartOf') { part_of }
147137
end
148138

149139
factory :cer_competency, parent: :base_resource do
150140
transient { part_of { nil } }
151141
transient { competency_text { 'This is the competency text...' } }
152-
transient { ctid { Envelope.generate_ctid } }
153142
id { "http://credentialengineregistry.org/resources/#{ctid}" }
154143
add_attribute(:@id) { id }
155144
add_attribute(:@type) { 'ceasn:Competency' }
156-
add_attribute(:'ceterms:ctid') { ctid }
157145
add_attribute(:'ceasn:isPartOf') { part_of }
158146
add_attribute(:'ceasn:inLanguage') { ['en'] }
159147
add_attribute(:'ceasn:competencyText') { { 'en-us' => competency_text } }
160148
end
161149

162150
factory :cer_competency_framework, parent: :base_resource do
163-
transient { ctid { Envelope.generate_ctid } }
164151
id { "http://credentialengineregistry.org/resources/#{ctid}" }
165152
add_attribute(:@id) { id }
166153
add_attribute(:@type) { 'ceasn:CompetencyFramework' }
167-
add_attribute(:'ceterms:ctid') { ctid }
168154
add_attribute(:'ceasn:inLanguage') { ['en'] }
169155
add_attribute(:'ceasn:name') { { 'en-us' => 'Competency Framework name' } }
170156
add_attribute(:'ceasn:description') { { 'en-us' => 'Competency Framework description' } }
171157
end
172158

173159
factory :cer_graph_competency_framework, parent: :base_resource do
174-
transient { ctid { Envelope.generate_ctid } }
175160
id { "http://credentialengineregistry.org/resources/#{ctid}" }
176161
add_attribute(:@id) { id }
177162
add_attribute(:@type) { 'ceasn:CompetencyFramework' }
@@ -186,6 +171,5 @@
186171
attributes_for(:cer_competency_framework, ctid: ctid)
187172
]
188173
end
189-
add_attribute(:'ceterms:ctid') { ctid }
190174
end
191175
end
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
RSpec.describe SyncEnvelopeGraphWithS3 do # rubocop:todo RSpec/MultipleMemoizedHelpers
2+
let(:envelope) { build(:envelope, :from_cer) }
3+
let(:s3_bucket) { double('s3_bucket') } # rubocop:todo RSpec/VerifiedDoubles
4+
let(:s3_bucket_name) { Faker::Lorem.word }
5+
let(:s3_object) { double('s3_object') } # rubocop:todo RSpec/VerifiedDoubles
6+
let(:s3_region) { 'aws-s3_region-test' }
7+
let(:s3_resource) { double('s3_resource') } # rubocop:todo RSpec/VerifiedDoubles
8+
let(:s3_url) { Faker::Internet.url }
9+
10+
context 'without bucket' do # rubocop:todo RSpec/MultipleMemoizedHelpers
11+
describe '.upload' do # rubocop:todo RSpec/MultipleMemoizedHelpers
12+
it 'does nothing' do
13+
expect { described_class.upload(envelope) }.not_to raise_error
14+
end
15+
end
16+
17+
describe '.remove' do # rubocop:todo RSpec/MultipleMemoizedHelpers
18+
it 'does nothing' do
19+
expect { described_class.remove(envelope) }.not_to raise_error
20+
end
21+
end
22+
end
23+
24+
context 'with bucket' do # rubocop:todo RSpec/MultipleMemoizedHelpers
25+
before do
26+
ENV['AWS_REGION'] = s3_region
27+
ENV['ENVELOPE_GRAPHS_BUCKET'] = s3_bucket_name
28+
29+
# rubocop:todo RSpec/MessageSpies
30+
expect(Aws::S3::Resource).to receive(:new) # rubocop:todo RSpec/ExpectInHook, RSpec/MessageSpies
31+
# rubocop:enable RSpec/MessageSpies
32+
.with(region: s3_region)
33+
.and_return(s3_resource)
34+
.at_least(:once)
35+
36+
# rubocop:todo RSpec/MessageSpies
37+
expect(s3_resource).to receive(:bucket) # rubocop:todo RSpec/ExpectInHook, RSpec/MessageSpies
38+
# rubocop:enable RSpec/MessageSpies
39+
.with(s3_bucket_name)
40+
.and_return(s3_bucket)
41+
.at_least(:once)
42+
43+
# rubocop:todo RSpec/MessageSpies
44+
expect(s3_bucket).to receive(:object) # rubocop:todo RSpec/ExpectInHook, RSpec/MessageSpies
45+
# rubocop:enable RSpec/MessageSpies
46+
.with("ce_registry/#{envelope.envelope_ceterms_ctid}.json")
47+
.and_return(s3_object)
48+
.at_least(:once)
49+
50+
# rubocop:todo RSpec/MessageSpies
51+
expect(s3_object).to receive(:put).with( # rubocop:todo RSpec/ExpectInHook, RSpec/MessageSpies
52+
# rubocop:enable RSpec/MessageSpies
53+
body: envelope.processed_resource.to_json,
54+
content_type: 'application/json'
55+
)
56+
57+
# rubocop:todo RSpec/StubbedMock
58+
# rubocop:todo RSpec/MessageSpies
59+
expect(s3_object).to receive(:public_url).and_return(s3_url) # rubocop:todo RSpec/ExpectInHook, RSpec/MessageSpies, RSpec/StubbedMock
60+
# rubocop:enable RSpec/MessageSpies
61+
# rubocop:enable RSpec/StubbedMock
62+
end
63+
64+
describe '.upload' do # rubocop:todo RSpec/MultipleMemoizedHelpers
65+
it 'uploads the s3_resource to S3' do
66+
envelope.save!
67+
expect(envelope.s3_url).to eq(s3_url)
68+
end
69+
end
70+
71+
describe '.remove' do # rubocop:todo RSpec/MultipleMemoizedHelpers
72+
it 'uploads the s3_resource to S3' do
73+
expect(s3_object).to receive(:delete) # rubocop:todo RSpec/MessageSpies
74+
envelope.save!
75+
expect { envelope.destroy }.not_to raise_error
76+
end
77+
end
78+
end
79+
end

0 commit comments

Comments
 (0)