Skip to content

Commit

Permalink
Merge pull request #239 from hashicorp/f/recaser-scoped-ids
Browse files Browse the repository at this point in the history
`recaser` - extend functionality and expose error handling for better utility
  • Loading branch information
jackofallops authored Jul 11, 2024
2 parents f25923e + 8a5b641 commit 00ad0d2
Show file tree
Hide file tree
Showing 3 changed files with 156 additions and 26 deletions.
148 changes: 125 additions & 23 deletions resourcemanager/recaser/recase.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ package recaser

import (
"fmt"
"reflect"
"regexp"
"strings"

"github.com/hashicorp/go-azure-helpers/lang/pointer"
"github.com/hashicorp/go-azure-helpers/resourcemanager/resourceids"
)

Expand All @@ -17,54 +19,109 @@ func ReCase(input string) string {
}

// reCaseWithIds tries to determine the type of Resource ID defined in `input` to be able to re-case it based on an input list of Resource IDs
// this is a "best-effort" function and can return the input unmodified. Functionality of this method is intended to be
// limited to resource IDs that have been registered with the package via the RegisterResourceId() function at init.
// However, some common static segments are corrected even when a corresponding ID type is not present.
func reCaseWithIds(input string, ids map[string]resourceids.ResourceId) string {
result, err := reCaseKnownId(input, ids)
if err == nil {
return pointer.From(result)
}

output := input
recased := false

// if we didn't find a matching id then re-case these known segments for best effort
segmentsToFix := []string{
"/subscriptions/",
"/resourceGroups/",
"/managementGroups/",
"/tenants/",
}

for _, segment := range segmentsToFix {
output = fixSegment(output, segment)
}

return output
}

// ReCaseKnownId attempts to correct the casing on the static segments of an Azure resourceId. Functionality of this
// method is intended to be limited to resource IDs that have been registered with the package via the
// RegisterResourceId() function at init.
func ReCaseKnownId(input string) (*string, error) {
return reCaseKnownId(input, knownResourceIds)
}

