aurora-data-api 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.
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