Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
798 changes: 798 additions & 0 deletions +brainstem/BrainstemTests.m

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions +brainstem/delete.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
function output = delete(id, model, varargin)
% DELETE Delete a record from a BrainSTEM API endpoint.
%
% output = delete(ID, MODEL)
% output = delete(ID, MODEL, 'portal', 'private', 'settings', settings)
%
% Parameters:
% id - UUID string of the record to delete (required)
% model - Model name, e.g. 'session', 'project', 'subject' (required)
% portal - 'private' (default) or 'public'
% app - App name; auto-detected from model if omitted
% settings - Settings struct (auto-resolved from BRAINSTEM_TOKEN env var or token cache)
%
% Example:
% brainstem.delete('<session_uuid>', 'session');

p = inputParser;
addParameter(p,'portal', 'private', @ischar);
addParameter(p,'app', '', @ischar);
addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x));
parse(p, varargin{:})
parameters = p.Results;
if isempty(parameters.settings)
parameters.settings = brainstem_get_settings();
end

% Guard against deleting the collection endpoint by accident
if isempty(id)
error('BrainSTEM:delete', 'id must be a non-empty UUID string.');
end
if isempty(regexp(id, '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', 'once'))
error('BrainSTEM:delete', ...
'id must be a valid UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx), got: %s', id);
end

if isempty(parameters.app)
parameters.app = brainstem.get_app_from_model(model);
end

if isempty(parameters.settings.token)
error('BrainSTEM:delete', ...
'A token is required to delete records. Set BRAINSTEM_TOKEN or call brainstem.get_token().');
end

options = weboptions( ...
'HeaderFields', {'Authorization', ['Bearer ' parameters.settings.token]}, ...
'ContentType', 'json', ...
'Timeout', 30, ...
'RequestMethod','delete');

endpoint = brainstem_build_url(parameters.settings.url, parameters.portal, ...
parameters.app, model, id);
try
output = webread(endpoint, options);
catch ME
% A 204 No Content response is a successful delete; webread raises an
% error for non-200 responses, so tolerate the expected 204 case.
if contains(ME.message, '204')
output = struct('status', 'deleted', 'id', id);
else
error('BrainSTEM:delete', 'API error deleting %s %s: %s', ...
model, id, brainstem_parse_api_error(ME));
end
end
25 changes: 25 additions & 0 deletions +brainstem/get_app_from_model.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
function app = get_app_from_model(modelname)

