Automate everything.
halolink.indicalab.jp.What's in this manual
| Section | What you'll learn |
|---|---|
| Architecture | How the IdP, GraphQL server, auth proxy, and image tile server connect |
| Authentication (Service) | OAuth 2.0 Client Credentials flow for backend scripts and services |
| Browser Auth + BFF | OIDC browser login via an auth proxy running on the HALOLink server — no secrets in the browser |
| Cloudflare Pages Deploy | How to host this manual (and your dev frontend) on Cloudflare Pages |
| Queries / Mutations / Subscriptions | Live runnable examples for every operation type |
| Setup guides | Step-by-step for Python and .NET 8 |
Architecture Overview
The full stack including the recommended BFF (Backend For Frontend) proxy layer:
cookie auth only
holds token server-side
/graphql
/idsrv/connect/token
Identity Provider (IdP)
Handles all authentication. Issues short-lived RS256 JWTs to callers who present valid credentials. For services this is Client Credentials. For browser apps this is OIDC Authorization Code + PKCE, brokered by the auth proxy.
Auth Proxy (HALOLink server)
The recommended layer for any browser-facing integration. It's a small service (Python or .NET 8 — see Browser Auth + BFF) deployed alongside the GraphQL API and Identity Provider on the HALOLink server itself. It handles the OIDC dance, stores the access token server-side, and issues the browser an HttpOnly session cookie. The browser never sees the JWT. The proxy forwards requests to /graphql over the local network by injecting the real Bearer token — no separate Cloudflare account or extra public hop required.
GraphQL API
Single endpoint for all data — HTTPS POST for queries/mutations, WSS for subscriptions. Every request must carry Authorization: Bearer <token> and x-requested-service-version: ^2.0.
Image Server (Deep Zoom)
A separate tile server for whole-slide images. Call GraphQL first to get a per-image JWT, then use it to fetch pixel tiles directly.
GraphQL:
https://halolink.indicalab.jp/graphqlWorkbench:
https://halolink.indicalab.jp/graphql/workbenchToken endpoint:
https://halolink.indicalab.jp/idsrv/connect/token
Authentication — Service / Script
For backend services, scripts, and automation — no browser involved. Uses the OAuth 2.0 Client Credentials flow directly against the IdP.
exp claim is near.Step 1 — Create a service client (admin, one-time)
# Run on the Identity Provider server cd "C:\Program Files\Indica Labs\Identity Provider" IndicaLabs.ApplicationLayer.Halo.IdentityProvider.exe reconfigure ^ --script AddResearchServiceClient ^ "client_type=myapp;scopes=serviceuser|graphql"
# C:\ProgramData\Indica Labs\Configuration\...\local-production.yml identity_provider: clients: - !OidcClient/ClientCredentials id: integra_INDICA-CUSTOM scopes: - serviceuser - graphql require_client_secret: true secrets: - secret: cRaOX+2LW4gjrXHmlaKlag== implementation: !OidcImpl/HALO {}
Step 2 — Request an access token
curl -X POST https://halolink.indicalab.jp/idsrv/connect/token \ -d "client_id=integra_INDICA-CUSTOM" \ -d "client_secret=cRaOX+2LW4gjrXHmlaKlag==" \ -d "scope=serviceuser graphql" \ -d "grant_type=client_credentials"
import aiohttp, ssl, asyncio CLIENT_ID = "integra_INDICA-CUSTOM" CLIENT_SECRET = "cRaOX+2LW4gjrXHmlaKlag==" TOKEN_URL = "https://halolink.indicalab.jp/idsrv/connect/token" async def request_access_token(): async with aiohttp.ClientSession() as session: async with session.post(TOKEN_URL, data={ "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "scope": "serviceuser graphql", "grant_type": "client_credentials", }, ssl=ssl.SSLContext(ssl.PROTOCOL_TLS)) as resp: data = await resp.json() return data["access_token"]
var resp = await client.PostAsync(TOKEN_URL, new FormUrlEncodedContent(new[] { KeyValuePair.Create("client_id", "integra_INDICA-CUSTOM"), KeyValuePair.Create("client_secret", "cRaOX+2LW4gjrXHmlaKlag=="), KeyValuePair.Create("scope", "serviceuser graphql"), KeyValuePair.Create("grant_type", "client_credentials"), })); var json = JsonSerializer.Deserialize<TokenResponse>(await resp.Content.ReadAsStringAsync()); return json.AccessToken;
Browser Auth — On-Server Proxy
Browser apps can't safely hold a client secret, and a full user-delegated session needs a real login — not a shared service account. The proxy that brokers this now runs on the HALOLink server itself (a small Python or .NET 8 service, deployed next to the GraphQL API and Identity Provider), instead of as a separate Cloudflare Worker. Same machine, same network, one fewer hop, no external account to manage.
never sees the JWT
Python or .NET 8
same host, localhost call
Flow — per-user login
Proxy routes
| Route | Purpose |
|---|---|
GET /auth-proxy/login | Starts the OIDC Authorization Code + PKCE flow |
GET /auth-proxy/callback | Exchanges the auth code for a token, opens the session |
GET /auth-proxy/session | Returns {authenticated, expires_at} — never the raw token |
POST /auth-proxy/graphql | Forwards to /graphql with the Bearer token injected |
GET /auth-proxy/graphql-ws | WebSocket proxy for subscriptions (graphql-ws protocol) |
POST /auth-proxy/logout | Clears the session and calls the IdP end-session endpoint |
Reference implementation — forwarding a request
@app.post("/auth-proxy/graphql") async def proxy_graphql(request: Request): session = await get_session(request) if not session or session.is_expired(): return JSONResponse({"error": "not_authenticated"}, status_code=401) body = await request.body() async with httpx.AsyncClient() as client: upstream = await client.post( "https://localhost/graphql", content=body, headers={ "Authorization": f"Bearer {session.access_token}", "x-requested-service-version": "^2.0", "Content-Type": "application/json", }, ) return Response(upstream.content, status_code=upstream.status_code, media_type="application/json")
app.MapPost("/auth-proxy/graphql", async (HttpContext ctx, ISessionStore sessions, IHttpClientFactory factory) => { var session = await sessions.GetAsync(ctx); if (session is null || session.IsExpired) return Results.Json(new { error = "not_authenticated" }, statusCode: 401); var client = factory.CreateClient("upstream"); var req = new HttpRequestMessage(HttpMethod.Post, "https://localhost/graphql") { Content = new StreamContent(ctx.Request.Body) }; req.Headers.Authorization = new("Bearer", session.AccessToken); req.Headers.Add("x-requested-service-version", "^2.0"); var upstream = await client.SendAsync(req); return Results.Stream(await upstream.Content.ReadAsStreamAsync(), "application/json", statusCode: (int)upstream.StatusCode); });
proxy/python/ (FastAPI) and proxy/dotnet/ (ASP.NET Core 8) in this repository — both implement the full login/callback/session/graphql/graphql-ws/logout route set above, ready to deploy on the HALOLink server./graphql over localhost/internal network instead of the public internet.POST /auth-proxy/graphql with your session cookie, no token ever touches this page's JavaScript.Hosting This Manual on Cloudflare Pages
This file is a single static index.html — no build step, no server-side rendering. Cloudflare Pages (or any static host) can serve it directly. The only thing that has to stay reachable is the auth proxy on the HALOLink server.
index.html) to a GitHub/GitLab repo, or drag-and-drop the file in the Cloudflare dashboard under Workers & Pages → Create → Pages → Upload assets./ (project root).docs.indicalab.jp.SameSite=None; Secure and answers CORS preflights with Access-Control-Allow-Origin + Allow-Credentials — but only for the single origin in AuthProxy:AllowedOrigin (appsettings.json / AUTH_PROXY_ALLOWED_ORIGIN for the Python build), which defaults to https://dev.indicalab.jp. A Cloudflare Pages domain (e.g. docs.indicalab.jp or a *.pages.dev preview URL) won't be on that list automatically — add it to AllowedOrigin (currently a single string, so a new Pages preview URL per branch would need to be added manually) on the proxy before live queries will work from it. The Authentication (Service) demo button is a separate case: the IdP's token endpoint sends no CORS headers for any origin, so that one only ever works when the page is loaded from halolink.indicalab.jp itself.API Workbench
The GraphiQL workbench at halolink.indicalab.jp/graphql/workbench is the fastest way to prototype queries before writing code.
| Panel | What it does |
|---|---|
| Left — Query Editor | Write GraphQL. Hit ▶ to execute. PRETTIFY auto-formats. HISTORY recalls past queries. |
| Right — Schema Browser | Click the green SCHEMA tab to browse all queries, mutations, subscriptions, and types with their argument signatures. |
| Bottom — Variables & Headers | QUERY VARIABLES: pass {"pk": 42} JSON into $pk variables. HTTP HEADERS: set Authorization: Bearer <token> here. |
| COPY CURL button | Converts the current query to a cURL command you can paste into a terminal or script. |
Setting your token in the workbench
{"Authorization": "Bearer YOUR_TOKEN_HERE"}GraphQL Concepts
| Concept | Description | Tag |
|---|---|---|
| Query | Read-only. Ask for exactly the fields you need — no over-fetching. Parameterise with $vars. | Query |
| Mutation | Write operations. Return the modified node plus a failed list for partial errors. | Mutation |
| Subscription | Long-lived WebSocket. Server pushes events as they happen — no polling. | Sub |
IDs vs Primary Keys
| Field | Type | Use when |
|---|---|---|
id | ID! (Base64 Relay node ID) | Connecting objects in mutations, subscriptions, references across types |
pk | Int! (database integer) | Looking up from HALO's own UI, logs, or external LIMS systems |
Pagination (Relay Cursor Pattern)
query GetImages($first: Int, $after: Cursor) { images(first: $first, after: $after) { totalCount pageInfo { hasNextPage endCursor } edges { cursor node { id pk location tag } } } }
Filtering with where clauses
query FilterImages { images(first: 25, query: { where: { q: [{ f: "location", pred: { op: CONTAINS, v: "sample" } }] } orderBy: { q: [{ f: "createdTime", dir: DESC }] } }) { totalCount edges { node { id location createdTime } } } }
| Operator | Meaning |
|---|---|
EQ / NEQ | Equal / Not equal |
GT / GTE / LT / LTE | Numeric/date comparison |
CONTAINS / NCONTAINS | String substring match |
STARTS / ENDS | Prefix / suffix |
IN_SET | Semicolon-separated set membership |
LIKE | SQL LIKE syntax (% wildcards) |
Query Image by Primary Key
The most common starting point. Use imageByPk when you know the integer pk from HALO's database or UI. Returns the image's location path, barcode, stain, and metadata.
query GetImage($pk: Int!) { imageByPk(pk: $pk) { id pk location barcode tag stain width height } } // Variables { "pk": 1 }
from gql import gql, Client from gql.transport.aiohttp import AIOHTTPTransport transport = AIOHTTPTransport( url="https://halolink.indicalab.jp/graphql", headers={"Authorization": f"Bearer {token}"} ) async with Client(transport=transport) as session: result = await session.execute( gql(""" query GetImage($pk: Int!) { imageByPk(pk: $pk) { id pk location barcode tag stain width height } } """), variable_values={"pk": 1} ) print(result["imageByPk"]["location"])
using System.Net.Http.Json; var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new("Bearer", token); var payload = new { query = @"query GetImage($pk: Int!) { imageByPk(pk: $pk) { id pk location barcode tag stain width height } }", variables = new { pk = 1 } }; var resp = await client.PostAsJsonAsync( "https://halolink.indicalab.jp/graphql", payload); var json = await resp.Content.ReadAsStringAsync();
Return fields explained
| Field | Type | Notes |
|---|---|---|
id | ID! | Relay node ID — use this in mutations |
pk | Int! | Database integer — matches HALO's UI pk |
location | String | File system path to the slide file |
barcode | String | Barcode value if scanned |
stain | String | Stain type e.g. H&E, IHC |
width / height | Int | Pixel dimensions of the whole slide |
Query Study by Primary Key
Studies are the top-level containers for images in HALO. Use studyByPk to retrieve a study and its metadata.
query GetStudy($pk: Int!) { studyByPk(pk: $pk) { pk id name description createdTime permission } } // Variables { "pk": 1 }
result = await session.execute( gql(""" query GetStudy($pk: Int!) { studyByPk(pk: $pk) { pk id name description createdTime permission } } """), variable_values={"pk": 1} ) study = result["studyByPk"] print(f"{study['name']} — {study['createdTime']}")
var payload = new { query = @"query GetStudy($pk: Int!) { studyByPk(pk: $pk) { pk id name description createdTime permission } }", variables = new { pk = 1 } }; var resp = await client.PostAsJsonAsync( "https://halolink.indicalab.jp/graphql", payload);
Query Analysis Settings by Name
Find a saved analysis algorithm profile by its savedName. The returned id is what you pass to scheduleAnalysis.
query FindSettings($name: String!) { settings(query: { where: { q: { f: "savedName", pred: { op: EQ, v: $name } } } }) { edges { node { pk id algorithmName savedName } } } } // Variables { "name": "Shared Classifier" }
result = await session.execute( gql(""" query FindSettings($name: String!) { settings(query: { where: { q: { f: "savedName", pred: { op: EQ, v: $name } } } }) { edges { node { pk id algorithmName savedName } } } } """), variable_values={"name": "Shared Classifier"} ) nodes = [e["node"] for e in result["settings"]["edges"]]
var payload = new { query = @"query FindSettings($name: String!) { settings(query: { where: { q: { f: ""savedName"", pred: { op: EQ, v: $name } } } }) { edges { node { pk id algorithmName savedName } } } }", variables = new { name = "Shared Classifier" } };
Query Analysis Job Progress
Poll a running job with jobById. The condition field cycles through QUEUED → RUNNING → COMPLETE (or FAILED / CANCELLED).
analysisProgressEvent subscription instead.query GetJobProgress($id: ID!) { jobById(id: $id) { pk id settingsSavedName progress { condition percentComplete errorMessage } image { id pk location } } } // Variables — replace with a real job ID { "id": "QW5hbHlzaXNKb2I6MTIz" }
import asyncio async def poll_job(session, job_id): while True: result = await session.execute( gql(""" query GetJobProgress($id: ID!) { jobById(id: $id) { progress { condition percentComplete errorMessage } } } """), variable_values={"id": job_id} ) prog = result["jobById"]["progress"] print(f"{prog['condition']} — {prog['percentComplete']}%") if prog["condition"] in ("COMPLETE", "FAILED", "CANCELLED"): break await asyncio.sleep(5)
var payload = new { query = @"query GetJobProgress($id: ID!) { jobById(id: $id) { pk id settingsSavedName progress { condition percentComplete errorMessage } } }", variables = new { id = "QW5hbHlzaXNKb2I6MTIz" } };
Mutation Catalog an Image
Register a whole-slide image file into HALO's database. Provide the UNC/network path and optionally link to an existing study.
mutation CatalogImage($path: String!, $studyId: ID!) { catalogImage(input: { location: $path studyId: $studyId }) { mutated { node { pk id location } } failed { error } } } // Variables { "path": "\\\\server\\share\\images\\slide001.svs", "studyId": "U3R1ZHk6NQ==" }
result = await session.execute( gql(""" mutation CatalogImage($path: String!, $studyId: ID!) { catalogImage(input: { location: $path, studyId: $studyId }) { mutated { node { pk id location } } failed { error } } } """), variable_values={ "path": r"\\server\share\images\slide001.svs", "studyId": "U3R1ZHk6NQ==" } ) if result["catalogImage"]["failed"]: print("Errors:", result["catalogImage"]["failed"]) else: node = result["catalogImage"]["mutated"][0]["node"] print(f"Cataloged pk={node['pk']}")
var payload = new { query = @"mutation CatalogImage($path: String!, $studyId: ID!) { catalogImage(input: { location: $path, studyId: $studyId }) { mutated { node { pk id location } } failed { error } } }", variables = new { path = @"\\server\share\images\slide001.svs", studyId = "U3R1ZHk6NQ==" } };
Response structure
| Field | Meaning |
|---|---|
mutated[].node | The newly created image record(s) |
failed[].error | Error message if the file path wasn't accessible, or the study ID was invalid |
Mutation Create Annotation Layer + Draw Region
Annotations are two-step: first create a layer on the image, then draw one or more regions (polygons, rectangles, etc.) on that layer.
# Step 1 — Create a layer mutation CreateLayer($imageId: ID!) { createAnnotationLayer(input: { id: $imageId }) { mutated { node { id name } } failed { error } } } # Step 2 — Draw a region on the layer (pentagon example) mutation DrawRegion($input: DrawAnnotationRegionInput!) { drawAnnotationRegion(input: $input) { failed { error } } } // Variables for Step 2 { "input": { "layerId": "<layer_id from step 1>", "shapeType": "POLYGON", "geometry": "{\"type\":\"LineString\",\"coordinates\":[[5000,0],[9755,-3090],[6545,-8090],[3455,-8090],[245,-3090],[5000,0]]}" } }
import math, json # Compute pentagon vertices ORIGIN, RADIUS = (5000, -5000), 5000 vertices = [ [round(ORIGIN[0] + RADIUS * math.sin(2*math.pi*i/5)), round(ORIGIN[1] + RADIUS * math.cos(2*math.pi*i/5))] for i in range(5) ] vertices.append(vertices[0]) # close ring geometry = json.dumps({"type": "LineString", "coordinates": vertices}) # Step 1 — create layer r1 = await session.execute(gql(""" mutation CreateLayer($imageId: ID!) { createAnnotationLayer(input: { id: $imageId }) { mutated { node { id } } } } """), variable_values={"imageId": image_id}) layer_id = r1["createAnnotationLayer"]["mutated"][0]["node"]["id"] # Step 2 — draw await session.execute(gql(""" mutation DrawRegion($input: DrawAnnotationRegionInput!) { drawAnnotationRegion(input: $input) { failed { error } } } """), variable_values={"input": { "layerId": layer_id, "shapeType": "POLYGON", "geometry": geometry }})
// Step 1 — create layer var step1 = new { query = @"mutation CreateLayer($imageId: ID!) { createAnnotationLayer(input: { id: $imageId }) { mutated { node { id name } } failed { error } } }", variables = new { imageId = imageId } }; // execute step1, extract layerId from response // Step 2 — draw region var step2 = new { query = @"mutation DrawRegion($input: DrawAnnotationRegionInput!) { drawAnnotationRegion(input: $input) { failed { error } } }", variables = new { input = new { layerId = layerId, shapeType = "POLYGON", geometry = "{\"type\":\"LineString\",\"coordinates\":[[5000,0]...]}" } } };
Mutation Schedule an Analysis Job
Queue a HALO analysis on an image using a saved settings profile. You need the settings id (from the settings query) and the image id.
mutation ScheduleJob( $settingsId: ID! $imageId: ID! ) { scheduleAnalysis(input: { settingsId: $settingsId analysisDescriptions: [{ imageId: $imageId, fieldOfView: null }] }) { mutated { node { id pk settingsSavedName image { id pk } } } failed { error } } } // Variables { "settingsId": "U2F2ZWRBbGdvcml0aG1BbmFseXNpc1NldHRpbmdzOjE=", "imageId": "SW1hZ2U6NDI=" }
result = await session.execute( gql(""" mutation ScheduleJob($settingsId: ID!, $imageId: ID!) { scheduleAnalysis(input: { settingsId: $settingsId analysisDescriptions: [{ imageId: $imageId, fieldOfView: null }] }) { mutated { node { id pk settingsSavedName } } failed { error } } } """), variable_values={ "settingsId": settings_id, "imageId": image_id, } ) job_id = result["scheduleAnalysis"]["mutated"][0]["node"]["id"] # now poll with jobById or subscribe to analysisFinishedEvent
var payload = new { query = @"mutation ScheduleJob($settingsId: ID!, $imageId: ID!) { scheduleAnalysis(input: { settingsId: $settingsId analysisDescriptions: [{ imageId: $imageId, fieldOfView: null }] }) { mutated { node { id pk settingsSavedName } } failed { error } } }", variables = new { settingsId = settingsId, imageId = imageId } };
id and poll with jobById (see Job Progress query) or subscribe to analysisFinishedEvent for a push notification.Subscription Barcode Scanned Event
Fires in real time whenever a barcode is scanned or changed on any image in HALO Link. Subscriptions require a WebSocket connection — they don't work over plain HTTP POST.
graphql-ws or Apollo subprotocol over wss://halolink.indicalab.jp/graphql. The Run button below simulates the connection.subscription { barcodeScannedEvent { imageId } } # Then fetch image details on each event: query GetById($id: ID!) { imageById(id: $id) { id pk barcode } }
from gql.transport.websockets import WebsocketsTransport transport = WebsocketsTransport( url="wss://halolink.indicalab.jp/graphql", headers={"authorization": f"bearer {token}"}, subprotocols=[WebsocketsTransport.APOLLO_SUBPROTOCOL,] ) async with Client(transport=transport) as session: async for msg in session.subscribe(gql(""" subscription { barcodeScannedEvent { imageId } } """)): image_id = msg["barcodeScannedEvent"]["imageId"] # fetch full image details image = await session.execute( gql('query($id:ID!){imageById(id:$id){barcode}}'), variable_values={"id": image_id} ) print(f"Barcode: {image['imageById']['barcode']}")
using GraphQL.Client.Http; using GraphQL.Client.Serializer.SystemTextJson; var graphQL = new GraphQLHttpClient( new GraphQLHttpClientOptions { EndPoint = new Uri("https://halolink.indicalab.jp/graphql"), WebSocketEndPoint = new Uri("wss://halolink.indicalab.jp/graphql") }, new SystemTextJsonSerializer() ); graphQL.HttpClient.DefaultRequestHeaders.Authorization = new("Bearer", token); var sub = graphQL.CreateSubscriptionStream<BarcodeEvent>( new GraphQLRequest { Query = "subscription { barcodeScannedEvent { imageId } }" }); sub.Subscribe(msg => Console.WriteLine(msg.Data.BarcodeScannedEvent.ImageId));
Subscription Image Cataloged + Study Events
Two events useful for LIMS integration: react whenever a new image lands in HALO, or whenever an image is added to a study.
# Fires when a new image is cataloged subscription { imageCatalogedEvent { imageId imageLocation } } # Fires when an image is added to a study subscription { imageAddedToStudyEvent { studyId imageId } }
import asyncio async def on_cataloged(session): async for msg in session.subscribe(gql(""" subscription { imageCatalogedEvent { imageId imageLocation } } """)): ev = msg["imageCatalogedEvent"] print(f"New image: {ev['imageLocation']}") async def on_added_to_study(session): async for msg in session.subscribe(gql(""" subscription { imageAddedToStudyEvent { studyId imageId } } """)): ev = msg["imageAddedToStudyEvent"] # update LIMS here async def main(): session = await create_client_session(token) await asyncio.gather( asyncio.create_task(on_cataloged(session)), asyncio.create_task(on_added_to_study(session)), )
// Run two subscriptions concurrently var sub1 = graphQL.CreateSubscriptionStream<CatalogedEvent>( new GraphQLRequest { Query = "subscription{imageCatalogedEvent{imageId imageLocation}}" }); var sub2 = graphQL.CreateSubscriptionStream<StudyEvent>( new GraphQLRequest { Query = "subscription{imageAddedToStudyEvent{studyId imageId}}" }); sub1.Subscribe(e => Console.WriteLine($"Cataloged: {e.Data}")); sub2.Subscribe(e => Console.WriteLine($"Added to study: {e.Data}"));
Python Setup
Official example code: gitlab.com/indica_labs_public/example-code
requirements.txt
aiohttp==3.8.5 gql[all]==3.4.1 pyyaml==6.0.1 requests==2.32.3 urllib3==1.26.20 pillow==11.1.0
Setup steps
cd research && setup.bat — creates a virtual env and installs all depssettings.yml
authorization: hostname: halolink.indicalab.jp client_id: example_WHERE-IS-WALDO client_secret: your-secret-here
.\Scripts\activate.bat then python main.py — should print an access tokenpython .\examples\query_image.py 42Client session helper
from gql import Client from gql.transport.websockets import WebsocketsTransport import ssl async def create_client_session(token: str): transport = WebsocketsTransport( url="wss://halolink.indicalab.jp/graphql", headers={"authorization": f"bearer {token}"}, subprotocols=[WebsocketsTransport.APOLLO_SUBPROTOCOL], ssl=ssl.SSLContext(ssl.PROTOCOL_TLS), ) client = Client(transport=transport) return await client.connect_async()
.NET 8 Setup
Use GraphQL.Client for HTTP queries/mutations and WebSocket subscriptions. Pair with System.Text.Json for deserialization.
NuGet packages
<!-- Add to your .csproj --> <PackageReference Include="GraphQL.Client" Version="6.*" /> <PackageReference Include="GraphQL.Client.Serializer.SystemTextJson" Version="6.*" /> <PackageReference Include="Microsoft.Extensions.Http" Version="8.*" /> # Or via CLI dotnet add package GraphQL.Client dotnet add package GraphQL.Client.Serializer.SystemTextJson
Client factory (DI-friendly)
using GraphQL.Client.Http; using GraphQL.Client.Serializer.SystemTextJson; public static class HaloClientFactory { public static GraphQLHttpClient Create(string token) { var options = new GraphQLHttpClientOptions { EndPoint = new Uri("https://halolink.indicalab.jp/graphql"), WebSocketEndPoint = new Uri("wss://halolink.indicalab.jp/graphql"), }; var client = new GraphQLHttpClient(options, new SystemTextJsonSerializer()); client.HttpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); return client; } }
Executing a query
var graphQL = HaloClientFactory.Create(token); var request = new GraphQLRequest { Query = @"query GetImage($pk: Int!) { imageByPk(pk: $pk) { id pk location barcode stain } }", Variables = new { pk = 42 } }; var response = await graphQL.SendQueryAsync<ImageResponse>(request); Console.WriteLine(response.Data.ImageByPk.Location);
Troubleshooting & Tips
| Symptom | Fix |
|---|---|
401 Unauthorized | Check (1) IdP service is running, (2) client_id/secret match local-production.yml exactly, (3) scopes include both serviceuser and graphql. |
| SSL/TLS certificate error | Python examples use ssl.SSLContext(ssl.PROTOCOL_TLS) without certificate verification. In production use ssl.create_default_context() with your CA bundle. |
| Subscription receives no events | Confirm APOLLO_SUBPROTOCOL is set. Verify the event is actually triggering in HALO Link (e.g. physically scan a barcode to test barcodeScannedEvent). |
| Mutation returns wrong ID type error | Mutations expect the opaque Base64 id (type ID!), not the integer pk. Always read back id from a query before passing to a mutation. |
| Large dataset takes too long | Default pagination returns a page at a time. Pass first: -1 to get all records — but use with caution on large collections. |
null returned for a known object | The token may not have permission to that resource, or the pk/id doesn't exist in this environment. |
Filtering quick reference
# Single filter query: { where: { q: { f: "location", pred: { op: CONTAINS, v: "sample" } } } } # Multiple filters (AND) query: { where: { q: [ { f: "stain", pred: { op: EQ, v: "H&E" } }, { f: "tag", pred: { op: STARTS, v: "2024" } } ] } } # Nested field path query: { where: { q: { f: "systemField.name", pred: { op: EQ, v: "Sample" } } } } # Get all (no pagination limit) images(first: -1) { edges { node { id location } } }