Generate line chart data
Generate line chart data in Nivo format. Only rec_type=currency_pairs is implemented; the chart type is fixed to line by the handler and is not a request parameter. Series cap: when the grouping produces more series than charts.generation.max_series (AppConfig, default 10), the response is TRUNCATED to that many series and still answers 200. There is no flag in the body saying truncation happened, and no error — a client that groups by a high-cardinality field silently sees only an arbitrary N of them (the truncation takes Go map iteration order, which is randomised — the set you get is not the first N by any ordering and can differ between identical requests). Search filters: a value of null on .eq or .ne is a null test (IS NULL / IS NOT NULL). On ANY OTHER operator, null is accepted and the whole filter is then SILENTLY DROPPED by ParseSearchQuery — ?search.rate.gt=null does not narrow the result set and does not error. Rate source: search.source is honoured when given; when omitted, the AppConfig value fx.convert.default_source is injected before the query runs (the same default FX convert uses). If that key is empty, no source filter is applied at all. Data points: y is null on points produced by fill_gaps, so a series may legitimately contain nulls between real values. Ordering: THERE IS NONE. The storage layer issues Select(...).Limit(...).Offset(...) with no ORDER BY, and the charts handler never reads the sort parameter — query.ParseQueryParameters accepts it, nothing consumes it. With the effective default limit of 10, x_field=date returns an arbitrary ten rows in an arbitrary order, so the "line" is not chronological and paging with offset is not stable between requests. Permissions: the module's own record-type check cannot refuse this route. checkPermissions returns early for reference-data tables, and referenceDataTables contains currency_pairs — the only accepted rec_type — so rbac.RecPermission is never called and neither the common.forbidden path nor the unregistered-record-type 403 can fire. Every 403 a caller sees here comes from the auth or licence middleware.
Authorization
bearerAuth Bearer token authentication. Use your user token in the format: Bearer {{user_token}}
In: header
Query Parameters
Record type to query. Only currency_pairs is accepted; any other value is rejected with 400 (charts_m.invalid_record_type).
Field to plot on the X axis. Must be one of the fields valid for rec_type=currency_pairs, otherwise 400.
Field to plot on the Y axis. Must be a NUMERIC column: rate is the only one. The handler validates y_field against the same six-field set as x_field and does NOT reject a text column — but the response then cannot be encoded. y is built as a json.Number from the raw column value, and json.Number("MID") is not a valid number literal, so encoding/json fails. The failure is silent and total: apireply.WithJSON has already called WriteHeader(200), and Encoder.Encode marshals into a buffer and returns the error before writing anything. The client receives 200 with a ZERO-LENGTH body — not a truncated one, and not an error.
Field to split the data into multiple series. Must be one of the fields valid for rec_type=currency_pairs, otherwise 400. See the series cap in the operation description.
Maximum number of data points. NEITHER of the AppConfig bounds this module reads binds at its seeded value, because query.ParseQueryParameters has already normalised the value before the handler sees it:
- it pre-seeds queryMap["limit"] with constants.DefaultLimit = 10 unconditionally, so the handler's "parameter absent" branch never runs and charts.defaults.limit is never consulted. THE DEFAULT IS 10, not 100.
- parseLimitOffset clamps anything above constants.MaxLimit = 100 down to 100, so the handler's comparison against charts.generation.max_data_points cannot be true at the seeded default of 1000. ?limit=5000 answers 200 with at most 100 points; it is not rejected. That key is not dead, though: extractLimit compares the clamped value against it and generateGenericChart truncates the result set to it, so a value BELOW 100 binds — with 50, ?limit=100 answers 400 charts_m.invalid_field. The schema carries NO minimum on purpose: the server clamps rather than rejects, so a generated client that enforced minimum: 1 would refuse ?limit=0, which the API accepts. What actually happens by value: a non-numeric value is 400 common.invalid_input (parseLimitOffset); -1 is passed through as LimitSkipDataFetching and is then the ONLY value that reaches the module's own check, answering 400 charts_m.invalid_field; 0 and any other negative become 10; 1..100 are used as given; anything above 100 becomes 100.
Offset for pagination. A negative value is NOT rejected: parseLimitOffset turns it into 0 before the handler runs, so the handler's offset < 0 branch is unreachable and ?offset=-5 answers 200 with offset 0. A non-numeric value is 400 common.invalid_input. The schema carries no minimum for the same reason as limit: the server clamps rather than rejects. Paging here is not stable — see the ordering note in the operation description.
Base colour for the generated series, HSL format. A value that is not hsl(h, s%, l%) is rejected with 400 charts_m.invalid_color — the check is shape-only, the three components are not range-checked. IT HAS NO EFFECT ON A GROUPED RESPONSE. It is read by getBaseColor, which only feeds the single-series path; when group_by is present each series takes generateColor(index, total), whose signature discards the base colour entirely and returns hsl(index*360/total, 70%, 50%). Two grouped series are always hsl(0, 70%, 50%) and hsl(180, 70%, 50%).
Gap filling strategy for missing time points. Silently does nothing in two cases neither value nor status reveals:
- createChartResponse applies it only when x_field == "date". ?x_field=base_currency&fill_gaps=daily is accepted and has no effect.
- fillGapsInSeries needs a resolvable date range. It prefers search.date.gte/.lte, and falls back to the min and max of the rows actually returned — which, with the effective default limit of 10, is not the window you asked about. If neither yields a range the series is returned untouched. Points it inserts carry y: null, never 0.
Filter by rate type, e.g. SELL, BUY, MID.
Filter by base currency of the pair.
Filter by target currency of the pair.
Filter by rate value. Numeric field: the comparison suffixes (.gte, .lte, .gt, .lt) apply as they do for date.
Reserved free-text filter across every string column of the record type — base_currency, target_currency, type and source. parseReservedTextSearch routes it and BuildTextSearchWhereClause compiles it into an ILIKE-OR over those four columns, so this is the only case-insensitive filter on the route that does not name a column. Its operator set is its OWN: .eq .like .ilike .contains .start_with .end_with. A bare ?search._text=US means .like. Anything else, including .ne and the comparison suffixes, is 400 query_m.invalid_operator — the mirror image of the named fields, where .ilike and .contains are the rejected ones. An empty or whitespace-only value is accepted and the filter is dropped.
Filter by rate provider, e.g. SNB, OER, EB. When omitted, the AppConfig value fx.convert.default_source is injected before the query runs (the same default FX convert uses); if that key is empty, no source filter is applied. THE OVERRIDE ONLY WORKS FOR PLAIN EQUALITY. applyCurrencyPairsSearchDefaults inspects the bare map key "source" alone, and an operator suffix produces a different key — search.source.in=SNB,OER lands as source.in, search.source.ne=OER as source.ne — so the default is injected IN ADDITION to what you asked for. Concretely, ?search.source.ne=OER against a default of OER compiles to source IS DISTINCT FROM 'OER' AND source = 'OER', which can never match: the route answers 404 charts_m.no_data_found. Note also that plain equality on a string column compiles to =, which Postgres evaluates case-sensitively; .like, .start_with and .end_with are the operators the shared search builder compiles to ILIKE, and therefore the case-insensitive ones.
Filter by date greater than or equal to (RFC3339 format). The value is NOT validated: validateFieldValue only type-checks uuid fields, and normalizeDateSearchString hands back anything it cannot parse. A malformed value reaches Postgres, which rejects the date input syntax, and the failure surfaces as 500 charts_m.chart_generation_failed — not as a 400.
Filter by date less than or equal to (RFC3339 format). The value is NOT validated: validateFieldValue only type-checks uuid fields, and normalizeDateSearchString hands back anything it cannot parse. A malformed value reaches Postgres, which rejects the date input syntax, and the failure surfaces as 500 charts_m.chart_generation_failed — not as a 400.
Filter by date greater than (RFC3339 format). The value is NOT validated: validateFieldValue only type-checks uuid fields, and normalizeDateSearchString hands back anything it cannot parse. A malformed value reaches Postgres, which rejects the date input syntax, and the failure surfaces as 500 charts_m.chart_generation_failed — not as a 400.
Filter by date less than (RFC3339 format). The value is NOT validated: validateFieldValue only type-checks uuid fields, and normalizeDateSearchString hands back anything it cannot parse. A malformed value reaches Postgres, which rejects the date input syntax, and the failure surfaces as 500 charts_m.chart_generation_failed — not as a 400.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/v1/charts/line?rec_type=currency_pairs&x_field=date&y_field=rate&group_by=base_currency&limit=50&offset=0&color=hsl%2858%2C+70%25%2C+50%25%29&fill_gaps=daily&search.base_currency=EUR&search.target_currency=CHF&search.rate=1.05&search._text=US&search.source=SNB"[
{
"id": "string",
"color": "string",
"data": [
{
"x": "2026-08-01",
"y": 1.0543
}
]
}
]{
"status": 400,
"code": "charts_m.invalid_record_type",
"message": "Invalid chart record type",
"details": [
{}
],
"class": "validation",
"retryable": false
}{
"status": 400,
"code": "charts_m.invalid_record_type",
"message": "Invalid chart record type",
"details": [
{}
],
"class": "validation",
"retryable": false
}{
"status": 400,
"code": "charts_m.invalid_record_type",
"message": "Invalid chart record type",
"details": [
{}
],
"class": "validation",
"retryable": false
}{
"status": 400,
"code": "charts_m.invalid_record_type",
"message": "Invalid chart record type",
"details": [
{}
],
"class": "validation",
"retryable": false
}{
"status": 429,
"message": "rate limit exceeded",
"code": "rate_limits_m.exceeded",
"class": "temporary",
"retryable": true
}{
"status": 400,
"code": "charts_m.invalid_record_type",
"message": "Invalid chart record type",
"details": [
{}
],
"class": "validation",
"retryable": false
}{
"overall_status": "unhealthy",
"message": "Service is shutting down",
"timestamp": "2026-08-27T15:04:05Z"
}