aurora-data-api 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.rake_tasks~ +10 -0
  3. data/.standard.yml +6 -0
  4. data/CHANGELOG.md +3 -0
  5. data/CODE_OF_CONDUCT.md +84 -0
  6. data/Gemfile +15 -0
  7. data/Gemfile.lock +103 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +236 -0
  10. data/Rakefile +18 -0
  11. data/Steepfile +5 -0
  12. data/aurora-data-api.gemspec +34 -0
  13. data/example/.gitignore +58 -0
  14. data/example/.ruby-version +1 -0
  15. data/example/Dockerfile +26 -0
  16. data/example/Gemfile +9 -0
  17. data/example/Gemfile.lock +48 -0
  18. data/example/README.md +45 -0
  19. data/example/Rakefile +48 -0
  20. data/example/app/depots/comment_depot.rb +6 -0
  21. data/example/app/depots/entry_depot.rb +6 -0
  22. data/example/app/depots/user_depot.rb +6 -0
  23. data/example/app/handlers/main.rb +86 -0
  24. data/example/app/models/comment.rb +10 -0
  25. data/example/app/models/entry.rb +12 -0
  26. data/example/app/models/user.rb +13 -0
  27. data/example/compose.yml +43 -0
  28. data/example/db/.gitignore +2 -0
  29. data/example/db/.keep +0 -0
  30. data/example/package-lock.json +4740 -0
  31. data/example/package.json +17 -0
  32. data/example/serverless.yml +184 -0
  33. data/exe/aurora-data-api +161 -0
  34. data/lib/aurora-data-api/data_service.rb +42 -0
  35. data/lib/aurora-data-api/depot.rb +132 -0
  36. data/lib/aurora-data-api/environment.rb +41 -0
  37. data/lib/aurora-data-api/model.rb +152 -0
  38. data/lib/aurora-data-api/version.rb +5 -0
  39. data/lib/aurora-data-api.rb +14 -0
  40. data/sig/aurora-data-api.rbs +20 -0
  41. data/sig/data_service.rbs +7 -0
  42. data/sig/depot.rbs +23 -0
  43. data/sig/environment.rbs +13 -0
  44. data/sig/model.rbs +39 -0
  45. data/sig/version.rbs +3 -0
  46. metadata +120 -0
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "aurora-data-api-example",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "author": "",
10
+ "license": "ISC",
11
+ "devDependencies": {
12
+ "serverless": "^3.17.0",
13
+ "serverless-offline": "^8.8.0",
14
+ "serverless-ruby-layer": "^1.6.0",
15
+ "serverless-vpc-plugin": "^1.0.4"
16
+ }
17
+ }
@@ -0,0 +1,184 @@
1
+ service: aurora-data-api-example
2
+
3
+ frameworkVersion: '3'
4
+
5
+ provider:
6
+ name: aws
7
+ runtime: ruby2.7
8
+ stage: ${opt:stage, self:custom.defaultStage}
9
+ endpointType: REGIONAL
10
+ region: ap-northeast-1
11
+ environment:
12
+ STAGE: ${sls:stage}
13
+ TZ: Asia/Tokyo
14
+ DATA_API_ENDPOINT:
15
+ Fn::Join:
16
+ - ""
17
+ - - "https://"
18
+ - !GetAtt "RDSCluster.Endpoint.Address"
19
+ RDS_RESOURCE_ARN: ${self:custom.db_resource_arn}
20
+ RDS_SECRET_ARN: !Ref DBSecret
21
+ PGDATABASE: ${self:custom.myEnvironment.PGDATABASE}
22
+ iam:
23
+ role:
24
+ statements:
25
+ - Effect: "Allow"
26
+ Action: lambda:InvokeFunction
27
+ Resource:
28
+ - !Join
29
+ - ""
30
+ - - "arn:aws:lambda:${self:provider.region}:"
31
+ - !Ref AWS::AccountId
32
+ - ":function:"
33
+ - "*"
34
+ - Effect: "Allow"
35
+ Action: secretsmanager:GetSecretValue
36
+ Resource:
37
+ - !Ref DBSecret
38
+ - Effect: "Allow"
39
+ Action:
40
+ - rds-data:BatchExecuteStatement
41
+ - rds-data:BeginTransaction
42
+ - rds-data:CommitTransaction
43
+ - rds-data:ExecuteStatement
44
+ - rds-data:RollbackTransaction
45
+ Resource:
46
+ - ${self:custom.db_resource_arn}
47
+ logs:
48
+ restApi:
49
+ accessLogging: true
50
+ format: '{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","requestTime":"$context.requestTime","httpMethod":"$context.httpMethod","routeKey":"$context.routeKey","status":"$context.status","protocol":"$context.protocol","responseLength":"$context.responseLength","errorMessage":"$context.integrationErrorMessage"}'
51
+ executionLogging: true
52
+ level: INFO
53
+ fullExecutionData: true
54
+
55
+ plugins:
56
+ - serverless-offline
57
+ - serverless-vpc-plugin
58
+ - serverless-ruby-layer
59
+
60
+ custom:
61
+ defaultStage: offline
62
+ serverless-offline:
63
+ httpPort: 4000
64
+ db_resource_arn: !Join
65
+ - ""
66
+ - - "arn:aws:rds:${self:provider.region}:"
67
+ - !Ref AWS::AccountId
68
+ - ":cluster:"
69
+ - !Ref RDSCluster
70
+ myEnvironment:
71
+ DBMaxCapacity:
72
+ prod: 4
73
+ PGDATABASE: mydatabase
74
+ ALLOWED_ORIGIN:
75
+ offline: http://localhost:4000
76
+ prod: "*" # TODO: You have to specify it
77
+ vpcConfig:
78
+ # see https://www.serverless.com/plugins/serverless-vpc-plugin
79
+ enabled: true
80
+ cidrBlock: '10.0.0.0/16'
81
+ createNatGateway: false
82
+ createNetworkAcl: false
83
+ createDbSubnet: true
84
+ createFlowLogs: false
85
+ createBastionHost: false
86
+ createNatInstance: false
87
+ createParameters: true
88
+ services:
89
+ - secretsmanager
90
+ subnetGroups:
91
+ - rds
92
+ exportOutputs: true
93
+
94
+ resources:
95
+ Resources:
96
+ RDSCluster:
97
+ Type: AWS::RDS::DBCluster
98
+ Properties:
99
+ DBClusterIdentifier: !Sub "${self:service}-${self:provider.stage}-aurora-psql"
100
+ MasterUsername: !Join ['', ['{{resolve:secretsmanager:', !Ref DBSecret, ':SecretString:username}}' ]]
101
+ MasterUserPassword: !Join ['', ['{{resolve:secretsmanager:', !Ref DBSecret, ':SecretString:password}}' ]]
102
+ DatabaseName: ${self:custom.myEnvironment.PGDATABASE}
103
+ Engine: aurora-postgresql
104
+ EngineMode: serverless
105
+ EnableHttpEndpoint: true
106
+ EngineVersion: 10.14
107
+ ScalingConfiguration:
108
+ AutoPause: true
109
+ MaxCapacity: ${self:custom.myEnvironment.DBMaxCapacity.${sls:stage}, 2}
110
+ MinCapacity: 2
111
+ SecondsUntilAutoPause: 900 # 15 min for example
112
+ DBSubnetGroupName:
113
+ Ref: DBSubnetGroup
114
+ DBSecret:
115
+ Type: AWS::SecretsManager::Secret
116
+ Properties:
117
+ Name: !Sub "${self:service}-${self:provider.stage}-AuroraUserSecret"
118
+ Description: RDS database auto-generated user password
119
+ GenerateSecretString:
120
+ SecretStringTemplate: !Sub '{"username": "${self:provider.stage}Root"}'
121
+ GenerateStringKey: "password"
122
+ PasswordLength: 30
123
+ ExcludeCharacters: '"@/\'
124
+ DBSubnetGroup:
125
+ Type: AWS::RDS::DBSubnetGroup
126
+ Properties:
127
+ DBSubnetGroupDescription: CloudFormation managed DB subnet group.
128
+ SubnetIds:
129
+ - !Ref "DBSubnet1"
130
+ - !Ref "DBSubnet2"
131
+ - !Ref "DBSubnet3"
132
+
133
+ functions:
134
+ hello:
135
+ handler: app/handlers/main.hello
136
+ events:
137
+ - http:
138
+ path: hello
139
+ method: get
140
+ users:
141
+ handler: app/handlers/main.users
142
+ events:
143
+ - http:
144
+ path: users
145
+ method: get
146
+ create_user:
147
+ handler: app/handlers/main.create_user
148
+ events:
149
+ - http:
150
+ path: create_user
151
+ method: post
152
+ update_user:
153
+ handler: app/handlers/main.update_user
154
+ events:
155
+ - http:
156
+ path: update_user
157
+ method: put
158
+ entries:
159
+ handler: app/handlers/main.entries
160
+ events:
161
+ - http:
162
+ path: entries
163
+ method: get
164
+ create_entry:
165
+ handler: app/handlers/main.create_entry
166
+ events:
167
+ - http:
168
+ path: create_entry
169
+ method: post
170
+ delete_entry:
171
+ handler: app/handlers/main.delete_entry
172
+ events:
173
+ - http:
174
+ path: delete_entry
175
+ method: post
176
+ count_entry:
177
+ handler: app/handlers/main.count_entry
178
+ events:
179
+ - http:
180
+ path: count_entry
181
+ method: get
182
+
183
+
184
+
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "aurora-data-api/version"
4
+ require "thor"
5
+
6
+ class Date
7
+ end
8
+
9
+ module AuroraDataApi
10
+ class Error < StandardError; end
11
+
12
+ class Schema
13
+ CREATE_TABLE = []
14
+ ALTER_TABLE = []
15
+ end
16
+
17
+ class Model
18
+ SCHEMA = {literal_id: :id}
19
+
20
+ def self.literal_id(lit)
21
+ SCHEMA[:literal_id] = lit
22
+ end
23
+
24
+ def self.table(name)
25
+ SCHEMA[:table_name] = name
26
+ end
27
+
28
+ def self.schema(&block)
29
+ converter = Converter.new(SCHEMA, self)
30
+ converter.head
31
+ converter.instance_eval(&block)
32
+ converter.tail
33
+ Schema::CREATE_TABLE << converter.create_table
34
+ Schema::ALTER_TABLE << converter.alter_table
35
+ end
36
+ end
37
+
38
+ class Converter
39
+ TYPES = {
40
+ ::Date => "date",
41
+ ::Time => "timestamp with time zone",
42
+ ::String => "text",
43
+ ::Integer => "bigint",
44
+ ::Float => "double precision"
45
+ }
46
+
47
+ def initialize(schema, klass)
48
+ @table_name = schema[:table_name] || "#{klass.name.downcase}s"
49
+ @literal_id = schema[:literal_id]
50
+ @create_table = []
51
+ @alter_table = []
52
+ end
53
+
54
+ attr_reader :create_table, :alter_table
55
+
56
+ def head
57
+ @create_table << %/CREATE TABLE "#{@table_name}" (/
58
+ @create_table << %( "#{@literal_id}" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,)
59
+ end
60
+
61
+ def tail
62
+ @create_table << %/ PRIMARY KEY ("#{@literal_id}")/
63
+ @create_table << %/);\n/
64
+ end
65
+
66
+ def timestamp
67
+ @create_table << %( "created_at" #{TYPES[Time]} NOT NULL,)
68
+ @create_table << %( "updated_at" #{TYPES[Time]} NOT NULL,)
69
+ end
70
+
71
+ def col(name, type, **params)
72
+ line = " "
73
+ case type
74
+ when Symbol
75
+ col_name = "#{name}_#{@literal_id}"
76
+ line << %("#{col_name}" )
77
+ line << TYPES[Integer]
78
+ @alter_table << %/ALTER TABLE ONLY "#{@table_name}" ADD CONSTRAINT "#{@table_name}_#{col_name}_fkey" FOREIGN KEY ("#{col_name}") REFERENCES "#{params[:table]}" ("#{@literal_id}");/
79
+ else
80
+ line << %("#{name}" )
81
+ line << TYPES[type]
82
+ end
83
+ params.each do |k, v|
84
+ case k
85
+ when :null
86
+ line << " NOT NULL" unless v
87
+ when :default
88
+ line << " DEFAULT "
89
+ line << case v
90
+ when String, Symbol
91
+ "'#{v}'"
92
+ else
93
+ v.to_s
94
+ end
95
+ when :unique
96
+ line << " UNIQUE" if v
97
+ end
98
+ end
99
+ line << ","
100
+ @create_table << line
101
+ end
102
+ end
103
+
104
+ class Tool < Thor
105
+ DEFAULT_MODELS_DIR = "app/models"
106
+ DEFAULT_OUTPUT_PATH = "db/schema.sql"
107
+
108
+ desc "version", "Print version"
109
+ def version
110
+ puts "aurora-data-api v#{AuroraDataApi::VERSION}"
111
+ end
112
+
113
+ desc "export", "Overwrite #{DEFAULT_OUTPUT_PATH} by aggregating #{DEFAULT_MODELS_DIR}/*.rb"
114
+ option :models, aliases: :m, default: DEFAULT_MODELS_DIR
115
+ option :output, aliases: :o, default: DEFAULT_OUTPUT_PATH
116
+ def export
117
+ models_dir = options[:models]
118
+ output_path = options[:output]
119
+ Dir.glob("#{models_dir}/*.rb").each do |rb|
120
+ load rb
121
+ end
122
+ if Schema::CREATE_TABLE.empty? && Schema::ALTER_TABLE.empty?
123
+ puts "Nothing to be exported."
124
+ exit 1
125
+ end
126
+ overwrite = false
127
+ if File.exist? output_path
128
+ print "#{output_path} exists. Overwrite? [Y/n]: "
129
+ answer = $stdin.gets.chomp
130
+ if %w[Y y yes].include?(answer.chomp)
131
+ overwrite = true
132
+ else
133
+ puts "Abort."
134
+ exit 1
135
+ end
136
+ else
137
+ FileUtils.touch output_path
138
+ overwrite = true
139
+ end
140
+ if overwrite
141
+ File.open output_path, "w" do |f|
142
+ f.write <<~COMMENT
143
+ /*
144
+ * This file was automatically genarated by the command:
145
+ * aurora-data-api export --models #{models_dir} --output #{output_path}
146
+ *
147
+ * Genarated at #{Time.now}
148
+ *
149
+ * https://github.com/hasumikin/aurora-data-api
150
+ */\n
151
+ COMMENT
152
+ f.write Schema::CREATE_TABLE.flatten.join("\n")
153
+ f.write "\n"
154
+ f.write Schema::ALTER_TABLE.flatten.join("\n")
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
160
+
161
+ AuroraDataApi::Tool.start
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aws-sdk-rdsdataservice"
4
+ require_relative "environment"
5
+
6
+ module AuroraDataApi
7
+ class DataService
8
+ def initialize
9
+ client_param = {region: Environment.region}
10
+ if Environment.offline?
11
+ offline_config_update
12
+ client_param[:endpoint] = Environment.offline_endpoint
13
+ end
14
+ @client = Aws::RDSDataService::Client.new(client_param)
15
+ @execute_params = {
16
+ database: Environment.database_name,
17
+ secret_arn: Environment.secret_arn,
18
+ resource_arn: Environment.resource_arn,
19
+ include_result_metadata: true
20
+ }
21
+ end
22
+
23
+ def execute(hash, without_database = false)
24
+ if without_database
25
+ @client.execute_statement(
26
+ hash.merge(@execute_params.reject { |k, _v| k == :database })
27
+ )
28
+ else
29
+ @client.execute_statement(hash.merge(@execute_params))
30
+ end
31
+ end
32
+
33
+ private def offline_config_update
34
+ Aws.config.update({
35
+ credentials: Aws::Credentials.new(
36
+ "AWS_ACCESS_KEY_dummy",
37
+ "AWS_SECRET_ACCESS_KEY_dummy"
38
+ )
39
+ })
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AuroraDataApi
4
+ class Depot
5
+ def self.[](model, &block)
6
+ # @type var block: Proc
7
+ depot = new(model, block)
8
+ AuroraDataApi.const_set("#{model.name}Depot".to_sym, depot)
9
+ depot
10
+ end
11
+
12
+ def initialize(model, block)
13
+ @model = model
14
+ instance_eval { block.call }
15
+ @data_service = DataService.new
16
+ end
17
+
18
+ def table_name
19
+ @table_name ||= Model::SCHEMA[@model.name.to_sym][:table_name] || "#{@model.to_s.downcase}s"
20
+ end
21
+
22
+ def literal_id
23
+ @literal_id ||= Model::SCHEMA[@model.name.to_sym][:literal_id] || :id
24
+ end
25
+
26
+ def insert(obj)
27
+ obj.set_timestamp(at: :create)
28
+ params = obj.build_params
29
+ res = query(<<~SQL, **params)
30
+ INSERT INTO "#{obj.table_name}"
31
+ (#{params.keys.map { |k| "\"#{k}\"" }.join(",")})
32
+ VALUES
33
+ (#{params.keys.map { |k| ":#{k}" }.join(",")})
34
+ RETURNING "#{obj.literal_id}";
35
+ SQL
36
+ obj._set_id(res.records[0][0].value)
37
+ end
38
+
39
+ def update(obj)
40
+ obj.set_timestamp(at: :update)
41
+ params = obj.build_params
42
+ query(<<~SQL, **params)
43
+ UPDATE "#{obj.table_name}" SET
44
+ #{params.keys.reject { |k| k == obj.literal_id }.map { |k| "\"#{k}\" = :#{k}" }.join(", ")}
45
+ WHERE "#{obj.literal_id}" = :#{obj.literal_id};
46
+ SQL
47
+ true
48
+ end
49
+
50
+ def delete(obj)
51
+ query(<<~SQL, id: obj.id)
52
+ DELETE FROM "#{obj.table_name}" WHERE "#{obj.literal_id}" = :id;
53
+ SQL
54
+ obj._destroy
55
+ true
56
+ end
57
+
58
+ def count(str = "", **params)
59
+ query(
60
+ "SELECT COUNT(\"#{literal_id}\") FROM \"#{table_name}\" #{str};",
61
+ **params
62
+ ).records[0][0].long_value
63
+ end
64
+
65
+ def select(str, **params)
66
+ result = query("select * from \"#{table_name}\" #{str};", **params)
67
+ related_objects = {}
68
+ result.records.map do |record|
69
+ relationships = {}
70
+ attributes = {}.tap do |attrribute|
71
+ result.column_metadata.each_with_index do |meta, index|
72
+ if meta.table_name == table_name.to_s
73
+ attrribute[meta.name.to_sym] = column_data(meta, record[index])
74
+ else
75
+ table_sym = meta.table_name.to_sym
76
+ name, rel_model = @model.relationship_by(table_sym)
77
+ if name
78
+ relationships[name] ||= {}
79
+ relationships[name][:attr] ||= {}
80
+ relationships[name][:model] ||= rel_model
81
+ relationships[name][:attr][meta.name.to_sym] = column_data(meta, record[index])
82
+ end
83
+ end
84
+ end
85
+ end
86
+ relationships.each do |name, data|
87
+ related_objects[name] ||= {}
88
+ id = data[:attr][literal_id]
89
+ obj = related_objects.dig(name, id)
90
+ unless obj
91
+ obj = Kernel.const_get(data[:model]).new(**data[:attr])
92
+ related_objects[name][id] = obj
93
+ end
94
+ attributes[name] = obj
95
+ end
96
+ @model.new(attributes)
97
+ end
98
+ end
99
+
100
+ def column_data(meta, col)
101
+ return nil if col.is_null
102
+ case meta.type_name
103
+ when "text"
104
+ col.value.gsub("''", "'")
105
+ when "timestamptz"
106
+ Time.parse(col.value) + 9 * 60 * 60 # workaround
107
+ else
108
+ col.value
109
+ end
110
+ end
111
+
112
+ def query(str, **params)
113
+ @data_service.execute({
114
+ sql: str,
115
+ parameters: params.map do |param|
116
+ hash = {name: param[0].to_s, value: {}}
117
+ case param[1]
118
+ when Integer
119
+ hash[:value][:long_value] = param[1]
120
+ when Float
121
+ hash[:value][:double_value] = param[1]
122
+ when TrueClass, FalseClass
123
+ hash[:value][:boolean_value] = param[1]
124
+ else # TODO: confirm format and timezone when Time
125
+ hash[:value][:string_value] = param[1].to_s.gsub("'", "''")
126
+ end
127
+ hash
128
+ end
129
+ })
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AuroraDataApi
4
+ class Environment
5
+ # Resources about "OFFLINE":
6
+ # https://www.serverless.com/plugins/serverless-offline
7
+ # https://github.com/koxudaxi/local-data-api
8
+
9
+ def self.offline?
10
+ ENV["IS_OFFLINE"] == "true"
11
+ end
12
+
13
+ def self.offline_endpoint
14
+ "http://local-data-api"
15
+ end
16
+
17
+ def self.database_name
18
+ ENV["PGDATABASE"] || ENV["MYSQL_DATABASE"]
19
+ end
20
+
21
+ def self.region
22
+ ENV.fetch("AWS_DEFAULT_REGION", "ap-northeast-1")
23
+ end
24
+
25
+ def self.secret_arn
26
+ if offline?
27
+ "arn:aws:secretsmanager:us-east-1:123456789012:secret:dummy"
28
+ else
29
+ ENV["RDS_SECRET_ARN"]
30
+ end
31
+ end
32
+
33
+ def self.resource_arn
34
+ if offline?
35
+ "arn:aws:rds:us-east-1:123456789012:cluster:dummy"
36
+ else
37
+ ENV["RDS_RESOURCE_ARN"]
38
+ end
39
+ end
40
+ end
41
+ end