switch modelname
case {'project','subject','session','collection','cohort', ...
'breeding','projectmembershipinvitation','projectgroupmembershipinvitation'}
app = 'stem';
case {'procedure','equipment','consumablestock','behavior','dataacquisition','manipulation','procedurelog','subjectlog'}
app = 'modules';
case {'behavioralassay','datastorage','setup','inventory','license','protocol'}
app = 'personal_attributes';
case {'consumable','hardwaredevice','supplier'}
app = 'resources';
case {'brainregion','setuptype','species','strain','behavioralcategory','behavioralparadigm','regulatoryauthority'}
app = 'taxonomies';
case {'journal','publication'}
app = 'dissemination';
case {'user','laboratory','groupmembershipinvitation','groupmembershiprequest'}
app = 'users';
case {'group'}
% Groups live under the 'auth' app namespace in the API URL, even
% though they are documented under the Users section of the API docs.
app = 'auth';
otherwise
app = '';
end
178 changes: 178 additions & 0 deletions +brainstem/get_token.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
function token = get_token(url)
% GET_TOKEN Obtain a Personal Access Token from the BrainSTEM server.
%
% Uses the browser-based device authorization flow:
% 1. POST /api/auth/device/ → {session_id, device_code, verification_uri_complete, expires_in}
% 2. Opens verification_uri_complete in the default browser so the user
% can log in (including 2FA if enabled).
% 3. Polls POST /api/auth/device/token/ with {device_code} until the
% user completes the web login, then returns the issued token.
%
% Fallback: if the server does not support the device flow (older
% deployments), the user is prompted to paste a Personal Access Token
% obtained from <url>private/users/tokens/.
%
% Parameters:
% url - Server base URL (default: https://www.brainstem.org/)
if nargin < 1 || isempty(url)
url = 'https://www.brainstem.org/';
end

% Attempt device authorization flow
options_post = weboptions( ...
'MediaType', 'application/json', ...
'ContentType', 'json', ...
'RequestMethod','post');
try
resp = webwrite([url 'api/auth/device/'], struct(), options_post);
catch
% Server does not support device flow — prompt for manual PAT entry
token = manual_pat_flow_(url);
if ~isempty(token)
save_token_(url, token);
end
return
end

token = device_flow_(url, resp);

if ~isempty(token)
save_token_(url, token);
end
end

% -------------------------------------------------------------------------
function token = device_flow_(url, resp)
device_code = resp.device_code;
auth_url = resp.verification_uri_complete;
expires_in = 300;
if isfield(resp, 'expires_in'), expires_in = resp.expires_in; end

fprintf('\nAuthenticating with BrainSTEM...\n');
fprintf('Opening login page in your browser.\n');
fprintf('If the browser does not open automatically, visit:\n %s\n\n', auth_url);
try
web(char(auth_url), '-browser');
catch
% Headless or web() unavailable — user must open manually
end

fprintf('Waiting for authentication (timeout: %d s) ...', expires_in);
options_poll = weboptions( ...
'MediaType', 'application/json', ...
'ContentType', 'json', ...
'RequestMethod','post');
poll_url = [url 'api/auth/device/token/'];
poll_body = struct('device_code', device_code);
deadline = now + expires_in / 86400;
token = '';
while now < deadline
pause(3);
fprintf('.');
try
r = webwrite(poll_url, poll_body, options_poll);
catch
continue
end
if isfield(r, 'status') && strcmp(r.status, 'success')
token = r.token;
fprintf('\nAuthenticated successfully.\n\n');
return
end
if isfield(r, 'error')
switch r.error
case 'expired_token'
fprintf('\n');
error('BrainSTEM:deviceAuthExpired', ...
'Authentication request expired. Please call get_token() again.');
case 'access_denied'
fprintf('\n');
error('BrainSTEM:deviceAuthDenied', ...
'Access denied. Please try again.');
otherwise
fprintf('\n');
error('BrainSTEM:deviceAuthError', ...
'Unexpected error: %s', r.error);
end
end
% authorization_pending → keep waiting
end
fprintf('\n');
error('BrainSTEM:deviceAuthTimeout', ...
'Authentication timed out after %d seconds. Please try again.', expires_in);
end

% -------------------------------------------------------------------------
function token = manual_pat_flow_(url)
tokens_url = [url 'private/users/tokens/'];
fprintf(['\nThis server does not support the device authorization flow.\n' ...
'To authenticate:\n' ...
' 1. Open: %s\n' ...
' 2. Create a Personal Access Token\n' ...
' 3. Copy the token and paste it below\n\n'], tokens_url);
answer = inputdlg( ...
{sprintf('Personal Access Token\n(from %s)', tokens_url)}, ...
'BrainSTEM Authentication', [1 72]);
if isempty(answer) || isempty(strtrim(answer{1}))
token = '';
else
token = strtrim(answer{1});
end
end

% -------------------------------------------------------------------------
function save_token_(url, token)
auth_path = fullfile(prefdir, 'brainstem_authentication.mat');
expires_at = now + 365; % Personal Access Tokens are valid for ~1 year

if exist(auth_path, 'file')
existing = load(auth_path, 'authentication');
tbl = existing.authentication;
% Upgrade old table schemas that are missing new columns
if ~ismember('token_type', tbl.Properties.VariableNames)
tbl.token_type = repmat({'personal'}, height(tbl), 1);
end
if ~ismember('refresh_tokens', tbl.Properties.VariableNames)
tbl.refresh_tokens = repmat({''}, height(tbl), 1);
end
if ~ismember('usernames', tbl.Properties.VariableNames)
tbl.usernames = repmat({''}, height(tbl), 1);
end
if ~ismember('saved_at', tbl.Properties.VariableNames)
tbl.saved_at = repmat({now}, height(tbl), 1);
end
if ~ismember('expires_at', tbl.Properties.VariableNames)
if ismember('saved_at', tbl.Properties.VariableNames)
tbl.expires_at = cellfun(@(t) t + 365, tbl.saved_at, 'UniformOutput', false);
else
tbl.expires_at = repmat({now + 365}, height(tbl), 1);
end
end
% Reorder tbl to canonical schema, then build new_row explicitly
canonical_vars = {'tokens','usernames','urls','saved_at','token_type','refresh_tokens','expires_at'};
tbl = tbl(:, canonical_vars);
new_row = table({token}, {''}, {url}, {now}, {'personal'}, {''}, {expires_at}, ...
'VariableNames', canonical_vars);

idx = find(strcmp(url, tbl.urls));
if ~isempty(idx)
tbl.tokens{idx} = token;
tbl.usernames{idx} = '';
tbl.saved_at{idx} = now;
tbl.token_type{idx} = 'personal';
tbl.refresh_tokens{idx} = '';
tbl.expires_at{idx} = expires_at;
authentication = tbl; %#ok<NASGU>
else
authentication = [tbl; new_row]; %#ok<NASGU>
end
else
new_row = table({token}, {''}, {url}, {now}, {'personal'}, {''}, {expires_at}, ...
'VariableNames', {'tokens','usernames','urls','saved_at', ...
'token_type','refresh_tokens','expires_at'});
authentication = new_row; %#ok<NASGU>
end

save(auth_path, 'authentication');
fprintf('Token saved to %s\n', auth_path);
end
125 changes: 125 additions & 0 deletions +brainstem/load.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
function output = load(varargin)
% LOAD Retrieve records from a BrainSTEM API endpoint.
%
% output = load('model', MODEL) returns records for MODEL.
%
% Parameters:
% model - Model name, e.g. 'session', 'project', 'subject' (default: 'session')
% portal - 'private' (default) or 'public'
% app - App name; auto-detected from model if omitted
% id - UUID string; fetches a single record at /<model>/<id>/
% filter - Cell array of {field, value} pairs, e.g. {'name.icontains','Rat'}
% sort - Cell array of field names; prefix '-' for descending
% include - Cell array of relational fields to embed
% limit - Max records per page (API default: 20, max: 100)
% offset - Records to skip (for manual paging)
% load_all - true to auto-follow pagination and return all records (default: false)
% settings - Settings struct (auto-resolved from BRAINSTEM_TOKEN env var or token cache)
%
% Examples:
% output = brainstem.load('model','session');
% output = brainstem.load('model','session','id','<session_uuid>');
% output = brainstem.load('model','session','filter',{'name.icontains','Rat'},'sort',{'-name'});
% output = brainstem.load('model','session','include',{'behaviors','manipulations'});
% output = brainstem.load('model','session','load_all',true);
% output = brainstem.load('model','project','portal','public');

p = inputParser;
addParameter(p,'portal', 'private', @ischar);
addParameter(p,'app', '', @ischar);
addParameter(p,'model', 'session', @ischar);
addParameter(p,'id', '', @ischar);
addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x));
addParameter(p,'filter', {}, @iscell);
addParameter(p,'sort', {}, @iscell);
addParameter(p,'include', {}, @iscell);
addParameter(p,'limit', [], @(x) isempty(x) || (isnumeric(x) && isscalar(x)));
addParameter(p,'offset', 0, @(x) isnumeric(x) && isscalar(x));
addParameter(p,'load_all',false, @islogical);
parse(p, varargin{:})
parameters = p.Results;
if isempty(parameters.settings)
parameters.settings = brainstem_get_settings();
end

