Résumé Builder Part II

26 Feb 2016

In Part I, I wrote about the inspiration for the tool, and walked through transforming free-form data into a more structured form. Today, we’ll work on how our tool will work with the data.

Our résumé is defined fairly easily, based on the json file we already created. If the ultimate intention is only to correctly read from the json file, we can omit the tags. Since I might want to have my tool output json in the future, I went ahead and added tags to enforce the lowerCamelCase I like for my json keys.

// Container for all information used to present a CV or Résumé
type Resume struct {
	Contact     Contact      `json:"contact"`
	Objective   string       `json:"objective"`
	Experiences []Experience `json:"experiences"`
	Skills      []string     `json:"skills"`
	Education   []Education  `json:"education"`
	Awards      []Award      `json:"awards"`
}

Let’s start by defining a type to represent our first résumé section. Most of the fields here are very simple to define. The Address struct can be defined directly inside the Contact struct because we do not intend to add any methods. The Phone type however, will need a little more attention.

type Contact struct {
	Name    string `json:"name"`
	Address struct {
		StreetAddress string `json:"streetAddress"`
		Locality      string `json:"locality"`
		Region        string `json:"region"`
		PostalCode    string `json:"postalCode"`
	} `json:"address"`
	Phone []Phone `json:"phone"`
	Email string  `json:"email"`
}

The Phone type requires some more detailed implementation. I’ve added an additional field here representing an extension. Making Extension independent of Number gives us some more powerful options for handling the number down the line. We also provide a PrimaryPhone() method on the Contact type to select only the first phone listed as primary. This further clarifies the behavior we discussed in Part I.

type Phone struct {
	Type      PhoneType   `json:"type"`
	Number    PhoneNumber `json:"number"`
	Extension string      `json:"extension"`
	Primary   bool        `json:"primary"`
}

// Use to select only a single, primary phone number. Selects the first phone listed as primary.
func (c Contact) PrimaryPhone() *Phone {
	var p Phone
	for _, p = range c.Phone {
		if p.Primary {
			return &p
		}
	}
	return nil
}

We want to have a consistent set of phone types available. It would not serve to have “mobile”, “cell”, “iphone”, or other inconsistent names for the same basic thing. Enter one of the more fun and interesting recent features in go: generate.

type PhoneType int

//go:generate stringer -type=PhoneType
//go:generate jsonenums -type=PhoneType

const (
	unknown PhoneType = iota
	mobile
	home
	office
	other
)

func (t PhoneType) Short() string {
	typeMap := map[PhoneType]string{
		unknown: "",
		mobile:  "m",
		home:    "h",
		office:  "o",
		other:   "",
	}
	return typeMap[t]
}

func (t PhoneType) Title() string {
	return strings.ToTitle(t.String())
}

There are a number of uses for the generate tool, but one of the simplest is the stringer tool that creates a String() method on an int type used as an enum. The jsonenums tool is based on the stringer tool, but provides UnmarshalJson() and MarshalJson() for the type. One small caveat in this tool, and one that may be worth writing a custom tool for in the future, is that the methods are all case sensitive. This could prove problematic for writing the json by hand – enforcing all lowercase is non-obvious here. For now, we can leave it. I did add a couple of little helper methods we can use when executing templates. The Title() and Short() methods provide some formatting options.

The last part of the Phone type to be addressed is the actual Number property. We could have left this as a string, but in order to add some flexibility to the formatting, we added a specific type. Being in the United States, I created methods specifically to deal with the North American Numbering Plan. But, I can’t allow my implementation to be incompatible with phone numbers in other countries – it’s a small world these days. Instead, the PhoneNumber type is just a string, and it can be used transparently. The method used to enforce the NANP standard, cleanPhoneNumber(), returns an error we can check against. When we call our formatting methods, if we see the ERROR_NOT_NANP_NUMBER, we can act accordingly. We do not have to treat all errors equally. I didn’t take advantage of this in my formatting methods, but the option is there for future additions.

// Phone Number (for now, expects 11, 10, or 7 digit number)
type PhoneNumber string

var ERROR_NOT_NANP_NUMBER = errors.New("Phone Number is not a valid North American Numbering Plan phone number.")

// Removes non-numeric chars from phone number.
// If the resulting length is not 11, 10, or 7 digits, returns ERROR_NOT_NANP_NUMBER.
func (n PhoneNumber) cleanPhoneNumber() (PhoneNumber, error) {
	m := strings.Replace(strings.Map(func(r rune) rune {
		if r >= '0' && r <= '9' {
			return r
		}
		return 'x'
	}, string(n)), "x", "", -1)
	if len(m) != 11 && len(m) != 10 && len(m) != 7 {
		return n, ERROR_NOT_NANP_NUMBER
	}
	return PhoneNumber(m), nil
}

// Format phone number as NANP (North American Numbering Plan) "1 (NPA) NXX-XXXX"
// If the number is not a valid NANP number, the original number is returned unformatted.
func (n PhoneNumber) FormatTraditional() string {
	m, err := n.cleanPhoneNumber()
	if err != nil { // this includes ERROR_NOT_NANP_NUMBER
		return string(n)
	}
	b := []byte(m) // make a copy to parse. We can do this by byte because 0-9 are represented as single bytes
	s := ""
	if len(b) == 11 {
		s += string(b[0])
		b = b[1:]
	}
	if len(b) == 10 {
		s += "(" + string(b[0:3]) + ") "
		b = b[3:]
	}
	if len(b) == 7 {
		s += string(b[0:3]) + "-" + string(b[3:])
	}
	return s
}

// Format phone number as NANP (North American Numbering Plan) "1-NPA-NXX-XXXX"
// If the number is not a valid NANP number, the original number is returned unformatted.
func (n PhoneNumber) FormatSeparator(sep string) string {
	m, err := n.cleanPhoneNumber()
	if err != nil { // this includes ERROR_NOT_NANP_NUMBER
		return string(n)
	}
	b := []byte(m) // make a copy to parse. We can do this by byte because 0-9 are represented as single bytes
	s := ""
	if len(b) == 11 {
		s += string(b[0]) + sep
		b = b[1:]
	}
	if len(b) == 10 {
		s += string(b[0:3]) + sep
		b = b[3:]
	}
	if len(b) == 7 {
		s += string(b[0:3]) + sep + string(b[3:])
	}
	return s
}

I created two formatting methods that rely on the same basic technique. Starting with a clean number consisting of only the characters 0123456789, I can assume each is only a single byte, allowing me to directly address the byte slice instead of iterating through characters. See the golang blog post, Strings, bytes, runes and characters in Go, for more information about this potential pitfall. I could probably have used a switch instead of the if statements, but I think that would not have been as clear and obvious to another developer taking a quick look.

With these types defined, a template like the following becomes fairly easy to navigate (the - in the brackets is a nice new feature of go 1.6):


{{range .Contact}}
{{- .Name}}
{{range .Address}}
{{- .StreetAddress}}
{{.Locality}}, {{.Region}} {{.PostalCode -}}
{{end}}
{{with .PrimaryPhone}}
{{- .Number.FormatTraditional -}}{{with .Extension}} ext. {{. -}}{{end}}
{{end}}
{{.Email -}}
{{end}}

Next time, we’ll talk more about the design of the CLI.

comments powered by Disqus