From d56cf219d74912c2ed346a3c6e6d96c32ec1ae34 Mon Sep 17 00:00:00 2001 From: Ryan4n6 <131580830+Ryan4n6@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:27:59 -0500 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20Zod=20schema=20fo?= =?UTF-8?q?r=20golfers.getFollowing()=20response?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/ghin/models/golfers/following.ts | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/client/ghin/models/golfers/following.ts diff --git a/src/client/ghin/models/golfers/following.ts b/src/client/ghin/models/golfers/following.ts new file mode 100644 index 0000000..5e70b53 --- /dev/null +++ b/src/client/ghin/models/golfers/following.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' +import { number, string } from '../../../../models' + +const schemaFollowingGolfer = z.object({ + ghin: number, + first_name: string, + last_name: string, + email: z.string().email().nullable().optional(), + status: string, + handicap_index: z.union([z.number(), z.string()]).nullable().optional(), + association_id: number.nullable().optional(), + association_name: string.nullable().optional(), + club_id: number.nullable().optional(), + club_name: string.nullable().optional(), + state: string.nullable().optional(), + country: string.nullable().optional(), +}) + +const schemaFollowingResponse = z.object({ + golfers: z.array(schemaFollowingGolfer), +}) + +type FollowingGolfer = z.infer +type FollowingResponse = z.infer + +export { schemaFollowingGolfer, schemaFollowingResponse } +export type { FollowingGolfer, FollowingResponse } From 6577ba1404dc752cb862bb5ebcdf70133f536742 Mon Sep 17 00:00:00 2001 From: Ryan4n6 <131580830+Ryan4n6@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:29:15 -0500 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E2=9C=A8=20re-export=20following?= =?UTF-8?q?=20module=20from=20golfers=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/ghin/models/golfers/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/ghin/models/golfers/index.ts b/src/client/ghin/models/golfers/index.ts index 74b6aa9..4725ae2 100644 --- a/src/client/ghin/models/golfers/index.ts +++ b/src/client/ghin/models/golfers/index.ts @@ -1 +1,2 @@ export * from './search' +export * from './following' From b657193c70d852329f5188a8a3a61ade69b0725e Mon Sep 17 00:00:00 2001 From: Ryan4n6 <131580830+Ryan4n6@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:34:28 -0500 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20golfers.getFollow?= =?UTF-8?q?ing()=20method=20to=20GhinClient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/ghin/index.ts | 812 +++++++++++++++++++++------------------ 1 file changed, 430 insertions(+), 382 deletions(-) diff --git a/src/client/ghin/index.ts b/src/client/ghin/index.ts index a99960a..e108c8f 100644 --- a/src/client/ghin/index.ts +++ b/src/client/ghin/index.ts @@ -4,496 +4,544 @@ import { type ClientConfig, number, schemaClientConfig } from '../../models' import { InMemoryCacheClient } from '../in-memory-cache-client' import { CLIENT_SOURCE, RequestClient } from '../request-client' import { - type CourseCountriesResponse, - type CourseCountry, - type CourseDetailsRequest, - type CourseDetailsResponse, - type CourseHandicapsRequest, - type CoursePlayerHandicapsResponse, - type CourseSearchRequest, - type CourseSearchResponse, - type FacilitySearchRequest, - type FacilitySearchResponse, - type GolferCourseHandicapRequest, - type GolfersGlobalSearchRequest, - type GolfersSearchRequest, - type GolfersSearchResponse, - type HandicapResponse, - type ScoresRequest, - type ScoresResponse, - type TeeSetRatingRequest, - type TeeSetRatingResponse, - schemaCourseCountriesResponse, - schemaCourseDetailsRequest, - schemaCourseDetailsResponse, - schemaCoursePlayerHandicapsResponse, - schemaCourseSearchRequest, - schemaCourseSearchResponse, - schemaFacilitySearchRequest, - schemaFacilitySearchResponse, - schemaGolferCourseHandicapRequest, - schemaGolferHandicapResponse, - schemaGolfersGlobalSearchRequest, - schemaGolfersSearchRequest, - schemaGolfersSearchResponse, - schemaScoresRequest, - schemaScoresResponse, - schemaTeeSetRatingRequest, - schemaTeeSetRatingResponse, + type CourseCountriesResponse, + type CourseCountry, + type CourseDetailsRequest, + type CourseDetailsResponse, + type CourseHandicapsRequest, + type CoursePlayerHandicapsResponse, + type CourseSearchRequest, + type CourseSearchResponse, + type FacilitySearchRequest, + type FacilitySearchResponse, + type FollowingResponse, + type GolferCourseHandicapRequest, + type GolfersGlobalSearchRequest, + type GolfersSearchRequest, + type GolfersSearchResponse, + type HandicapResponse, + type ScoresRequest, + type ScoresResponse, + type TeeSetRatingRequest, + type TeeSetRatingResponse, + schemaCourseCountriesResponse, + schemaCourseDetailsRequest, + schemaCourseDetailsResponse, + schemaCoursePlayerHandicapsResponse, + schemaCourseSearchRequest, + schemaCourseSearchResponse, + schemaFacilitySearchRequest, + schemaFacilitySearchResponse, + schemaFollowingResponse, + schemaGolferCourseHandicapRequest, + schemaGolferHandicapResponse, + schemaGolfersGlobalSearchRequest, + schemaGolfersSearchRequest, + schemaGolfersSearchResponse, + schemaScoresRequest, + schemaScoresResponse, + schemaTeeSetRatingRequest, + schemaTeeSetRatingResponse, } from './models' const searchParameters = { - GOLFER_ID: 'golfer_id', - SOURCE: 'source', + GOLFER_ID: 'golfer_id', + SOURCE: 'source', } as const export class GhinClient { - private httpClient: RequestClient + private httpClient: RequestClient courses: { - getCountries: () => Promise - getDetails: (request: CourseDetailsRequest) => Promise - search: (request: CourseSearchRequest) => Promise - getTeeSetRating: (request: TeeSetRatingRequest) => Promise + getCountries: () => Promise + getDetails: (request: CourseDetailsRequest) => Promise + search: (request: CourseSearchRequest) => Promise + getTeeSetRating: (request: TeeSetRatingRequest) => Promise } facilities: { - search: (request: FacilitySearchRequest) => Promise + search: (request: FacilitySearchRequest) => Promise } golfers: { - getOne: (ghinNumber: number) => Promise - getScores: (ghinNumber: number, request?: ScoresRequest) => Promise - search: (request: GolfersSearchRequest) => Promise - globalSearch: (request: GolfersGlobalSearchRequest) => Promise + getOne: (ghinNumber: number) => Promise + getScores: (ghinNumber: number, request?: ScoresRequest) => Promise + search: (request: GolfersSearchRequest) => Promise + globalSearch: (request: GolfersGlobalSearchRequest) => Promise + getFollowing: (ghinNumber: number) => Promise } handicaps: { - getOne: (ghinNumber: number) => Promise - getCoursePlayerHandicaps: (requests: GolferCourseHandicapRequest[]) => Promise + getOne: (ghinNumber: number) => Promise + getCoursePlayerHandicaps: (requests: GolferCourseHandicapRequest[]) => Promise } constructor(config: ClientConfig) { - const results = schemaClientConfig.safeParse(config) - - if (!results.success) { - throw new ConfigurationError(`Invalid GhinClientConfig: ${results.error.message}`) - } - - this.httpClient = new RequestClient({ - ...results.data, - cache: results.data.cache ?? new InMemoryCacheClient(), - }) - - this.courses = { - getCountries: this.coursesGetCountries.bind(this), - getDetails: this.courseGetDetails.bind(this), - search: this.courseSearch.bind(this), - getTeeSetRating: this.courseGetTeeSetRating.bind(this), - } - - this.facilities = { - search: this.facilitySearch.bind(this), - } - - this.handicaps = { - getOne: this.handicapsGetOne.bind(this), - getCoursePlayerHandicaps: this.handicapsGetCoursePlayerHandicaps.bind(this), - } - - this.golfers = { - getOne: this.golfersGetOne.bind(this), - getScores: this.golfersGetScores.bind(this), - search: this.golfersSearch.bind(this), - globalSearch: this.golfersGlobalSearch.bind(this), - } - } + const results = schemaClientConfig.safeParse(config) - private async coursesGetCountries(): Promise { - try { - const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) - const options: Parameters[0]['options'] = { - searchParams, + if (!results.success) { + throw new ConfigurationError(`Invalid GhinClientConfig: ${results.error.message}`) } - const result = await this.httpClient.fetch({ - entity: 'course_countries', - options, - schema: schemaCourseCountriesResponse, + this.httpClient = new RequestClient({ + ...results.data, + cache: results.data.cache ?? new InMemoryCacheClient(), }) - if (result.isErr()) { - throw result.error + this.courses = { + getCountries: this.coursesGetCountries.bind(this), + getDetails: this.courseGetDetails.bind(this), + search: this.courseSearch.bind(this), + getTeeSetRating: this.courseGetTeeSetRating.bind(this), } - return result.value.countries - } catch (error) { - throw error instanceof Error ? error : new Error(String(error)) - } + this.facilities = { + search: this.facilitySearch.bind(this), + } + + this.handicaps = { + getOne: this.handicapsGetOne.bind(this), + getCoursePlayerHandicaps: this.handicapsGetCoursePlayerHandicaps.bind(this), + } + + this.golfers = { + getOne: this.golfersGetOne.bind(this), + getScores: this.golfersGetScores.bind(this), + search: this.golfersSearch.bind(this), + globalSearch: this.golfersGlobalSearch.bind(this), + getFollowing: this.golfersGetFollowing.bind(this), + } + } + + private async coursesGetCountries(): Promise { + try { + const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) + + const options: Parameters[0]['options'] = { + searchParams, + } + + const result = await this.httpClient.fetch({ + entity: 'course_countries', + options, + schema: schemaCourseCountriesResponse, + }) + + if (result.isErr()) { + throw result.error + } + + return result.value.countries + } catch (error) { + throw error instanceof Error ? error : new Error(String(error)) + } } private async courseGetDetails(request: CourseDetailsRequest): Promise { - try { - const validRequest = schemaCourseDetailsRequest.parse(request) - const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) + try { + const validRequest = schemaCourseDetailsRequest.parse(request) + const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) - for (const [key, value] of Object.entries(validRequest)) { - searchParams.set(key, value.toString()) - } + for (const [key, value] of Object.entries(validRequest)) { + searchParams.set(key, value.toString()) + } - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'course_details', - options, - schema: schemaCourseDetailsResponse, - }) + const result = await this.httpClient.fetch({ + entity: 'course_details', + options, + schema: schemaCourseDetailsResponse, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid course details request: ${error.message}`) - } - throw error instanceof Error ? error : new Error(String(error)) - } + return result.value + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid course details request: ${error.message}`) + } + + throw error instanceof Error ? error : new Error(String(error)) + } } private async courseGetTeeSetRating(request: TeeSetRatingRequest): Promise { - try { - const validRequest = schemaTeeSetRatingRequest.parse(request) - const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) + try { + const validRequest = schemaTeeSetRatingRequest.parse(request) + const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) - if (validRequest.include_altered_tees !== undefined) { - searchParams.set('include_altered_tees', validRequest.include_altered_tees.toString()) - } + if (validRequest.include_altered_tees !== undefined) { + searchParams.set('include_altered_tees', validRequest.include_altered_tees.toString()) + } - const path = `/TeeSetRatings/${validRequest.tee_set_rating_id}.json` + const path = `/TeeSetRatings/${validRequest.tee_set_rating_id}.json` - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetchCustomPath({ - path, - options, - schema: schemaTeeSetRatingResponse, - }) + const result = await this.httpClient.fetchCustomPath({ + path, + options, + schema: schemaTeeSetRatingResponse, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid tee set rating request: ${error.message}`) - } - throw error instanceof Error ? error : new Error(String(error)) - } + return result.value + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid tee set rating request: ${error.message}`) + } + + throw error instanceof Error ? error : new Error(String(error)) + } } private async courseSearch(request: CourseSearchRequest): Promise { - try { - const validRequest = schemaCourseSearchRequest.parse(request) - const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) + try { + const validRequest = schemaCourseSearchRequest.parse(request) + const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) - for (const [key, value] of Object.entries(validRequest)) { - searchParams.set(key, value.toString()) - } + for (const [key, value] of Object.entries(validRequest)) { + searchParams.set(key, value.toString()) + } - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'course_search', - options, - schema: schemaCourseSearchResponse, - }) + const result = await this.httpClient.fetch({ + entity: 'course_search', + options, + schema: schemaCourseSearchResponse, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value.courses - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid course search request: ${error.message}`) - } - throw error instanceof Error ? error : new Error(String(error)) - } + return result.value.courses + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid course search request: ${error.message}`) + } + + throw error instanceof Error ? error : new Error(String(error)) + } } private async facilitySearch(request: FacilitySearchRequest): Promise { - try { - const validRequest = schemaFacilitySearchRequest.parse(request) - const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) + try { + const validRequest = schemaFacilitySearchRequest.parse(request) + const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) - for (const [key, value] of Object.entries(validRequest)) { - searchParams.set(key, value.toString()) - } + for (const [key, value] of Object.entries(validRequest)) { + searchParams.set(key, value.toString()) + } - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'facility_search', - options, - schema: schemaFacilitySearchResponse, - }) + const result = await this.httpClient.fetch({ + entity: 'facility_search', + options, + schema: schemaFacilitySearchResponse, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid facility search request: ${error.message}`) - } - throw error instanceof Error ? error : new Error(String(error)) - } + return result.value + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid facility search request: ${error.message}`) + } + + throw error instanceof Error ? error : new Error(String(error)) + } } private async handicapsGetOne(ghin: number): Promise { - try { - const ghinNumber = number.parse(ghin) - - const searchParams = new URLSearchParams([ - ['source', CLIENT_SOURCE], - ['ghin', ghinNumber.toString()], - ]) + try { + const ghinNumber = number.parse(ghin) + const searchParams = new URLSearchParams([ + ['source', CLIENT_SOURCE], + ['ghin', ghinNumber.toString()], + ]) + + const options: Parameters[0]['options'] = { + searchParams, + } - const options: Parameters[0]['options'] = { - searchParams, - } + const result = await this.httpClient.fetch({ + entity: 'golfer', + options, + schema: schemaGolferHandicapResponse, + }) - const result = await this.httpClient.fetch({ - entity: 'golfer', - options, - schema: schemaGolferHandicapResponse, - }) + if (result.isErr()) { + throw result.error + } - if (result.isErr()) { - throw result.error - } + return result.value.golfer + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid GHIN number: ${error.message}`) + } - return result.value.golfer - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid GHIN number: ${error.message}`) - } - throw error instanceof Error ? error : new Error(String(error)) - } + throw error instanceof Error ? error : new Error(String(error)) + } } private async handicapsGetCoursePlayerHandicaps( - request: GolferCourseHandicapRequest[], - ): Promise { - try { - const golfers = z - .array(schemaGolferCourseHandicapRequest) - .parse(request) - .map(({ ghin, ...golfer }) => ({ - ...golfer, - [searchParameters.GOLFER_ID]: ghin, - })) - - const searchParams = new URLSearchParams() - - const courseHandicapRequest: CourseHandicapsRequest = { - golfers, - source: CLIENT_SOURCE, - } + request: GolferCourseHandicapRequest[], + ): Promise { + try { + const golfers = z + .array(schemaGolferCourseHandicapRequest) + .parse(request) + .map(({ ghin, ...golfer }) => ({ + ...golfer, + [searchParameters.GOLFER_ID]: ghin, + })) + + const searchParams = new URLSearchParams() + + const courseHandicapRequest: CourseHandicapsRequest = { + golfers, + source: CLIENT_SOURCE, + } - const options: Parameters[0]['options'] = { - body: JSON.stringify(courseHandicapRequest), - method: 'POST', - searchParams, - } + const options: Parameters[0]['options'] = { + body: JSON.stringify(courseHandicapRequest), + method: 'POST', + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'course_handicaps', - options, - schema: schemaCoursePlayerHandicapsResponse, - }) + const result = await this.httpClient.fetch({ + entity: 'course_handicaps', + options, + schema: schemaCoursePlayerHandicapsResponse, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid course handicap request: ${error.message}`) - } - throw error instanceof Error ? error : new Error(String(error)) - } + return result.value + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid course handicap request: ${error.message}`) + } + + throw error instanceof Error ? error : new Error(String(error)) + } } private async golfersSearch(request: GolfersSearchRequest): Promise { - try { - const params = schemaGolfersSearchRequest.parse(request) - const searchParams = new URLSearchParams() - - const searchDefaults = { - page: 1, - per_page: 25, - sorting_criteria: 'last_name_first_name', - status: 'Active', - order: 'asc', - } + try { + const params = schemaGolfersSearchRequest.parse(request) + const searchParams = new URLSearchParams() + + const searchDefaults = { + page: 1, + per_page: 25, + sorting_criteria: 'last_name_first_name', + status: 'Active', + order: 'asc', + } - for (const [key, value] of Object.entries(searchDefaults)) { - searchParams.set(key, value.toString()) - } + for (const [key, value] of Object.entries(searchDefaults)) { + searchParams.set(key, value.toString()) + } - for (const [key, value] of Object.entries(params)) { - searchParams.set(key, value?.toString() ?? '') - } + for (const [key, value] of Object.entries(params)) { + searchParams.set(key, value?.toString() ?? '') + } - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'golfers_search', - schema: schemaGolfersSearchResponse, - options, - }) + const result = await this.httpClient.fetch({ + entity: 'golfers_search', + schema: schemaGolfersSearchResponse, + options, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value.golfers - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid golfer search request: ${error.message}`) - } - throw error instanceof Error ? error : new Error(String(error)) - } + return result.value.golfers + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid golfer search request: ${error.message}`) + } + + throw error instanceof Error ? error : new Error(String(error)) + } } private async golfersGlobalSearch(request: GolfersGlobalSearchRequest): Promise { - try { - const { ghin } = schemaGolfersGlobalSearchRequest.parse(request) - const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) - - const searchDefaults = { - from_ghin: true, - per_page: 25, - sorting_criteria: 'full_name', - order: 'asc', - page: 1, - } + try { + const { ghin } = schemaGolfersGlobalSearchRequest.parse(request) + const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) + + const searchDefaults = { + from_ghin: true, + per_page: 25, + sorting_criteria: 'full_name', + order: 'asc', + page: 1, + } - for (const [key, value] of Object.entries(searchDefaults)) { - searchParams.set(key, value.toString()) - } + for (const [key, value] of Object.entries(searchDefaults)) { + searchParams.set(key, value.toString()) + } - if (ghin) { - searchParams.set(searchParameters.GOLFER_ID, ghin.toString()) - } + if (ghin) { + searchParams.set(searchParameters.GOLFER_ID, ghin.toString()) + } - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'golfers_global_search', - schema: schemaGolfersSearchResponse, - options, - }) + const result = await this.httpClient.fetch({ + entity: 'golfers_global_search', + schema: schemaGolfersSearchResponse, + options, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value.golfers - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid golfer search request: ${error.message}`) - } - throw error instanceof Error ? error : new Error(String(error)) - } + return result.value.golfers + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid golfer search request: ${error.message}`) + } + + throw error instanceof Error ? error : new Error(String(error)) + } } private async golfersGetOne(ghinNumber: number): Promise { - try { - const ghin = number.parse(ghinNumber) - const results = await this.golfersGlobalSearch({ - ghin: ghin, - status: 'Active', - }) + try { + const ghin = number.parse(ghinNumber) - return results.find((golfer) => golfer.status === 'Active') - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid GHIN number: ${error.message}`) - } - throw error instanceof Error ? error : new Error(String(error)) - } + const results = await this.golfersGlobalSearch({ + ghin: ghin, + status: 'Active', + }) + + return results.find((golfer) => golfer.status === 'Active') + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid GHIN number: ${error.message}`) + } + + throw error instanceof Error ? error : new Error(String(error)) + } } private async golfersGetScores(ghinNumber: number, request?: ScoresRequest): Promise { - try { - const validRequest = schemaScoresRequest.parse(request) ?? {} - const ghin = number.parse(ghinNumber) - - const searchParams = new URLSearchParams([ - [searchParameters.GOLFER_ID, ghin.toString()], - ['source', CLIENT_SOURCE], - ]) - - for (const [key, value] of Object.entries(validRequest)) { - if (value === null) { - continue - } + try { + const validRequest = schemaScoresRequest.parse(request) ?? {} + const ghin = number.parse(ghinNumber) - if (Array.isArray(value)) { - for (const v of value) { - searchParams.append(key, v.toString()) + const searchParams = new URLSearchParams([ + [searchParameters.GOLFER_ID, ghin.toString()], + ['source', CLIENT_SOURCE], + ]) + + for (const [key, value] of Object.entries(validRequest)) { + if (value === null) { + continue + } + + if (Array.isArray(value)) { + for (const v of value) { + searchParams.append(key, v.toString()) + } + + continue + } + + if (typeof value === 'object' && value instanceof Date) { + searchParams.set(key, value.toISOString().split('T')[0] as string) + + continue + } + + searchParams.set(key, value.toString()) } - continue - } - if (typeof value === 'object' && value instanceof Date) { - searchParams.set(key, value.toISOString().split('T')[0] as string) - continue + const options: Parameters[0]['options'] = { + searchParams, + } + + const result = await this.httpClient.fetch({ + entity: 'scores', + options, + schema: schemaScoresResponse, + }) + + if (result.isErr()) { + throw result.error + } + + return result.value + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid scores request: ${error.message}`) + } + + throw error instanceof Error ? error : new Error(String(error)) } + } - searchParams.set(key, value.toString()) - } + private async golfersGetFollowing(ghinNumber: number): Promise { + try { + const ghin = number.parse(ghinNumber) + const path = `/golfers/${ghin}/following.json` - const options: Parameters[0]['options'] = { - searchParams, - } + const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) - const result = await this.httpClient.fetch({ - entity: 'scores', - options, - schema: schemaScoresResponse, - }) + const options: Parameters[0]['options'] = { + searchParams, + } - if (result.isErr()) { - throw result.error - } + const result = await this.httpClient.fetchCustomPath({ + path, + options, + schema: schemaFollowingResponse, + }) - return result.value - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid scores request: ${error.message}`) - } - throw error instanceof Error ? error : new Error(String(error)) - } + if (result.isErr()) { + throw result.error + } + + return result.value.golfers + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid GHIN number: ${error.message}`) + } + + throw error instanceof Error ? error : new Error(String(error)) + } } } From e2443379dd560005aca8212e5acf2f0884012340 Mon Sep 17 00:00:00 2001 From: Ryan4n6 <131580830+Ryan4n6@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:38:20 -0500 Subject: [PATCH 4/7] =?UTF-8?q?test:=20=E2=9C=A8=20add=20tests=20for=20gol?= =?UTF-8?q?fers.getFollowing()=20+=20mock=20fetchCustomPath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add fetchCustomPath method to RequestClient mock and update tests to use it for fetching following golfers. --- src/client/ghin/index.test.ts | 686 ++++++++++++++++++---------------- 1 file changed, 354 insertions(+), 332 deletions(-) diff --git a/src/client/ghin/index.test.ts b/src/client/ghin/index.test.ts index 8cdc55e..aa4224f 100644 --- a/src/client/ghin/index.test.ts +++ b/src/client/ghin/index.test.ts @@ -6,340 +6,362 @@ import { GhinClient } from './index' // Mock the RequestClient const mockFetch = vi.fn() +const mockFetchCustomPath = vi.fn() + vi.mock('../request-client', () => ({ - RequestClient: vi.fn().mockImplementation(() => ({ - fetch: mockFetch, - })), - CLIENT_SOURCE: 'GHINcom', + RequestClient: vi.fn().mockImplementation(() => ({ + fetch: mockFetch, + fetchCustomPath: mockFetchCustomPath, + })), + CLIENT_SOURCE: 'GHINcom', })) describe('GhinClient', () => { - let ghinClient: GhinClient - - beforeEach(() => { - vi.clearAllMocks() - mockFetch.mockReset() - - ghinClient = new GhinClient({ - username: 'testuser', - password: 'testpass', - cache: new InMemoryCacheClient(), - }) - }) - - describe('constructor', () => { - it('should create instance with valid config', () => { - expect(ghinClient).toBeInstanceOf(GhinClient) - expect(ghinClient.courses).toBeDefined() - expect(ghinClient.golfers).toBeDefined() - expect(ghinClient.handicaps).toBeDefined() - expect(ghinClient.facilities).toBeDefined() - }) - - it('should throw error with invalid config', () => { - expect(() => { - new GhinClient({ - username: '', - password: 'testpass', - } as unknown as { username: string; password: string }) - }).toThrow('Invalid GhinClientConfig') - }) - - it('should use default cache when not provided', () => { - const client = new GhinClient({ - username: 'testuser', - password: 'testpass', - }) - expect(client).toBeInstanceOf(GhinClient) - }) - }) - - describe('courses.getCountries', () => { - it('should fetch and return countries', async () => { - const mockCountries = { - countries: [ - { code: 'USA', name: 'United States' }, - { code: 'CAN', name: 'Canada' }, - ], - } - mockFetch.mockResolvedValue(ok(mockCountries)) - - const result = await ghinClient.courses.getCountries() - - expect(result).toEqual(mockCountries.countries) - expect(mockFetch).toHaveBeenCalledWith({ - entity: 'course_countries', - options: expect.objectContaining({ - searchParams: expect.any(URLSearchParams), - }), - schema: expect.anything(), - }) - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Network error'))) - - await expect(ghinClient.courses.getCountries()).rejects.toThrow('Network error') - }) - }) - - describe('courses.getDetails', () => { - it('should fetch and return course details', async () => { - const mockDetails = { - course_id: 12345, - name: 'Test Course', - city: 'Test City', - } - mockFetch.mockResolvedValue(ok(mockDetails)) - - const result = await ghinClient.courses.getDetails({ course_id: 12345 }) - - expect(result).toEqual(mockDetails) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw validation error with invalid request', async () => { - // @ts-expect-error - Testing invalid input type - await expect(ghinClient.courses.getDetails({ course_id: 'invalid' })).rejects.toThrow(ValidationError) - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Not found'))) - - await expect(ghinClient.courses.getDetails({ course_id: 12345 })).rejects.toThrow('Not found') - }) - }) - - describe('courses.search', () => { - it('should search and return courses', async () => { - const mockResponse = { - courses: [ - { course_id: 1, name: 'Course 1' }, - { course_id: 2, name: 'Course 2' }, - ], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.courses.search({ name: 'Test' }) - - expect(result).toEqual(mockResponse.courses) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Search failed'))) - - await expect(ghinClient.courses.search({ name: 'Test' })).rejects.toThrow('Search failed') - }) - }) - - describe('facilities.search', () => { - it('should search and return facilities', async () => { - const mockResponse = { - facilities: [{ facility_id: 1, name: 'Facility 1' }], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.facilities.search({ - name: 'Test', - }) - - expect(result).toEqual(mockResponse) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Search failed'))) - - await expect(ghinClient.facilities.search({ name: 'Test' })).rejects.toThrow('Search failed') - }) - }) - - describe('handicaps.getOne', () => { - it('should fetch and return golfer handicap', async () => { - const mockResponse = { - golfer: { - ghin: 1234567, - handicap_index: 12.5, - }, - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.handicaps.getOne(1234567) - - expect(result).toEqual(mockResponse.golfer) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw validation error with invalid ghin', async () => { - // @ts-expect-error - Testing invalid input type - await expect(ghinClient.handicaps.getOne('invalid')).rejects.toThrow(ValidationError) - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Not found'))) - - await expect(ghinClient.handicaps.getOne(1234567)).rejects.toThrow('Not found') - }) - }) - - describe('handicaps.getCoursePlayerHandicaps', () => { - it('should fetch and return course player handicaps', async () => { - const mockResponse = { - handicaps: [{ ghin: 1234567, course_handicap: 15 }], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.handicaps.getCoursePlayerHandicaps([ - { ghin: 1234567, tee_set_id: 12345, tee_set_side: 'All 18' }, - ]) - - expect(result).toEqual(mockResponse) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Calculation failed'))) - - await expect( - ghinClient.handicaps.getCoursePlayerHandicaps([{ ghin: 1234567, tee_set_id: 12345, tee_set_side: 'All 18' }]), - ).rejects.toThrow('Calculation failed') - }) - }) - - describe('golfers.search', () => { - it('should search and return golfers', async () => { - const mockResponse = { - golfers: [{ ghin: 1234567, first_name: 'John', last_name: 'Doe' }], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.golfers.search({ last_name: 'Doe' }) - - expect(result).toEqual(mockResponse.golfers) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Search failed'))) - - await expect(ghinClient.golfers.search({ last_name: 'Doe' })).rejects.toThrow('Search failed') - }) - }) - - describe('golfers.globalSearch', () => { - it('should search globally and return golfers', async () => { - const mockResponse = { - golfers: [ - { - ghin: 1234567, - first_name: 'John', - last_name: 'Doe', - status: 'Active', - }, - ], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.golfers.globalSearch({ ghin: 1234567 }) - - expect(result).toEqual(mockResponse.golfers) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Search failed'))) - - await expect(ghinClient.golfers.globalSearch({ ghin: 1234567 })).rejects.toThrow('Search failed') - }) - }) - - describe('golfers.getOne', () => { - it('should fetch and return one active golfer', async () => { - const mockResponse = { - golfers: [ - { - ghin: 1234567, - first_name: 'John', - last_name: 'Doe', - status: 'Active', - }, - ], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.golfers.getOne(1234567) - - expect(result).toEqual(mockResponse.golfers[0]) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should return undefined when no active golfer found', async () => { - const mockResponse = { - golfers: [ - { - ghin: 1234567, - first_name: 'John', - last_name: 'Doe', - status: 'Inactive', - }, - ], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.golfers.getOne(1234567) - - expect(result).toBeUndefined() - }) - - it('should throw validation error with invalid ghin', async () => { - // @ts-expect-error - Testing invalid input type - await expect(ghinClient.golfers.getOne('invalid')).rejects.toThrow(ValidationError) - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Not found'))) - - await expect(ghinClient.golfers.getOne(1234567)).rejects.toThrow('Not found') - }) - }) - - describe('golfers.getScores', () => { - it('should fetch and return golfer scores', async () => { - const mockResponse = { - scores: [{ score_id: 1, adjusted_gross_score: 85 }], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.golfers.getScores(1234567) - - expect(result).toEqual(mockResponse) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should handle optional request parameters', async () => { - const mockResponse = { scores: [] } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.golfers.getScores(1234567, { - from_date_played: new Date('2024-01-01'), - to_date_played: new Date('2024-12-31'), - score_types: ['H', 'A'], - }) - - expect(result).toEqual(mockResponse) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw validation error with invalid ghin', async () => { - // @ts-expect-error - Testing invalid input type - await expect(ghinClient.golfers.getScores('invalid')).rejects.toThrow(ValidationError) - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Fetch failed'))) - - await expect(ghinClient.golfers.getScores(1234567)).rejects.toThrow('Fetch failed') - }) - }) + let ghinClient: GhinClient + + beforeEach(() => { + vi.clearAllMocks() + mockFetch.mockReset() + mockFetchCustomPath.mockReset() + ghinClient = new GhinClient({ + username: 'testuser', + password: 'testpass', + cache: new InMemoryCacheClient(), + }) + }) + + describe('constructor', () => { + it('should create instance with valid config', () => { + expect(ghinClient).toBeInstanceOf(GhinClient) + expect(ghinClient.courses).toBeDefined() + expect(ghinClient.golfers).toBeDefined() + expect(ghinClient.handicaps).toBeDefined() + expect(ghinClient.facilities).toBeDefined() + }) + + it('should throw error with invalid config', () => { + expect(() => { + new GhinClient({ + username: '', + password: 'testpass', + } as unknown as { username: string; password: string }) + }).toThrow('Invalid GhinClientConfig') + }) + + it('should use default cache when not provided', () => { + const client = new GhinClient({ + username: 'testuser', + password: 'testpass', + }) + expect(client).toBeInstanceOf(GhinClient) + }) + }) + + describe('courses.getCountries', () => { + it('should fetch and return countries', async () => { + const mockCountries = { + countries: [ + { code: 'USA', name: 'United States' }, + { code: 'CAN', name: 'Canada' }, + ], + } + mockFetch.mockResolvedValue(ok(mockCountries)) + + const result = await ghinClient.courses.getCountries() + expect(result).toEqual(mockCountries.countries) + expect(mockFetch).toHaveBeenCalledWith({ + entity: 'course_countries', + options: expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }), + schema: expect.anything(), + }) + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Network error'))) + await expect(ghinClient.courses.getCountries()).rejects.toThrow('Network error') + }) + }) + + describe('courses.getDetails', () => { + it('should fetch and return course details', async () => { + const mockDetails = { + course_id: 12345, + name: 'Test Course', + city: 'Test City', + } + mockFetch.mockResolvedValue(ok(mockDetails)) + + const result = await ghinClient.courses.getDetails({ course_id: 12345 }) + expect(result).toEqual(mockDetails) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw validation error with invalid request', async () => { + // @ts-expect-error - Testing invalid input type + await expect(ghinClient.courses.getDetails({ course_id: 'invalid' })).rejects.toThrow(ValidationError) + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Not found'))) + await expect(ghinClient.courses.getDetails({ course_id: 12345 })).rejects.toThrow('Not found') + }) + }) + + describe('courses.search', () => { + it('should search and return courses', async () => { + const mockResponse = { + courses: [ + { course_id: 1, name: 'Course 1' }, + { course_id: 2, name: 'Course 2' }, + ], + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.courses.search({ name: 'Test' }) + expect(result).toEqual(mockResponse.courses) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Search failed'))) + await expect(ghinClient.courses.search({ name: 'Test' })).rejects.toThrow('Search failed') + }) + }) + + describe('facilities.search', () => { + it('should search and return facilities', async () => { + const mockResponse = { facilities: [{ facility_id: 1, name: 'Facility 1' }] } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.facilities.search({ + name: 'Test', + }) + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Search failed'))) + await expect(ghinClient.facilities.search({ name: 'Test' })).rejects.toThrow('Search failed') + }) + }) + + describe('handicaps.getOne', () => { + it('should fetch and return golfer handicap', async () => { + const mockResponse = { + golfer: { + ghin: 1234567, + handicap_index: 12.5, + }, + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.handicaps.getOne(1234567) + expect(result).toEqual(mockResponse.golfer) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw validation error with invalid ghin', async () => { + // @ts-expect-error - Testing invalid input type + await expect(ghinClient.handicaps.getOne('invalid')).rejects.toThrow(ValidationError) + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Not found'))) + await expect(ghinClient.handicaps.getOne(1234567)).rejects.toThrow('Not found') + }) + }) + + describe('handicaps.getCoursePlayerHandicaps', () => { + it('should fetch and return course player handicaps', async () => { + const mockResponse = { + handicaps: [{ ghin: 1234567, course_handicap: 15 }], + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.handicaps.getCoursePlayerHandicaps([ + { ghin: 1234567, tee_set_id: 12345, tee_set_side: 'All 18' }, + ]) + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Calculation failed'))) + await expect( + ghinClient.handicaps.getCoursePlayerHandicaps([{ ghin: 1234567, tee_set_id: 12345, tee_set_side: 'All 18' }]), + ).rejects.toThrow('Calculation failed') + }) + }) + + describe('golfers.search', () => { + it('should search and return golfers', async () => { + const mockResponse = { + golfers: [{ ghin: 1234567, first_name: 'John', last_name: 'Doe' }], + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.search({ last_name: 'Doe' }) + expect(result).toEqual(mockResponse.golfers) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Search failed'))) + await expect(ghinClient.golfers.search({ last_name: 'Doe' })).rejects.toThrow('Search failed') + }) + }) + + describe('golfers.globalSearch', () => { + it('should search globally and return golfers', async () => { + const mockResponse = { + golfers: [ + { + ghin: 1234567, + first_name: 'John', + last_name: 'Doe', + status: 'Active', + }, + ], + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.globalSearch({ ghin: 1234567 }) + expect(result).toEqual(mockResponse.golfers) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Search failed'))) + await expect(ghinClient.golfers.globalSearch({ ghin: 1234567 })).rejects.toThrow('Search failed') + }) + }) + + describe('golfers.getOne', () => { + it('should fetch and return one active golfer', async () => { + const mockResponse = { + golfers: [ + { + ghin: 1234567, + first_name: 'John', + last_name: 'Doe', + status: 'Active', + }, + ], + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.getOne(1234567) + expect(result).toEqual(mockResponse.golfers[0]) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should return undefined when no active golfer found', async () => { + const mockResponse = { + golfers: [ + { + ghin: 1234567, + first_name: 'John', + last_name: 'Doe', + status: 'Inactive', + }, + ], + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.getOne(1234567) + expect(result).toBeUndefined() + }) + + it('should throw validation error with invalid ghin', async () => { + // @ts-expect-error - Testing invalid input type + await expect(ghinClient.golfers.getOne('invalid')).rejects.toThrow(ValidationError) + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Not found'))) + await expect(ghinClient.golfers.getOne(1234567)).rejects.toThrow('Not found') + }) + }) + + describe('golfers.getScores', () => { + it('should fetch and return golfer scores', async () => { + const mockResponse = { + scores: [{ score_id: 1, adjusted_gross_score: 85 }], + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.getScores(1234567) + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should handle optional request parameters', async () => { + const mockResponse = { scores: [] } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.getScores(1234567, { + from_date_played: new Date('2024-01-01'), + to_date_played: new Date('2024-12-31'), + score_types: ['H', 'A'], + }) + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw validation error with invalid ghin', async () => { + // @ts-expect-error - Testing invalid input type + await expect(ghinClient.golfers.getScores('invalid')).rejects.toThrow(ValidationError) + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Fetch failed'))) + await expect(ghinClient.golfers.getScores(1234567)).rejects.toThrow('Fetch failed') + }) + }) + + describe('golfers.getFollowing', () => { + it('should fetch and return the list of followed golfers', async () => { + const mockResponse = { + golfers: [ + { + ghin: 7654321, + first_name: 'Jane', + last_name: 'Smith', + status: 'Active', + }, + ], + } + mockFetchCustomPath.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.getFollowing(1234567) + expect(result).toEqual(mockResponse.golfers) + expect(mockFetchCustomPath).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/golfers/1234567/following.json', + schema: expect.anything(), + }), + ) + }) + + it('should return empty array when golfer follows nobody', async () => { + const mockResponse = { golfers: [] } + mockFetchCustomPath.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.getFollowing(1234567) + expect(result).toEqual([]) + }) + + it('should throw validation error with invalid ghin', async () => { + // @ts-expect-error - Testing invalid input type + await expect(ghinClient.golfers.getFollowing('invalid')).rejects.toThrow(ValidationError) + }) + + it('should throw error when fetch fails', async () => { + mockFetchCustomPath.mockResolvedValue(err(new Error('Not found'))) + await expect(ghinClient.golfers.getFollowing(1234567)).rejects.toThrow('Not found') + }) + }) }) From e7773f1ec7383c701746ea55c9fca2d1a86ca93c Mon Sep 17 00:00:00 2001 From: Ryan4n6 <131580830+Ryan4n6@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:39:56 -0500 Subject: [PATCH 5/7] =?UTF-8?q?docs:=20=E2=9C=A8=20add=20AUTHENTICATION.md?= =?UTF-8?q?=20=E2=80=94=20two-step=20Firebase=20+=20GHIN=20auth=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This document outlines the two-step authentication process for the GHIN API, detailing Firebase Authentication and GHIN Token Exchange, along with implementation examples and security notes. --- docs/AUTHENTICATION.md | 113 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/AUTHENTICATION.md diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md new file mode 100644 index 0000000..2a67b67 --- /dev/null +++ b/docs/AUTHENTICATION.md @@ -0,0 +1,113 @@ +# GHIN Authentication Flow + +This document describes the two-step authentication flow used by the GHIN API. + +## Overview + +GHIN uses a two-step authentication process: + +1. **Firebase Authentication** — Exchange username/password for a Firebase ID token +2. 2. **GHIN Token Exchange** — Exchange the Firebase token for a GHIN session token + + 3. The `GhinClient` handles both steps automatically. You only need to provide your GHIN credentials (username/password) in the client configuration. + + 4. ## Step 1: Firebase Authentication + + 5. GHIN's login endpoint uses Google Firebase under the hood. + + 6. **Endpoint:** `POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword` + + 7. **Query params:** `?key=` + + 8. **Request body:** + 9. ```json + { + "email": "your-ghin-username@example.com", + "password": "your-ghin-password", + "returnSecureToken": true + } + ``` + + **Response:** + ```json + { + "idToken": "", + "email": "your-ghin-username@example.com", + "refreshToken": "", + "expiresIn": "3600", + "localId": "" + } + ``` + + The `idToken` is a signed JWT issued by Firebase that is valid for ~1 hour. + + ## Step 2: GHIN Token Exchange + + The Firebase `idToken` is exchanged for a GHIN session token via the GHIN login endpoint. + + **Endpoint:** `POST https://api2.ghin.com/api/v1/golfer_login.json` + + **Request body:** + ```json + { + "user": { + "email_or_ghin": "your-ghin-username@example.com", + "password": "your-ghin-password", + "token": "", + "remember_me": true + }, + "source": "GHINcom" + } + ``` + + **Response:** + ```json + { + "golfer_user": { + "golfer_user_token": "", + "golfer_id": 1234567, + "email": "your-ghin-username@example.com", + "expires_at": "2026-02-25T04:00:00.000Z" + } + } + ``` + + The `golfer_user_token` is used as a Bearer token for all subsequent authenticated API requests. + + ## Token Auto-Refresh + + The `GhinClient` automatically refreshes tokens before they expire. When a request fails with a `401 Unauthorized` response, the client re-authenticates using the stored credentials and retries the request. + + Tokens are cached (in-memory by default, or via a custom cache adapter) to avoid unnecessary re-authentication on every request. + + ## Custom Cache Adapter + + You can provide a custom cache adapter to persist tokens across process restarts: + + ```typescript + import { GhinClient, type CacheClient } from '@spicygolf/ghin' + + class RedisCacheClient implements CacheClient { + async get(key: string): Promise { + // return await redis.get(key) + } + async set(key: string, value: string, ttlSeconds?: number): Promise { + // await redis.set(key, value, 'EX', ttlSeconds ?? 3600) + } + async delete(key: string): Promise { + // await redis.del(key) + } + } + + const ghin = new GhinClient({ + username: process.env.GHIN_USERNAME, + password: process.env.GHIN_PASSWORD, + cache: new RedisCacheClient(), + }) + ``` + + ## Security Notes + + - Never commit GHIN credentials to source control. Use environment variables. + - - The GHIN API key for Firebase is a public client-side key embedded in the ghin.com SPA — it is not a secret, but the credentials themselves must be kept private. + - - Tokens expire after approximately 24 hours. The client handles renewal automatically. From 33930da5dedaef4fc231409873ea2c31e18bdb06 Mon Sep 17 00:00:00 2001 From: Ryan4n6 <131580830+Ryan4n6@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:42:31 -0500 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20=E2=9C=A8=20add=20API=5FENDPOINTS.m?= =?UTF-8?q?d=20=E2=80=94=20complete=20endpoint=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This document provides a comprehensive reference for GHIN API endpoints used by this library, including paths, methods, query parameters, and response structures. --- docs/API_ENDPOINTS.md | 299 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 docs/API_ENDPOINTS.md diff --git a/docs/API_ENDPOINTS.md b/docs/API_ENDPOINTS.md new file mode 100644 index 0000000..0709164 --- /dev/null +++ b/docs/API_ENDPOINTS.md @@ -0,0 +1,299 @@ +# GHIN API Endpoint Reference + +This document provides a comprehensive reference for GHIN API endpoints used by this library, including paths, methods, query parameters, and response structures. + +**Base URL:** `https://api2.ghin.com/api/v1` + +All requests require an `Authorization: Bearer ` header (handled automatically by `GhinClient`) and a `source=GHINcom` query parameter. + +--- + +## Authentication + +### Login + +**POST** `/golfer_login.json` + +Exchange Firebase token for a GHIN session token. See [AUTHENTICATION.md](./AUTHENTICATION.md) for the full two-step flow. + +--- + +## Golfers + +### Search Golfers + +**GET** `/golfers.json` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `last_name` | string | Golfer's last name (partial match) | +| `first_name` | string | Golfer's first name (partial match) | +| `ghin` | number | GHIN number (exact match) | +| `status` | string | `Active` or `Inactive` | +| `page` | number | Page number (default: 1) | +| `per_page` | number | Results per page (default: 25) | +| `sorting_criteria` | string | Sort field (default: `last_name_first_name`) | +| `order` | string | `asc` or `desc` | + +**Response:** +```json +{ + "golfers": [ + { + "ghin": 1234567, + "first_name": "John", + "last_name": "Doe", + "status": "Active", + "handicap_index": "12.5", + "club_name": "Augusta National", + "state": "US-GA", + "country": "USA" + } + ] +} +``` + +### Global Search (from GHIN) + +**GET** `/golfers.json` + +Same as Search Golfers but with `from_ghin=true`. Used internally by `golfers.getOne()`. + +### Get Golfer Following List + +**GET** `/golfers/{ghinNumber}/following.json` + +Fetch the list of golfers that the authenticated golfer is following. + +> **Note:** This endpoint was discovered by inspecting network traffic on the ghin.com SPA. +> +> | Parameter | Type | Description | +> |-----------|------|-------------| +> | `source` | string | Always `GHINcom` | +> +> **Response:** +> ```json +> { +> "golfers": [ +> { +> "ghin": 7654321, +> "first_name": "Jane", +> "last_name": "Smith", +> "status": "Active", +> "handicap_index": "8.2", +> "club_name": "Pebble Beach", +> "state": "US-CA", +> "country": "USA" +> } +> ] +> } +> ``` +> +> --- +> +> ## Handicaps +> +> ### Get Golfer Handicap +> +> **GET** `/golfers/{ghin}/handicap_index.json` +> +> | Parameter | Type | Description | +> |-----------|------|-------------| +> | `ghin` | number | GHIN number | +> | `source` | string | Always `GHINcom` | +> +> **Response:** +> ```json +> { +> "golfer": { +> "ghin": 1234567, +> "handicap_index": "12.5", +> "low_handicap_index": "10.2", +> "handicap_index_display": "12.5", +> "trend": "0.1" +> } +> } +> ``` +> +> ### Get Course Player Handicaps +> +> **POST** `/course_handicaps.json` +> +> Calculate course handicap for one or more golfers on a specific tee set. +> +> **Request body:** +> ```json +> { +> "golfers": [ +> { +> "golfer_id": 1234567, +> "tee_set_id": 98765, +> "tee_set_side": "All 18" +> } +> ], +> "source": "GHINcom" +> } +> ``` +> +> **Response:** +> ```json +> { +> "course_handicaps": [ +> { +> "golfer_id": 1234567, +> "course_handicap": 14, +> "playing_handicap": 14 +> } +> ] +> } +> ``` +> +> --- +> +> ## Scores +> +> ### Get Golfer Scores +> +> **GET** `/golfers/{golfer_id}/scores.json` +> +> | Parameter | Type | Description | +> |-----------|------|-------------| +> | `golfer_id` | number | GHIN number | +> | `from_date_played` | string | Start date (YYYY-MM-DD) | +> | `to_date_played` | string | End date (YYYY-MM-DD) | +> | `score_types` | string[] | `H` (Home), `A` (Away), `T` (Tournament) | +> | `source` | string | Always `GHINcom` | +> +> **Response:** +> ```json +> { +> "scores": [ +> { +> "id": 99887766, +> "adjusted_gross_score": 85, +> "differential": "10.5", +> "course_name": "Augusta National", +> "played_at": "2024-06-15", +> "score_type": "H", +> "tee_name": "White", +> "number_of_holes": 18 +> } +> ] +> } +> ``` +> +> --- +> +> ## Courses +> +> ### Search Courses +> +> **GET** `/crsCourseMethods.asmx/SearchCourses.json` +> +> | Parameter | Type | Description | +> |-----------|------|-------------| +> | `name` | string | Course name (partial match) | +> | `city` | string | City | +> | `state_code` | string | State code (e.g., `US-GA`) | +> | `country_code` | string | Country code (e.g., `USA`) | +> | `source` | string | Always `GHINcom` | +> +> **Response:** +> ```json +> { +> "courses": [ +> { +> "CourseID": 13995, +> "CourseName": "Druid Hills Golf Club", +> "FacilityID": 11807, +> "FacilityName": "Druid Hills Golf Club", +> "FullName": "Druid Hills Golf Club - Druid Hills Golf Club", +> "Address1": "740 Clifton Road NE", +> "City": "Atlanta", +> "State": "US-GA", +> "Country": "USA", +> "Zip": "30307", +> "CourseStatus": "Active", +> "FacilityStatus": "Active", +> "Ratings": [] +> } +> ] +> } +> ``` +> +> ### Get Course Details +> +> **GET** `/crsCourseMethods.asmx/GetCourse.json` +> +> | Parameter | Type | Description | +> |-----------|------|-------------| +> | `course_id` | number | Course ID | +> | `source` | string | Always `GHINcom` | +> +> ### Get Course Countries +> +> **GET** `/course_countries.json` +> +> Returns a list of countries that have GHIN-rated courses. +> +> ### Get Tee Set Rating +> +> **GET** `/TeeSetRatings/{teeSetRatingId}.json` +> +> | Parameter | Type | Description | +> |-----------|------|-------------| +> | `tee_set_rating_id` | number | Tee set rating ID | +> | `include_altered_tees` | boolean | Include altered tee sets (optional) | +> | `source` | string | Always `GHINcom` | +> +> **Response:** Full tee set rating details including course rating, slope, and par. +> +> --- +> +> ## Facilities +> +> ### Search Facilities +> +> **GET** `/facilities/search.json` +> +> | Parameter | Type | Description | +> |-----------|------|-------------| +> | `name` | string | Facility name (partial match) | +> | `city` | string | City | +> | `state` | string | State code (e.g., `US-GA`) | +> | `country` | string | Country code (e.g., `USA`) | +> | `source` | string | Always `GHINcom` | +> +> **Response:** Array of facility objects (not wrapped — returns array directly): +> ```json +> [ +> { +> "FacilityId": 11807, +> "FacilityStatus": "Active", +> "FacilityName": "Druid Hills Golf Club", +> "City": "Atlanta", +> "State": "US-GA", +> "Country": "USA", +> "Associations": [], +> "Courses": [ +> { +> "CourseId": 13995, +> "CourseStatus": "Active", +> "CourseName": "Druid Hills Golf Club", +> "NumberOfHoles": 18 +> } +> ] +> } +> ] +> ``` +> +> > **Note:** The Facilities API returns an array directly (unlike most endpoints which wrap in an object). See the [Facilities vs Courses comparison](./llm-output/FACILITIES_VS_COURSES.md) for full details. +> > +> > --- +> > +> > ## Notes +> > +> > - All date parameters use `YYYY-MM-DD` format +> > - - The `source` parameter should always be `GHINcom` for web API access +> > - - Geolocation fields (`GeoLocationLatitude`, `GeoLocationLongitude`) may be absent in search responses and default to `null` +> > - - Field naming conventions differ between Facilities (`FacilityId`, `CourseId`) and Courses (`FacilityID`, `CourseID`) APIs From f6fdb6be5b24b7e41c07fe9650e46faeaec5ae48 Mon Sep 17 00:00:00 2001 From: Ryan4n6 <131580830+Ryan4n6@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:56:16 -0500 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20=F0=9F=94=A7=20fix=20auth=20docs=20?= =?UTF-8?q?+=20endpoint=20reference=20from=20real-world=20integration=20te?= =?UTF-8?q?sting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AUTHENTICATION.md: - Fix Step 1: was incorrectly documenting Firebase Identity Toolkit (identitytoolkit.googleapis.com/accounts:signInWithPassword) but the code actually uses Firebase Installations API (firebaseinstallations.googleapis.com/installations) - Document all three auth paths: Firebase+GHIN login (default), direct API login (apiAccess: true), and direct login without Firebase - Fix broken markdown formatting (nested numbered lists, indentation) - Add real-world D1 cache adapter example for serverless environments - Correct token TTL documentation (variable, not fixed 24h) API_ENDPOINTS.md: - Fix Get Course Details endpoint: was GetCourse.json, actually GetCourseDetails.json (matches codebase apiPathnames.course_details) - Add full GetCourseDetails response example with hole-by-hole data (verified against Saddleback Golf Club, courseId 24279) - Add breaking change notice: all endpoints require auth as of 2026 - Fix massive formatting issue: everything after "Following" section was accidentally in nested blockquotes, making it unreadable - Add combo tee set edge case documentation (empty Holes arrays) - Add Male/Female tee set merging guidance - Fix course_handicaps endpoint path (playing_handicaps.json) - Distinguish golfers/search.json from golfers.json endpoints Also runs biome format on 3 existing .ts files with indent issues. Findings from integrating @spicygolf/ghin with the TPS League platform (tpsleague.com) — a women's golf league management app using Cloudflare Workers + D1 that imports course data from GHIN for 39+ Colorado courses. Co-Authored-By: Claude Opus 4.6 --- docs/API_ENDPOINTS.md | 591 ++++++++------ docs/AUTHENTICATION.md | 299 ++++--- src/client/ghin/index.test.ts | 704 ++++++++--------- src/client/ghin/index.ts | 822 ++++++++++---------- src/client/ghin/models/golfers/following.ts | 26 +- 5 files changed, 1313 insertions(+), 1129 deletions(-) diff --git a/docs/API_ENDPOINTS.md b/docs/API_ENDPOINTS.md index 0709164..21ce815 100644 --- a/docs/API_ENDPOINTS.md +++ b/docs/API_ENDPOINTS.md @@ -1,20 +1,34 @@ # GHIN API Endpoint Reference -This document provides a comprehensive reference for GHIN API endpoints used by this library, including paths, methods, query parameters, and response structures. +Comprehensive reference for GHIN API endpoints used by this library, including paths, methods, query parameters, and response structures. **Base URL:** `https://api2.ghin.com/api/v1` All requests require an `Authorization: Bearer ` header (handled automatically by `GhinClient`) and a `source=GHINcom` query parameter. +> **Breaking change (2026):** As of early 2026, all GHIN API endpoints require authentication. Previously, some endpoints (e.g., `golfers/search.json`) allowed unauthenticated access. Unauthenticated requests now return `401 Unauthorized`. + --- ## Authentication -### Login +### Firebase Installation Token + +**POST** `https://firebaseinstallations.googleapis.com/v1/projects/ghin-mobile-app/installations` + +Obtain a Firebase installation token (Step 1 of the default auth flow). See [AUTHENTICATION.md](./AUTHENTICATION.md) for full details. + +### Golfer Login **POST** `/golfer_login.json` -Exchange Firebase token for a GHIN session token. See [AUTHENTICATION.md](./AUTHENTICATION.md) for the full two-step flow. +Exchange Firebase installation token + GHIN credentials for a bearer token (Step 2 of default flow). + +### Direct API Login + +**POST** `/users/login.json` + +Direct credential exchange for accounts with API access enabled (`apiAccess: true`). --- @@ -22,25 +36,22 @@ Exchange Firebase token for a GHIN session token. See [AUTHENTICATION.md](./AUTH ### Search Golfers -**GET** `/golfers.json` +**GET** `/golfers/search.json` + +Search for a specific golfer by GHIN number. | Parameter | Type | Description | |-----------|------|-------------| -| `last_name` | string | Golfer's last name (partial match) | -| `first_name` | string | Golfer's first name (partial match) | -| `ghin` | number | GHIN number (exact match) | -| `status` | string | `Active` or `Inactive` | -| `page` | number | Page number (default: 1) | +| `golfer_id` | number | GHIN number (exact match) | | `per_page` | number | Results per page (default: 25) | -| `sorting_criteria` | string | Sort field (default: `last_name_first_name`) | -| `order` | string | `asc` or `desc` | +| `page` | number | Page number (default: 1) | **Response:** ```json { "golfers": [ { - "ghin": 1234567, + "ghin_number": 1234567, "first_name": "John", "last_name": "Doe", "status": "Active", @@ -53,11 +64,23 @@ Exchange Firebase token for a GHIN session token. See [AUTHENTICATION.md](./AUTH } ``` -### Global Search (from GHIN) +### Global Golfer Search **GET** `/golfers.json` -Same as Search Golfers but with `from_ghin=true`. Used internally by `golfers.getOne()`. +Search golfers by name, club, or other criteria. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `last_name` | string | Golfer's last name (partial match) | +| `first_name` | string | Golfer's first name (partial match) | +| `ghin` | number | GHIN number (exact match) | +| `status` | string | `Active` or `Inactive` | +| `from_ghin` | boolean | Search from GHIN registry (used by `golfers.getOne()`) | +| `page` | number | Page number (default: 1) | +| `per_page` | number | Results per page (default: 25) | +| `sorting_criteria` | string | Sort field (default: `last_name_first_name`) | +| `order` | string | `asc` or `desc` | ### Get Golfer Following List @@ -66,234 +89,312 @@ Same as Search Golfers but with `from_ghin=true`. Used internally by `golfers.ge Fetch the list of golfers that the authenticated golfer is following. > **Note:** This endpoint was discovered by inspecting network traffic on the ghin.com SPA. -> -> | Parameter | Type | Description | -> |-----------|------|-------------| -> | `source` | string | Always `GHINcom` | -> -> **Response:** -> ```json -> { -> "golfers": [ -> { -> "ghin": 7654321, -> "first_name": "Jane", -> "last_name": "Smith", -> "status": "Active", -> "handicap_index": "8.2", -> "club_name": "Pebble Beach", -> "state": "US-CA", -> "country": "USA" -> } -> ] -> } -> ``` -> -> --- -> -> ## Handicaps -> -> ### Get Golfer Handicap -> -> **GET** `/golfers/{ghin}/handicap_index.json` -> -> | Parameter | Type | Description | -> |-----------|------|-------------| -> | `ghin` | number | GHIN number | -> | `source` | string | Always `GHINcom` | -> -> **Response:** -> ```json -> { -> "golfer": { -> "ghin": 1234567, -> "handicap_index": "12.5", -> "low_handicap_index": "10.2", -> "handicap_index_display": "12.5", -> "trend": "0.1" -> } -> } -> ``` -> -> ### Get Course Player Handicaps -> -> **POST** `/course_handicaps.json` -> -> Calculate course handicap for one or more golfers on a specific tee set. -> -> **Request body:** -> ```json -> { -> "golfers": [ -> { -> "golfer_id": 1234567, -> "tee_set_id": 98765, -> "tee_set_side": "All 18" -> } -> ], -> "source": "GHINcom" -> } -> ``` -> -> **Response:** -> ```json -> { -> "course_handicaps": [ -> { -> "golfer_id": 1234567, -> "course_handicap": 14, -> "playing_handicap": 14 -> } -> ] -> } -> ``` -> -> --- -> -> ## Scores -> -> ### Get Golfer Scores -> -> **GET** `/golfers/{golfer_id}/scores.json` -> -> | Parameter | Type | Description | -> |-----------|------|-------------| -> | `golfer_id` | number | GHIN number | -> | `from_date_played` | string | Start date (YYYY-MM-DD) | -> | `to_date_played` | string | End date (YYYY-MM-DD) | -> | `score_types` | string[] | `H` (Home), `A` (Away), `T` (Tournament) | -> | `source` | string | Always `GHINcom` | -> -> **Response:** -> ```json -> { -> "scores": [ -> { -> "id": 99887766, -> "adjusted_gross_score": 85, -> "differential": "10.5", -> "course_name": "Augusta National", -> "played_at": "2024-06-15", -> "score_type": "H", -> "tee_name": "White", -> "number_of_holes": 18 -> } -> ] -> } -> ``` -> -> --- -> -> ## Courses -> -> ### Search Courses -> -> **GET** `/crsCourseMethods.asmx/SearchCourses.json` -> -> | Parameter | Type | Description | -> |-----------|------|-------------| -> | `name` | string | Course name (partial match) | -> | `city` | string | City | -> | `state_code` | string | State code (e.g., `US-GA`) | -> | `country_code` | string | Country code (e.g., `USA`) | -> | `source` | string | Always `GHINcom` | -> -> **Response:** -> ```json -> { -> "courses": [ -> { -> "CourseID": 13995, -> "CourseName": "Druid Hills Golf Club", -> "FacilityID": 11807, -> "FacilityName": "Druid Hills Golf Club", -> "FullName": "Druid Hills Golf Club - Druid Hills Golf Club", -> "Address1": "740 Clifton Road NE", -> "City": "Atlanta", -> "State": "US-GA", -> "Country": "USA", -> "Zip": "30307", -> "CourseStatus": "Active", -> "FacilityStatus": "Active", -> "Ratings": [] -> } -> ] -> } -> ``` -> -> ### Get Course Details -> -> **GET** `/crsCourseMethods.asmx/GetCourse.json` -> -> | Parameter | Type | Description | -> |-----------|------|-------------| -> | `course_id` | number | Course ID | -> | `source` | string | Always `GHINcom` | -> -> ### Get Course Countries -> -> **GET** `/course_countries.json` -> -> Returns a list of countries that have GHIN-rated courses. -> -> ### Get Tee Set Rating -> -> **GET** `/TeeSetRatings/{teeSetRatingId}.json` -> -> | Parameter | Type | Description | -> |-----------|------|-------------| -> | `tee_set_rating_id` | number | Tee set rating ID | -> | `include_altered_tees` | boolean | Include altered tee sets (optional) | -> | `source` | string | Always `GHINcom` | -> -> **Response:** Full tee set rating details including course rating, slope, and par. -> -> --- -> -> ## Facilities -> -> ### Search Facilities -> -> **GET** `/facilities/search.json` -> -> | Parameter | Type | Description | -> |-----------|------|-------------| -> | `name` | string | Facility name (partial match) | -> | `city` | string | City | -> | `state` | string | State code (e.g., `US-GA`) | -> | `country` | string | Country code (e.g., `USA`) | -> | `source` | string | Always `GHINcom` | -> -> **Response:** Array of facility objects (not wrapped — returns array directly): -> ```json -> [ -> { -> "FacilityId": 11807, -> "FacilityStatus": "Active", -> "FacilityName": "Druid Hills Golf Club", -> "City": "Atlanta", -> "State": "US-GA", -> "Country": "USA", -> "Associations": [], -> "Courses": [ -> { -> "CourseId": 13995, -> "CourseStatus": "Active", -> "CourseName": "Druid Hills Golf Club", -> "NumberOfHoles": 18 -> } -> ] -> } -> ] -> ``` -> -> > **Note:** The Facilities API returns an array directly (unlike most endpoints which wrap in an object). See the [Facilities vs Courses comparison](./llm-output/FACILITIES_VS_COURSES.md) for full details. -> > -> > --- -> > -> > ## Notes -> > -> > - All date parameters use `YYYY-MM-DD` format -> > - - The `source` parameter should always be `GHINcom` for web API access -> > - - Geolocation fields (`GeoLocationLatitude`, `GeoLocationLongitude`) may be absent in search responses and default to `null` -> > - - Field naming conventions differ between Facilities (`FacilityId`, `CourseId`) and Courses (`FacilityID`, `CourseID`) APIs + +| Parameter | Type | Description | +|-----------|------|-------------| +| `source` | string | Always `GHINcom` | + +**Response:** +```json +{ + "golfers": [ + { + "ghin": 7654321, + "first_name": "Jane", + "last_name": "Smith", + "status": "Active", + "handicap_index": "8.2", + "club_name": "Pebble Beach", + "state": "US-CA", + "country": "USA" + } + ] +} +``` + +--- + +## Handicaps + +### Get Golfer Handicap + +**GET** `/golfers/{ghin}/handicap_index.json` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `ghin` | number | GHIN number | +| `source` | string | Always `GHINcom` | + +**Response:** +```json +{ + "golfer": { + "ghin": 1234567, + "handicap_index": "12.5", + "low_handicap_index": "10.2", + "handicap_index_display": "12.5", + "trend": "0.1" + } +} +``` + +### Get Course Player Handicaps + +**POST** `/playing_handicaps.json` + +Calculate course handicap for one or more golfers on a specific tee set. + +**Request body:** +```json +{ + "golfers": [ + { + "golfer_id": 1234567, + "tee_set_id": 98765, + "tee_set_side": "All 18" + } + ], + "source": "GHINcom" +} +``` + +**Response:** +```json +{ + "course_handicaps": [ + { + "golfer_id": 1234567, + "course_handicap": 14, + "playing_handicap": 14 + } + ] +} +``` + +--- + +## Scores + +### Get Golfer Scores + +**GET** `/golfers/{golfer_id}/scores.json` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `golfer_id` | number | GHIN number | +| `from_date_played` | string | Start date (YYYY-MM-DD) | +| `to_date_played` | string | End date (YYYY-MM-DD) | +| `score_types` | string[] | `H` (Home), `A` (Away), `T` (Tournament) | +| `source` | string | Always `GHINcom` | + +**Response:** +```json +{ + "scores": [ + { + "id": 99887766, + "adjusted_gross_score": 85, + "differential": "10.5", + "course_name": "Augusta National", + "played_at": "2024-06-15", + "score_type": "H", + "tee_name": "White", + "number_of_holes": 18 + } + ] +} +``` + +--- + +## Courses + +### Search Courses + +**GET** `/crsCourseMethods.asmx/SearchCourses.json` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `name` | string | Course name (partial match) | +| `city` | string | City | +| `state_code` | string | State code (e.g., `US-GA`) | +| `country_code` | string | Country code (e.g., `USA`) | +| `source` | string | Always `GHINcom` | + +**Response:** +```json +{ + "courses": [ + { + "CourseID": 13995, + "CourseName": "Druid Hills Golf Club", + "FacilityID": 11807, + "FacilityName": "Druid Hills Golf Club", + "FullName": "Druid Hills Golf Club - Druid Hills Golf Club", + "Address1": "740 Clifton Road NE", + "City": "Atlanta", + "State": "US-GA", + "Country": "USA", + "Zip": "30307", + "CourseStatus": "Active", + "FacilityStatus": "Active", + "Ratings": [] + } + ] +} +``` + +### Get Course Details + +**GET** `/crsCourseMethods.asmx/GetCourseDetails.json` + +Returns full course details including all tee sets with ratings and **hole-by-hole data** (par, yardage, handicap allocation per hole). This is the primary endpoint for importing course data. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `courseId` | number | Course ID (from search results) | +| `include_altered_tees` | boolean | Include altered tee configurations (optional, default: false) | +| `source` | string | Always `GHINcom` | + +**Response:** +```json +{ + "CourseId": 24279, + "CourseName": "Saddleback Golf Club", + "CourseCity": "Firestone", + "CourseState": "US-CO", + "CourseStatus": "Active", + "CourseNumber": 1, + "Facility": { + "FacilityId": 20601, + "FacilityName": "Saddleback Golf Club", + "FacilityNumber": null, + "FacilityStatus": "Active", + "GolfAssociationId": null, + "GeoLocationFormattedAddress": "...", + "GeoLocationLatitude": 40.123, + "GeoLocationLongitude": -104.987 + }, + "Season": { + "SeasonName": "Annual", + "SeasonStartDate": "3/1", + "SeasonEndDate": "11/30", + "IsAllYear": false + }, + "TeeSets": [ + { + "TeeSetRatingId": 282104, + "TeeSetRatingName": "Silver", + "Gender": "Female", + "HolesNumber": 18, + "TotalPar": 72, + "TotalYardage": 4855, + "TotalMeters": 4440, + "StrokeAllocation": true, + "IsShorter": null, + "LegacyCRPTeeId": 0, + "EligibleSides": null, + "Ratings": [ + { + "RatingType": "Total", + "CourseRating": 67.0, + "SlopeRating": 122, + "BogeyRating": 91.3 + }, + { + "RatingType": "Front", + "CourseRating": 33.1, + "SlopeRating": 119, + "BogeyRating": 45.4 + }, + { + "RatingType": "Back", + "CourseRating": 33.9, + "SlopeRating": 126, + "BogeyRating": 45.9 + } + ], + "Holes": [ + { "Number": 1, "HoleId": 123, "Par": 4, "Allocation": 5, "Length": 299 }, + { "Number": 2, "HoleId": 124, "Par": 4, "Allocation": 11, "Length": 270 }, + { "Number": 3, "HoleId": 125, "Par": 5, "Allocation": 1, "Length": 417 } + ] + } + ] +} +``` + +> **Note on combo tee sets:** Tee sets with combined names (e.g., "Black/Gold") may declare `HolesNumber: 18` but return an **empty** `Holes` array. Consumers should handle `Holes.length === 0` even when `HolesNumber > 0`. These combo tees typically exist for rating purposes and don't represent a playable tee configuration. + +> **Note on Male/Female tee sets:** GHIN stores Male and Female ratings as separate tee sets with the **same name** (e.g., two "Silver" entries — one with `Gender: "Female"`, one with `Gender: "Male"`). When building a unified tee sheet, merge by `TeeSetRatingName` and extract gender-specific ratings and hole data from each. + +### Get Course Countries + +**GET** `/get_countries_and_states.json` + +Returns a list of countries (with nested states) that have GHIN-rated courses. + +### Get Tee Set Rating + +**GET** `/TeeSetRatings/{teeSetRatingId}.json` + +Fetch a single tee set's full rating data by its ID (alternative to parsing from `GetCourseDetails`). + +| Parameter | Type | Description | +|-----------|------|-------------| +| `tee_set_rating_id` | number | Tee set rating ID (from course details `TeeSetRatingId`) | +| `include_altered_tees` | boolean | Include altered tee sets (optional) | +| `source` | string | Always `GHINcom` | + +**Response:** Same structure as a single entry in the `TeeSets` array from `GetCourseDetails`. + +--- + +## Facilities + +### Search Facilities + +**GET** `/facilities/search.json` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `name` | string | Facility name (partial match) | +| `city` | string | City | +| `state` | string | State code (e.g., `US-GA`) | +| `country` | string | Country code (e.g., `USA`) | +| `source` | string | Always `GHINcom` | + +**Response:** Array of facility objects (**not wrapped** — returns array directly, unlike most endpoints): +```json +[ + { + "FacilityId": 11807, + "FacilityStatus": "Active", + "FacilityName": "Druid Hills Golf Club", + "City": "Atlanta", + "State": "US-GA", + "Country": "USA", + "Associations": [], + "Courses": [ + { + "CourseId": 13995, + "CourseStatus": "Active", + "CourseName": "Druid Hills Golf Club", + "NumberOfHoles": 18 + } + ] + } +] +``` + +> **Note:** The Facilities API returns an array directly (unlike most endpoints which wrap in an object). + +--- + +## Notes + +- All date parameters use `YYYY-MM-DD` format +- The `source` parameter should always be `GHINcom` for web API access +- Geolocation fields (`GeoLocationLatitude`, `GeoLocationLongitude`) may be absent in search responses and default to `null` +- Field naming conventions differ between Facilities (`FacilityId`, `CourseId`) and Courses (`FacilityID`, `CourseID`) APIs — note the inconsistent casing of "ID" vs "Id" +- The course handicap endpoint path in the codebase is `/playing_handicaps.json`, not `/course_handicaps.json` diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 2a67b67..2f15a10 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -1,113 +1,196 @@ # GHIN Authentication Flow -This document describes the two-step authentication flow used by the GHIN API. +This document describes the authentication flows used by the GHIN API, as implemented by this library and verified through real-world integration testing. ## Overview -GHIN uses a two-step authentication process: - -1. **Firebase Authentication** — Exchange username/password for a Firebase ID token -2. 2. **GHIN Token Exchange** — Exchange the Firebase token for a GHIN session token - - 3. The `GhinClient` handles both steps automatically. You only need to provide your GHIN credentials (username/password) in the client configuration. - - 4. ## Step 1: Firebase Authentication - - 5. GHIN's login endpoint uses Google Firebase under the hood. - - 6. **Endpoint:** `POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword` - - 7. **Query params:** `?key=` - - 8. **Request body:** - 9. ```json - { - "email": "your-ghin-username@example.com", - "password": "your-ghin-password", - "returnSecureToken": true - } - ``` - - **Response:** - ```json - { - "idToken": "", - "email": "your-ghin-username@example.com", - "refreshToken": "", - "expiresIn": "3600", - "localId": "" - } - ``` - - The `idToken` is a signed JWT issued by Firebase that is valid for ~1 hour. - - ## Step 2: GHIN Token Exchange - - The Firebase `idToken` is exchanged for a GHIN session token via the GHIN login endpoint. - - **Endpoint:** `POST https://api2.ghin.com/api/v1/golfer_login.json` - - **Request body:** - ```json - { - "user": { - "email_or_ghin": "your-ghin-username@example.com", - "password": "your-ghin-password", - "token": "", - "remember_me": true - }, - "source": "GHINcom" - } - ``` - - **Response:** - ```json - { - "golfer_user": { - "golfer_user_token": "", - "golfer_id": 1234567, - "email": "your-ghin-username@example.com", - "expires_at": "2026-02-25T04:00:00.000Z" - } - } - ``` - - The `golfer_user_token` is used as a Bearer token for all subsequent authenticated API requests. - - ## Token Auto-Refresh - - The `GhinClient` automatically refreshes tokens before they expire. When a request fails with a `401 Unauthorized` response, the client re-authenticates using the stored credentials and retries the request. - - Tokens are cached (in-memory by default, or via a custom cache adapter) to avoid unnecessary re-authentication on every request. - - ## Custom Cache Adapter - - You can provide a custom cache adapter to persist tokens across process restarts: - - ```typescript - import { GhinClient, type CacheClient } from '@spicygolf/ghin' - - class RedisCacheClient implements CacheClient { - async get(key: string): Promise { - // return await redis.get(key) - } - async set(key: string, value: string, ttlSeconds?: number): Promise { - // await redis.set(key, value, 'EX', ttlSeconds ?? 3600) - } - async delete(key: string): Promise { - // await redis.del(key) - } - } - - const ghin = new GhinClient({ - username: process.env.GHIN_USERNAME, - password: process.env.GHIN_PASSWORD, - cache: new RedisCacheClient(), - }) - ``` - - ## Security Notes - - - Never commit GHIN credentials to source control. Use environment variables. - - - The GHIN API key for Firebase is a public client-side key embedded in the ghin.com SPA — it is not a secret, but the credentials themselves must be kept private. - - - Tokens expire after approximately 24 hours. The client handles renewal automatically. +The GHIN API requires authentication for **all endpoints** (as of early 2026 — previously some endpoints like `golfers/search.json` allowed unauthenticated access). + +This library supports two authentication paths: + +1. **Firebase Installations + GHIN Login** (default) — Two-step flow used by the ghin.com web app +2. **Direct API Login** (`apiAccess: true`) — Single-step flow for accounts with API access enabled + +The `GhinClient` handles both paths automatically. You only need to provide your GHIN credentials in the client configuration. + +## Path A: Firebase Installations + GHIN Login (Default) + +This is the flow used by the ghin.com Single Page Application. It involves two HTTP requests. + +### Step 1: Firebase Installation Token + +The client requests a session token from the Google Firebase Installations API. This step does **not** use your GHIN credentials — it uses hardcoded Firebase app identifiers from the ghin.com web app. + +**Endpoint:** `POST https://firebaseinstallations.googleapis.com/v1/projects/ghin-mobile-app/installations` + +**Headers:** +``` +Content-Type: application/json +x-goog-api-key: AIzaSyBxgTOAWxiud0HuaE5tN-5NTlzFnrtyz-I +``` + +**Request body:** +```json +{ + "appId": "1:884417644529:web:47fb315bc6c70242f72650", + "authVersion": "FIS_v2", + "fid": "fg6JfS0U01YmrelthLX9Iz", + "sdkVersion": "w:0.5.7" +} +``` + +**Response:** +```json +{ + "authToken": { + "token": "", + "expiresIn": "604800s" + } +} +``` + +The `expiresIn` value is parsed to calculate the token's absolute expiry time. + +> **Note:** This is the [Firebase Installations API](https://firebase.google.com/docs/reference/installations/rest), **not** the Firebase Identity Toolkit (`identitytoolkit.googleapis.com`). The Installations API authenticates the _app_, not the _user_. The API key and app ID are public client-side values embedded in the ghin.com SPA — they are not secrets. + +### Step 2: GHIN Login with Installation Token + +The Firebase installation token is sent alongside GHIN credentials to obtain a GHIN session token. + +**Endpoint:** `POST https://api2.ghin.com/api/v1/golfer_login.json` + +**Request body:** +```json +{ + "token": "", + "user": { + "email_or_ghin": "your-username-or-ghin-number", + "password": "your-password" + } +} +``` + +**Response:** +```json +{ + "golfer_user": { + "golfer_user_token": "", + "golfer_id": 1234567, + "email": "user@example.com", + "expires_at": "2026-02-25T04:00:00.000Z" + } +} +``` + +The `golfer_user_token` is used as a `Bearer` token in the `Authorization` header for all subsequent API requests. + +## Path B: Direct API Login (`apiAccess: true`) + +For GHIN accounts with explicit API access, a simpler single-step flow is available. + +**Endpoint:** `POST https://api2.ghin.com/api/v1/users/login.json` + +**Request body:** +```json +{ + "user": { + "email": "your-email@example.com", + "password": "your-password", + "remember_me": true + } +} +``` + +**Response:** +```json +{ + "token": "" +} +``` + +Enable this path by setting `apiAccess: true` in the client config: + +```typescript +const ghin = new GhinClient({ + username: process.env.GHIN_USERNAME, + password: process.env.GHIN_PASSWORD, + apiAccess: true, +}) +``` + +## Real-World Finding: Direct Login Without Firebase Token + +During integration testing with the [TPS League](https://tpsleague.com) platform, we discovered that the `/golfer_login.json` endpoint (Step 2) also accepts requests **without** the Firebase installation token: + +```json +{ + "user": { + "email_or_ghin": "your-username", + "password": "your-password", + "remember_me": true + } +} +``` + +This simpler flow works for server-side integrations where the Firebase Installations step adds unnecessary latency. However, this behavior is undocumented by GHIN and may change. The library uses the full two-step flow by default for maximum compatibility. + +## Token Lifecycle + +### Validation + +Before each API request, the client checks whether the current token is still valid by: +1. Decoding the JWT payload (using `jwt-decode`, no signature verification) +2. Comparing the `exp` claim against the current time +3. If expired or missing, triggering a refresh + +### Three-Tier Caching + +Token retrieval follows a priority chain: +1. **In-memory** — Fastest; checked first on every request +2. **Cache adapter** — Checked if in-memory token is missing/expired +3. **Full refresh** — Re-authenticates via Firebase + GHIN login (or API login) + +A mutex lock prevents concurrent token refreshes when multiple requests fire simultaneously. + +### Token TTL + +Token expiry varies. The `golfer_user_token` JWT contains an `exp` claim that the client respects automatically. In practice, tokens have been observed to last anywhere from 1 hour to 24 hours depending on the login path and GHIN's server-side configuration. Do not assume a fixed TTL — always rely on the JWT `exp` claim. + +## Custom Cache Adapter + +Provide a custom cache adapter to persist tokens across process restarts (useful for serverless environments like Cloudflare Workers where in-memory state doesn't persist between requests): + +```typescript +import { GhinClient, type CacheClient } from '@spicygolf/ghin' + +class D1CacheClient implements CacheClient { + constructor(private db: D1Database) {} + + async read(): Promise { + const row = await this.db + .prepare("SELECT value FROM cache WHERE key = 'ghin_token'") + .first() + return row?.value as string | undefined + } + + async write(value: string): Promise { + await this.db + .prepare("INSERT OR REPLACE INTO cache (key, value) VALUES ('ghin_token', ?)") + .bind(value) + .run() + } +} + +const ghin = new GhinClient({ + username: process.env.GHIN_USERNAME, + password: process.env.GHIN_PASSWORD, + cache: new D1CacheClient(env.DB), +}) +``` + +## Security Notes + +- Never commit GHIN credentials to source control. Use environment variables or a secrets manager. +- The Firebase API key and app ID are public client-side values from the ghin.com SPA — they are not secrets. +- The `golfer_user_token` is a JWT that should be treated as sensitive. Store it securely if persisting via a cache adapter. +- All GHIN API communication uses HTTPS. diff --git a/src/client/ghin/index.test.ts b/src/client/ghin/index.test.ts index aa4224f..28b832b 100644 --- a/src/client/ghin/index.test.ts +++ b/src/client/ghin/index.test.ts @@ -9,359 +9,359 @@ const mockFetch = vi.fn() const mockFetchCustomPath = vi.fn() vi.mock('../request-client', () => ({ - RequestClient: vi.fn().mockImplementation(() => ({ - fetch: mockFetch, - fetchCustomPath: mockFetchCustomPath, - })), - CLIENT_SOURCE: 'GHINcom', + RequestClient: vi.fn().mockImplementation(() => ({ + fetch: mockFetch, + fetchCustomPath: mockFetchCustomPath, + })), + CLIENT_SOURCE: 'GHINcom', })) describe('GhinClient', () => { - let ghinClient: GhinClient - - beforeEach(() => { - vi.clearAllMocks() - mockFetch.mockReset() - mockFetchCustomPath.mockReset() - ghinClient = new GhinClient({ - username: 'testuser', - password: 'testpass', - cache: new InMemoryCacheClient(), - }) - }) - - describe('constructor', () => { - it('should create instance with valid config', () => { - expect(ghinClient).toBeInstanceOf(GhinClient) - expect(ghinClient.courses).toBeDefined() - expect(ghinClient.golfers).toBeDefined() - expect(ghinClient.handicaps).toBeDefined() - expect(ghinClient.facilities).toBeDefined() - }) - - it('should throw error with invalid config', () => { - expect(() => { - new GhinClient({ - username: '', - password: 'testpass', - } as unknown as { username: string; password: string }) - }).toThrow('Invalid GhinClientConfig') - }) - - it('should use default cache when not provided', () => { - const client = new GhinClient({ - username: 'testuser', - password: 'testpass', - }) - expect(client).toBeInstanceOf(GhinClient) - }) - }) - - describe('courses.getCountries', () => { - it('should fetch and return countries', async () => { - const mockCountries = { - countries: [ - { code: 'USA', name: 'United States' }, - { code: 'CAN', name: 'Canada' }, - ], - } - mockFetch.mockResolvedValue(ok(mockCountries)) - - const result = await ghinClient.courses.getCountries() - expect(result).toEqual(mockCountries.countries) - expect(mockFetch).toHaveBeenCalledWith({ - entity: 'course_countries', - options: expect.objectContaining({ - searchParams: expect.any(URLSearchParams), - }), - schema: expect.anything(), - }) - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Network error'))) - await expect(ghinClient.courses.getCountries()).rejects.toThrow('Network error') - }) - }) - - describe('courses.getDetails', () => { - it('should fetch and return course details', async () => { - const mockDetails = { - course_id: 12345, - name: 'Test Course', - city: 'Test City', - } - mockFetch.mockResolvedValue(ok(mockDetails)) - - const result = await ghinClient.courses.getDetails({ course_id: 12345 }) - expect(result).toEqual(mockDetails) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw validation error with invalid request', async () => { - // @ts-expect-error - Testing invalid input type - await expect(ghinClient.courses.getDetails({ course_id: 'invalid' })).rejects.toThrow(ValidationError) - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Not found'))) - await expect(ghinClient.courses.getDetails({ course_id: 12345 })).rejects.toThrow('Not found') - }) - }) - - describe('courses.search', () => { - it('should search and return courses', async () => { - const mockResponse = { - courses: [ - { course_id: 1, name: 'Course 1' }, - { course_id: 2, name: 'Course 2' }, - ], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.courses.search({ name: 'Test' }) - expect(result).toEqual(mockResponse.courses) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Search failed'))) - await expect(ghinClient.courses.search({ name: 'Test' })).rejects.toThrow('Search failed') - }) - }) - - describe('facilities.search', () => { - it('should search and return facilities', async () => { - const mockResponse = { facilities: [{ facility_id: 1, name: 'Facility 1' }] } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.facilities.search({ - name: 'Test', - }) - expect(result).toEqual(mockResponse) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Search failed'))) - await expect(ghinClient.facilities.search({ name: 'Test' })).rejects.toThrow('Search failed') - }) - }) - - describe('handicaps.getOne', () => { - it('should fetch and return golfer handicap', async () => { - const mockResponse = { - golfer: { - ghin: 1234567, - handicap_index: 12.5, - }, - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.handicaps.getOne(1234567) - expect(result).toEqual(mockResponse.golfer) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw validation error with invalid ghin', async () => { - // @ts-expect-error - Testing invalid input type - await expect(ghinClient.handicaps.getOne('invalid')).rejects.toThrow(ValidationError) - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Not found'))) - await expect(ghinClient.handicaps.getOne(1234567)).rejects.toThrow('Not found') - }) - }) - - describe('handicaps.getCoursePlayerHandicaps', () => { - it('should fetch and return course player handicaps', async () => { - const mockResponse = { - handicaps: [{ ghin: 1234567, course_handicap: 15 }], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.handicaps.getCoursePlayerHandicaps([ - { ghin: 1234567, tee_set_id: 12345, tee_set_side: 'All 18' }, - ]) - expect(result).toEqual(mockResponse) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Calculation failed'))) - await expect( - ghinClient.handicaps.getCoursePlayerHandicaps([{ ghin: 1234567, tee_set_id: 12345, tee_set_side: 'All 18' }]), - ).rejects.toThrow('Calculation failed') - }) - }) - - describe('golfers.search', () => { - it('should search and return golfers', async () => { - const mockResponse = { - golfers: [{ ghin: 1234567, first_name: 'John', last_name: 'Doe' }], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.golfers.search({ last_name: 'Doe' }) - expect(result).toEqual(mockResponse.golfers) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Search failed'))) - await expect(ghinClient.golfers.search({ last_name: 'Doe' })).rejects.toThrow('Search failed') - }) - }) - - describe('golfers.globalSearch', () => { - it('should search globally and return golfers', async () => { - const mockResponse = { - golfers: [ - { - ghin: 1234567, - first_name: 'John', - last_name: 'Doe', - status: 'Active', - }, - ], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.golfers.globalSearch({ ghin: 1234567 }) - expect(result).toEqual(mockResponse.golfers) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Search failed'))) - await expect(ghinClient.golfers.globalSearch({ ghin: 1234567 })).rejects.toThrow('Search failed') - }) - }) - - describe('golfers.getOne', () => { - it('should fetch and return one active golfer', async () => { - const mockResponse = { - golfers: [ - { - ghin: 1234567, - first_name: 'John', - last_name: 'Doe', - status: 'Active', - }, - ], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.golfers.getOne(1234567) - expect(result).toEqual(mockResponse.golfers[0]) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should return undefined when no active golfer found', async () => { - const mockResponse = { - golfers: [ - { - ghin: 1234567, - first_name: 'John', - last_name: 'Doe', - status: 'Inactive', - }, - ], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.golfers.getOne(1234567) - expect(result).toBeUndefined() - }) - - it('should throw validation error with invalid ghin', async () => { - // @ts-expect-error - Testing invalid input type - await expect(ghinClient.golfers.getOne('invalid')).rejects.toThrow(ValidationError) - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Not found'))) - await expect(ghinClient.golfers.getOne(1234567)).rejects.toThrow('Not found') - }) - }) - - describe('golfers.getScores', () => { - it('should fetch and return golfer scores', async () => { - const mockResponse = { - scores: [{ score_id: 1, adjusted_gross_score: 85 }], - } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.golfers.getScores(1234567) - expect(result).toEqual(mockResponse) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should handle optional request parameters', async () => { - const mockResponse = { scores: [] } - mockFetch.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.golfers.getScores(1234567, { - from_date_played: new Date('2024-01-01'), - to_date_played: new Date('2024-12-31'), - score_types: ['H', 'A'], - }) - expect(result).toEqual(mockResponse) - expect(mockFetch).toHaveBeenCalled() - }) - - it('should throw validation error with invalid ghin', async () => { - // @ts-expect-error - Testing invalid input type - await expect(ghinClient.golfers.getScores('invalid')).rejects.toThrow(ValidationError) - }) - - it('should throw error when fetch fails', async () => { - mockFetch.mockResolvedValue(err(new Error('Fetch failed'))) - await expect(ghinClient.golfers.getScores(1234567)).rejects.toThrow('Fetch failed') - }) - }) - - describe('golfers.getFollowing', () => { - it('should fetch and return the list of followed golfers', async () => { - const mockResponse = { - golfers: [ - { - ghin: 7654321, - first_name: 'Jane', - last_name: 'Smith', - status: 'Active', - }, - ], - } - mockFetchCustomPath.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.golfers.getFollowing(1234567) - expect(result).toEqual(mockResponse.golfers) - expect(mockFetchCustomPath).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/golfers/1234567/following.json', - schema: expect.anything(), - }), - ) - }) - - it('should return empty array when golfer follows nobody', async () => { - const mockResponse = { golfers: [] } - mockFetchCustomPath.mockResolvedValue(ok(mockResponse)) - - const result = await ghinClient.golfers.getFollowing(1234567) - expect(result).toEqual([]) - }) - - it('should throw validation error with invalid ghin', async () => { - // @ts-expect-error - Testing invalid input type - await expect(ghinClient.golfers.getFollowing('invalid')).rejects.toThrow(ValidationError) - }) - - it('should throw error when fetch fails', async () => { - mockFetchCustomPath.mockResolvedValue(err(new Error('Not found'))) - await expect(ghinClient.golfers.getFollowing(1234567)).rejects.toThrow('Not found') - }) - }) + let ghinClient: GhinClient + + beforeEach(() => { + vi.clearAllMocks() + mockFetch.mockReset() + mockFetchCustomPath.mockReset() + ghinClient = new GhinClient({ + username: 'testuser', + password: 'testpass', + cache: new InMemoryCacheClient(), + }) + }) + + describe('constructor', () => { + it('should create instance with valid config', () => { + expect(ghinClient).toBeInstanceOf(GhinClient) + expect(ghinClient.courses).toBeDefined() + expect(ghinClient.golfers).toBeDefined() + expect(ghinClient.handicaps).toBeDefined() + expect(ghinClient.facilities).toBeDefined() + }) + + it('should throw error with invalid config', () => { + expect(() => { + new GhinClient({ + username: '', + password: 'testpass', + } as unknown as { username: string; password: string }) + }).toThrow('Invalid GhinClientConfig') + }) + + it('should use default cache when not provided', () => { + const client = new GhinClient({ + username: 'testuser', + password: 'testpass', + }) + expect(client).toBeInstanceOf(GhinClient) + }) + }) + + describe('courses.getCountries', () => { + it('should fetch and return countries', async () => { + const mockCountries = { + countries: [ + { code: 'USA', name: 'United States' }, + { code: 'CAN', name: 'Canada' }, + ], + } + mockFetch.mockResolvedValue(ok(mockCountries)) + + const result = await ghinClient.courses.getCountries() + expect(result).toEqual(mockCountries.countries) + expect(mockFetch).toHaveBeenCalledWith({ + entity: 'course_countries', + options: expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }), + schema: expect.anything(), + }) + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Network error'))) + await expect(ghinClient.courses.getCountries()).rejects.toThrow('Network error') + }) + }) + + describe('courses.getDetails', () => { + it('should fetch and return course details', async () => { + const mockDetails = { + course_id: 12345, + name: 'Test Course', + city: 'Test City', + } + mockFetch.mockResolvedValue(ok(mockDetails)) + + const result = await ghinClient.courses.getDetails({ course_id: 12345 }) + expect(result).toEqual(mockDetails) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw validation error with invalid request', async () => { + // @ts-expect-error - Testing invalid input type + await expect(ghinClient.courses.getDetails({ course_id: 'invalid' })).rejects.toThrow(ValidationError) + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Not found'))) + await expect(ghinClient.courses.getDetails({ course_id: 12345 })).rejects.toThrow('Not found') + }) + }) + + describe('courses.search', () => { + it('should search and return courses', async () => { + const mockResponse = { + courses: [ + { course_id: 1, name: 'Course 1' }, + { course_id: 2, name: 'Course 2' }, + ], + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.courses.search({ name: 'Test' }) + expect(result).toEqual(mockResponse.courses) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Search failed'))) + await expect(ghinClient.courses.search({ name: 'Test' })).rejects.toThrow('Search failed') + }) + }) + + describe('facilities.search', () => { + it('should search and return facilities', async () => { + const mockResponse = { facilities: [{ facility_id: 1, name: 'Facility 1' }] } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.facilities.search({ + name: 'Test', + }) + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Search failed'))) + await expect(ghinClient.facilities.search({ name: 'Test' })).rejects.toThrow('Search failed') + }) + }) + + describe('handicaps.getOne', () => { + it('should fetch and return golfer handicap', async () => { + const mockResponse = { + golfer: { + ghin: 1234567, + handicap_index: 12.5, + }, + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.handicaps.getOne(1234567) + expect(result).toEqual(mockResponse.golfer) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw validation error with invalid ghin', async () => { + // @ts-expect-error - Testing invalid input type + await expect(ghinClient.handicaps.getOne('invalid')).rejects.toThrow(ValidationError) + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Not found'))) + await expect(ghinClient.handicaps.getOne(1234567)).rejects.toThrow('Not found') + }) + }) + + describe('handicaps.getCoursePlayerHandicaps', () => { + it('should fetch and return course player handicaps', async () => { + const mockResponse = { + handicaps: [{ ghin: 1234567, course_handicap: 15 }], + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.handicaps.getCoursePlayerHandicaps([ + { ghin: 1234567, tee_set_id: 12345, tee_set_side: 'All 18' }, + ]) + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Calculation failed'))) + await expect( + ghinClient.handicaps.getCoursePlayerHandicaps([{ ghin: 1234567, tee_set_id: 12345, tee_set_side: 'All 18' }]), + ).rejects.toThrow('Calculation failed') + }) + }) + + describe('golfers.search', () => { + it('should search and return golfers', async () => { + const mockResponse = { + golfers: [{ ghin: 1234567, first_name: 'John', last_name: 'Doe' }], + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.search({ last_name: 'Doe' }) + expect(result).toEqual(mockResponse.golfers) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Search failed'))) + await expect(ghinClient.golfers.search({ last_name: 'Doe' })).rejects.toThrow('Search failed') + }) + }) + + describe('golfers.globalSearch', () => { + it('should search globally and return golfers', async () => { + const mockResponse = { + golfers: [ + { + ghin: 1234567, + first_name: 'John', + last_name: 'Doe', + status: 'Active', + }, + ], + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.globalSearch({ ghin: 1234567 }) + expect(result).toEqual(mockResponse.golfers) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Search failed'))) + await expect(ghinClient.golfers.globalSearch({ ghin: 1234567 })).rejects.toThrow('Search failed') + }) + }) + + describe('golfers.getOne', () => { + it('should fetch and return one active golfer', async () => { + const mockResponse = { + golfers: [ + { + ghin: 1234567, + first_name: 'John', + last_name: 'Doe', + status: 'Active', + }, + ], + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.getOne(1234567) + expect(result).toEqual(mockResponse.golfers[0]) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should return undefined when no active golfer found', async () => { + const mockResponse = { + golfers: [ + { + ghin: 1234567, + first_name: 'John', + last_name: 'Doe', + status: 'Inactive', + }, + ], + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.getOne(1234567) + expect(result).toBeUndefined() + }) + + it('should throw validation error with invalid ghin', async () => { + // @ts-expect-error - Testing invalid input type + await expect(ghinClient.golfers.getOne('invalid')).rejects.toThrow(ValidationError) + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Not found'))) + await expect(ghinClient.golfers.getOne(1234567)).rejects.toThrow('Not found') + }) + }) + + describe('golfers.getScores', () => { + it('should fetch and return golfer scores', async () => { + const mockResponse = { + scores: [{ score_id: 1, adjusted_gross_score: 85 }], + } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.getScores(1234567) + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should handle optional request parameters', async () => { + const mockResponse = { scores: [] } + mockFetch.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.getScores(1234567, { + from_date_played: new Date('2024-01-01'), + to_date_played: new Date('2024-12-31'), + score_types: ['H', 'A'], + }) + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalled() + }) + + it('should throw validation error with invalid ghin', async () => { + // @ts-expect-error - Testing invalid input type + await expect(ghinClient.golfers.getScores('invalid')).rejects.toThrow(ValidationError) + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue(err(new Error('Fetch failed'))) + await expect(ghinClient.golfers.getScores(1234567)).rejects.toThrow('Fetch failed') + }) + }) + + describe('golfers.getFollowing', () => { + it('should fetch and return the list of followed golfers', async () => { + const mockResponse = { + golfers: [ + { + ghin: 7654321, + first_name: 'Jane', + last_name: 'Smith', + status: 'Active', + }, + ], + } + mockFetchCustomPath.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.getFollowing(1234567) + expect(result).toEqual(mockResponse.golfers) + expect(mockFetchCustomPath).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/golfers/1234567/following.json', + schema: expect.anything(), + }), + ) + }) + + it('should return empty array when golfer follows nobody', async () => { + const mockResponse = { golfers: [] } + mockFetchCustomPath.mockResolvedValue(ok(mockResponse)) + + const result = await ghinClient.golfers.getFollowing(1234567) + expect(result).toEqual([]) + }) + + it('should throw validation error with invalid ghin', async () => { + // @ts-expect-error - Testing invalid input type + await expect(ghinClient.golfers.getFollowing('invalid')).rejects.toThrow(ValidationError) + }) + + it('should throw error when fetch fails', async () => { + mockFetchCustomPath.mockResolvedValue(err(new Error('Not found'))) + await expect(ghinClient.golfers.getFollowing(1234567)).rejects.toThrow('Not found') + }) + }) }) diff --git a/src/client/ghin/index.ts b/src/client/ghin/index.ts index e108c8f..4912b34 100644 --- a/src/client/ghin/index.ts +++ b/src/client/ghin/index.ts @@ -4,544 +4,544 @@ import { type ClientConfig, number, schemaClientConfig } from '../../models' import { InMemoryCacheClient } from '../in-memory-cache-client' import { CLIENT_SOURCE, RequestClient } from '../request-client' import { - type CourseCountriesResponse, - type CourseCountry, - type CourseDetailsRequest, - type CourseDetailsResponse, - type CourseHandicapsRequest, - type CoursePlayerHandicapsResponse, - type CourseSearchRequest, - type CourseSearchResponse, - type FacilitySearchRequest, - type FacilitySearchResponse, - type FollowingResponse, - type GolferCourseHandicapRequest, - type GolfersGlobalSearchRequest, - type GolfersSearchRequest, - type GolfersSearchResponse, - type HandicapResponse, - type ScoresRequest, - type ScoresResponse, - type TeeSetRatingRequest, - type TeeSetRatingResponse, - schemaCourseCountriesResponse, - schemaCourseDetailsRequest, - schemaCourseDetailsResponse, - schemaCoursePlayerHandicapsResponse, - schemaCourseSearchRequest, - schemaCourseSearchResponse, - schemaFacilitySearchRequest, - schemaFacilitySearchResponse, - schemaFollowingResponse, - schemaGolferCourseHandicapRequest, - schemaGolferHandicapResponse, - schemaGolfersGlobalSearchRequest, - schemaGolfersSearchRequest, - schemaGolfersSearchResponse, - schemaScoresRequest, - schemaScoresResponse, - schemaTeeSetRatingRequest, - schemaTeeSetRatingResponse, + type CourseCountriesResponse, + type CourseCountry, + type CourseDetailsRequest, + type CourseDetailsResponse, + type CourseHandicapsRequest, + type CoursePlayerHandicapsResponse, + type CourseSearchRequest, + type CourseSearchResponse, + type FacilitySearchRequest, + type FacilitySearchResponse, + type FollowingResponse, + type GolferCourseHandicapRequest, + type GolfersGlobalSearchRequest, + type GolfersSearchRequest, + type GolfersSearchResponse, + type HandicapResponse, + type ScoresRequest, + type ScoresResponse, + type TeeSetRatingRequest, + type TeeSetRatingResponse, + schemaCourseCountriesResponse, + schemaCourseDetailsRequest, + schemaCourseDetailsResponse, + schemaCoursePlayerHandicapsResponse, + schemaCourseSearchRequest, + schemaCourseSearchResponse, + schemaFacilitySearchRequest, + schemaFacilitySearchResponse, + schemaFollowingResponse, + schemaGolferCourseHandicapRequest, + schemaGolferHandicapResponse, + schemaGolfersGlobalSearchRequest, + schemaGolfersSearchRequest, + schemaGolfersSearchResponse, + schemaScoresRequest, + schemaScoresResponse, + schemaTeeSetRatingRequest, + schemaTeeSetRatingResponse, } from './models' const searchParameters = { - GOLFER_ID: 'golfer_id', - SOURCE: 'source', + GOLFER_ID: 'golfer_id', + SOURCE: 'source', } as const export class GhinClient { - private httpClient: RequestClient + private httpClient: RequestClient courses: { - getCountries: () => Promise - getDetails: (request: CourseDetailsRequest) => Promise - search: (request: CourseSearchRequest) => Promise - getTeeSetRating: (request: TeeSetRatingRequest) => Promise + getCountries: () => Promise + getDetails: (request: CourseDetailsRequest) => Promise + search: (request: CourseSearchRequest) => Promise + getTeeSetRating: (request: TeeSetRatingRequest) => Promise } facilities: { - search: (request: FacilitySearchRequest) => Promise + search: (request: FacilitySearchRequest) => Promise } golfers: { - getOne: (ghinNumber: number) => Promise - getScores: (ghinNumber: number, request?: ScoresRequest) => Promise - search: (request: GolfersSearchRequest) => Promise - globalSearch: (request: GolfersGlobalSearchRequest) => Promise - getFollowing: (ghinNumber: number) => Promise + getOne: (ghinNumber: number) => Promise + getScores: (ghinNumber: number, request?: ScoresRequest) => Promise + search: (request: GolfersSearchRequest) => Promise + globalSearch: (request: GolfersGlobalSearchRequest) => Promise + getFollowing: (ghinNumber: number) => Promise } handicaps: { - getOne: (ghinNumber: number) => Promise - getCoursePlayerHandicaps: (requests: GolferCourseHandicapRequest[]) => Promise + getOne: (ghinNumber: number) => Promise + getCoursePlayerHandicaps: (requests: GolferCourseHandicapRequest[]) => Promise } constructor(config: ClientConfig) { - const results = schemaClientConfig.safeParse(config) - - if (!results.success) { - throw new ConfigurationError(`Invalid GhinClientConfig: ${results.error.message}`) - } - - this.httpClient = new RequestClient({ - ...results.data, - cache: results.data.cache ?? new InMemoryCacheClient(), - }) - - this.courses = { - getCountries: this.coursesGetCountries.bind(this), - getDetails: this.courseGetDetails.bind(this), - search: this.courseSearch.bind(this), - getTeeSetRating: this.courseGetTeeSetRating.bind(this), - } - - this.facilities = { - search: this.facilitySearch.bind(this), - } - - this.handicaps = { - getOne: this.handicapsGetOne.bind(this), - getCoursePlayerHandicaps: this.handicapsGetCoursePlayerHandicaps.bind(this), - } - - this.golfers = { - getOne: this.golfersGetOne.bind(this), - getScores: this.golfersGetScores.bind(this), - search: this.golfersSearch.bind(this), - globalSearch: this.golfersGlobalSearch.bind(this), - getFollowing: this.golfersGetFollowing.bind(this), - } + const results = schemaClientConfig.safeParse(config) + + if (!results.success) { + throw new ConfigurationError(`Invalid GhinClientConfig: ${results.error.message}`) + } + + this.httpClient = new RequestClient({ + ...results.data, + cache: results.data.cache ?? new InMemoryCacheClient(), + }) + + this.courses = { + getCountries: this.coursesGetCountries.bind(this), + getDetails: this.courseGetDetails.bind(this), + search: this.courseSearch.bind(this), + getTeeSetRating: this.courseGetTeeSetRating.bind(this), + } + + this.facilities = { + search: this.facilitySearch.bind(this), + } + + this.handicaps = { + getOne: this.handicapsGetOne.bind(this), + getCoursePlayerHandicaps: this.handicapsGetCoursePlayerHandicaps.bind(this), + } + + this.golfers = { + getOne: this.golfersGetOne.bind(this), + getScores: this.golfersGetScores.bind(this), + search: this.golfersSearch.bind(this), + globalSearch: this.golfersGlobalSearch.bind(this), + getFollowing: this.golfersGetFollowing.bind(this), + } } private async coursesGetCountries(): Promise { - try { - const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) + try { + const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'course_countries', - options, - schema: schemaCourseCountriesResponse, - }) + const result = await this.httpClient.fetch({ + entity: 'course_countries', + options, + schema: schemaCourseCountriesResponse, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value.countries - } catch (error) { - throw error instanceof Error ? error : new Error(String(error)) - } + return result.value.countries + } catch (error) { + throw error instanceof Error ? error : new Error(String(error)) + } } private async courseGetDetails(request: CourseDetailsRequest): Promise { - try { - const validRequest = schemaCourseDetailsRequest.parse(request) - const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) + try { + const validRequest = schemaCourseDetailsRequest.parse(request) + const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) - for (const [key, value] of Object.entries(validRequest)) { - searchParams.set(key, value.toString()) - } + for (const [key, value] of Object.entries(validRequest)) { + searchParams.set(key, value.toString()) + } - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'course_details', - options, - schema: schemaCourseDetailsResponse, - }) + const result = await this.httpClient.fetch({ + entity: 'course_details', + options, + schema: schemaCourseDetailsResponse, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid course details request: ${error.message}`) - } + return result.value + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid course details request: ${error.message}`) + } - throw error instanceof Error ? error : new Error(String(error)) - } + throw error instanceof Error ? error : new Error(String(error)) + } } private async courseGetTeeSetRating(request: TeeSetRatingRequest): Promise { - try { - const validRequest = schemaTeeSetRatingRequest.parse(request) - const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) + try { + const validRequest = schemaTeeSetRatingRequest.parse(request) + const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) - if (validRequest.include_altered_tees !== undefined) { - searchParams.set('include_altered_tees', validRequest.include_altered_tees.toString()) - } + if (validRequest.include_altered_tees !== undefined) { + searchParams.set('include_altered_tees', validRequest.include_altered_tees.toString()) + } - const path = `/TeeSetRatings/${validRequest.tee_set_rating_id}.json` + const path = `/TeeSetRatings/${validRequest.tee_set_rating_id}.json` - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetchCustomPath({ - path, - options, - schema: schemaTeeSetRatingResponse, - }) + const result = await this.httpClient.fetchCustomPath({ + path, + options, + schema: schemaTeeSetRatingResponse, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid tee set rating request: ${error.message}`) - } + return result.value + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid tee set rating request: ${error.message}`) + } - throw error instanceof Error ? error : new Error(String(error)) - } + throw error instanceof Error ? error : new Error(String(error)) + } } private async courseSearch(request: CourseSearchRequest): Promise { - try { - const validRequest = schemaCourseSearchRequest.parse(request) - const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) + try { + const validRequest = schemaCourseSearchRequest.parse(request) + const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) - for (const [key, value] of Object.entries(validRequest)) { - searchParams.set(key, value.toString()) - } + for (const [key, value] of Object.entries(validRequest)) { + searchParams.set(key, value.toString()) + } - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'course_search', - options, - schema: schemaCourseSearchResponse, - }) + const result = await this.httpClient.fetch({ + entity: 'course_search', + options, + schema: schemaCourseSearchResponse, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value.courses - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid course search request: ${error.message}`) - } + return result.value.courses + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid course search request: ${error.message}`) + } - throw error instanceof Error ? error : new Error(String(error)) - } + throw error instanceof Error ? error : new Error(String(error)) + } } private async facilitySearch(request: FacilitySearchRequest): Promise { - try { - const validRequest = schemaFacilitySearchRequest.parse(request) - const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) + try { + const validRequest = schemaFacilitySearchRequest.parse(request) + const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) - for (const [key, value] of Object.entries(validRequest)) { - searchParams.set(key, value.toString()) - } + for (const [key, value] of Object.entries(validRequest)) { + searchParams.set(key, value.toString()) + } - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'facility_search', - options, - schema: schemaFacilitySearchResponse, - }) + const result = await this.httpClient.fetch({ + entity: 'facility_search', + options, + schema: schemaFacilitySearchResponse, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid facility search request: ${error.message}`) - } + return result.value + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid facility search request: ${error.message}`) + } - throw error instanceof Error ? error : new Error(String(error)) - } + throw error instanceof Error ? error : new Error(String(error)) + } } private async handicapsGetOne(ghin: number): Promise { - try { - const ghinNumber = number.parse(ghin) - const searchParams = new URLSearchParams([ - ['source', CLIENT_SOURCE], - ['ghin', ghinNumber.toString()], - ]) - - const options: Parameters[0]['options'] = { - searchParams, - } + try { + const ghinNumber = number.parse(ghin) + const searchParams = new URLSearchParams([ + ['source', CLIENT_SOURCE], + ['ghin', ghinNumber.toString()], + ]) + + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'golfer', - options, - schema: schemaGolferHandicapResponse, - }) + const result = await this.httpClient.fetch({ + entity: 'golfer', + options, + schema: schemaGolferHandicapResponse, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value.golfer - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid GHIN number: ${error.message}`) - } + return result.value.golfer + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid GHIN number: ${error.message}`) + } - throw error instanceof Error ? error : new Error(String(error)) - } + throw error instanceof Error ? error : new Error(String(error)) + } } private async handicapsGetCoursePlayerHandicaps( - request: GolferCourseHandicapRequest[], - ): Promise { - try { - const golfers = z - .array(schemaGolferCourseHandicapRequest) - .parse(request) - .map(({ ghin, ...golfer }) => ({ - ...golfer, - [searchParameters.GOLFER_ID]: ghin, - })) - - const searchParams = new URLSearchParams() - - const courseHandicapRequest: CourseHandicapsRequest = { - golfers, - source: CLIENT_SOURCE, - } + request: GolferCourseHandicapRequest[], + ): Promise { + try { + const golfers = z + .array(schemaGolferCourseHandicapRequest) + .parse(request) + .map(({ ghin, ...golfer }) => ({ + ...golfer, + [searchParameters.GOLFER_ID]: ghin, + })) + + const searchParams = new URLSearchParams() + + const courseHandicapRequest: CourseHandicapsRequest = { + golfers, + source: CLIENT_SOURCE, + } - const options: Parameters[0]['options'] = { - body: JSON.stringify(courseHandicapRequest), - method: 'POST', - searchParams, - } + const options: Parameters[0]['options'] = { + body: JSON.stringify(courseHandicapRequest), + method: 'POST', + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'course_handicaps', - options, - schema: schemaCoursePlayerHandicapsResponse, - }) + const result = await this.httpClient.fetch({ + entity: 'course_handicaps', + options, + schema: schemaCoursePlayerHandicapsResponse, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid course handicap request: ${error.message}`) - } + return result.value + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid course handicap request: ${error.message}`) + } - throw error instanceof Error ? error : new Error(String(error)) - } + throw error instanceof Error ? error : new Error(String(error)) + } } private async golfersSearch(request: GolfersSearchRequest): Promise { - try { - const params = schemaGolfersSearchRequest.parse(request) - const searchParams = new URLSearchParams() - - const searchDefaults = { - page: 1, - per_page: 25, - sorting_criteria: 'last_name_first_name', - status: 'Active', - order: 'asc', - } + try { + const params = schemaGolfersSearchRequest.parse(request) + const searchParams = new URLSearchParams() + + const searchDefaults = { + page: 1, + per_page: 25, + sorting_criteria: 'last_name_first_name', + status: 'Active', + order: 'asc', + } - for (const [key, value] of Object.entries(searchDefaults)) { - searchParams.set(key, value.toString()) - } + for (const [key, value] of Object.entries(searchDefaults)) { + searchParams.set(key, value.toString()) + } - for (const [key, value] of Object.entries(params)) { - searchParams.set(key, value?.toString() ?? '') - } + for (const [key, value] of Object.entries(params)) { + searchParams.set(key, value?.toString() ?? '') + } - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'golfers_search', - schema: schemaGolfersSearchResponse, - options, - }) + const result = await this.httpClient.fetch({ + entity: 'golfers_search', + schema: schemaGolfersSearchResponse, + options, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value.golfers - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid golfer search request: ${error.message}`) - } + return result.value.golfers + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid golfer search request: ${error.message}`) + } - throw error instanceof Error ? error : new Error(String(error)) - } + throw error instanceof Error ? error : new Error(String(error)) + } } private async golfersGlobalSearch(request: GolfersGlobalSearchRequest): Promise { - try { - const { ghin } = schemaGolfersGlobalSearchRequest.parse(request) - const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) - - const searchDefaults = { - from_ghin: true, - per_page: 25, - sorting_criteria: 'full_name', - order: 'asc', - page: 1, - } + try { + const { ghin } = schemaGolfersGlobalSearchRequest.parse(request) + const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) + + const searchDefaults = { + from_ghin: true, + per_page: 25, + sorting_criteria: 'full_name', + order: 'asc', + page: 1, + } - for (const [key, value] of Object.entries(searchDefaults)) { - searchParams.set(key, value.toString()) - } + for (const [key, value] of Object.entries(searchDefaults)) { + searchParams.set(key, value.toString()) + } - if (ghin) { - searchParams.set(searchParameters.GOLFER_ID, ghin.toString()) - } + if (ghin) { + searchParams.set(searchParameters.GOLFER_ID, ghin.toString()) + } - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'golfers_global_search', - schema: schemaGolfersSearchResponse, - options, - }) + const result = await this.httpClient.fetch({ + entity: 'golfers_global_search', + schema: schemaGolfersSearchResponse, + options, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value.golfers - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid golfer search request: ${error.message}`) - } + return result.value.golfers + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid golfer search request: ${error.message}`) + } - throw error instanceof Error ? error : new Error(String(error)) - } + throw error instanceof Error ? error : new Error(String(error)) + } } private async golfersGetOne(ghinNumber: number): Promise { - try { - const ghin = number.parse(ghinNumber) + try { + const ghin = number.parse(ghinNumber) - const results = await this.golfersGlobalSearch({ - ghin: ghin, - status: 'Active', - }) + const results = await this.golfersGlobalSearch({ + ghin: ghin, + status: 'Active', + }) - return results.find((golfer) => golfer.status === 'Active') - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid GHIN number: ${error.message}`) - } + return results.find((golfer) => golfer.status === 'Active') + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid GHIN number: ${error.message}`) + } - throw error instanceof Error ? error : new Error(String(error)) - } + throw error instanceof Error ? error : new Error(String(error)) + } } private async golfersGetScores(ghinNumber: number, request?: ScoresRequest): Promise { - try { - const validRequest = schemaScoresRequest.parse(request) ?? {} - const ghin = number.parse(ghinNumber) - - const searchParams = new URLSearchParams([ - [searchParameters.GOLFER_ID, ghin.toString()], - ['source', CLIENT_SOURCE], - ]) - - for (const [key, value] of Object.entries(validRequest)) { - if (value === null) { - continue - } + try { + const validRequest = schemaScoresRequest.parse(request) ?? {} + const ghin = number.parse(ghinNumber) + + const searchParams = new URLSearchParams([ + [searchParameters.GOLFER_ID, ghin.toString()], + ['source', CLIENT_SOURCE], + ]) + + for (const [key, value] of Object.entries(validRequest)) { + if (value === null) { + continue + } - if (Array.isArray(value)) { - for (const v of value) { - searchParams.append(key, v.toString()) - } + if (Array.isArray(value)) { + for (const v of value) { + searchParams.append(key, v.toString()) + } - continue - } + continue + } - if (typeof value === 'object' && value instanceof Date) { - searchParams.set(key, value.toISOString().split('T')[0] as string) + if (typeof value === 'object' && value instanceof Date) { + searchParams.set(key, value.toISOString().split('T')[0] as string) - continue - } + continue + } - searchParams.set(key, value.toString()) - } + searchParams.set(key, value.toString()) + } - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetch({ - entity: 'scores', - options, - schema: schemaScoresResponse, - }) + const result = await this.httpClient.fetch({ + entity: 'scores', + options, + schema: schemaScoresResponse, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid scores request: ${error.message}`) - } + return result.value + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid scores request: ${error.message}`) + } - throw error instanceof Error ? error : new Error(String(error)) - } + throw error instanceof Error ? error : new Error(String(error)) + } } private async golfersGetFollowing(ghinNumber: number): Promise { - try { - const ghin = number.parse(ghinNumber) - const path = `/golfers/${ghin}/following.json` + try { + const ghin = number.parse(ghinNumber) + const path = `/golfers/${ghin}/following.json` - const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) + const searchParams = new URLSearchParams([['source', CLIENT_SOURCE]]) - const options: Parameters[0]['options'] = { - searchParams, - } + const options: Parameters[0]['options'] = { + searchParams, + } - const result = await this.httpClient.fetchCustomPath({ - path, - options, - schema: schemaFollowingResponse, - }) + const result = await this.httpClient.fetchCustomPath({ + path, + options, + schema: schemaFollowingResponse, + }) - if (result.isErr()) { - throw result.error - } + if (result.isErr()) { + throw result.error + } - return result.value.golfers - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(`Invalid GHIN number: ${error.message}`) - } + return result.value.golfers + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError(`Invalid GHIN number: ${error.message}`) + } - throw error instanceof Error ? error : new Error(String(error)) - } + throw error instanceof Error ? error : new Error(String(error)) + } } } diff --git a/src/client/ghin/models/golfers/following.ts b/src/client/ghin/models/golfers/following.ts index 5e70b53..1b2797c 100644 --- a/src/client/ghin/models/golfers/following.ts +++ b/src/client/ghin/models/golfers/following.ts @@ -2,22 +2,22 @@ import { z } from 'zod' import { number, string } from '../../../../models' const schemaFollowingGolfer = z.object({ - ghin: number, - first_name: string, - last_name: string, - email: z.string().email().nullable().optional(), - status: string, - handicap_index: z.union([z.number(), z.string()]).nullable().optional(), - association_id: number.nullable().optional(), - association_name: string.nullable().optional(), - club_id: number.nullable().optional(), - club_name: string.nullable().optional(), - state: string.nullable().optional(), - country: string.nullable().optional(), + ghin: number, + first_name: string, + last_name: string, + email: z.string().email().nullable().optional(), + status: string, + handicap_index: z.union([z.number(), z.string()]).nullable().optional(), + association_id: number.nullable().optional(), + association_name: string.nullable().optional(), + club_id: number.nullable().optional(), + club_name: string.nullable().optional(), + state: string.nullable().optional(), + country: string.nullable().optional(), }) const schemaFollowingResponse = z.object({ - golfers: z.array(schemaFollowingGolfer), + golfers: z.array(schemaFollowingGolfer), }) type FollowingGolfer = z.infer