% Validate UUID format when an id is supplied
if ~isempty(parameters.id) && isempty(regexp(parameters.id, ...
'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', 'once'))
error('BrainSTEM:load', ...
'id must be a valid UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx), got: %s', parameters.id);
end

if isempty(parameters.app)
parameters.app = brainstem.get_app_from_model(parameters.model);
end

% Auth header — omit when token is empty (e.g. public portal requests)
if isempty(parameters.settings.token)
options = weboptions( ...
'ContentType', 'json', ...
'ArrayFormat', 'json', ...
'Timeout', 30, ...
'RequestMethod','get');
else
options = weboptions( ...
'HeaderFields', {'Authorization', ['Bearer ' parameters.settings.token]}, ...
'ContentType', 'json', ...
'ArrayFormat', 'json', ...
'Timeout', 30, ...
'RequestMethod','get');
end

% Single-record fetch by id
if ~isempty(parameters.id)
url = brainstem_build_url(parameters.settings.url, parameters.portal, ...
parameters.app, parameters.model, parameters.id);
try
output = webread(url, options);
catch ME
error('BrainSTEM:load', 'API error fetching %s: %s', url, brainstem_parse_api_error(ME));
end
return
end

% Collection fetch (with optional pagination)
qs = brainstem_build_query_string(parameters.filter, parameters.sort, ...
parameters.include, parameters.limit, parameters.offset);
url = [brainstem_build_url(parameters.settings.url, parameters.portal, ...
parameters.app, parameters.model), qs];
try
output = webread(url, options);
catch ME
error('BrainSTEM:load', 'API error fetching %s: %s', url, brainstem_parse_api_error(ME));
end
output = brainstem_normalize_list_response(output);

% Auto-paginate: keep fetching while there is a 'next' URL
if parameters.load_all
% Detect the data key dynamically: it is the response field whose
% value is a struct array (i.e. not the scalar metadata fields).
metadata_keys = {'count','next','previous'};
all_keys = fieldnames(output);
data_keys = all_keys(~ismember(all_keys, metadata_keys));
if ~isempty(data_keys)
model_key = data_keys{1}; % e.g. 'sessions', 'dataacquisitions'
else
model_key = '';
end
Comment on lines +98 to +107
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-pagination selects the first non-metadata fieldname as the data key. When include[] is used, responses may contain multiple data arrays (e.g., sessions + behaviors + manipulations), and alphabetical field order can cause the wrong key to be appended, dropping/under-fetching the primary model. Prefer deriving the primary key from the requested model (e.g., sessions for model=session) and/or appending all non-metadata keys that are present in both pages.

Copilot uses AI. Check for mistakes.
while isfield(output, 'next') && ~isempty(output.next)
try
next_page = webread(output.next, options);
catch ME
error('BrainSTEM:load', 'API error fetching next page: %s', brainstem_parse_api_error(ME));
end
next_page = brainstem_normalize_list_response(next_page);
% Append records
if ~isempty(model_key) && isfield(output, model_key) && isfield(next_page, model_key)
output.(model_key) = [output.(model_key), next_page.(model_key)];
end
if isfield(next_page, 'next')
output.next = next_page.next;
else
output.next = [];
end
end
end
Loading