From 83a951def1a1de8742cb2c7adbf0525aa5a3ccde Mon Sep 17 00:00:00 2001 From: Mat Ruff <6045126+meruff@users.noreply.github.com> Date: Fri, 23 Aug 2024 13:50:20 -0700 Subject: [PATCH] Convert all endpoints to Trailhead's GraphQL API (#19) * Remove Supabase edge functions that were being used in favor of just using Go. * Update endpoint handlers to use new GraphQL apis. * profile, rank, skills, certifications, badges * Cleanup: * Added more error handling. * Refactor functions for new APIs. * Add `queries.go` to hold GraphQL queries. --- main.go | 423 +++++++++++++---------------------------- trailhead/queries.go | 221 +++++++++++++++++++++ trailhead/trailhead.go | 374 +++++++++++++++++++++--------------- 3 files changed, 579 insertions(+), 439 deletions(-) create mode 100644 trailhead/queries.go diff --git a/main.go b/main.go index 565eff2..0d3650d 100644 --- a/main.go +++ b/main.go @@ -3,10 +3,11 @@ package main import ( "encoding/json" "fmt" - "io/ioutil" + "io" "log" "net/http" "os" + "regexp" "strconv" "strings" "time" @@ -16,18 +17,10 @@ import ( ) const ( - trailblazerUrl = "https://trailblazer.me/" - supabaseUrl = "https://nvmegutajwfwssbzpgdb.functions.supabase.co/" - meIdUrl = trailblazerUrl + "id/" - apexExecUrl = trailblazerUrl + "aura?r=0&aura.ApexAction.execute=2" - profileAppConfigUrl = trailblazerUrl + "c/ProfileApp.app?aura.format=JSON&aura.formatAdapter=LIGHTNING_OUT" - rankUrl = supabaseUrl + "rank" - badgesUrl = supabaseUrl + "badges" - skillsUrl = supabaseUrl + "skills" + trailheadApiUrl = "https://profile.api.trailhead.com/graphql" + trailblazerUrl = "https://www.salesforce.com/trailblazer/" ) -var auraContext = "" - func main() { r := mux.NewRouter() r.HandleFunc("/trailblazer/{id}", profileHandler) @@ -47,141 +40,178 @@ func main() { if port == "" { http.ListenAndServe(":8000", nil) - fmt.Println("Server started") } else { http.ListenAndServe(":"+os.Getenv("PORT"), nil) } } -// profileHandler gets profile information of the Trailblazer i.e. Name, Location, Company, Title etc. -// Uses a Trailblazer handle only, not an ID. +// profileHandler gets profile information of the Trailblazer i.e. Name, Company, Title etc. Uses a +// Trailblazer handle only, not an ID. func profileHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userAlias := vars["id"] if strings.HasPrefix(userAlias, "005") { - writeErrorToBrowser(w, `{"error":"/profile requires a Trailblazer handle, not an ID as a parameter."}`, 503) + writeErrorToBrowser(w, "/profile requires a trailblazer handle, not an ID as a parameter.", 503) return } - res, err := http.Get(meIdUrl + userAlias) - + res, err := http.Get(trailblazerUrl + userAlias) if res != nil { defer res.Body.Close() } if err != nil { log.Println(err) - writeErrorToBrowser(w, `{"error":"Problem retrieving profile data."}`, 503) + writeErrorToBrowser(w, "Problem retrieving profile data.", 503) return } - body, err := ioutil.ReadAll(res.Body) - + body, err := io.ReadAll(res.Body) if err != nil { log.Println(err) - writeErrorToBrowser(w, `{"error":"Problem retrieving profile data."}`, 503) + writeErrorToBrowser(w, "Problem reading profile body.", 503) return } - jsonString := strings.Replace(string(body), "\\'", "\\\\'", -1) - - if !strings.Contains(jsonString, "var profileData = JSON.parse(") { - writeErrorToBrowser(w, `{"error":"Problem retrieving profile data."}`, 503) + if !strings.Contains(string(body), "var profile = ") { + writeErrorToBrowser( + w, + fmt.Sprintf("Cannot find profile data for %s. Does this trailblazer exist?", vars["id"]), + 503, + ) return } - jsonString = jsonString[strings.Index(jsonString, "var profileData = JSON.parse(")+29 : strings.Index(jsonString, "trailblazer.me\\\"}\");")+18] - out, err := strconv.Unquote(jsonString) + re := regexp.MustCompile(`var profile = (.*);`) + match := re.FindStringSubmatch(string(body)) + + if len(match) > 1 { + var trailheadProfileData trailhead.Profile + json.Unmarshal([]byte(match[1]), &trailheadProfileData) + profileDataForUi := trailhead.ProfileReturn{} + profileDataForUi.ProfilePhotoUrl = trailheadProfileData.PhotoURL + profileDataForUi.ProfileUser.TBID_Role = trailheadProfileData.Role + profileDataForUi.ProfileUser.CompanyName = trailheadProfileData.Company.Name + profileDataForUi.ProfileUser.TrailblazerId = vars["id"] + profileDataForUi.ProfileUser.Title = trailheadProfileData.Title + profileDataForUi.ProfileUser.FirstName = trailheadProfileData.FirstName + profileDataForUi.ProfileUser.LastName = trailheadProfileData.LastName + profileDataForUi.ProfileUser.Id = trailheadProfileData.ID + encodeAndWriteToBrowser(w, profileDataForUi) + } else { + writeErrorToBrowser(w, "No profile data found.", 503) + } +} + +// rankHandler returns information about a Trailblazer's rank and overall points +func rankHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + responseBody, err := doTrailheadCallout( + trailhead.GetGraphqlPayload("GetTrailheadRank", vars["id"], "", trailhead.GetRankQuery()), + ) if err != nil { - log.Println(err) - writeErrorToBrowser(w, `{"error":"Problem retrieving profile data."}`, 503) - return + writeErrorToBrowser(w, "No rank data returned from Trailhead.", 503) } - out = strings.Replace(out, "\\'", "'", -1) - writeJSONToBrowser(w, out) + var trailheadRankData trailhead.Rank + json.Unmarshal([]byte(responseBody), &trailheadRankData) + encodeAndWriteToBrowser(w, trailheadRankData.Data) } // skillsHandler returns information about a Trailblazer's skills func skillsHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) - userID := getTrailheadID(w, vars["id"]) - if userID == "" { - writeErrorToBrowser(w, fmt.Sprintf(`{"error":"Could not retrieve trailhead Id with provided alias %s"}`, userID), 503) - return + responseBody, err := doTrailheadCallout( + trailhead.GetGraphqlPayload( + "GetEarnedSkills", + vars["id"], + "", + trailhead.GetSkillsQuery(), + ), + ) + if err != nil { + writeErrorToBrowser(w, "No skills data returned from Trailhead.", 503) } - responseBody, err := doSupabaseCallout(skillsUrl, fmt.Sprintf(`{ - "queryProfile": true, - "trailblazerId": "%s" - }`, userID)) - var trailheadSkillsData trailhead.Skills json.Unmarshal([]byte(responseBody), &trailheadSkillsData) - - if err != nil { - writeErrorToBrowser(w, `{"error":"No data returned from Trailhead."}`, 503) - } else if trailheadSkillsData.Profile.EarnedSkills != nil { - encodeAndWriteToBrowser(w, trailheadSkillsData) - } + encodeAndWriteToBrowser(w, trailheadSkillsData.Data) } -// rankHandler returns information about a Trailblazer's rank and overall points -func rankHandler(w http.ResponseWriter, r *http.Request) { +// certificationsHandler gets Salesforce certifications the Trailblazer has earned. +func certificationsHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) - userID := getTrailheadID(w, vars["id"]) - - if userID == "" { - writeErrorToBrowser(w, fmt.Sprintf(`{"error":"Could not retrieve trailhead Id with provided alias %s"}`, userID), 503) - return - } - - responseBody, err := doSupabaseCallout(rankUrl, fmt.Sprintf(`{ - "queryProfile": true, - "trailblazerId": "%s" - }`, userID)) - - var trailheadRankData trailhead.Rank - json.Unmarshal([]byte(responseBody), &trailheadRankData) + responseBody, err := doTrailheadCallout( + trailhead.GetGraphqlPayload( + "GetUserCertifications", + vars["id"], + "", + trailhead.GetCertificationsQuery(), + ), + ) if err != nil { - writeErrorToBrowser(w, `{"error":"No data returned from Trailhead."}`, 503) - } else if trailheadRankData.Profile.TrailheadStats.Typename != "" { - encodeAndWriteToBrowser(w, trailheadRankData) + writeErrorToBrowser(w, "No certification data returned from Trailhead.", 503) + } + + var trailheadCertificationsData trailhead.Certifications + json.Unmarshal([]byte(responseBody), &trailheadCertificationsData) + certificationReturnData := trailhead.CertificationsReturn{} + + for _, certification := range trailheadCertificationsData.Data.Profile.Credential.Certifications { + cReturn := trailhead.Certification{} + cReturn.DateCompleted = certification.DateCompleted + cReturn.CertificationUrl = certification.InfoURL + cReturn.Description = certification.PublicDescription + cReturn.CertificationStatus = certification.Status.Title + cReturn.Title = certification.Title + cReturn.CertificationImageUrl = certification.LogoURL + + if dateExpired, ok := certification.DateExpired.(string); ok { + cReturn.DateExpired = dateExpired + } else { + cReturn.DateExpired = "" + } + + certificationReturnData.CertificationsList = append( + certificationReturnData.CertificationsList, cReturn, + ) } + + encodeAndWriteToBrowser(w, certificationReturnData) } // badgeshandler gets badges the Trailblazer has earned. Returns first 8. Optionally can // provide filter criteria, or additional return count. i.e. "event" type badges, count by 30. func badgesHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) - userID := getTrailheadID(w, vars["id"]) - - if userID == "" { - writeErrorToBrowser(w, fmt.Sprintf(`{"error":"Could not retrieve trailhead Id with provided alias %s"}`, userID), 503) - return - } - filter, after, count := vars["filter"], vars["after"], vars["count"] - countConvert, err := strconv.Atoi(count) - - // Create the request - var badgeRequestStruct = trailhead.BadgeRequest{QueryProfile: true, TrailblazerId: userID} + badgeRequestStruct := trailhead.BadgeRequest{} // Set filter if contains(getValidBadgeFilters(), filter) { var upperFilter = strings.ToUpper(filter) - badgeRequestStruct.Filter = &upperFilter + badgeRequestStruct.Filter = upperFilter } else if filter != "all" && filter != "" { - writeErrorToBrowser(w, `{"error":"Expected badge filter to be one of: MODULE, PROJECT, SUPERBADGE, EVENT, STANDALONE."}`, 501) + writeErrorToBrowser( + w, + fmt.Sprintf("Expected badge filter to be one of: %s.", strings.Join(getValidBadgeFilters(), ", ")), + 501, + ) return } // Set count - if countConvert != 0 { + if count != "" { + countConvert, err := strconv.Atoi(count) + if err != nil { + log.Println("Error parsing badge count from params.") + } + badgeRequestStruct.Count = countConvert } else { badgeRequestStruct.Count = 8 @@ -189,40 +219,24 @@ func badgesHandler(w http.ResponseWriter, r *http.Request) { // Set after if after != "" { - badgeRequestStruct.After = &after + badgeRequestStruct.After = after } - badgeRequestBody, err := json.Marshal(badgeRequestStruct) - responseBody, err := doSupabaseCallout(badgesUrl, string(badgeRequestBody)) - - var trailheadBadgeData trailhead.Badges - json.Unmarshal([]byte(responseBody), &trailheadBadgeData) - + responseBody, err := doTrailheadCallout( + trailhead.GetGraphqlPayload( + "GetTrailheadBadges", + vars["id"], + trailhead.GetBadgesFilterPayload(vars["id"], badgeRequestStruct), + trailhead.GetBadgesQuery(), + ), + ) if err != nil { - writeErrorToBrowser(w, `{"error":"No data returned from Trailhead."}`, 503) - } else if trailheadBadgeData.Profile.EarnedAwards.Edges != nil { - encodeAndWriteToBrowser(w, trailheadBadgeData) + writeErrorToBrowser(w, "No badge data returned from Trailhead.", 503) } -} -// certificationsHandler gets Salesforce certifications the Trailblazer has earned. -func certificationsHandler(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - userID := getTrailheadID(w, vars["id"]) - - if userID == "" { - writeErrorToBrowser(w, fmt.Sprintf(`{"error":"Could not retrieve trailhead Id with provided alias %s"}`, userID), 503) - return - } - - trailheadData := doTrailheadAuraCallout(trailhead.GetApexAction("AchievementService", "fetchAchievements", userID, "", ""), "") - - if trailheadData.Actions != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(trailheadData.Actions[0].ReturnValue.ReturnValue.CertificationsResult) - } else { - writeErrorToBrowser(w, `{"error":"No data returned from Trailhead."}`, 503) - } + var trailheadBadgeData trailhead.Badges + json.Unmarshal([]byte(responseBody), &trailheadBadgeData) + encodeAndWriteToBrowser(w, trailheadBadgeData.Data) } // loggingHandler logs time spent to access each request/what page was requested. @@ -238,104 +252,17 @@ func loggingHandler(next http.Handler) http.Handler { // catchAllHandler is the default message if no Trailblazer Id or handle is provided, // or if the u ser has navigated to an unsupported page. func catchAllHandler(w http.ResponseWriter, r *http.Request) { - writeErrorToBrowser(w, `{"error":"Please provide a valid Trialhead user Id/handle or visit a valid URL. Example: /trailblazer/{id}"}`, 501) + writeErrorToBrowser( + w, + "Please provide a valid handle or visit a valid URL. Example: /trailblazer/{id}", + 501, + ) } -// getTrailheadID gets the Trailblazer's user Id from Trailhead, if provided with a custom user handle i.e. "matruff" => "0051I000004UgTlQAK" -func getTrailheadID(w http.ResponseWriter, userAlias string) string { - if !strings.HasPrefix(userAlias, "005") { - res, err := http.Get(meIdUrl + userAlias) - - if res != nil { - defer res.Body.Close() - } - - if err != nil { - log.Println(err) - writeErrorToBrowser(w, `{"error":"Problem retrieving Trailblazer ID."}`, 503) - } - - body, err := ioutil.ReadAll(res.Body) - - if err != nil { - log.Println(err) - writeErrorToBrowser(w, `{"error":"Problem retrieving Trailblazer ID."}`, 503) - } - - userID, strBody := "", string(body) - - // Try finding userID using TDIDUserId__c if present in response. - var index = strings.Index(strBody, `"TBIDUserId__c":"005`) - - if -1 != index { - userID = string(strBody[index+17 : index+35]) - } - - // Try parsing userID from profileData. - if !strings.HasPrefix(userID, "005") { - index = strings.Index(strBody, `\"Id\":\"`) - - if -1 != index { - userID = string(strBody[index+9 : index+27]) - } - } - - // Fall back to trying uid. - if !strings.HasPrefix(userID, "005") { - index = strings.Index(strBody, "uid: '005") - - if -1 != index { - userID = string(strBody[index+6 : index+24]) - } - } - - // If no ID found, write to browser and return empty string. - if !strings.HasPrefix(userID, "005") { - writeErrorToBrowser(w, fmt.Sprintf(`{"error":"Could not find Trailhead ID for user: %s(%s)'. Does this profile exist? Is it set to public?"}%s`, userAlias, userID, strBody), 404) - return "" - } - - return userID - } - - return userAlias -} - -// doTrailheadAuraCallout wraps doTrailheadCallout specifically for calls to the Profile App for Aura which needs the FwUID. -// It will retreive the FwUID if unknown or if the initial call fails and retry the call so that the calling method does not -// need to know about the FwUID -func doTrailheadAuraCallout(apexAction string, pageURI string) trailhead.Data { - // If config has been retrieved, try aura call - if 0 != len(auraContext) { - var trailheadData = doTrailheadCallout( - `message={"actions":[` + apexAction + `]}` + - `&aura.context=` + auraContext + `&aura.pageURI=` + pageURI + `&aura.token="`) - - // If the response is not nil, call was successful - if trailheadData.Actions != nil { - return trailheadData - } - - // Else the response is nil, try getting the new fwuid and retry call before failing - } - - // Get fwuid from profile app config - updateAuraProfileAppConfig() - - // Make aura call - if 0 != len(auraContext) { - return doTrailheadCallout( - `message={"actions":[` + apexAction + `]}` + - `&aura.context=` + auraContext + `&aura.pageURI=` + pageURI + `&aura.token="`) - } - - return trailhead.Data{Actions: nil} -} - -// updateAuraProfileAppConfig retrives the profile app config to extract the aura context -func updateAuraProfileAppConfig() { +// doTrailheadCallout makes a callout to the given URL using the given +func doTrailheadCallout(payload string) (string, error) { client := &http.Client{} - req, err := http.NewRequest("GET", profileAppConfigUrl, nil) + req, err := http.NewRequest("POST", trailheadApiUrl, strings.NewReader(payload)) if err != nil { log.Println(err) @@ -343,13 +270,9 @@ func updateAuraProfileAppConfig() { req.Header.Add("Accept", "*/*") req.Header.Add("Accept-Language", "en-US,en;q=0.5") - req.Header.Add("Referer", "https://trailblazer.me/id") - req.Header.Add("Origin", "https://trailblazer.me") - req.Header.Add("DNT", "1") - req.Header.Add("Connection", "keep-alive") + req.Header.Add("Content-Type", "application/json") res, err := client.Do(req) - if res != nil { defer res.Body.Close() } @@ -358,92 +281,16 @@ func updateAuraProfileAppConfig() { log.Println(err) } - body, err := ioutil.ReadAll(res.Body) - - // Deserialize the entire app config - var profileAppConfig trailhead.ProfileAppConfig - json.Unmarshal(body, &profileAppConfig) - - if 0 != len(profileAppConfig.AuraConfig.Context.FwUID) { - bytes, err := json.Marshal(profileAppConfig.AuraConfig.Context.Loaded) - - if err != nil { - log.Println(err) - } - - auraContext = trailhead.GetAuraContext(profileAppConfig.AuraConfig.Context.FwUID, string(bytes)) - } else { - auraContext = "" - } -} - -// doTrailheadCallout does the callout and returns the Apex REST response from Trailhead. -func doTrailheadCallout(messagePayload string) trailhead.Data { - client := &http.Client{} - req, err := http.NewRequest("POST", apexExecUrl, strings.NewReader(messagePayload)) + body, err := io.ReadAll(res.Body) - if err != nil { - log.Println(err) - } - - req.Header.Add("Accept", "*/*") - req.Header.Add("Accept-Language", "en-US,en;q=0.5") - req.Header.Add("Referer", "https://trailblazer.me/id") - req.Header.Add("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8") - req.Header.Add("Origin", "https://trailblazer.me") - req.Header.Add("DNT", "1") - req.Header.Add("Connection", "keep-alive") - - res, err := client.Do(req) - - if res != nil { - defer res.Body.Close() - } - - if err != nil { - log.Println(err) - } - - body, err := ioutil.ReadAll(res.Body) - var trailheadData trailhead.Data - json.Unmarshal(body, &trailheadData) - - return trailheadData -} - -// doSupabaseCallout make a callout to the given URL using the given payload. -func doSupabaseCallout(url string, payload string) (string, error) { - client := &http.Client{} - req, err := http.NewRequest("POST", url, strings.NewReader(payload)) - - if err != nil { - log.Println(err) - } - - res, err := client.Do(req) - if res != nil { - defer res.Body.Close() - } - - if err != nil { - log.Println(err) - } - - body, err := ioutil.ReadAll(res.Body) return string(body), err } -// writeJSONToBrowser simply writes a provided string to the browser in JSON format with optional HTTP code. -func writeJSONToBrowser(w http.ResponseWriter, message string) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(message)) -} - // writeErrorToBrowser writes an HTTP error to the broswer in JSON. -func writeErrorToBrowser(w http.ResponseWriter, err string, code int) { +func writeErrorToBrowser(w http.ResponseWriter, errorMsg string, code int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) - w.Write([]byte(err)) + w.Write([]byte(fmt.Sprintf(`{"error":"%s"`, errorMsg))) } // encodeAndWriteToBrowser encodes a given interface and writes it to the browser as JSON. diff --git a/trailhead/queries.go b/trailhead/queries.go new file mode 100644 index 0000000..12558af --- /dev/null +++ b/trailhead/queries.go @@ -0,0 +1,221 @@ +package trailhead + +// GetRankQuery returns GraphQL query for TrailheadRank +func GetRankQuery() string { + return ` + fragment TrailheadRank on TrailheadRank { + __typename + title + requiredPointsSum + requiredBadgesCount + imageUrl + } + + fragment PublicProfile on PublicProfile { + __typename + trailheadStats { + __typename + earnedPointsSum + earnedBadgesCount + completedTrailCount + rank { + ...TrailheadRank + } + nextRank { + ...TrailheadRank + } + } + } + + query GetTrailheadRank($slug: String, $hasSlug: Boolean!) { + profile(slug: $slug) @include(if: $hasSlug) { + ... on PublicProfile { + ...PublicProfile + } + ... on PrivateProfile { + __typename + } + } + }` +} + +// GetSkillsQuery returns GraphQL query for EarnedSkill +func GetSkillsQuery() string { + return ` + fragment EarnedSkill on EarnedSkill { + __typename + earnedPointsSum + id + itemProgressEntryCount + skill { + __typename + apiName + id + name + } + } + + query GetEarnedSkills($slug: String, $hasSlug: Boolean!) { + profile(slug: $slug) @include(if: $hasSlug) { + __typename + ... on PublicProfile { + id + earnedSkills { + ...EarnedSkill + } + } + } + }` +} + +// GetCertificationsQuery returns GraphQL query for GetUserCertifications +func GetCertificationsQuery() string { + return ` + query GetUserCertifications($slug: String, $hasSlug: Boolean!) { + profile(slug: $slug) @include(if: $hasSlug) { + __typename + id + ... on PublicProfile { + credential { + messages { + __typename + body + header + location + image + cta { + __typename + label + url + } + orientation + } + messagesOnly + brands { + __typename + id + name + logo + } + certifications { + cta { + __typename + label + url + } + dateCompleted + dateExpired + downloadLogoUrl + logoUrl + infoUrl + maintenanceDueDate + product + publicDescription + status { + __typename + title + expired + date + color + order + } + title + } + } + } + } + }` +} + +// GetBadgesQuery returns GraphQL query for EarnedAward +func GetBadgesQuery() string { + return ` + fragment EarnedAward on EarnedAwardBase { + __typename + id + award { + __typename + id + title + type + icon + content { + __typename + webUrl + description + } + } + } + + fragment EarnedAwardSelf on EarnedAwardSelf { + __typename + id + award { + __typename + id + title + type + icon + content { + __typename + webUrl + description + } + } + earnedAt + earnedPointsSum + } + + fragment StatsBadgeCount on TrailheadProfileStats { + __typename + earnedBadgesCount + superbadgeCount + } + + fragment ProfileBadges on PublicProfile { + __typename + trailheadStats { + ... on TrailheadProfileStats { + ...StatsBadgeCount + } + } + earnedAwards(first: $count, after: $after, awardType: $filter) { + edges { + node { + ... on EarnedAwardBase { + ...EarnedAward + } + ... on EarnedAwardSelf { + ...EarnedAwardSelf + } + } + } + pageInfo { + ...PageInfoBidirectional + } + } + } + + fragment PageInfoBidirectional on PageInfo { + __typename + endCursor + hasNextPage + startCursor + hasPreviousPage + } + + query GetTrailheadBadges( + $slug: String + $hasSlug: Boolean! + $count: Int = 8 + $after: String = null + $filter: AwardTypeFilter = null + ) { + profile(slug: $slug) @include(if: $hasSlug) { + __typename + ... on PublicProfile { + ...ProfileBadges + } + } + }` +} diff --git a/trailhead/trailhead.go b/trailhead/trailhead.go index c5cdc7d..377b7c3 100644 --- a/trailhead/trailhead.go +++ b/trailhead/trailhead.go @@ -1,183 +1,255 @@ package trailhead -import "strings" - -// Data represents a response from trailhead. -type Data struct { - Actions []struct { - ID string `json:"id"` - State string `json:"state"` - ReturnValue struct { - ReturnValue struct { - Body string `json:"body"` - SuperbadgesResult string `json:"superbadgesResult"` - CertificationsResult struct { - CertificationsList []struct { - CertificationImageURL string `json:"certificationImageUrl"` - CertificationStatus string `json:"certificationStatus"` - CertificationURL string `json:"certificationUrl"` - DateCompleted string `json:"dateCompleted"` - DateExpired string `json:"dateExpired"` - Description string `json:"description"` - Title string `json:"title"` - } `json:"certificationsList"` - StatusCode string `json:"statusCode"` - StatusMessage string `json:"statusMessage"` - } `json:"certificationsResult"` - IsMyTrailheadUser bool `json:"isMyTrailheadUser"` - } `json:"returnValue"` - Cacheable bool `json:"cacheable"` - } `json:"returnValue"` - Error []interface{} `json:"error"` - } `json:"actions"` - Context struct { - Fwuid string `json:"fwuid"` - } `json:"context"` +import ( + "strconv" +) + +// ProfileReturn represents the basic trailhead data returned via the Go API. +type ProfileReturn struct { + Error string + ProfilePhotoUrl string + ProfileUser struct { + TBID_Role string + CompanyName string + TrailblazerId string + Title string + FirstName string + LastName string + Id string + } +} + +// Profile represents basic trailhead data i.e. name, title, company. +type Profile struct { + ID string `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Username string `json:"username"` + ProfileURL string `json:"profileUrl"` + BackgroundImageURL string `json:"backgroundImageUrl"` + IsPublicProfile bool `json:"isPublicProfile"` + Role string `json:"role"` + Title string `json:"title"` + RelationshipToSalesforce string `json:"relationshipToSalesforce"` + Nickname string `json:"nickname"` + PhotoURL string `json:"photoUrl"` + Bio string `json:"bio"` + LinkedinHandle string `json:"linkedinHandle"` + WebsiteURL string `json:"websiteUrl"` + Company struct { + Name string `json:"name"` + Size string `json:"size"` + Website string `json:"website"` + } `json:"company"` + Address struct { + State string `json:"state"` + Country string `json:"country"` + } `json:"address"` } // Rank represents skill data returned from trailhead. type Rank struct { - Profile struct { - Typename string `json:"__typename"` - TrailheadStats struct { - Typename string `json:"__typename"` - EarnedPointsSum int `json:"earnedPointsSum"` - EarnedBadgesCount int `json:"earnedBadgesCount"` - CompletedTrailCount int `json:"completedTrailCount"` - Rank struct { + Data struct { + Profile struct { + Typename string `json:"__typename"` + TrailheadStats struct { Typename string `json:"__typename"` - Title string `json:"title"` - RequiredPointsSum int `json:"requiredPointsSum"` - RequiredBadgesCount int `json:"requiredBadgesCount"` - ImageURL string `json:"imageUrl"` - } `json:"rank"` - NextRank interface{} `json:"nextRank"` - } `json:"trailheadStats"` - } `json:"profile"` + EarnedPointsSum int `json:"earnedPointsSum"` + EarnedBadgesCount int `json:"earnedBadgesCount"` + CompletedTrailCount int `json:"completedTrailCount"` + Rank struct { + Typename string `json:"__typename"` + Title string `json:"title"` + RequiredPointsSum int `json:"requiredPointsSum"` + RequiredBadgesCount int `json:"requiredBadgesCount"` + ImageURL string `json:"imageUrl"` + } `json:"rank"` + NextRank interface{} `json:"nextRank"` + } `json:"trailheadStats"` + } `json:"profile"` + } `json:"data"` } // Skills represents skill data returned from trailhead. type Skills struct { - Profile struct { - Typename string `json:"__typename"` - EarnedSkills []struct { - Typename string `json:"__typename"` - EarnedPointsSum int `json:"earnedPointsSum"` - ID string `json:"id"` - ItemProgressEntryCount int `json:"itemProgressEntryCount"` - Skill struct { - Typename string `json:"__typename"` - APIName string `json:"apiName"` - ID string `json:"id"` - Name string `json:"name"` - } `json:"skill"` - } `json:"earnedSkills"` - } `json:"profile"` + Data struct { + Profile struct { + Typename string `json:"__typename"` + EarnedSkills []struct { + Typename string `json:"__typename"` + EarnedPointsSum int `json:"earnedPointsSum"` + ID string `json:"id"` + ItemProgressEntryCount int `json:"itemProgressEntryCount"` + Skill struct { + Typename string `json:"__typename"` + APIName string `json:"apiName"` + ID string `json:"id"` + Name string `json:"name"` + } `json:"skill"` + } `json:"earnedSkills"` + } `json:"profile"` + } `json:"data"` } -// Badges represents skill data returned from trailhead. -type Badges struct { - Profile struct { - Typename string `json:"__typename"` - EarnedAwards struct { - Edges []struct { - Node struct { +// CertificationsReturn represents the certification data returned via the Go API. +type CertificationsReturn struct { + Error string + CertificationsList []Certification +} + +// Certification represents a single salesforce certification. Used in CertificationsReturn. +type Certification struct { + DateExpired string + DateCompleted string + CertificationUrl string + Description string + CertificationStatus string + Title string + CertificationImageUrl string +} + +// Certifications represents certification records returned from trailhead. +type Certifications struct { + Data struct { + Profile struct { + Typename string `json:"__typename"` + ID string `json:"id"` + Credential struct { + Messages []struct { + Typename string `json:"__typename"` + Body string `json:"body"` + Header string `json:"header"` + Location string `json:"location"` + Image string `json:"image"` + Cta struct { + Typename string `json:"__typename"` + Label string `json:"label"` + URL string `json:"url"` + } `json:"cta"` + Orientation string `json:"orientation"` + } `json:"messages"` + MessagesOnly bool `json:"messagesOnly"` + Brands []struct { Typename string `json:"__typename"` ID string `json:"id"` - Award struct { + Name string `json:"name"` + Logo string `json:"logo"` + } `json:"brands"` + Certifications []struct { + Cta struct { + Typename string `json:"__typename"` + Label string `json:"label"` + URL string `json:"url"` + } `json:"cta"` + DateCompleted string `json:"dateCompleted"` + DateExpired any `json:"dateExpired"` + DownloadLogoURL string `json:"downloadLogoUrl"` + LogoURL string `json:"logoUrl"` + InfoURL string `json:"infoUrl"` + MaintenanceDueDate string `json:"maintenanceDueDate"` + Product string `json:"product"` + PublicDescription string `json:"publicDescription"` + Status struct { Typename string `json:"__typename"` - ID string `json:"id"` Title string `json:"title"` - Type string `json:"type"` - Icon string `json:"icon"` - Content struct { - Typename string `json:"__typename"` - WebURL string `json:"webUrl"` - Description string `json:"description"` - } `json:"content"` - } `json:"award"` - EarnedAt string `json:"earnedAt"` - EarnedPointsSum string `json:"earnedPointsSum"` - } `json:"node"` - } `json:"edges"` - PageInfo struct { - Typename string `json:"__typename"` - EndCursor string `json:"endCursor"` - HasNextPage bool `json:"hasNextPage"` - StartCursor string `json:"startCursor"` - HasPreviousPage bool `json:"hasPreviousPage"` - } `json:"pageInfo"` - } `json:"earnedAwards"` - } `json:"profile"` + Expired bool `json:"expired"` + Date string `json:"date"` + Color string `json:"color"` + Order int `json:"order"` + } `json:"status"` + Title string `json:"title"` + } `json:"certifications"` + } `json:"credential"` + } `json:"profile"` + } `json:"data"` } -// ProfileAppConfig represents the full configuration for the Salesforce Trailhead profile app -type ProfileAppConfig struct { - AuraConfig struct { - Context struct { - FwUID string `json:"fwuid"` - Loaded interface{} `json:"loaded"` - } `json:"context"` - } `json:"auraConfig"` +// Badges represents skill data returned from trailhead. +type Badges struct { + Data struct { + Profile struct { + Typename string `json:"__typename"` + EarnedAwards struct { + Edges []struct { + Node struct { + Typename string `json:"__typename"` + ID string `json:"id"` + Award struct { + Typename string `json:"__typename"` + ID string `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Icon string `json:"icon"` + Content struct { + Typename string `json:"__typename"` + WebURL string `json:"webUrl"` + Description string `json:"description"` + } `json:"content"` + } `json:"award"` + EarnedAt string `json:"earnedAt"` + EarnedPointsSum string `json:"earnedPointsSum"` + } `json:"node"` + } `json:"edges"` + PageInfo struct { + Typename string `json:"__typename"` + EndCursor string `json:"endCursor"` + HasNextPage bool `json:"hasNextPage"` + StartCursor string `json:"startCursor"` + HasPreviousPage bool `json:"hasPreviousPage"` + } `json:"pageInfo"` + } `json:"earnedAwards"` + } `json:"profile"` + } `json:"data"` } -// BadgeRequest represents a request to the /badges endpoint. The variables to send to graphql +// BadgeRequest represents a request to the /badges trailhead endpoint. type BadgeRequest struct { - QueryProfile bool `json:"queryProfile"` - TrailblazerId string `json:"trailblazerId"` - Filter *string `json:"filter"` - After *string `json:"after"` - Count int `json:"count"` + Filter string `json:"filter"` + After string `json:"after"` + Count int `json:"count"` } -// GetAuraContext returns a JSON string containing the Aura "context" to use in the callout to Trailhead. -func GetAuraContext(fwUID string, loaded string) string { +// GetGraphqlPayload returns a JSON string to use in Trailhead graphql callouts. +func GetGraphqlPayload(operationName string, userID string, variables string, query string) string { + var variablesJsonString string + + if variables != "" { + variablesJsonString = variables + } else { + variablesJsonString = `"variables": { + "hasSlug": true, + "slug": "` + userID + `" + }` + } + return `{ - "mode":"PROD", - "fwuid":"` + fwUID + `", - "app":"c:ProfileApp", - "loaded":` + loaded + `, - "dn":[], - "globals":{ - "srcdoc":true - }, - "uad":true - }` + "operationName": "` + operationName + `", + ` + variablesJsonString + `, + "query": "` + query + `" + }` } -// GetApexAction returns a JSON string representing an Apex action to be used in the callout to Trailhead. -func GetApexAction(className string, methodName string, userID string, skip string, filter string) string { - actionString := - `{ - "id":"212;a", - "descriptor":"aura://ApexActionController/ACTION$execute", - "callingDescriptor":"UNKNOWN", - "params":{ - "namespace":"", - "classname":"` + className + `", - "method":"` + methodName + `", - "params":{ - "userId":"` + userID + `", - "language":"en-US", - "featureAdditionalCerts": true` - - if skip != "" { - actionString += `, - "skip":` + skip + `, - "perPage":30` - } +// GetBadgesFilterPayload returns a variables json string to be used on the GraphQL callout. +func GetBadgesFilterPayload(userID string, badgeFilters BadgeRequest) string { + var afterLine, filterLine string - if filter != "" { - actionString += `, - "filter":"` + strings.Title(filter) + `"` + if badgeFilters.After != "" { + afterLine = `"after": "` + badgeFilters.After + `",` + } else { + afterLine = `"after": null,` } - actionString += ` - }, - "cacheable":false, - "isContinuation":false - } - }` + if badgeFilters.Filter != "" { + filterLine = `"filter": "` + badgeFilters.Filter + `",` + } else { + filterLine = `"filter": null,` + } - return actionString + return `"variables": { + "count": ` + strconv.Itoa(badgeFilters.Count) + `, + ` + afterLine + ` + ` + filterLine + ` + "hasSlug": true, + "slug": "` + userID + `" + }` }