func reCaseKnownId(input string, ids map[string]resourceids.ResourceId) (*string, error) {
output := input
parsed := false
key, ok := buildInputKey(input)
if ok {
id := ids[*key]
if id != nil {
output, recased = parseId(id, input)
var parseError error
output, parseError = parseId(id, input)
if parseError != nil {
return &output, fmt.Errorf("fixing case for ID '%s': %+v", input, parseError)
}
parsed = true
} else {
for _, v := range PotentialScopeValues() {
trimmedKey := strings.TrimPrefix(*key, v)
if id = knownResourceIds[trimmedKey]; id != nil {
var parseError error
output, parseError = parseId(id, input)
if parseError != nil {
return &output, fmt.Errorf("fixing case for ID '%s': %+v", input, parseError)
} else {
parsed = true
break
}
}
// We have some cases where an erroneous trailing '/' causes problems. These may be data errors in specs, or API responses.
// Either way, we can try and compensate for it.
if id = knownResourceIds[strings.TrimPrefix(*key, strings.TrimSuffix(v, "/"))]; id != nil {
var parseError error
output, parseError = parseId(id, input)
if parseError != nil {
return &output, fmt.Errorf("fixing case for ID '%s': %+v", input, parseError)
} else {
parsed = true
break
}
}
}
}
}

// if we can't find a matching id recase these known segments
if !recased {

segmentsToFix := []string{
"/subscriptions/",
"/resourceGroups/",
"/managementGroups/",
"/tenants/",
}

for _, segment := range segmentsToFix {
output = fixSegment(output, segment)
}
if !parsed {
return &output, fmt.Errorf("could not determine ID type for '%s', or ID type not supported", input)
}

return output
return &output, nil
}

// parseId uses the specified ResourceId to parse the input and returns the id string with correct casing
func parseId(id resourceids.ResourceId, input string) (string, bool) {
func parseId(id resourceids.ResourceId, input string) (string, error) {

// we need to take a local copy of id to work against else we're mutating the original
localId := id

parser := resourceids.NewParserFromResourceIdType(localId)
parsed, err := parser.Parse(input, true)
if err != nil {
return input, false
return input, err
}

if scope := parsed.Parsed["scope"]; scope != "" {
parsed.Parsed["scope"] = reCaseWithIds(scope, knownResourceIds)
}

if err = id.FromParseResult(*parsed); err != nil {
return input, false
return input, err
}
input = id.ID()

return input, true
return input, err
}

// fixSegment searches the input id string for a specified segment case-insensitively
Expand All @@ -81,9 +138,13 @@ func fixSegment(input, segment string) string {
// so it can be used as a key to extract the correct id from knownResourceIds
func buildInputKey(input string) (*string, bool) {

// don't attempt to build a key if this isn't a standard resource id
// Attempt to determine if this is just missing a leading slash and prepend it if it seems to be
if !strings.HasPrefix(input, "/") {
return nil, false
if len(input) == 0 || !strings.Contains(input, "/") {
return nil, false
}

input = "/" + input
}

output := ""
Expand Down Expand Up @@ -111,3 +172,44 @@ func buildInputKey(input string) (*string, bool) {
output = strings.ToLower(output)
return &output, true
}

// PotentialScopeValues returns a list of possible ScopeSegment values from all registered ID types
// This is a best effort process, limited to scope targets that are prefixed with '/subscriptions/' or '/providers/'
func PotentialScopeValues() []string {
result := make([]string, 0)
for k := range knownResourceIds {
if strings.HasPrefix(k, "/subscriptions/") || strings.HasPrefix(k, "/providers/") {
result = append(result, k)
}
}

return result
}

// ResourceIdTypeFromResourceId takes a Azure Resource ID as a string and attempts to return the corresponding
// resourceids.ResourceId type. If a matching resourceId is not found in the supported/registered resourceId types then
// a `nil` value is returned.
func ResourceIdTypeFromResourceId(input string) resourceids.ResourceId {
key, ok := buildInputKey(input)
if ok {
id := knownResourceIds[*key]
if id != nil {
result := reflect.New(reflect.TypeOf(id).Elem())
return result.Interface().(resourceids.ResourceId)
} else {
for _, v := range PotentialScopeValues() {
trimmedKey := strings.TrimPrefix(*key, v)
if id = knownResourceIds[trimmedKey]; id != nil {
result := reflect.New(reflect.TypeOf(id).Elem())
return result.Interface().(resourceids.ResourceId)
}
if id = knownResourceIds[strings.TrimPrefix(*key, strings.TrimSuffix(v, "/"))]; id != nil {
result := reflect.New(reflect.TypeOf(id).Elem())
return result.Interface().(resourceids.ResourceId)
}
}
}
}

return nil
}
25 changes: 22 additions & 3 deletions resourcemanager/recaser/recaser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package recaser

import (
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -132,6 +133,15 @@ func TestReCaserWithOddNumberOfSegmentsAndIncorrectCasing(t *testing.T) {
}
}

func TestReCaserWithScopedID(t *testing.T) {
expected := "/subscriptions/12345678-1234-9876-4563-123456789012/resourceGroups/resGroup1/providers/Microsoft.Chaos/targets/target1"
actual := reCaseWithIds("/SubsCriptions/12345678-1234-9876-4563-123456789012/resourcegroups/resGroup1/providers/microsoft.chaos/Targets/target1", getTestIds())

if actual != expected {
t.Fatalf("Expected %q but got %q", expected, actual)
}
}

func TestReCaserWithURIAndCorrectCasing(t *testing.T) {
expected := "https://management.azure.com:80/subscriptions/12345"
actual := reCaseWithIds("https://management.azure.com:80/subscriptions/12345", getTestIds())
Expand Down Expand Up @@ -161,8 +171,17 @@ func TestReCaserWithDataPlaneURI(t *testing.T) {

func getTestIds() map[string]resourceids.ResourceId {
return map[string]resourceids.ResourceId{
strings.ToLower(commonids.AppServiceId{}.ID()): &commonids.AppServiceId{},
strings.ToLower(commonids.AvailabilitySetId{}.ID()): &commonids.AvailabilitySetId{},
strings.ToLower(commonids.BotServiceId{}.ID()): &commonids.BotServiceId{},
strings.ToLower(commonids.AppServiceId{}.ID()): &commonids.AppServiceId{},
strings.ToLower(commonids.AvailabilitySetId{}.ID()): &commonids.AvailabilitySetId{},
strings.ToLower(commonids.BotServiceId{}.ID()): &commonids.BotServiceId{},
strings.ToLower(commonids.ChaosStudioTargetId{}.ID()): &commonids.ChaosStudioTargetId{},
}
}

func TestResourceIdTypeFromResourceId(t *testing.T) {
for k, v := range getTestIds() {
if actual := ResourceIdTypeFromResourceId(k); !reflect.DeepEqual(v, actual) {
t.Fatalf("Expected %q but got %q", v, actual)
}
}
}
9 changes: 9 additions & 0 deletions resourcemanager/recaser/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ import (

var knownResourceIds = make(map[string]resourceids.ResourceId)

// KnownResourceIds returns the map of resource IDs that have been registered by each API imported via the
// RegisterResourceId function. This is the case for all APIs generated via the Pandora project via init().
// The keys for the map are the lower-cased ID strings with the user-specified segments
// stripped out, leaving the path intact. Example:
// "/subscriptions//resourceGroups//providers/Microsoft.BotService/botServices/"
func KnownResourceIds() map[string]resourceids.ResourceId {
return knownResourceIds
}

var resourceIdsWriteLock = &sync.Mutex{}

func init() {
Expand Down

0 comments on commit 00ad0d2

Please sign in to comment.