Skip to content

Commit 6aaec68

Browse files
committed
Add Smithy model support to generator
- Implement Smithy shape converter to handle traits-based format - Add service namespace resolution for qualified shape lookups - Implement staged pattern transformation with practical compatibility - Handle both enum-as-trait and enum-as-type formats - Sort enum values for deterministic output Key design decisions: - Prioritize clean, documented pattern transforms over exhaustive mapping - Accept some breaking changes for simpler architecture - Maintain critical ARN and validation pattern compatibility
1 parent e14f29c commit 6aaec68

File tree

130 files changed

+684
-149
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

130 files changed

+684
-149
lines changed

rules/models/generator/main.go

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"path/filepath"
1111
"sort"
12+
"strings"
1213

1314
hcl "github.com/hashicorp/hcl/v2"
1415
"github.com/hashicorp/hcl/v2/gohcl"
@@ -80,7 +81,13 @@ func main() {
8081
if shapeName == "any" {
8182
continue
8283
}
83-
model := shapes[shapeName].(map[string]interface{})
84+
85+
model := findShape(shapes, shapeName)
86+
if model == nil {
87+
fmt.Printf("Shape `%s` not found, skipping\n", shapeName)
88+
continue
89+
}
90+
8491
schema, err := fetchSchema(mapping.Resource, attribute, model, awsProvider)
8592
if err != nil {
8693
fmt.Fprintf(os.Stderr, "Error processing `%s.%s`: %v\n", mapping.Resource, attribute, err)
@@ -153,3 +160,119 @@ func validMapping(model map[string]interface{}) bool {
153160
return false
154161
}
155162
}
163+
164+
// findShape locates a shape in Smithy format with namespace-qualified lookup
165+
func findShape(shapes map[string]interface{}, shapeName string) map[string]interface{} {
166+
// Try with service namespace qualification (Smithy format)
167+
serviceNamespace := extractServiceNamespace(shapes)
168+
if serviceNamespace != "" {
169+
qualifiedName := fmt.Sprintf("%s#%s", serviceNamespace, shapeName)
170+
if shape, ok := shapes[qualifiedName]; ok {
171+
return convertSmithyShape(shape.(map[string]interface{}))
172+
}
173+
}
174+
175+
// Fallback to direct lookup (legacy format or unqualified shapes)
176+
if shape, ok := shapes[shapeName]; ok {
177+
if shapeMap, ok := shape.(map[string]interface{}); ok {
178+
return shapeMap
179+
}
180+
}
181+
182+
return nil
183+
}
184+
185+
// extractServiceNamespace extracts the namespace from the Smithy service definition
186+
func extractServiceNamespace(shapes map[string]interface{}) string {
187+
for shapeName, shape := range shapes {
188+
if shapeMap, ok := shape.(map[string]interface{}); ok {
189+
if shapeType, ok := shapeMap["type"].(string); ok && shapeType == "service" {
190+
// Extract namespace from shape name (e.g., "com.amazonaws.acmpca#ACMPrivateCA")
191+
if parts := strings.Split(shapeName, "#"); len(parts) == 2 {
192+
return parts[0]
193+
}
194+
}
195+
}
196+
}
197+
return ""
198+
}
199+
200+
// convertSmithyShape converts Smithy model format to internal format
201+
// Smithy uses traits for metadata while our internal format uses direct fields
202+
func convertSmithyShape(smithyShape map[string]interface{}) map[string]interface{} {
203+
result := make(map[string]interface{})
204+
205+
// Copy type
206+
if shapeType, ok := smithyShape["type"]; ok {
207+
result["type"] = shapeType
208+
}
209+
210+
// Extract constraints and patterns from Smithy traits
211+
if traits, ok := smithyShape["traits"].(map[string]interface{}); ok {
212+
// Length constraints
213+
if lengthTrait, ok := traits["smithy.api#length"].(map[string]interface{}); ok {
214+
if min, ok := lengthTrait["min"]; ok {
215+
result["min"] = min
216+
}
217+
if max, ok := lengthTrait["max"]; ok {
218+
result["max"] = max
219+
}
220+
}
221+
222+
// Pattern constraint
223+
if pattern, ok := traits["smithy.api#pattern"].(string); ok {
224+
result["pattern"] = pattern
225+
}
226+
227+
// Enum as trait (older Smithy style)
228+
if enumTrait, ok := traits["smithy.api#enum"]; ok {
229+
if enumList, ok := enumTrait.([]interface{}); ok {
230+
enumValues := make([]string, 0, len(enumList))
231+
for _, enumItem := range enumList {
232+
if enumMap, ok := enumItem.(map[string]interface{}); ok {
233+
if value, ok := enumMap["value"].(string); ok {
234+
enumValues = append(enumValues, value)
235+
}
236+
}
237+
}
238+
sort.Strings(enumValues)
239+
result["enum"] = enumValues
240+
}
241+
}
242+
}
243+
244+
// Enum as type (newer Smithy style: type="enum" with members)
245+
if shapeType, ok := smithyShape["type"].(string); ok && shapeType == "enum" {
246+
if members, ok := smithyShape["members"].(map[string]interface{}); ok {
247+
enumValues := make([]string, 0, len(members))
248+
249+
// Sort member names for deterministic ordering
250+
memberNames := make([]string, 0, len(members))
251+
for memberName := range members {
252+
memberNames = append(memberNames, memberName)
253+
}
254+
sort.Strings(memberNames)
255+
256+
// Extract enum values
257+
for _, memberName := range memberNames {
258+
memberData := members[memberName]
259+
enumValue := memberName
260+
261+
// Check for explicit enumValue in traits
262+
if memberMap, ok := memberData.(map[string]interface{}); ok {
263+
if traits, ok := memberMap["traits"].(map[string]interface{}); ok {
264+
if enumValueTrait, ok := traits["smithy.api#enumValue"].(string); ok {
265+
enumValue = enumValueTrait
266+
}
267+
}
268+
}
269+
enumValues = append(enumValues, enumValue)
270+
}
271+
272+
result["enum"] = enumValues
273+
result["type"] = "string" // Normalize enum type to string
274+
}
275+
}
276+
277+
return result
278+
}

rules/models/generator/rule.go

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,17 @@ func fetchNumber(model map[string]interface{}, key string) int {
8888

8989
func fetchStrings(model map[string]interface{}, key string) []string {
9090
if raw, ok := model[key]; ok {
91-
list := raw.([]interface{})
92-
ret := make([]string, len(list))
93-
for i, v := range list {
94-
ret[i] = v.(string)
91+
// Handle both []interface{} and []string for compatibility
92+
switch v := raw.(type) {
93+
case []interface{}:
94+
ret := make([]string, len(v))
95+
for i, item := range v {
96+
ret[i] = item.(string)
97+
}
98+
return ret
99+
case []string:
100+
return v
95101
}
96-
return ret
97102
}
98103
return []string{}
99104
}
@@ -109,14 +114,73 @@ func replacePattern(pattern string) string {
109114
if pattern == "" {
110115
return pattern
111116
}
112-
reg := regexp.MustCompile(`\\u([0-9A-F]{4})`)
113-
replaced := reg.ReplaceAllString(pattern, `\x{$1}`)
117+
118+
// Handle placeholder patterns (documentation text, not validation patterns)
119+
if pattern == "^See rules in parameter description$" {
120+
return ""
121+
}
122+
123+
// Convert Unicode escapes from \uXXXX to \x{XXXX} format
124+
unicodeEscapeRegex := regexp.MustCompile(`\\u([0-9A-F]{4})`)
125+
replaced := unicodeEscapeRegex.ReplaceAllString(pattern, `\x{$1}`)
126+
127+
// Fix incomplete patterns
128+
if replaced == "^(?s)" {
129+
return "^(?s).*$"
130+
}
131+
132+
// Handle patterns missing anchors
114133
if !strings.HasPrefix(replaced, "^") && !strings.HasSuffix(replaced, "$") {
134+
if replaced == "\\S" {
135+
return "^.*\\S.*$"
136+
}
115137
return fmt.Sprintf("^%s$", replaced)
116138
}
139+
140+
// Apply compatibility transforms to maintain backward compatibility with Ruby SDK
141+
if transformed := applyCompatibilityTransforms(replaced); transformed != "" {
142+
return transformed
143+
}
144+
117145
return replaced
118146
}
119147

148+
func applyCompatibilityTransforms(pattern string) string {
149+
// IAM role ARN patterns: Smithy models end at the role prefix (e.g., "role/")
150+
// but Terraform configurations include role paths (e.g., "role/my-app/my-role").
151+
// Add .* suffix to match the role name/path after the prefix.
152+
arnRolePatterns := map[string]string{
153+
"^arn:aws:iam::[0-9]*:role/": "^arn:aws:iam::[0-9]*:role/.*$",
154+
"^arn:aws:iam::\\d{12}:role/": "^arn:aws:iam::\\d{12}:role/.*$",
155+
"^arn:aws(-[\\w]+)*:iam::[0-9]{12}:role/": "^arn:aws(-[\\w]+)*:iam::[0-9]{12}:role/.*$",
156+
"^arn:aws(-[a-z]{1,3}){0,2}:iam::\\d+:role/": "^arn:aws(-[a-z]{1,3}){0,2}:iam::\\d+:role/.*$",
157+
}
158+
159+
// Cognito SMS authentication messages: The {####} placeholder must appear
160+
// within a message, not be the entire message. Transform from matching
161+
// literal "{####}" to matching any message containing "{####}".
162+
// Example valid message: "Your code is {####}. Do not share it."
163+
cognitoMessagePatterns := map[string]string{
164+
"^\\{####\\}$": "^.*\\{####\\}.*$",
165+
}
166+
167+
// WorkSpaces directory IDs: Smithy pattern has redundant end anchors ($)
168+
// inside each alternation branch AND at the end of the pattern.
169+
// Pattern "(foo$)|(bar$)$" is equivalent to "(foo)|(bar)" when the outer
170+
// anchors are present. Remove inner anchors for cleaner regex.
171+
workspacesDirectoryPatterns := map[string]string{
172+
"^(d-[0-9a-f]{8,63}$)|(wsd-[0-9a-z]{8,63}$)$": "(d-[0-9a-f]{8,63}$)|(wsd-[0-9a-z]{8,63}$)",
173+
}
174+
175+
for _, transforms := range []map[string]string{arnRolePatterns, cognitoMessagePatterns, workspacesDirectoryPatterns} {
176+
if transformed, exists := transforms[pattern]; exists {
177+
return transformed
178+
}
179+
}
180+
181+
return ""
182+
}
183+
120184
func formatTest(body string) string {
121185
if strings.Contains(body, "\n") {
122186
return fmt.Sprintf("<<TEXT\n%sTEXT", body)

rules/models/mappings/access-analyzer.hcl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import = "aws-sdk-ruby/apis/accessanalyzer/2019-11-01/api-2.json"
1+
import = "api-models-aws/models/accessanalyzer/service/2019-11-01/accessanalyzer-2019-11-01.json"
22

33
mapping "aws_accessanalyzer_analyzer" {
44
analyzer_name = Name

rules/models/mappings/account.hcl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import = "aws-sdk-ruby/apis/account/2021-02-01/api-2.json"
1+
import = "api-models-aws/models/account/service/2021-02-01/account-2021-02-01.json"
22

33
mapping "aws_account_alternate_contact" {
44
account_id = AccountId

rules/models/mappings/acm-pca.hcl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import = "aws-sdk-ruby/apis/acm-pca/2017-08-22/api-2.json"
1+
import = "api-models-aws/models/acm-pca/service/2017-08-22/acm-pca-2017-08-22.json"
22

33
mapping "aws_acmpca_certificate" {
44
certificate_authority_arn = Arn

rules/models/mappings/acm.hcl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import = "aws-sdk-ruby/apis/acm/2015-12-08/api-2.json"
1+
import = "api-models-aws/models/acm/service/2015-12-08/acm-2015-12-08.json"
22

33
mapping "aws_acm_certificate" {
44
// domain_name = DomainNameString

rules/models/mappings/amplify.hcl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import = "aws-sdk-ruby/apis/amplify/2017-07-25/api-2.json"
1+
import = "api-models-aws/models/amplify/service/2017-07-25/amplify-2017-07-25.json"
22

33
mapping "aws_amplify_app" {
44
name = Name

rules/models/mappings/apigateway.hcl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import = "aws-sdk-ruby/apis/apigateway/2015-07-09/api-2.json"
1+
import = "api-models-aws/models/api-gateway/service/2015-07-09/api-gateway-2015-07-09.json"
22

33
mapping "aws_api_gateway_documentation_part" {
44
location = DocumentationPartLocation

rules/models/mappings/apigatewayv2.hcl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import = "aws-sdk-ruby/apis/apigatewayv2/2018-11-29/api-2.json"
1+
import = "api-models-aws/models/apigatewayv2/service/2018-11-29/apigatewayv2-2018-11-29.json"
22

33
mapping "aws_apigatewayv2_api" {
44
name = StringWithLengthBetween1And128

rules/models/mappings/appconfig.hcl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import = "aws-sdk-ruby/apis/appconfig/2019-10-09/api-2.json"
1+
import = "api-models-aws/models/appconfig/service/2019-10-09/appconfig-2019-10-09.json"
22

33
mapping "aws_appconfig_application" {
44
name = Name

0 commit comments

Comments
 (0)