-
Notifications
You must be signed in to change notification settings - Fork 0
Matlab api tool v2 #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b41fc49
de2e39a
619d673
df290a2
12e8c23
bbaafbd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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 | ||
| 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 |
| 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 | ||
petersenpeter marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
| 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
|
||
| 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 | ||
Uh oh!
There was an error while loading. Please reload this page.