Why your GeoPoint never reaches Optimizely Graph, and how to fix it
Geo search lets Optimizely Graph match and rank content against geographic coordinates, so applications can deliver location-aware experiences such as nearby-store lookups, regional concert venues, sports event listings, and travel content. Geo search - Graph docs
Fancy! but running the Content Graph synchronisation job in CMS, will not automatically index Location as GeoPoint, because the field arrived as two plain numbers rather than a geo field.
Lets start from a simple example OfficePage [ContentType(GUID = "<new-guid>")]
public class OfficePage : PageData
{
public virtual string OfficeName { get; set; }
public virtual double Latitude { get; set; }
public virtual double Longitude { get; set; }
}
Optimizely Graph supports geo search: a field of type GeoPoint can be filtered by distance and ordered by distance from an origin [4]. So the obvious move is to send a lat/lon object through the CMS sync, for example with a conventions IncludeField:
public struct OfficeGeoPoint { public double lat { get; set; } public double lon { get; set; } }conventions.ForInstancesOf<OfficePage>() .IncludeField(p => p.GeoLocation()); // extension method returning OfficeGeoPoint
What goes wrong
The field does reach Graph, but as a nested object with two float fields, not as a GeoPoint, so the geo operators can't be used on it [1]. We saw exactly this on a real project.
Why it happens
Graph's query side supports GeoPoint, but the CMS sync package can only produce a fixed set of field types, and GeoPoint isn't one of them [1]. A custom PropertyData, a class with lat/lon, computed fields, indexing attributes or a "lat,lon" string all end up as one of the existing types [1].
The fix: a separate content source
Keep the CMS sync for everything else, and send the coordinates to an external content source whose schema you define yourself [1][2]. Give each document a key back to its CMS item, such as the content ID, so results can be joined back to the page [1].
1. Define the content type
A full type sync is a PUT to /api/content/v3/types?id={source}. It uses Basic authentication with your Graph AppKey and Secret [2]. A source ID can be at most four characters, using only a-z and 0-9 [3].
`{
"useTypedFieldNames": true,
"languages": ["en"],
"contentTypes": {
"OfficeLocation": {
"contentType": [],
"properties": {
"ContentId": { "type": "Int" },
"OfficeName": { "type": "String", "searchable": true },
"Location": { "type": "GeoPoint" }
}
}
}
}`
Warning: a full sync replaces the source's schema and deletes all of its data [2]. Always send your own source ID, never the CMS source. Check that your Graph account allows another source: if you're over the limit, the call fails [2].
2. Send the documents
Data is sent as NDJSON in a POST to /api/content/v2/data?id={source}, with an action line before each document [3]. With typed field names, a geo field is named Location$$GeoPoint and holds lat and lon [4]. The metadata fields below (Status, __typename, _rbac, ContentType, Language) follow Optimizely's Graph Source SDK [5].
`{"index":{"_id":"42_en","language_routing":"en"}}
{"Status$$String":"Published","__typename":"OfficeLocation","_rbac":"r:Everyone:Read","ContentType$$String":["OfficeLocation"],"Language":{"Name$$String":"en"},"ContentId$$Int":42,"OfficeName$$String___searchable":"London","Location$$GeoPoint":{"lat":51.5072,"lon":-0.1276}}`
To remove a document, send {"delete":{"_id":"42_en","language_routing":"en"}} to the same endpoint, as the SDK does [5].
A minimal client
The SDK has no GeoPoint field type (its converter handles Boolean, DateTime, Int, Float and String, plus nested property types) [5], so a small HttpClient works:
public class OfficeLocationClient(HttpClient http, string appKey, string secret, string source){ public Task PushSchemaAsync(string json) => SendAsync(HttpMethod.Put, "/api/content/v3/types", json);
public Task UpsertAsync(OfficePage page, string lang) { var action = JsonSerializer.Serialize(new { index = new { _id = $"{page.ContentLink.ID}_{lang}", language_routing = lang } }); var doc = JsonSerializer.Serialize(new Dictionary<string, object> { ["Status$$String"] = "Published", ["__typename"] = "OfficeLocation", ["_rbac"] = "r:Everyone:Read", ["ContentType$$String"] = new[] { "OfficeLocation" }, ["Language"] = new Dictionary<string, string> { ["Name$$String"] = lang }, ["ContentId$$Int"] = page.ContentLink.ID, ["OfficeName$$String___searchable"] = page.OfficeName, ["Location$$GeoPoint"] = new { lat = page.Latitude, lon = page.Longitude } }); return SendAsync(HttpMethod.Post, "/api/content/v2/data", action + "\n" + doc + "\n"); }
private async Task SendAsync(HttpMethod method, string path, string body) { using var request = new HttpRequestMessage(method, $"https://cg.optimizely.com{path}?id={source}"); request.Headers.Authorization = new("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{appKey}:{secret}"))); request.Content = new StringContent(body, Encoding.UTF8, "application/json"); (await http.SendAsync(request)).EnsureSuccessStatusCode(); }}
Query by distance
The distance filter needs an origin. Its radius defaults to 1000 and its unit to metres, and orderBy sorts nearest first unless you set order: DESC [4]:
`query NearestOffices {
OfficeLocation(
where: { Location: { distance: { origin: { lat: 00.0000, lon: -0.0000 }, radius: 10, unit: MI } } }
orderBy: { Location: { origin: { lat: 00.0000, lon: -0.0000 } } }
) {
items { ContentId OfficeName Location { lat lon } }
}
}`
Summary
The CMS sync can't produce a GeoPoint, so a geo field never arrives that way [1]. Define a small external content type with a real GeoPoint [2][4], keep it in step with content events and a rebuild job, and join the results back to your pages by content ID.
References
- https://medium.com/@phamduckhanh2411/why-your-geopoint-field-never-shows-up-in-optimizely-graph-97a2ae01a2f5
- https://docs.optimizely.com/graph/docs/sync-content-types-for-external-data
- https://docs.optimizely.com/graph/docs/sync-content-data
- https://docs.optimizely.com/graph/docs/geo-search
- https://github.com/episerver/graph-source-sdk
Comments