Rest API Design for OpenSearch Integration
Portal Search: REST API Reference
Author: @Bryan Fauble
- 1 Portal Search: REST API Reference
- 2 Overview
- 2.1 Resources
- 2.2 Design Principles
- 3 Quick Start
- 4 Endpoint Summary
- 5 Security & Authorization
- 6 Data Model
- 6.1 SynonymSet
- 6.2 SynonymRule
- 6.3 ColumnAnalyzerOverride
- 6.4 ColumnAnalyzerOverrideEntry
- 6.5 TextAnalyzer
- 6.6 TextAnalyzerSettings
- 6.7 SearchConfiguration
- 6.8 SearchIndex
- 6.9 SearchIndexState
- 6.10 SearchQuery
- 6.11 SearchResults
- 6.12 SearchHit
- 6.13 FacetColumnResult (reused)
- 7 Enums
- 8 Filter & Sort Types
- 8.1 KeyValue (reused)
- 8.2 KeyRange (reused)
- 8.3 KeyValues
- 8.4 FacetRequest
- 8.5 SortField
- 9 List Request/Response Pattern
- 10 Configuration Notes
- 10.1 SearchConfiguration - Composition and Resolution
- 10.2 Project Settings Integration (Fallback Inheritance)
- 10.3 Defining SQL
- 10.4 Analyzer Configuration
- 10.4.1 Defaults by Column Type
- 10.4.2 Priority Order
- 10.4.3 Multi-Field Mapping
- 10.5 Synonym Configuration
- 10.5.1 Rule Types
- 10.5.2 Multi-Word Handling
- 10.5.3 Synonym Propagation
- 11 Endpoints
- 11.1 Synonym Sets
- 11.1.1 Create Synonym Set
- 11.1.2 Get Synonym Set
- 11.1.3 Update Synonym Set
- 11.1.4 Delete Synonym Set
- 11.1.5 List Synonym Sets
- 11.2 Column Analyzer Overrides
- 11.3 Text Analyzers
- 11.3.1 Get Text Analyzer
- 11.3.2 List Text Analyzers
- 11.4 Search Configurations
- 11.4.1 Create Search Configuration
- 11.4.2 Get Search Configuration
- 11.4.3 Update Search Configuration
- 11.4.4 Delete Search Configuration
- 11.4.5 List Search Configurations
- 11.5 Search Operations
- 11.5.1 Search Async Start
- 11.5.2 Search Async Get
- 11.5.3 Autocomplete
- 11.1 Synonym Sets
- 12 Query Examples
- 13 Error Responses
Overview
Portal Search provides REST endpoints for creating OpenSearch-backed search indexes over Synapse table-like entities and executing full-text search queries against them.
Resources
The API is organized around four standalone configuration resources under /repo/v1/search/* and one SearchIndex Synapse Entity managed via the standard entity controller under /repo/v1/entity/*.
All resources are logically scoped to an Organization (an existing Synapse concept — CRUD and ACL management at /schema/organizations/*), but note that SearchIndex does not have its own organizationId field; it is scoped via the standard entity parentId (Project or Folder).
Resource | Purpose |
|---|---|
SynonymSet | Reusable synonym rules (e.g., |
ColumnAnalyzerOverride | Per-column text analysis overrides. Cannot be deleted while referenced by a |
TextAnalyzer | A standalone reusable resource that defines how text is analyzed (tokenizer, filters, synonym awareness). Referenced by ColumnAnalyzerOverrideEntry ( |
SearchConfiguration | Bundles synonym sets and column overrides. References a default TextAnalyzer ( |
SearchIndex (Entity) | A Synapse Entity ( |
Resources compose as:
SearchIndex → SearchConfiguration → {SynonymSets[], ColumnAnalyzerOverrides[]}.
TextAnalyzer is a standalone resource referenced by ColumnAnalyzerOverrideEntry.indexAnalyzerId/searchAnalyzerId and SearchConfiguration.defaultAnalyzerId.
Design Principles
Organization-scoped authorization for configuration. The four configuration resources (
SynonymSet,ColumnAnalyzerOverride,TextAnalyzer,SearchConfiguration) are Organization-scoped and identified byorganizationId. They are publicly readable; mutations require Organization ACL permissions.SearchIndexis a standard Synapse Entity without its ownorganizationIdfield; it is scoped byparentId(Project or Folder), and its ACL follows normal entity rules.Secure search access. Search queries and autocomplete requests are not fully public. Callers must have:
READon theSearchIndexentity andSufficient permissions to read from the underlying table referenced by
definingSQL(mirrors the MaterializedView authorization pattern).
Build-once semantics with rebuild on update. AOSS indexes are point-in-time snapshots. There are no incremental updates. The lifecycle worker builds (or rebuilds) indexes on both CREATE and UPDATE of
SearchIndexentities.Single-entity constraint.
definingSQLmust reference exactly one entity. Multi-entity JOINs are rejected with 400 Bad Request.Shared resource protection.
SynonymSets andColumnAnalyzerOverrides cannot be deleted while referenced by anySearchConfiguration.Name uniqueness.
For
SynonymSet,ColumnAnalyzerOverride, andSearchConfiguration, names are unique per Organization:UNIQUE(organizationId, name).For
TextAnalyzer, system analyzer names are globally unique; user-defined analyzer names are unique per Organization.For
SearchIndex(as a Synapse Entity), name uniqueness follows the standard entity rules: unique within its parent container (parentId).
Single polling point. Search queries run as async jobs. If an index is still building, the worker automatically retries — the client only polls the async result endpoint.
Quick Start
Step 1: Create Supporting Resources
Create a synonym set in your Organization:
POST /repo/v1/search/synonym/set{
"organizationId": "42",
"name": "NF Disease Terms",
"description": "Synonym mappings for neurofibromatosis-related disease terminology",
"rules": [
{ "ruleType": "EQUIVALENT", "terms": ["NF1", "neurofibromatosis 1", "von Recklinghausen disease"] },
{ "ruleType": "EQUIVALENT", "terms": ["schwannoma", "vestibular schwannoma", "acoustic neuroma"] }
]
}Create a search configuration referencing the synonym set:
POST /repo/v1/search/configuration{
"organizationId": "42",
"name": "NF Portal Config",
"synonymSetIds": ["501"],
"defaultAnalyzerId": "1",
"columnAnalyzerOverrideIds": []
}Step 2: Create a Search Index Entity
SearchIndex is a Synapse Entity, not a standalone /search/index resource. Creation goes through the standard entity controller. Use the SearchIndex concrete type and provide a parentId (a Project or Folder):
POST /repo/v1/entity{
"concreteType": "org.sagebionetworks.repo.model.table.search.SearchIndex",
"parentId": "syn123",
"name": "Studies Search",
"definingSQL": "SELECT studyName, summary, diseaseFocus, species, assay FROM syn52694652",
"searchConfigurationId": "301"
}Sample Response (201 Created):
{
"id": "syn101",
"concreteType": "org.sagebionetworks.repo.model.table.search.SearchIndex",
"parentId": "syn123",
"name": "Studies Search",
"definingSQL": "SELECT studyName, summary, diseaseFocus, species, assay FROM syn52694652",
"searchConfigurationId": "301",
"versionNumber": 1,
"etag": "aaa-bbb-ccc",
"createdBy": "3350396",
"createdOn": "2026-02-18T12:00:00.000Z",
"modifiedBy": "3350396",
"modifiedOn": "2026-02-18T12:00:00.000Z"
}Note: Index state (
CREATING,ACTIVE,FAILED,DELETING) is stored in the indexing DB'sSEARCH_INDEX_STATUStable and is not a property of theSearchIndexentity itself.
The index build is handled asynchronously by the SearchIndexLifecycleWorker. Builds are triggered on both create and update of the SearchIndex entity.
Step 3: Submit a Search Query
Search operations remain under the /repo/v1/search/* namespace.
POST /repo/v1/search/query/async/start{
"searchIndexId": "syn101",
"queryText": "schwannoma gene expression",
"size": 10
}Response (201 Created):
{ "token": "98765" }Step 4: Poll for Results
Poll until the job completes. If the index is still building (CREATING), the worker automatically retries — the client only polls this endpoint.
GET /repo/v1/search/query/async/get/98765Response (200 OK when ready, 202 Accepted while processing):
{
"searchIndexId": "syn101",
"totalHits": 3,
"hits": [
{
"rowId": 42,
"score": 8.73,
"fields": {
"studyName": "Genomic Landscape of Schwannoma",
"diseaseFocus": "Schwannomatosis"
}
}
],
"facets": []
}Endpoint Summary
All URLs are prefixed with /repo/v1.
Configuration Resources
Method | URL | Auth | Description |
|---|---|---|---|
|
| CREATE on Organization | Create synonym set |
|
| Public | Get synonym set |
|
| UPDATE on Organization | Update synonym set |
|
| DELETE on Organization | Delete synonym set |
|
| Public | List synonym sets |
|
| CREATE on Organization | Create column analyzer override |
|
| Public | Get column analyzer override |
|
| UPDATE on Organization | Update column analyzer override |
|
| DELETE on Organization | Delete column analyzer override |
|
| Public | List column analyzer overrides |
|
| Public | Get text analyzer |
|
| Public | List text analyzers |
|
| CREATE on Organization | Create search configuration |
|
| Public | Get search configuration |
|
| UPDATE on Organization | Update search configuration |
|
| DELETE on Organization | Delete search configuration |
|
| Public | List search configurations |
Note:
SearchIndexCRUD (create, get, update, delete, trash/restore) is handled by the standard Synapse Entity API under/repo/v1/entity/*and is not enumerated above.SearchIndexbehaves like other entity types (e.g., Table, View) with additional search-specific fields.
Search Operations
Method | URL | Auth | Description |
|---|---|---|---|
|
| READ on SearchIndex and read access to source table | Start async search |
|
| Same as start | Get async results |
|
| READ on SearchIndex and read access to source table | Autocomplete |
Security & Authorization
Organization-Scoped ACL for Configuration Resources
Configuration resources (SynonymSet, ColumnAnalyzerOverride, SearchConfiguration) belong to an Organization (via organizationId). Authorization reuses the existing Organization ACL model (same as JSON Schemas):
ACL scope: Per-Organization (not per-resource).
Public read: All configuration resources are publicly readable (no auth for GET/list).
Mutating operations: Require appropriate
ACCESS_TYPEon the Organization ACL.Admin bypass: Admins skip ACL checks (
UserInfo.isAdmin()).Default ACL: When an Organization is created, the creator gets
{READ, CREATE, CHANGE_PERMISSIONS, UPDATE, DELETE}.
Operation | Required ACCESS_TYPE / Permissions |
|---|---|
Create configuration resource |
|
Get / List configuration resources | Public (no auth) |
Update configuration resource |
|
Delete configuration resource |
|
Manage Organization ACL |
|
TextAnalyzer endpoints are currently read-only (GET and LIST only) and publicly accessible. System analyzers are managed by the bootstrapper on startup; user-defined analyzer CRUD is not yet exposed via REST.
Data Plane: Search & Autocomplete
Actual behavior (mirrors MaterializedView authorization):
Search queries and autocomplete requests are not public.
To execute a search or autocomplete request, the caller must have:
READon theSearchIndexentity andSufficient permissions to read from the source table referenced by
definingSQL.
The implementation loads data as the anonymous user when building the OpenSearch index:
The
SearchIndexLifecycleWorkerusesTableQueryManager.runQueryAsStream()with the anonymous user.Only publicly accessible rows are indexed. Even if a caller has elevated permissions on the table, the underlying index contains only public rows; search results cannot leak non-public data.
Operation | Effective Authorization |
|---|---|
Execute search query | READ on |
Autocomplete | Same as search query |
Data Model
This section defines all JSON schema types used across the API. Configuration resources are regular REST objects stored in dedicated tables. SearchIndex is implemented as a Synapse Entity backed by NODE/NODE_REVISION, with a separate indexing DB holding status.
SynonymSet
{
"description": "A shared set of synonym rules. SynonymSets belong to an Organization and can be referenced by SearchConfigurations. Cannot be deleted while referenced.",
"properties": {
"id": { "type": "string", "readOnly": true },
"organizationId": { "type": "string", "description": "Organization this resource belongs to." },
"name": { "type": "string", "description": "Unique within the organization." },
"description": { "type": "string" },
"rules": { "type": "array", "items": { "$ref": "#SynonymRule" } },
"etag": { "type": "string", "readOnly": true },
"createdOn": { "type": "string", "format": "date-time", "readOnly": true },
"createdBy": { "type": "string", "readOnly": true },
"modifiedOn": { "type": "string", "format": "date-time", "readOnly": true },
"modifiedBy": { "type": "string", "readOnly": true }
},
"required": ["organizationId", "name", "rules"]
}SynonymRule
{
"description": "A single synonym rule.",
"properties": {
"ruleType": {
"type": "string",
"enum": ["EQUIVALENT", "EXPLICIT"],
"description": "EQUIVALENT (bidirectional) or EXPLICIT (one-way expansion)."
},
"terms": {
"type": "array",
"items": { "type": "string" },
"minItems": 2,
"description": "For EQUIVALENT: all terms are interchangeable. For EXPLICIT: first term maps to the rest."
}
},
"required": ["ruleType", "terms"]
}Rule Type | Behavior | Example |
|---|---|---|
| Bidirectional. All terms are interchangeable. |
|
| One-way. First term expands to the rest. |
|
ColumnAnalyzerOverride
{
"description": "A shared resource containing per-column analyzer override entries. ColumnAnalyzerOverrides belong to an Organization and can be referenced by SearchConfigurations. Cannot be deleted while referenced.",
"properties": {
"id": { "type": "string", "readOnly": true },
"organizationId": { "type": "string" },
"name": { "type": "string", "description": "Unique within the organization." },
"description": { "type": "string" },
"overrides": { "type": "array", "items": { "$ref": "#ColumnAnalyzerOverrideEntry" } },
"etag": { "type": "string", "readOnly": true },
"createdOn": { "type": "string", "format": "date-time", "readOnly": true },
"createdBy": { "type": "string", "readOnly": true },
"modifiedOn": { "type": "string", "format": "date-time", "readOnly": true },
"modifiedBy": { "type": "string", "readOnly": true }
},
"required": ["organizationId", "name", "overrides"]
}ColumnAnalyzerOverrideEntry
{
"description": "A per-column analyzer override entry. Specifies which analyzers to use at index and search time for a specific column.",
"properties": {
"columnName": { "type": "string", "description": "Must exist in the entity's schema." },
"indexAnalyzerId": {
"type": "string",
"description": "The ID of the TextAnalyzer to use when indexing this column."
},
"searchAnalyzerId": {
"type": "string",
"description": "The ID of the TextAnalyzer to use when searching this column."
}
},
"required": ["columnName", "indexAnalyzerId", "searchAnalyzerId"]
}TextAnalyzer
{
"description": "A database-stored text analyzer configuration. System analyzers have IDs 1-999 and are read-only; user-defined analyzers start at 1000+.",
"properties": {
"id": { "type": "string", "readOnly": true },
"name": { "type": "string", "description": "Unique within the organization (or globally for system analyzers)." },
"description": { "type": "string" },
"organizationId": { "type": "string", "description": "Null for system analyzers." },
"settings": { "$ref": "#TextAnalyzerSettings", "description": "The analyzer configuration." },
"isSystem": { "type": "boolean", "description": "True for system analyzers (read-only)." },
"etag": { "type": "string", "readOnly": true },
"createdOn": { "type": "string", "format": "date-time", "readOnly": true },
"modifiedOn": { "type": "string", "format": "date-time", "readOnly": true }
}
}TextAnalyzerSettings
{
"description": "The OpenSearch analyzer configuration. Stores the full definition of how text is analyzed.",
"properties": {
"charFilters": { "type": "map(string, string)", "description": "Named character filter definitions. Values are JSON-serialized config objects." },
"tokenizer": { "type": "string", "description": "Tokenizer name (e.g., 'standard', 'whitespace', 'keyword')." },
"tokenizerConfig": { "type": "map(string, string)", "description": "Optional custom tokenizer config." },
"tokenFilters": { "type": "map(string, string)", "description": "Named token filter definitions. Values are JSON-serialized config objects." },
"filterOrder": { "type": "array", "items": { "type": "string" }, "description": "Ordered list of token filter names to apply." },
"charFilterOrder": { "type": "array", "items": { "type": "string" }, "description": "Ordered list of character filter names." },
"synonymAware": { "type": "boolean", "description": "Whether synonym filter should be appended when synonyms are configured." }
}
}SearchConfiguration
{
"description": "A reusable search configuration resource. References SynonymSets, ColumnAnalyzerOverrides, and a default text analyzer. Can be associated with projects via the Project Settings framework.",
"properties": {
"id": { "type": "string", "readOnly": true },
"organizationId": { "type": "string" },
"name": { "type": "string", "description": "Unique within the organization." },
"description": { "type": "string" },
"synonymSetIds": { "type": "array", "items": { "type": "string" } },
"columnAnalyzerOverrideIds": { "type": "array", "items": { "type": "string" } },
"defaultAnalyzerId": {
"type": "string",
"description": "Default text analyzer ID for columns without an explicit override."
},
"etag": { "type": "string", "readOnly": true },
"createdOn": { "type": "string", "format": "date-time", "readOnly": true },
"createdBy": { "type": "string", "readOnly": true },
"modifiedOn": { "type": "string", "format": "date-time", "readOnly": true },
"modifiedBy": { "type": "string", "readOnly": true }
},
"required": ["organizationId", "name"]
}SearchIndex
The SearchIndex schema has only two explicit properties (everything else is inherited from Entity):
{
"description": "An OpenSearch index definition for a specific entity. Represented as a Synapse Entity.",
"properties": {
"definingSQL": {
"type": "string",
"description": "Required. Must reference exactly one entity."
},
"searchConfigurationId": {
"type": "string",
"description": "Optional reference to a SearchConfiguration."
}
},
"required": ["definingSQL"]
}Note: Index state (
CREATING,ACTIVE,FAILED,DELETING) is stored in the indexing DB'sSEARCH_INDEX_STATUStable and is not a property of theSearchIndexentity itself.
As a Synapse Entity, SearchIndex also includes the standard entity fields inherited from Entity, such as:
idconcreteType(must be"org.sagebionetworks.repo.model.table.search.SearchIndex")parentId(Project or Folder)nameetagcreatedOn,createdBymodifiedOn,modifiedByversionNumberetc.
SearchIndexState
Index state is stored in the indexing DB, not on the entity itself.
Value | Description |
|---|---|
| Index build in progress. Queries submitted against this index will automatically retry until the build completes. |
| Index is live and serving queries. |
| Last build failed. Queries will fail with an error. Delete or update the |
| AOSS index cleanup in progress. |
SearchQuery
{
"description": "A structured query against a SearchIndex's OpenSearch index.",
"properties": {
"searchIndexId": {
"type": "string",
"description": "The ID of the SearchIndex entity to query. Supplied by the client in the request body."
},
"queryType": {
"type": "string",
"enum": [
"SIMPLE_QUERY_STRING",
"MATCH",
"MULTI_MATCH",
"MATCH_PHRASE",
"PREFIX",
"WILDCARD",
"MATCH_ALL"
],
"description": "Full-text query type. Default: SIMPLE_QUERY_STRING."
},
"queryText": {
"type": "string",
"description": "Search text. Null/empty = match all."
},
"queryFields": {
"type": "array",
"items": { "type": "string" },
"description": "Column names with optional boost (e.g., 'studyName^3'). Empty = all indexed fields."
},
"booleanFilters": {
"type": "array",
"items": { "$ref": "#KeyValue" },
"description": "Exact-match filters."
},
"termsFilters": {
"type": "array",
"items": { "$ref": "#KeyValues" },
"description": "Multi-value filters (IN clause)."
},
"rangeFilters": {
"type": "array",
"items": { "$ref": "#KeyRange" },
"description": "Range filters with min/max."
},
"existsFilters": {
"type": "array",
"items": { "type": "string" },
"description": "Columns that must have a non-null value."
},
"notExistsFilters": {
"type": "array",
"items": { "type": "string" },
"description": "Columns that must be null/missing."
},
"fuzziness": {
"type": "string",
"description": "Typo tolerance: 'AUTO', '0', '1', '2'."
},
"facetRequests": {
"type": "array",
"items": { "$ref": "#FacetRequest" },
"description": "Columns to aggregate as facets."
},
"returnFields": {
"type": "array",
"items": { "type": "string" },
"description": "Columns to include in results. Empty = all."
},
"sort": {
"type": "array",
"items": { "$ref": "#SortField" },
"description": "Sort order. Default: relevance descending."
},
"highlight": {
"type": "boolean",
"description": "Return highlighted snippets. Default: false."
},
"from": {
"type": "integer",
"description": "Zero-based pagination offset. Default: 0."
},
"size": {
"type": "integer",
"description": "Results per page. Default: 25. Max: 100."
}
},
"required": ["searchIndexId"]
}SearchResults
{
"description": "Results of a search query against a SearchIndex's OpenSearch index.",
"properties": {
"searchIndexId": { "type": "string" },
"totalHits": { "type": "integer" },
"hits": { "type": "array", "items": { "$ref": "#SearchHit" } },
"facets": { "type": "array", "items": { "$ref": "#FacetColumnResult" } },
"from": { "type": "integer" }
},
"required": ["searchIndexId", "totalHits", "hits"]
}SearchHit
{
"description": "A single search result hit.",
"properties": {
"rowId": { "type": "integer" },
"rowVersion": { "type": "integer" },
"score": { "type": "number" },
"fields": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"highlights": {
"type": "object",
"additionalProperties": { "type": "string" }
}
},
"required": ["rowId", "rowVersion", "score", "fields"]
}