From b41fc495adc088c2ba5216365b354cdbff14670e Mon Sep 17 00:00:00 2001 From: "Peter C. Petersen" Date: Sun, 29 Mar 2026 14:59:28 +0200 Subject: [PATCH 1/6] Add +brainstem API client, loaders, and tests Introduce a +brainstem MATLAB package implementing the BrainSTEM API client and helpers: BrainstemClient, load_model, get_token, delete_model, numerous load_.m convenience wrappers, and private helper functions (build_url, build_query_string, apply_field_filters, parse_api_error). Add comprehensive unit and integration tests (+brainstem/BrainstemTests.m) and update README and tutorial. Remove legacy top-level files replaced by the new package structure. --- +brainstem/BrainstemTests.m | 605 ++++++++++++++++++ +brainstem/delete_model.m | 46 ++ .../get_app_from_model.m | 9 +- +brainstem/get_token.m | 115 ++++ +brainstem/load_behavior.m | 29 + +brainstem/load_cohort.m | 30 + +brainstem/load_collection.m | 30 + +brainstem/load_consumablestock.m | 31 + +brainstem/load_dataacquisition.m | 29 + +brainstem/load_equipment.m | 34 + +brainstem/load_manipulation.m | 29 + +brainstem/load_model.m | 105 +++ +brainstem/load_procedure.m | 29 + +brainstem/load_procedurelog.m | 31 + +brainstem/load_project.m | 41 ++ +brainstem/load_session.m | 40 ++ +brainstem/load_settings.m | 70 ++ load_subject.m => +brainstem/load_subject.m | 36 +- +brainstem/load_subjectlog.m | 37 ++ passdlg.m => +brainstem/passdlg.m | 0 passfield.m => +brainstem/passfield.m | 0 .../private/brainstem_apply_field_filters.m | 32 + .../private/brainstem_build_query_string.m | 49 ++ +brainstem/private/brainstem_build_url.m | 16 + .../private/brainstem_parse_api_error.m | 63 ++ +brainstem/refresh_access_token.m | 58 ++ +brainstem/save_model.m | 81 +++ BrainstemClient.m | 382 +++++++++++ Contents.m | 37 ++ README.md | 153 +++-- brainstem_api_tutorial.m | 150 +++-- brainstem_local_storage.m | 4 - get_token.m | 52 -- load_model.m | 72 --- load_project.m | 52 -- load_session.m | 51 -- load_settings.m | 23 - save_model.m | 41 -- set_basic_authorization.m | 25 - 39 files changed, 2271 insertions(+), 446 deletions(-) create mode 100644 +brainstem/BrainstemTests.m create mode 100644 +brainstem/delete_model.m rename get_app_from_model.m => +brainstem/get_app_from_model.m (55%) create mode 100644 +brainstem/get_token.m create mode 100644 +brainstem/load_behavior.m create mode 100644 +brainstem/load_cohort.m create mode 100644 +brainstem/load_collection.m create mode 100644 +brainstem/load_consumablestock.m create mode 100644 +brainstem/load_dataacquisition.m create mode 100644 +brainstem/load_equipment.m create mode 100644 +brainstem/load_manipulation.m create mode 100644 +brainstem/load_model.m create mode 100644 +brainstem/load_procedure.m create mode 100644 +brainstem/load_procedurelog.m create mode 100644 +brainstem/load_project.m create mode 100644 +brainstem/load_session.m create mode 100644 +brainstem/load_settings.m rename load_subject.m => +brainstem/load_subject.m (52%) create mode 100644 +brainstem/load_subjectlog.m rename passdlg.m => +brainstem/passdlg.m (100%) rename passfield.m => +brainstem/passfield.m (100%) create mode 100644 +brainstem/private/brainstem_apply_field_filters.m create mode 100644 +brainstem/private/brainstem_build_query_string.m create mode 100644 +brainstem/private/brainstem_build_url.m create mode 100644 +brainstem/private/brainstem_parse_api_error.m create mode 100644 +brainstem/refresh_access_token.m create mode 100644 +brainstem/save_model.m create mode 100644 BrainstemClient.m create mode 100644 Contents.m delete mode 100644 brainstem_local_storage.m delete mode 100644 get_token.m delete mode 100644 load_model.m delete mode 100644 load_project.m delete mode 100644 load_session.m delete mode 100644 load_settings.m delete mode 100644 save_model.m delete mode 100644 set_basic_authorization.m diff --git a/+brainstem/BrainstemTests.m b/+brainstem/BrainstemTests.m new file mode 100644 index 0000000..50ea47c --- /dev/null +++ b/+brainstem/BrainstemTests.m @@ -0,0 +1,605 @@ +classdef BrainstemTests < matlab.unittest.TestCase +% BRAINSTEMTESTS Unit and integration tests for the +brainstem package. +% +% Run all offline tests (no network/token required): +% results = runtests('brainstem.BrainstemTests', 'Tag', 'offline'); +% disp(results) +% +% Run all tests including unauthenticated network tests: +% results = runtests('brainstem.BrainstemTests'); +% +% Run authenticated tests (requires BRAINSTEM_TOKEN to be set first): +% setenv('BRAINSTEM_TOKEN', ''); +% results = runtests('brainstem.BrainstemTests', 'Tag', 'authenticated'); +% +% Run with verbose output: +% runner = matlab.unittest.TestRunner.withTextOutput; +% results = runner.run(brainstem.BrainstemTests); + + properties (Constant) + % Set BRAINSTEM_TOKEN in your environment before running authenticated + % tests. The value is read once when the class is first loaded. + BASE_URL = 'https://www.brainstem.org/' + TOKEN = getenv('BRAINSTEM_TOKEN') + end + + % ====================================================================== + methods (Test, TestTags = {'offline'}) + % No network connection or token required. + % ====================================================================== + + % ------------------------------------------------------------------ + % get_app_from_model + % ------------------------------------------------------------------ + function testAppFromModelSession(tc) + tc.verifyEqual(get_app_from_model('session'), 'stem'); + end + + function testAppFromModelProject(tc) + tc.verifyEqual(get_app_from_model('project'), 'stem'); + end + + function testAppFromModelBreeding(tc) + tc.verifyEqual(get_app_from_model('breeding'), 'stem'); + end + + function testAppFromModelSubject(tc) + tc.verifyEqual(get_app_from_model('subject'), 'stem'); + end + + function testAppFromModelBehavior(tc) + tc.verifyEqual(get_app_from_model('behavior'), 'modules'); + end + + function testAppFromModelDataAcquisition(tc) + tc.verifyEqual(get_app_from_model('dataacquisition'), 'modules'); + end + + function testAppFromModelManipulation(tc) + tc.verifyEqual(get_app_from_model('manipulation'), 'modules'); + end + + function testAppFromModelSetup(tc) + tc.verifyEqual(get_app_from_model('setup'), 'personal_attributes'); + end + + function testAppFromModelConsumable(tc) + tc.verifyEqual(get_app_from_model('consumable'), 'resources'); + end + + function testAppFromModelSpecies(tc) + tc.verifyEqual(get_app_from_model('species'), 'taxonomies'); + end + + function testAppFromModelPublication(tc) + tc.verifyEqual(get_app_from_model('publication'), 'dissemination'); + end + + function testAppFromModelUser(tc) + tc.verifyEqual(get_app_from_model('user'), 'users'); + end + + function testAppFromModelUnknown(tc) + tc.verifyEmpty(get_app_from_model('nonexistent_model_xyz')); + end + + % stem (remaining) + function testAppFromModelCollection(tc) + tc.verifyEqual(get_app_from_model('collection'), 'stem'); + end + function testAppFromModelCohort(tc) + tc.verifyEqual(get_app_from_model('cohort'), 'stem'); + end + + % modules (remaining) + function testAppFromModelProcedure(tc) + tc.verifyEqual(get_app_from_model('procedure'), 'modules'); + end + function testAppFromModelEquipment(tc) + tc.verifyEqual(get_app_from_model('equipment'), 'modules'); + end + function testAppFromModelConsumableStock(tc) + tc.verifyEqual(get_app_from_model('consumablestock'), 'modules'); + end + function testAppFromModelProcedureLog(tc) + tc.verifyEqual(get_app_from_model('procedurelog'), 'modules'); + end + function testAppFromModelSubjectLog(tc) + tc.verifyEqual(get_app_from_model('subjectlog'), 'modules'); + end + + % personal_attributes + function testAppFromModelBehavioralAssay(tc) + tc.verifyEqual(get_app_from_model('behavioralassay'), 'personal_attributes'); + end + function testAppFromModelDataStorage(tc) + tc.verifyEqual(get_app_from_model('datastorage'), 'personal_attributes'); + end + function testAppFromModelInventory(tc) + tc.verifyEqual(get_app_from_model('inventory'), 'personal_attributes'); + end + function testAppFromModelProtocol(tc) + tc.verifyEqual(get_app_from_model('protocol'), 'personal_attributes'); + end + + % resources + function testAppFromModelHardwareDevice(tc) + tc.verifyEqual(get_app_from_model('hardwaredevice'), 'resources'); + end + function testAppFromModelSupplier(tc) + tc.verifyEqual(get_app_from_model('supplier'), 'resources'); + end + + % taxonomies + function testAppFromModelStrain(tc) + tc.verifyEqual(get_app_from_model('strain'), 'taxonomies'); + end + function testAppFromModelBrainRegion(tc) + tc.verifyEqual(get_app_from_model('brainregion'), 'taxonomies'); + end + function testAppFromModelSetupType(tc) + tc.verifyEqual(get_app_from_model('setuptype'), 'taxonomies'); + end + function testAppFromModelBehavioralParadigm(tc) + tc.verifyEqual(get_app_from_model('behavioralparadigm'), 'taxonomies'); + end + function testAppFromModelRegulatoryAuthority(tc) + tc.verifyEqual(get_app_from_model('regulatoryauthority'), 'taxonomies'); + end + + % dissemination + function testAppFromModelJournal(tc) + tc.verifyEqual(get_app_from_model('journal'), 'dissemination'); + end + + % users + function testAppFromModelLaboratory(tc) + tc.verifyEqual(get_app_from_model('laboratory'), 'users'); + end + function testAppFromModelGroupMembershipInvitation(tc) + tc.verifyEqual(get_app_from_model('groupmembershipinvitation'), 'users'); + end + function testAppFromModelGroupMembershipRequest(tc) + tc.verifyEqual(get_app_from_model('groupmembershiprequest'), 'users'); + end + + % auth + function testAppFromModelGroup(tc) + tc.verifyEqual(get_app_from_model('group'), 'auth'); + end + + % ------------------------------------------------------------------ + % brainstem_build_url (package-private helper) + % ------------------------------------------------------------------ + function testBuildUrlCollection(tc) + got = brainstem_build_url(tc.BASE_URL, 'private', 'stem', 'session', ''); + tc.verifyEqual(got, [tc.BASE_URL, 'api/private/stem/session/']); + end + + function testBuildUrlWithId(tc) + got = brainstem_build_url(tc.BASE_URL, 'private', 'stem', 'session', 'abc-123'); + tc.verifyEqual(got, [tc.BASE_URL, 'api/private/stem/session/abc-123/']); + end + + function testBuildUrlPublicPortal(tc) + got = brainstem_build_url(tc.BASE_URL, 'public', 'stem', 'project', ''); + tc.verifyEqual(got, [tc.BASE_URL, 'api/public/stem/project/']); + end + + function testBuildUrlTrailingSlashOnBase(tc) + % Base URL without trailing slash should still produce valid URL + got = brainstem_build_url('https://www.brainstem.org', 'private', 'stem', 'session', ''); + tc.verifyTrue(endsWith_(got, 'session/')); + end + + % ------------------------------------------------------------------ + % brainstem_build_query_string (package-private helper) + % ------------------------------------------------------------------ + function testQueryStringEmpty(tc) + qs = brainstem_build_query_string({}, {}, {}, [], 0); + tc.verifyEmpty(qs); + end + + function testQueryStringFilter(tc) + qs = brainstem_build_query_string({'name', 'rat'}, {}, {}, [], 0); + tc.verifyTrue(contains(qs, 'filter{name}=rat'), qs); + end + + function testQueryStringMultipleFilters(tc) + qs = brainstem_build_query_string({'name', 'rat', 'sex', 'M'}, {}, {}, [], 0); + tc.verifyTrue(contains(qs, 'filter{name}=rat'), qs); + tc.verifyTrue(contains(qs, 'filter{sex}=M'), qs); + end + + function testQueryStringSortDescending(tc) + qs = brainstem_build_query_string({}, {'-name'}, {}, [], 0); + tc.verifyTrue(contains(qs, 'sort[]=-name'), qs); + end + + function testQueryStringSortAscending(tc) + qs = brainstem_build_query_string({}, {'name'}, {}, [], 0); + tc.verifyTrue(contains(qs, 'sort[]=name'), qs); + end + + function testQueryStringInclude(tc) + qs = brainstem_build_query_string({}, {}, {'behaviors'}, [], 0); + tc.verifyTrue(contains(qs, 'include[]=behaviors.*'), qs); + end + + function testQueryStringLimit(tc) + qs = brainstem_build_query_string({}, {}, {}, 50, 0); + tc.verifyTrue(contains(qs, 'limit=50'), qs); + end + + function testQueryStringOffset(tc) + qs = brainstem_build_query_string({}, {}, {}, [], 20); + tc.verifyTrue(contains(qs, 'offset=20'), qs); + end + + function testQueryStringStartsWithQuestionMark(tc) + qs = brainstem_build_query_string({'name','x'}, {}, {}, [], 0); + tc.verifyTrue(startsWith_(qs, '?'), qs); + end + + % ------------------------------------------------------------------ + % brainstem_apply_field_filters (package-private helper) + % ------------------------------------------------------------------ + function testFieldFiltersEmptyWhenNoValues(tc) + % All extra fields empty → filter unchanged + p.filter = {}; + p.name = ''; + p.id = ''; + result = brainstem_apply_field_filters(p, {'id','name'}, ... + {'id','id'; 'name','name.icontains'}); + tc.verifyEmpty(result); + end + + function testFieldFiltersAppendsMapping(tc) + p.filter = {}; + p.name = 'Rat'; + p.id = ''; + result = brainstem_apply_field_filters(p, {'id','name'}, ... + {'id','id'; 'name','name.icontains'}); + tc.verifyEqual(result, {'name.icontains', 'Rat'}); + end + + function testFieldFiltersMultipleFields(tc) + p.filter = {}; + p.name = 'Rat'; + p.subject = 'uuid-123'; + result = brainstem_apply_field_filters(p, {'name','subject'}, ... + {'name','name.icontains'; 'subject','subject.id'}); + tc.verifyEqual(numel(result), 4); % 2 pairs × 2 elements + tc.verifyTrue(any(strcmp(result(:,1), 'name.icontains'))); + tc.verifyTrue(any(strcmp(result(:,2), 'Rat'))); + tc.verifyTrue(any(strcmp(result(:,1), 'subject.id'))); + end + + function testFieldFiltersPreservesExistingFilter(tc) + p.filter = {'type', 'Weighing'}; + p.name = 'Rat'; + p.id = ''; + result = brainstem_apply_field_filters(p, {'id','name'}, ... + {'id','id'; 'name','name.icontains'}); + % Original filter row must still be present + tc.verifyTrue(any(strcmp(result(:,1), 'type'))); + tc.verifyTrue(any(strcmp(result(:,1), 'name.icontains'))); + end + + function testFieldFiltersDefaultsToIcontains(tc) + % Fields not in filter_map get .icontains key + p.filter = {}; + p.description = 'baseline'; + result = brainstem_apply_field_filters(p, {'description'}, ... + containers.Map.empty); % empty map forces default + % Can't easily pass empty Map — test via a filter_map that + % does not include 'description': + result2 = brainstem_apply_field_filters(p, {'description'}, ... + {'name','name.icontains'}); + tc.verifyTrue(any(strcmp(result2(:,1), 'description.icontains'))); + end + + % ------------------------------------------------------------------ + % BrainstemClient constructor (no login attempted — token supplied) + % ------------------------------------------------------------------ + function testClientConstructorWithToken(tc) + client = BrainstemClient('token', 'mytoken123'); + tc.verifyEqual(client.token, 'mytoken123'); + tc.verifyEqual(client.token_type, 'personal'); + tc.verifyEqual(client.url, tc.BASE_URL); + end + + function testClientConstructorTokenTypeShortlived(tc) + client = BrainstemClient('token', 'tok', 'token_type', 'shortlived'); + tc.verifyEqual(client.token_type, 'shortlived'); + end + + function testClientConstructorTokenTypeCaseInsensitive(tc) + % 'Shortlived' and 'PERSONAL' both accepted + c1 = BrainstemClient('token', 'tok', 'token_type', 'Shortlived'); + tc.verifyEqual(c1.token_type, 'shortlived'); + c2 = BrainstemClient('token', 'tok', 'token_type', 'PERSONAL'); + tc.verifyEqual(c2.token_type, 'personal'); + end + + function testClientConstructorInvalidTokenTypeErrors(tc) + tc.verifyError( ... + @() BrainstemClient('token', 'tok', 'token_type', 'badtype'), ... + 'MATLAB:InputParser:ArgumentFailedValidation'); + end + + function testClientDispRunsWithoutError(tc) + client = BrainstemClient('token', 'mytoken123'); + % disp should not throw + tc.verifyWarningFree(@() disp(client)); + end + + function testClientCustomUrl(tc) + client = BrainstemClient('token', 'tok', 'url', 'http://localhost:8000/'); + tc.verifyEqual(client.url, 'http://localhost:8000/'); + end + + % ------------------------------------------------------------------ + % brainstem_parse_api_error — nested struct body + % ------------------------------------------------------------------ + function testParseApiErrorNestedStruct(tc) + me = MException('test:err', ... + '{"entries": {"date_time": ["This field is required."]}}'); + msg = brainstem_parse_api_error(me); + tc.verifyTrue(contains(msg, 'entries'), msg); + end + + function testParseApiErrorNonJsonObject(tc) + % Array at root is not a JSON object — should fall back to raw + raw = 'Server Error (500)'; + me = MException('test:err', raw); + msg = brainstem_parse_api_error(me); + tc.verifyEqual(msg, raw); + end + + % ------------------------------------------------------------------ + % get_token — input validation (no network call made) + % ------------------------------------------------------------------ + function testGetTokenRejectsInvalidType(tc) + % Should throw before any network call since token_type is invalid + tc.verifyError( ... + @() get_token(tc.BASE_URL, 'u@u.com', 'pass', 'badtype'), ... + 'BrainSTEM:getToken'); + end + + function testGetTokenAcceptsPersonal(tc) + % Valid token_type='personal' passes validation + % (will fail at network — that's expected, we just test the guard) + try + get_token(tc.BASE_URL, 'bad@user.com', 'wrongpass', 'personal'); + catch ME + tc.verifyNotEqual(ME.identifier, 'BrainSTEM:getToken', ... + 'Should not throw a getToken validation error for ''personal'''); + end + end + + function testGetTokenAcceptsShortlived(tc) + % Valid token_type='shortlived' passes validation + try + get_token(tc.BASE_URL, 'bad@user.com', 'wrongpass', 'shortlived'); + catch ME + tc.verifyNotEqual(ME.identifier, 'BrainSTEM:getToken', ... + 'Should not throw a getToken validation error for ''shortlived'''); + end + end + + % ------------------------------------------------------------------ + % save_model validation (no network needed) + % ------------------------------------------------------------------ + function testSaveModelPatchWithoutIdErrors(tc) + % PATCH without id in data must throw immediately, before any + % network call. + settings = struct('url', tc.BASE_URL, 'token', 'fake', 'storage', {{}}); + tc.verifyError( ... + @() save_model('data', struct('description', 'x'), ... + 'model', 'session', ... + 'method', 'patch', ... + 'settings', settings), ... + 'BrainSTEM:saveModel'); + end + + % ------------------------------------------------------------------ + % brainstem_parse_api_error (package-private helper) + % ------------------------------------------------------------------ + function testParseApiErrorExtractsJsonFields(tc) + me = MException('test:err', ... + '400 Bad Request {"name": ["This field is required."]}'); + msg = brainstem_parse_api_error(me); + tc.verifyTrue(contains(msg, 'name'), msg); + tc.verifyTrue(contains(msg, 'This field is required.'), msg); + end + + function testParseApiErrorMultipleFields(tc) + me = MException('test:err', ... + '{"name": ["blank"], "session": ["required"]}'); + msg = brainstem_parse_api_error(me); + tc.verifyTrue(contains(msg, 'name'), msg); + tc.verifyTrue(contains(msg, 'session'), msg); + end + + function testParseApiErrorFallsBackToRaw(tc) + raw = 'Some plain-text server error with no JSON'; + me = MException('test:err', raw); + msg = brainstem_parse_api_error(me); + tc.verifyEqual(msg, raw); + end + + end % offline tests + + % ====================================================================== + methods (Test, TestTags = {'network'}) + % Requires internet access but no authentication token. + % ====================================================================== + + function testLoadPublicProjects(tc) + settings = struct('url', tc.BASE_URL, 'token', '', 'storage', {{}}); + out = load_model('model', 'project', 'portal', 'public', ... + 'settings', settings, 'limit', 5); + tc.verifyTrue(isstruct(out)); + tc.verifyTrue(isfield(out, 'projects') || isfield(out, 'count'), ... + 'Response should have a projects or count field'); + end + + function testLoadPublicProjectsStructure(tc) + settings = struct('url', tc.BASE_URL, 'token', '', 'storage', {{}}); + out = load_model('model', 'project', 'portal', 'public', ... + 'settings', settings, 'limit', 1); + if isfield(out, 'projects') && ~isempty(out.projects) + tc.verifyTrue(isfield(out.projects(1), 'id'), ... + 'Project record should have an id field'); + tc.verifyTrue(isfield(out.projects(1), 'name'), ... + 'Project record should have a name field'); + end + end + + end % network tests + + % ====================================================================== + methods (Test, TestTags = {'authenticated'}) + % Requires BRAINSTEM_TOKEN to be set before running. + % ====================================================================== + + function testLoadSessions(tc) + tc.assumeNotEmpty(tc.TOKEN, ... + 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); + settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); + out = load_model('model', 'session', 'settings', settings, 'limit', 5); + tc.verifyTrue(isstruct(out)); + tc.verifyTrue(isfield(out, 'sessions') || isfield(out, 'count')); + end + + function testLoadSubjects(tc) + tc.assumeNotEmpty(tc.TOKEN, ... + 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); + settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); + out = load_model('model', 'subject', 'settings', settings, 'limit', 5); + tc.verifyTrue(isstruct(out)); + end + + function testLoadProjects(tc) + tc.assumeNotEmpty(tc.TOKEN, ... + 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); + settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); + out = load_model('model', 'project', 'settings', settings, 'limit', 5); + tc.verifyTrue(isstruct(out)); + end + + function testLoadModelById(tc) + tc.assumeNotEmpty(tc.TOKEN, ... + 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); + settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); + % First fetch a list to get a real id + out = load_model('model', 'project', 'settings', settings, 'limit', 1); + if isfield(out, 'projects') && ~isempty(out.projects) + id = out.projects(1).id; + rec = load_model('model', 'project', 'settings', settings, 'id', id); + tc.verifyTrue(isstruct(rec)); + tc.verifyTrue(isfield(rec, 'id') || isfield(rec, 'project')); + end + end + + function testLoadModelPagination(tc) + tc.assumeNotEmpty(tc.TOKEN, ... + 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); + settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); + out = load_model('model', 'session', 'settings', settings, ... + 'limit', 2, 'offset', 0); + tc.verifyTrue(isstruct(out)); + end + + function testBrainstemClientToken(tc) + tc.assumeNotEmpty(tc.TOKEN, ... + 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); + client = BrainstemClient('token', tc.TOKEN); + tc.verifyEqual(class(client), 'BrainstemClient'); + tc.verifyFalse(isempty(client.token)); + end + + function testClientLoadSessionConvenience(tc) + tc.assumeNotEmpty(tc.TOKEN, ... + 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); + client = BrainstemClient('token', tc.TOKEN); + out = client.load_session('limit', 3); + tc.verifyTrue(isstruct(out)); + end + + function testClientLoadSubjectConvenience(tc) + tc.assumeNotEmpty(tc.TOKEN, ... + 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); + client = BrainstemClient('token', tc.TOKEN); + out = client.load_subject('limit', 3); + tc.verifyTrue(isstruct(out)); + end + + function testClientLoadProjectConvenience(tc) + tc.assumeNotEmpty(tc.TOKEN, ... + 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); + client = BrainstemClient('token', tc.TOKEN); + out = client.load_project('limit', 3); + tc.verifyTrue(isstruct(out)); + end + + function testClientLoadAll(tc) + tc.assumeNotEmpty(tc.TOKEN, ... + 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); + client = BrainstemClient('token', tc.TOKEN); + % load_all should return at least as many records as a single page + out_page = client.load_model('project', 'limit', 1); + out_all = client.load_model('project', 'load_all', true); + if isfield(out_page, 'count') && out_page.count > 1 + data_key = setdiff(fieldnames(out_all), {'count','next','previous'}); + if ~isempty(data_key) + tc.verifyGreaterThan(numel(out_all.(data_key{1})), 1); + end + end + end + + function testClientDispAuthenticated(tc) + tc.assumeNotEmpty(tc.TOKEN, ... + 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); + client = BrainstemClient('token', tc.TOKEN); + % disp should run without error when authenticated + tc.verifyWarningFree(@() disp(client)); + end + + function testClientTokenTypeProperty(tc) + tc.assumeNotEmpty(tc.TOKEN, ... + 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); + client = BrainstemClient('token', tc.TOKEN); + tc.verifyEqual(client.token_type, 'personal'); + end + + function testClientSaveModelPatchGuard(tc) + tc.assumeNotEmpty(tc.TOKEN, ... + 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); + client = BrainstemClient('token', tc.TOKEN); + % PATCH without id must throw before any network round-trip + tc.verifyError( ... + @() client.save_model(struct('description','x'), 'session', 'method','patch'), ... + 'BrainSTEM:saveModel'); + end + + end % authenticated tests + + % ====================================================================== + methods (Access = private, Static) + % ====================================================================== + + function tf = endsWith_(str, suffix) + n = numel(suffix); + tf = numel(str) >= n && strcmp(str(end-n+1:end), suffix); + end + + function tf = startsWith_(str, prefix) + n = numel(prefix); + tf = numel(str) >= n && strcmp(str(1:n), prefix); + end + + end + +end diff --git a/+brainstem/delete_model.m b/+brainstem/delete_model.m new file mode 100644 index 0000000..dad5176 --- /dev/null +++ b/+brainstem/delete_model.m @@ -0,0 +1,46 @@ +function output = delete_model(id, model, varargin) +% DELETE_MODEL Delete a record from a BrainSTEM API endpoint. +% +% output = delete_model(ID, MODEL) +% output = delete_model(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 from load_settings (loaded automatically if omitted) +% +% Example: +% delete_model('c5547922-c973-4ad7-96d3-72789f140024', 'session'); + +p = inputParser; +addParameter(p,'portal', 'private', @ischar); +addParameter(p,'app', '', @ischar); +addParameter(p,'settings',load_settings,@isstruct); +parse(p, varargin{:}) +parameters = p.Results; + +if isempty(parameters.app) + parameters.app = get_app_from_model(model); +end + +options = weboptions( ... + 'HeaderFields', {'Authorization', ['Bearer ' parameters.settings.token]}, ... + 'ContentType', 'json', ... + '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:deleteModel', 'API error deleting %s %s: %s', ... + model, id, brainstem_parse_api_error(ME)); + end +end diff --git a/get_app_from_model.m b/+brainstem/get_app_from_model.m similarity index 55% rename from get_app_from_model.m rename to +brainstem/get_app_from_model.m index 65cc421..8a9e730 100644 --- a/get_app_from_model.m +++ b/+brainstem/get_app_from_model.m @@ -1,19 +1,20 @@ function app = get_app_from_model(modelname) switch modelname - case {'project','subject','session','collection','cohort'} + case {'project','subject','session','collection','cohort', ... + 'breeding','projectmembershipinvitation','projectgroupmembershipinvitation'} app = 'stem'; case {'procedure','equipment','consumablestock','behavior','dataacquisition','manipulation','procedurelog','subjectlog'} app = 'modules'; - case {'behavioralparadigm','datastorage','setup','inventory'} + case {'behavioralassay','datastorage','setup','inventory','license','protocol'} app = 'personal_attributes'; case {'consumable','hardwaredevice','supplier'} app = 'resources'; - case {'brainregion','setuptype','species','strain'} + case {'brainregion','setuptype','species','strain','behavioralcategory','behavioralparadigm','regulatoryauthority'} app = 'taxonomies'; case {'journal','publication'} app = 'dissemination'; - case {'user','laboratory'} + case {'user','laboratory','groupmembershipinvitation','groupmembershiprequest'} app = 'users'; case {'group'} app = 'auth'; diff --git a/+brainstem/get_token.m b/+brainstem/get_token.m new file mode 100644 index 0000000..60364c9 --- /dev/null +++ b/+brainstem/get_token.m @@ -0,0 +1,115 @@ +function token = get_token(url, username, password, token_type) +% GET_TOKEN Obtain an authentication token from the BrainSTEM server. +% +% BrainSTEM supports two token types: +% +% 'personal' (default) — Personal Access Token: long-lived, sliding +% 1-year window. Recommended for scripts and automation. +% POST /api/token/ → {access, token_id, message} +% +% 'shortlived' — Short-lived JWT pair: access token (1 hour) + refresh +% token (30 days). The access token is renewed silently +% via refresh_access_token when it expires; the refresh +% token rotates on each use. +% POST /api/auth/token/ → {access, refresh, expires_in} +% +% Parameters: +% url - Server URL (default: https://www.brainstem.org/) +% username - Email / username (prompted via GUI if omitted) +% password - Password (prompted via GUI if omitted) +% token_type - 'personal' (default) or 'shortlived' + +if nargin < 1 || isempty(url), url = 'https://www.brainstem.org/'; end +if nargin < 2, username = ''; end +if nargin < 3, password = ''; end +if nargin < 4 || isempty(token_type), token_type = 'personal'; end + +token_type = lower(token_type); +if ~ismember(token_type, {'personal','shortlived'}) + error('BrainSTEM:getToken', ... + 'token_type must be ''personal'' or ''shortlived'', got ''%s''.', token_type); +end + +% Show GUI dialog if credentials are missing +if isempty(username) || isempty(password) + answer = passdlg(username); + if isempty(answer.User{1}) || isempty(answer.Pass{1}) + token = ''; + return + end + username = answer.User{1}; + password = answer.Pass{1}; +end + +options_post = weboptions( ... + 'MediaType', 'application/json', ... + 'ContentType', 'json', ... + 'ArrayFormat', 'json', ... + 'RequestMethod','post'); + +if strcmp(token_type, 'shortlived') + % Short-lived JWT: access (1 h) + refresh (30 days) + % Note: this endpoint uses 'email' as the field name + json_data = jsonencode(struct('email', username, 'password', password)); + response = webwrite([url, 'api/auth/token/'], json_data, options_post); + token = response.access; + refresh_token = response.refresh; + expires_in = 3600; + if isfield(response, 'expires_in') + expires_in = response.expires_in; + end + expires_at = now + expires_in / 86400; +else + % Personal / backward-compatible: sliding ~1-year PAT + json_data = jsonencode(struct('username', username, 'password', password)); + response = webwrite([url, 'api/token/'], json_data, options_post); + token = response.access; + refresh_token = ''; + expires_at = now + 365; +end + +% --- Persist credentials, preserving any other stored URLs --------------- +auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); +new_row = table({token}, {username}, {url}, {now}, {token_type}, ... + {refresh_token}, {expires_at}, ... + 'VariableNames', {'tokens','usernames','urls','saved_at', ... + 'token_type','refresh_tokens','expires_at'}); + +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('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 + idx = find(strcmp(url, tbl.urls)); + if ~isempty(idx) + % Update the existing entry for this URL + tbl.tokens{idx} = token; + tbl.usernames{idx} = username; + tbl.saved_at{idx} = now; + tbl.token_type{idx} = token_type; + tbl.refresh_tokens{idx} = refresh_token; + tbl.expires_at{idx} = expires_at; + authentication = tbl; %#ok + else + % New URL — append a row + authentication = [tbl; new_row]; %#ok + end +else + authentication = new_row; %#ok +end + +save(auth_path, 'authentication') +disp(['Token saved to ', auth_path]) +end diff --git a/+brainstem/load_behavior.m b/+brainstem/load_behavior.m new file mode 100644 index 0000000..93cf449 --- /dev/null +++ b/+brainstem/load_behavior.m @@ -0,0 +1,29 @@ +function output = load_behavior(varargin) +% LOAD_BEHAVIOR Load behavior record(s) from BrainSTEM. +% +% Examples: +% output = load_behavior(); +% output = load_behavior('session',''); +% output = load_behavior('id',''); + +p = inputParser; +addParameter(p,'portal', 'private', @ischar); +addParameter(p,'app', 'modules', @ischar); +addParameter(p,'model', 'behavior', @ischar); +addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'filter', {}, @iscell); +addParameter(p,'sort', {}, @iscell); +addParameter(p,'include', {}, @iscell); +addParameter(p,'id', '', @ischar); +addParameter(p,'session', '', @ischar); +addParameter(p,'tags', '', @ischar); +parse(p, varargin{:}) +parameters = p.Results; + +extra_fields = {'id','session','tags'}; +filter_map = {'id','id'; 'session','session.id'; 'tags','tags'}; +parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); + +output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... + 'settings',parameters.settings,'sort',parameters.sort, ... + 'filter',parameters.filter,'include',parameters.include); diff --git a/+brainstem/load_cohort.m b/+brainstem/load_cohort.m new file mode 100644 index 0000000..84452fe --- /dev/null +++ b/+brainstem/load_cohort.m @@ -0,0 +1,30 @@ +function output = load_cohort(varargin) +% LOAD_COHORT Load cohort(s) from BrainSTEM. +% +% Examples: +% output = load_cohort(); +% output = load_cohort('name','My Cohort'); +% output = load_cohort('id',''); + +p = inputParser; +addParameter(p,'portal', 'private', @ischar); +addParameter(p,'app', 'stem', @ischar); +addParameter(p,'model', 'cohort', @ischar); +addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'filter', {}, @iscell); +addParameter(p,'sort', {}, @iscell); +addParameter(p,'include', {'subjects'}, @iscell); +addParameter(p,'id', '', @ischar); +addParameter(p,'name', '', @ischar); +addParameter(p,'description', '', @ischar); +addParameter(p,'tags', '', @ischar); +parse(p, varargin{:}) +parameters = p.Results; + +extra_fields = {'id','name','description','tags'}; +filter_map = {'id','id'; 'name','name.icontains'; 'tags','tags'}; +parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); + +output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... + 'settings',parameters.settings,'sort',parameters.sort, ... + 'filter',parameters.filter,'include',parameters.include); diff --git a/+brainstem/load_collection.m b/+brainstem/load_collection.m new file mode 100644 index 0000000..2e518b5 --- /dev/null +++ b/+brainstem/load_collection.m @@ -0,0 +1,30 @@ +function output = load_collection(varargin) +% LOAD_COLLECTION Load collection(s) from BrainSTEM. +% +% Examples: +% output = load_collection(); +% output = load_collection('name','My Collection'); +% output = load_collection('id',''); + +p = inputParser; +addParameter(p,'portal', 'private', @ischar); +addParameter(p,'app', 'stem', @ischar); +addParameter(p,'model', 'collection', @ischar); +addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'filter', {}, @iscell); +addParameter(p,'sort', {}, @iscell); +addParameter(p,'include', {'sessions'}, @iscell); +addParameter(p,'id', '', @ischar); +addParameter(p,'name', '', @ischar); +addParameter(p,'description', '', @ischar); +addParameter(p,'tags', '', @ischar); +parse(p, varargin{:}) +parameters = p.Results; + +extra_fields = {'id','name','description','tags'}; +filter_map = {'id','id'; 'name','name.icontains'; 'tags','tags'}; +parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); + +output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... + 'settings',parameters.settings,'sort',parameters.sort, ... + 'filter',parameters.filter,'include',parameters.include); diff --git a/+brainstem/load_consumablestock.m b/+brainstem/load_consumablestock.m new file mode 100644 index 0000000..592c9e5 --- /dev/null +++ b/+brainstem/load_consumablestock.m @@ -0,0 +1,31 @@ +function output = load_consumablestock(varargin) +% LOAD_CONSUMABLESTOCK Load consumable stock record(s) from BrainSTEM. +% +% Examples: +% output = load_consumablestock(); +% output = load_consumablestock('subject',''); +% output = load_consumablestock('id',''); + +p = inputParser; +addParameter(p,'portal', 'private', @ischar); +addParameter(p,'app', 'modules', @ischar); +addParameter(p,'model', 'consumablestock', @ischar); +addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'filter', {}, @iscell); +addParameter(p,'sort', {}, @iscell); +addParameter(p,'include', {}, @iscell); +addParameter(p,'id', '', @ischar); +addParameter(p,'subject', '', @ischar); +addParameter(p,'tags', '', @ischar); +parse(p, varargin{:}) +parameters = p.Results; + +extra_fields = {'id','subject','tags'}; +filter_map = {'id', 'id'; ... + 'subject', 'subject.id'; ... + 'tags', 'tags'}; +parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); + +output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... + 'settings',parameters.settings,'sort',parameters.sort, ... + 'filter',parameters.filter,'include',parameters.include); diff --git a/+brainstem/load_dataacquisition.m b/+brainstem/load_dataacquisition.m new file mode 100644 index 0000000..70ea27f --- /dev/null +++ b/+brainstem/load_dataacquisition.m @@ -0,0 +1,29 @@ +function output = load_dataacquisition(varargin) +% LOAD_DATAACQUISITION Load data acquisition record(s) from BrainSTEM. +% +% Examples: +% output = load_dataacquisition(); +% output = load_dataacquisition('session',''); +% output = load_dataacquisition('id',''); + +p = inputParser; +addParameter(p,'portal', 'private', @ischar); +addParameter(p,'app', 'modules', @ischar); +addParameter(p,'model', 'dataacquisition', @ischar); +addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'filter', {}, @iscell); +addParameter(p,'sort', {}, @iscell); +addParameter(p,'include', {}, @iscell); +addParameter(p,'id', '', @ischar); +addParameter(p,'session', '', @ischar); +addParameter(p,'tags', '', @ischar); +parse(p, varargin{:}) +parameters = p.Results; + +extra_fields = {'id','session','tags'}; +filter_map = {'id','id'; 'session','session.id'; 'tags','tags'}; +parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); + +output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... + 'settings',parameters.settings,'sort',parameters.sort, ... + 'filter',parameters.filter,'include',parameters.include); diff --git a/+brainstem/load_equipment.m b/+brainstem/load_equipment.m new file mode 100644 index 0000000..0fee259 --- /dev/null +++ b/+brainstem/load_equipment.m @@ -0,0 +1,34 @@ +function output = load_equipment(varargin) +% LOAD_EQUIPMENT Load equipment record(s) from BrainSTEM. +% +% Examples: +% output = load_equipment(); +% output = load_equipment('name','My Tetrode Drive'); +% output = load_equipment('session',''); +% output = load_equipment('id',''); + +p = inputParser; +addParameter(p,'portal', 'private', @ischar); +addParameter(p,'app', 'modules', @ischar); +addParameter(p,'model', 'equipment', @ischar); +addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'filter', {}, @iscell); +addParameter(p,'sort', {}, @iscell); +addParameter(p,'include', {}, @iscell); +addParameter(p,'id', '', @ischar); +addParameter(p,'name', '', @ischar); +addParameter(p,'session', '', @ischar); +addParameter(p,'tags', '', @ischar); +parse(p, varargin{:}) +parameters = p.Results; + +extra_fields = {'id','name','session','tags'}; +filter_map = {'id', 'id'; ... + 'name', 'name.icontains'; ... + 'session', 'session.id'; ... + 'tags', 'tags'}; +parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); + +output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... + 'settings',parameters.settings,'sort',parameters.sort, ... + 'filter',parameters.filter,'include',parameters.include); diff --git a/+brainstem/load_manipulation.m b/+brainstem/load_manipulation.m new file mode 100644 index 0000000..d80141c --- /dev/null +++ b/+brainstem/load_manipulation.m @@ -0,0 +1,29 @@ +function output = load_manipulation(varargin) +% LOAD_MANIPULATION Load manipulation record(s) from BrainSTEM. +% +% Examples: +% output = load_manipulation(); +% output = load_manipulation('session',''); +% output = load_manipulation('id',''); + +p = inputParser; +addParameter(p,'portal', 'private', @ischar); +addParameter(p,'app', 'modules', @ischar); +addParameter(p,'model', 'manipulation', @ischar); +addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'filter', {}, @iscell); +addParameter(p,'sort', {}, @iscell); +addParameter(p,'include', {}, @iscell); +addParameter(p,'id', '', @ischar); +addParameter(p,'session', '', @ischar); +addParameter(p,'tags', '', @ischar); +parse(p, varargin{:}) +parameters = p.Results; + +extra_fields = {'id','session','tags'}; +filter_map = {'id','id'; 'session','session.id'; 'tags','tags'}; +parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); + +output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... + 'settings',parameters.settings,'sort',parameters.sort, ... + 'filter',parameters.filter,'include',parameters.include); diff --git a/+brainstem/load_model.m b/+brainstem/load_model.m new file mode 100644 index 0000000..5afa1fd --- /dev/null +++ b/+brainstem/load_model.m @@ -0,0 +1,105 @@ +function output = load_model(varargin) +% LOAD_MODEL Retrieve records from a BrainSTEM API endpoint. +% +% output = load_model('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 /// +% 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 from load_settings (loaded automatically if omitted) +% +% Examples: +% output = load_model('model','session'); +% output = load_model('model','session','id','c5547922-c973-4ad7-96d3-72789f140024'); +% output = load_model('model','session','filter',{'name.icontains','Rat'},'sort',{'-name'}); +% output = load_model('model','session','include',{'behaviors','manipulations'}); +% output = load_model('model','session','load_all',true); +% output = load_model('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',load_settings, @isstruct); +addParameter(p,'filter', {}, @iscell); +addParameter(p,'sort', {}, @iscell); +addParameter(p,'include', {}, @iscell); +addParameter(p,'limit', [], @(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.app) + parameters.app = get_app_from_model(parameters.model); +end + +% Auth header +options = weboptions( ... + 'HeaderFields', {'Authorization', ['Bearer ' parameters.settings.token]}, ... + 'ContentType', 'json', ... + 'ArrayFormat', 'json', ... + 'RequestMethod','get'); + +% 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:loadModel', '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:loadModel', 'API error fetching %s: %s', url, brainstem_parse_api_error(ME)); +end + +% 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 + while isfield(output, 'next') && ~isempty(output.next) + try + next_page = webread(output.next, options); + catch ME + error('BrainSTEM:loadModel', 'API error fetching next page: %s', brainstem_parse_api_error(ME)); + end + % 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 + diff --git a/+brainstem/load_procedure.m b/+brainstem/load_procedure.m new file mode 100644 index 0000000..99296f2 --- /dev/null +++ b/+brainstem/load_procedure.m @@ -0,0 +1,29 @@ +function output = load_procedure(varargin) +% LOAD_PROCEDURE Load procedure record(s) from BrainSTEM. +% +% Examples: +% output = load_procedure(); +% output = load_procedure('subject',''); +% output = load_procedure('id',''); + +p = inputParser; +addParameter(p,'portal', 'private', @ischar); +addParameter(p,'app', 'modules', @ischar); +addParameter(p,'model', 'procedure', @ischar); +addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'filter', {}, @iscell); +addParameter(p,'sort', {}, @iscell); +addParameter(p,'include', {}, @iscell); +addParameter(p,'id', '', @ischar); +addParameter(p,'subject', '', @ischar); +addParameter(p,'tags', '', @ischar); +parse(p, varargin{:}) +parameters = p.Results; + +extra_fields = {'id','subject','tags'}; +filter_map = {'id','id'; 'subject','subject.id'; 'tags','tags'}; +parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); + +output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... + 'settings',parameters.settings,'sort',parameters.sort, ... + 'filter',parameters.filter,'include',parameters.include); diff --git a/+brainstem/load_procedurelog.m b/+brainstem/load_procedurelog.m new file mode 100644 index 0000000..5382693 --- /dev/null +++ b/+brainstem/load_procedurelog.m @@ -0,0 +1,31 @@ +function output = load_procedurelog(varargin) +% LOAD_PROCEDURELOG Load procedure log record(s) from BrainSTEM. +% +% Examples: +% output = load_procedurelog(); +% output = load_procedurelog('subject',''); +% output = load_procedurelog('id',''); + +p = inputParser; +addParameter(p,'portal', 'private', @ischar); +addParameter(p,'app', 'modules', @ischar); +addParameter(p,'model', 'procedurelog', @ischar); +addParameter(p,'settings', load_settings, @isstruct); +addParameter(p,'filter', {}, @iscell); +addParameter(p,'sort', {}, @iscell); +addParameter(p,'include', {}, @iscell); +addParameter(p,'id', '', @ischar); +addParameter(p,'subject', '', @ischar); +addParameter(p,'tags', '', @ischar); +parse(p, varargin{:}) +parameters = p.Results; + +extra_fields = {'id','subject','tags'}; +filter_map = {'id', 'id'; ... + 'subject', 'subject.id'; ... + 'tags', 'tags'}; +parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); + +output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... + 'settings',parameters.settings,'sort',parameters.sort, ... + 'filter',parameters.filter,'include',parameters.include); diff --git a/+brainstem/load_project.m b/+brainstem/load_project.m new file mode 100644 index 0000000..0f56b4e --- /dev/null +++ b/+brainstem/load_project.m @@ -0,0 +1,41 @@ +function output = load_project(varargin) +% Load project(s) from BrainSTEM + +% Example calls: +% output = load_project('id','ee57e766-fc0c-42e1-9277-7d40d6e9353a'); +% output = load_project('name','Peters Project'); +% output = load_project('filter',{'id','ee57e766-fc0c-42e1-9277-7d40d6e9353a'}); +% output = load_project('tags','1') + +p = inputParser; +addParameter(p,'portal','private',@ischar); % private, public, admin +addParameter(p,'app','stem',@ischar); % stem, modules, personal_attributes, resources, taxonomies, dissemination, users +addParameter(p,'model','project',@ischar); % project, subject, session, collection, ... +addParameter(p,'settings',load_settings,@isstruct); +addParameter(p,'filter',{},@iscell); % Filter parameters +addParameter(p,'sort',{},@iscell); % Sorting parameters +addParameter(p,'include',{'sessions','subjects','collections','cohorts'},@iscell); % Embed relational fields + +% Project fields (extra parameters) +addParameter(p,'id','',@ischar); % id of project +addParameter(p,'name','',@ischar); % name of project +addParameter(p,'description','',@ischar); % description of project +addParameter(p,'sessions','',@ischar); % sessions +addParameter(p,'subjects','',@ischar); % subjects +addParameter(p,'tags','',@ischar); % tags of project (id of tag) +addParameter(p,'is_public','',@islogical); % project is public + +parse(p,varargin{:}) +parameters = p.Results; + +extra_fields = {'id','name','description','sessions','subjects','tags','is_public'}; +filter_map = {'id', 'id'; ... + 'name', 'name.icontains'; ... + 'sessions', 'sessions.id'; ... + 'subjects', 'subjects.id'; ... + 'tags', 'tags'}; +parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); + +output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... + 'settings',parameters.settings,'sort',parameters.sort, ... + 'filter',parameters.filter,'include',parameters.include); diff --git a/+brainstem/load_session.m b/+brainstem/load_session.m new file mode 100644 index 0000000..0942513 --- /dev/null +++ b/+brainstem/load_session.m @@ -0,0 +1,40 @@ +function output = load_session(varargin) +% Load session(s) from BrainSTEM + +% Example calls: +% output = load_session('id','c5547922-c973-4ad7-96d3-72789f140024'); +% output = load_session('name','New session'); +% output = load_session('filter',{'id','c5547922-c973-4ad7-96d3-72789f140024'}); +% output = load_session('tags','1') + +p = inputParser; +addParameter(p,'portal','private',@ischar); % private, public, admin +addParameter(p,'app','stem',@ischar); % stem, modules, personal_attributes, resources, taxonomies, dissemination, users +addParameter(p,'model','session',@ischar); % project, subject, session, collection, ... +addParameter(p,'settings',load_settings,@isstruct); +addParameter(p,'filter',{},@iscell); % Filter parameters +addParameter(p,'sort',{},@iscell); % Sorting parameters +addParameter(p,'include',{'dataacquisition','behaviors','manipulations','epochs'},@iscell); % Embed relational fields + +% Session fields (extra parameters) +addParameter(p,'id','',@ischar); % id of session +addParameter(p,'name','',@ischar); % name of session +addParameter(p,'description','',@ischar); % description of session +addParameter(p,'projects','',@ischar); % date and time of session +addParameter(p,'datastorage','',@ischar); % datastorage of session +addParameter(p,'tags','',@ischar); % tags of session (id of tag) + +parse(p,varargin{:}) +parameters = p.Results; + +extra_fields = {'id','name','description','projects','datastorage','tags'}; +filter_map = {'id', 'id'; ... + 'name', 'name.icontains'; ... + 'projects', 'projects.id'; ... + 'datastorage', 'datastorage.id'; ... + 'tags', 'tags'}; +parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); + +output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... + 'settings',parameters.settings,'sort',parameters.sort, ... + 'filter',parameters.filter,'include',parameters.include); diff --git a/+brainstem/load_settings.m b/+brainstem/load_settings.m new file mode 100644 index 0000000..aeb13c8 --- /dev/null +++ b/+brainstem/load_settings.m @@ -0,0 +1,70 @@ +function settings = load_settings +% function for loading local settings used for connecting to BrainSTEM + +% url to server +settings.url = 'https://www.brainstem.org/'; +% settings.url = 'https://brainstem-development.herokuapp.com/'; +% settings.url = 'http://127.0.0.1:8000/'; + +% Authentication info +auth_path = fullfile(prefdir,'brainstem_authentication.mat'); +if exist(auth_path,'file') + credentials1 = load(auth_path,'authentication'); + % Bug fix: ismember returns scalar 0/1, not the row index. + % Use strcmp to get the correct index into a multi-URL table. + idx = find(strcmp(settings.url, credentials1.authentication.urls)); + if isempty(idx) + settings.token = get_token(settings.url); + else + auth_tbl = credentials1.authentication; + % Determine remaining lifetime using expires_at (preferred) or + % saved_at for backward-compatible old .mat files. + has_expires = ismember('expires_at', auth_tbl.Properties.VariableNames); + has_saved = ismember('saved_at', auth_tbl.Properties.VariableNames); + has_type = ismember('token_type', auth_tbl.Properties.VariableNames); + has_refresh = ismember('refresh_tokens', auth_tbl.Properties.VariableNames); + + is_short = has_type && strcmp(auth_tbl.token_type{idx}, 'shortlived'); + + if has_expires + days_left = auth_tbl.expires_at{idx} - now; + elseif has_saved + days_left = (auth_tbl.saved_at{idx} + 365) - now; % treat as personal PAT + is_short = false; + else + days_left = Inf; + end + + if days_left <= 0 + if is_short && has_refresh && ~isempty(auth_tbl.refresh_tokens{idx}) + % Silently renew using the refresh token + try + settings.token = refresh_access_token(settings.url, ... + auth_tbl.refresh_tokens{idx}); + return + catch + warning('BrainSTEM:refreshFailed', ... + 'Automatic token refresh failed — re-authenticating.'); + end + settings.token = get_token(settings.url, '', '', 'shortlived'); + else + warning('BrainSTEM:tokenExpired', ... + 'Saved token has expired — re-authenticating.'); + settings.token = get_token(settings.url); + end + elseif ~is_short && days_left < 15 + % Personal token approaching expiry — warn, but keep using it + warning('BrainSTEM:tokenNearExpiry', ... + ['BrainSTEM personal access token expires in ~%.0f days. ' ... + 'Regenerate at https://www.brainstem.org/private/users/tokens/'], days_left); + settings.token = auth_tbl.tokens{idx}; + else + settings.token = auth_tbl.tokens{idx}; + end + end +else + settings.token = get_token(settings.url); +end + +% Local storage (set to empty; configure manually if needed) +settings.storage = {}; diff --git a/load_subject.m b/+brainstem/load_subject.m similarity index 52% rename from load_subject.m rename to +brainstem/load_subject.m index 5a607c0..027a76a 100644 --- a/load_subject.m +++ b/+brainstem/load_subject.m @@ -12,8 +12,8 @@ p = inputParser; addParameter(p,'portal','private',@ischar); % private, public addParameter(p,'app','stem',@ischar); % stem, modules, personal_attributes, resources, taxonomies, dissemination, users -addParameter(p,'model','subject',@isstruct); % project, subject, session, collection, ... -addParameter(p,'settings',load_settings,@isstr); +addParameter(p,'model','subject',@ischar); % project, subject, session, collection, ... +addParameter(p,'settings',load_settings,@isstruct); addParameter(p,'filter',{},@iscell); % Filter parameters addParameter(p,'sort',{},@iscell); % Sorting parameters addParameter(p,'include',{'procedures','subjectlogs'},@iscell); % Embed relational fields @@ -30,25 +30,15 @@ parse(p,varargin{:}) parameters = p.Results; -% Filter query parameters -extra_parameters = {'id','name','description','projects','strain','sex','tags'}; -for i = 1:length(extra_parameters) - if ~isempty(parameters.(extra_parameters{i})) - switch extra_parameters{i} - case 'id' - parameters.filter = [parameters.filter; {'id',parameters.(extra_parameters{i})}]; - case 'projects' - parameters.filter = [parameters.filter; {'projects.id',parameters.(extra_parameters{i})}]; - case 'strain' - parameters.filter = [parameters.filter; {'strain.id',parameters.(extra_parameters{i})}]; - case 'sex' - parameters.filter = [parameters.filter; {'sex',parameters.(extra_parameters{i})}]; - case 'tags' - parameters.filter = [parameters.filter; {'tags',parameters.(extra_parameters{i})}]; - otherwise - parameters.filter = [parameters.filter; {[extra_parameters{i},'.icontains'],parameters.(extra_parameters{i})}]; - end - end -end +extra_fields = {'id','name','description','projects','strain','sex','tags'}; +filter_map = {'id', 'id'; ... + 'name', 'name.icontains'; ... + 'projects', 'projects.id'; ... + 'strain', 'strain.id'; ... + 'sex', 'sex'; ... + 'tags', 'tags'}; +parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model,'settings',parameters.settings,'sort',parameters.sort,'filter',parameters.filter,'include',parameters.include); +output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... + 'settings',parameters.settings,'sort',parameters.sort, ... + 'filter',parameters.filter,'include',parameters.include); diff --git a/+brainstem/load_subjectlog.m b/+brainstem/load_subjectlog.m new file mode 100644 index 0000000..0f66cd6 --- /dev/null +++ b/+brainstem/load_subjectlog.m @@ -0,0 +1,37 @@ +function output = load_subjectlog(varargin) +% LOAD_SUBJECTLOG Load subject log record(s) from BrainSTEM. +% +% Subject logs have fields: id, type, description, subject. +% (No session field — subject logs are linked to subjects, not sessions.) +% +% Examples: +% output = load_subjectlog(); +% output = load_subjectlog('subject',''); +% output = load_subjectlog('type','Weighing'); +% output = load_subjectlog('id',''); + +p = inputParser; +addParameter(p,'portal', 'private', @ischar); +addParameter(p,'app', 'modules', @ischar); +addParameter(p,'model', 'subjectlog', @ischar); +addParameter(p,'settings', load_settings, @isstruct); +addParameter(p,'filter', {}, @iscell); +addParameter(p,'sort', {}, @iscell); +addParameter(p,'include', {}, @iscell); +addParameter(p,'id', '', @ischar); +addParameter(p,'subject', '', @ischar); +addParameter(p,'type', '', @ischar); +addParameter(p,'description', '', @ischar); +parse(p, varargin{:}) +parameters = p.Results; + +extra_fields = {'id','subject','type','description'}; +filter_map = {'id', 'id'; ... + 'subject', 'subject.id'; ... + 'type', 'type'; ... + 'description', 'description.icontains'}; +parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); + +output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... + 'settings',parameters.settings,'sort',parameters.sort, ... + 'filter',parameters.filter,'include',parameters.include); diff --git a/passdlg.m b/+brainstem/passdlg.m similarity index 100% rename from passdlg.m rename to +brainstem/passdlg.m diff --git a/passfield.m b/+brainstem/passfield.m similarity index 100% rename from passfield.m rename to +brainstem/passfield.m diff --git a/+brainstem/private/brainstem_apply_field_filters.m b/+brainstem/private/brainstem_apply_field_filters.m new file mode 100644 index 0000000..781e8f4 --- /dev/null +++ b/+brainstem/private/brainstem_apply_field_filters.m @@ -0,0 +1,32 @@ +function filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map) +% BRAINSTEM_APPLY_FIELD_FILTERS Translate named parameters into filter pairs. +% +% filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map) +% +% parameters - struct from inputParser containing the named values +% extra_fields - cell array of field names to check, e.g. {'id','name','tags'} +% filter_map - n×2 cell array mapping field name → filter key, e.g. +% {'id', 'id'; +% 'name', 'name.icontains'; +% 'tags', 'tags'} +% Fields not listed in filter_map default to '.icontains'. +% +% Returns the updated filter cell array (appended to parameters.filter). + +filter = parameters.filter; + +% Build a quick lookup: field -> filter key +lookup = containers.Map(filter_map(:,1), filter_map(:,2)); + +for i = 1:numel(extra_fields) + field = extra_fields{i}; + value = parameters.(field); + if ~isempty(value) + if isKey(lookup, field) + key = lookup(field); + else + key = [field, '.icontains']; + end + filter = [filter; {key, value}]; %#ok + end +end diff --git a/+brainstem/private/brainstem_build_query_string.m b/+brainstem/private/brainstem_build_query_string.m new file mode 100644 index 0000000..31f91ee --- /dev/null +++ b/+brainstem/private/brainstem_build_query_string.m @@ -0,0 +1,49 @@ +function qs = brainstem_build_query_string(filter, sort, include, limit, offset) +% BRAINSTEM_BUILD_QUERY_STRING Build a URL query string from API parameters. +% +% qs = brainstem_build_query_string(filter, sort, include, limit, offset) +% +% All inputs are optional (pass [] or {} to omit). +% +% filter - cell array of {field, value} pairs, e.g. {'name','session1','tags','1'} +% sort - cell array of field names, prefix '-' for descending, e.g. {'-name'} +% include - cell array of relational fields to embed, e.g. {'behaviors','subjects'} +% limit - scalar integer, max records per page (API default: 20, max: 100) +% offset - scalar integer, number of records to skip + +parts = {}; + +% Filter parameters: filter{field}=value +if ~isempty(filter) + for i = 1:2:numel(filter) + parts{end+1} = ['filter{', filter{i}, '}=', urlencode(num2str(filter{i+1}))]; %#ok + end +end + +% Sort parameters: sort[]=field +if ~isempty(sort) + for i = 1:numel(sort) + parts{end+1} = ['sort[]=', sort{i}]; %#ok + end +end + +% Include parameters: include[]=relation.* +if ~isempty(include) + for i = 1:numel(include) + parts{end+1} = ['include[]=', include{i}, '.*']; %#ok + end +end + +% Pagination +if nargin >= 4 && ~isempty(limit) + parts{end+1} = ['limit=', num2str(limit)]; +end +if nargin >= 5 && ~isempty(offset) && offset > 0 + parts{end+1} = ['offset=', num2str(offset)]; +end + +if isempty(parts) + qs = ''; +else + qs = ['?', strjoin(parts, '&')]; +end diff --git a/+brainstem/private/brainstem_build_url.m b/+brainstem/private/brainstem_build_url.m new file mode 100644 index 0000000..8cded58 --- /dev/null +++ b/+brainstem/private/brainstem_build_url.m @@ -0,0 +1,16 @@ +function url = brainstem_build_url(base_url, portal, app, model, id) +% BRAINSTEM_BUILD_URL Assemble a BrainSTEM REST endpoint URL. +% +% url = brainstem_build_url(base_url, portal, app, model) +% url = brainstem_build_url(base_url, portal, app, model, id) +% +% When id is provided the URL points to the individual resource: +% /api///// +% Otherwise it points to the collection: +% /api//// + +if nargin < 5 || isempty(id) + url = [base_url, 'api/', portal, '/', app, '/', model, '/']; +else + url = [base_url, 'api/', portal, '/', app, '/', model, '/', id, '/']; +end diff --git a/+brainstem/private/brainstem_parse_api_error.m b/+brainstem/private/brainstem_parse_api_error.m new file mode 100644 index 0000000..5d0e834 --- /dev/null +++ b/+brainstem/private/brainstem_parse_api_error.m @@ -0,0 +1,63 @@ +function msg = brainstem_parse_api_error(ME) +% BRAINSTEM_PARSE_API_ERROR Extract a human-readable message from an API error. +% +% Modern MATLAB (R2020b+) includes the HTTP response body in the +% MException message when webread/webwrite encounters an HTTP error. +% This helper extracts and formats any JSON validation detail from that +% message, falling back to the raw message if no JSON is found. +% +% Example API error body included by MATLAB: +% {"name": ["This field is required."], "session": ["This field is required."]} +% Formatted output: +% 'name: This field is required. | session: This field is required.' + +raw = ME.message; + +% Collapse newlines so the regex can match JSON that spans multiple lines +raw_clean = regexprep(raw, '\r?\n', ' '); + +% Find the outermost JSON object in the message +json_match = regexp(raw_clean, '\{.+\}', 'match', 'once'); + +if ~isempty(json_match) + try + err_struct = jsondecode(json_match); + fields = fieldnames(err_struct); + parts = cell(1, numel(fields)); + for i = 1:numel(fields) + val = err_struct.(fields{i}); + if iscell(val) + % Array of error strings from DRF: ["This field is required."] + val_str = strjoin(val, '; '); + elseif ischar(val) + val_str = val; + elseif isstruct(val) + % Nested object — flatten one level + sub_fields = fieldnames(val); + sub_parts = cell(1, numel(sub_fields)); + for j = 1:numel(sub_fields) + sv = val.(sub_fields{j}); + if iscell(sv) + sub_parts{j} = sprintf('%s: %s', sub_fields{j}, strjoin(sv, '; ')); + elseif ischar(sv) + sub_parts{j} = sprintf('%s: %s', sub_fields{j}, sv); + else + sub_parts{j} = sprintf('%s: %s', sub_fields{j}, jsonencode(sv)); + end + end + val_str = strjoin(sub_parts, '; '); + else + val_str = num2str(val); + end + parts{i} = sprintf('%s: %s', fields{i}, val_str); + end + msg = strjoin(parts, ' | '); + return + catch + % JSON parse failed or unexpected structure — fall through + end +end + +% Fallback: return the raw message unchanged +msg = raw; +end diff --git a/+brainstem/refresh_access_token.m b/+brainstem/refresh_access_token.m new file mode 100644 index 0000000..f2583a7 --- /dev/null +++ b/+brainstem/refresh_access_token.m @@ -0,0 +1,58 @@ +function new_access = refresh_access_token(url, refresh_token_str) +% REFRESH_ACCESS_TOKEN Silently renew a short-lived access token. +% +% new_access = refresh_access_token(url, refresh_token) +% +% Calls POST /api/auth/token/refresh/ and returns a new access token. +% The stored credentials in prefdir are updated automatically with both +% the new access token and the new refresh token — the previous refresh +% token is invalidated by the server on each use. +% +% This is called automatically by load_settings and BrainstemClient when +% a short-lived access token has expired and a refresh token is available. +% For manual use, prefer BrainstemClient which handles all token management. +% +% See also: brainstem.get_token, BrainstemClient + +options = weboptions( ... + 'MediaType', 'application/json', ... + 'ContentType', 'json', ... + 'RequestMethod','post'); + +json_data = jsonencode(struct('refresh', refresh_token_str)); +try + response = webwrite([url, 'api/auth/token/refresh/'], json_data, options); +catch ME + error('BrainSTEM:refreshToken', ... + 'Failed to refresh access token: %s', brainstem_parse_api_error(ME)); +end + +new_access = response.access; +new_refresh = response.refresh; % rotated — old refresh token is now invalid +expires_in = 3600; +if isfield(response, 'expires_in') + expires_in = response.expires_in; +end + +% Update the saved credentials entry for this URL +auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); +if exist(auth_path, 'file') + existing = load(auth_path, 'authentication'); + tbl = existing.authentication; + idx = find(strcmp(url, tbl.urls)); + if ~isempty(idx) + tbl.tokens{idx} = new_access; + if ismember('refresh_tokens', tbl.Properties.VariableNames) + tbl.refresh_tokens{idx} = new_refresh; + end + if ismember('expires_at', tbl.Properties.VariableNames) + tbl.expires_at{idx} = now + expires_in / 86400; + end + if ismember('saved_at', tbl.Properties.VariableNames) + tbl.saved_at{idx} = now; + end + authentication = tbl; %#ok + save(auth_path, 'authentication'); + end +end +end diff --git a/+brainstem/save_model.m b/+brainstem/save_model.m new file mode 100644 index 0000000..dfed0d2 --- /dev/null +++ b/+brainstem/save_model.m @@ -0,0 +1,81 @@ +function output = save_model(varargin) +% SAVE_MODEL Create or update a record in a BrainSTEM API endpoint. +% +% output = save_model('data', DATA, 'model', MODEL) +% +% When DATA contains an 'id' field, an update is performed (PUT or PATCH). +% Otherwise a new record is created (POST). +% +% Parameters: +% data - Struct with the record fields to submit (required) +% model - Model name, e.g. 'session', 'project', 'subject' (default: 'session') +% portal - 'private' (default) or 'public' +% app - App name; auto-detected from model if omitted +% method - 'put' (default, full replace) or 'patch' (partial update) +% settings - Settings struct from load_settings (loaded automatically if omitted) +% +% Examples: +% % Update an existing session (full replace): +% output = save_model('data', session, 'model', 'session'); +% +% % Partial update (only send changed fields): +% output = save_model('data', struct('description','new desc'), ... +% 'model','session','method','patch'); +% +% % Create a new session: +% s.name = 'My session'; s.projects = {''}; s.tags = []; +% output = save_model('data', s, 'model', 'session'); + +p = inputParser; +addParameter(p,'portal', 'private', @ischar); +addParameter(p,'app', '', @ischar); +addParameter(p,'model', 'session', @ischar); +addParameter(p,'settings',load_settings,@isstruct); +addParameter(p,'data', struct(), @isstruct); +addParameter(p,'method', 'put', @(x) ismember(lower(x),{'put','patch'})); +parse(p, varargin{:}) +parameters = p.Results; + +if isempty(parameters.app) + parameters.app = get_app_from_model(parameters.model); +end + +% PATCH without an id in the data makes no sense: there is no record to update. +if strcmpi(parameters.method, 'patch') && ~isfield(parameters.data, 'id') + error('BrainSTEM:saveModel', ... + 'PATCH requires an ''id'' field in data to identify the record. ' ... + 'For new records omit the ''method'' parameter (POST is used automatically).'); +end + +options = weboptions( ... + 'HeaderFields', {'Authorization', ['Bearer ' parameters.settings.token]}, ... + 'MediaType', 'application/json', ... + 'ContentType', 'json', ... + 'ArrayFormat', 'json'); + +if isfield(parameters.data, 'id') + options.RequestMethod = lower(parameters.method); + endpoint = brainstem_build_url(parameters.settings.url, parameters.portal, ... + parameters.app, parameters.model, parameters.data.id); +else + options.RequestMethod = 'post'; + endpoint = brainstem_build_url(parameters.settings.url, parameters.portal, ... + parameters.app, parameters.model); +end + +try + output = webwrite(endpoint, parameters.data, options); + % Normalize an empty response body (some PATCH endpoints return 204 No Content) + if isempty(output) + output = parameters.data; + end +catch ME + % 204 No Content is a valid success response for PATCH + if contains(ME.message, '204') + output = parameters.data; + return + end + api_msg = brainstem_parse_api_error(ME); + error('BrainSTEM:saveModel', 'API error saving %s to %s: %s', ... + parameters.model, endpoint, api_msg); +end diff --git a/BrainstemClient.m b/BrainstemClient.m new file mode 100644 index 0000000..afe1409 --- /dev/null +++ b/BrainstemClient.m @@ -0,0 +1,382 @@ +classdef BrainstemClient < handle +% BRAINSTEMCLIENT Client for the BrainSTEM REST API. +% +% Create a client once per session; it holds the authentication token +% and base URL so you don't have to pass them to every call. +% +% CONSTRUCTION +% client = BrainstemClient() +% Prompts for credentials (GUI dialog) and stores the returned token. +% +% client = BrainstemClient('token', TOKEN) +% Use a Personal Access Token directly (recommended for scripts/HPC). +% Get your token at https://www.brainstem.org/private/users/tokens/ +% You can also set the environment variable BRAINSTEM_TOKEN and call: +% client = BrainstemClient('token', getenv('BRAINSTEM_TOKEN')) +% +% client = BrainstemClient('url', URL) +% Connect to a non-default server (e.g. local dev instance). +% +% client = BrainstemClient('token_type', 'shortlived') +% Use short-lived JWT tokens (access: 1 h, refresh: 30 days) instead of +% the default personal access token (sliding 1-year window). +% Short-lived tokens refresh silently when they expire; only the initial +% login requires credentials. Use 'personal' (default) for scripts. +% +% CORE METHODS +% output = client.load_model(model, ...) +% output = client.save_model(data, model, ...) +% output = client.delete_model(id, model, ...) +% +% CONVENIENCE LOADERS (named shortcuts with pre-set include defaults) +% output = client.load_project(...) 'name','id','tags', ... +% output = client.load_subject(...) 'name','id','sex','strain', ... +% output = client.load_session(...) 'name','id','projects', ... +% output = client.load_collection(...) 'name','id','tags' +% output = client.load_cohort(...) 'name','id','tags' +% output = client.load_behavior(...) 'session','id','tags' +% output = client.load_dataacquisition(...)'session','id','tags' +% output = client.load_manipulation(...) 'session','id','tags' +% output = client.load_procedure(...) 'subject','id','tags' +% output = client.load_subjectlog(...) 'subject','type','description','id' +% output = client.load_procedurelog(...) 'subject','id','tags' +% output = client.load_equipment(...) 'name','session','id','tags' +% output = client.load_consumablestock(...)'subject','id','tags' +% +% LOAD_MODEL parameters (all optional after model): +% 'portal' - 'private' (default) or 'public' +% 'id' - UUID; fetches a single record at /// +% 'filter' - cell array {field, value, ...} +% 'sort' - cell array of fields; 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 +% 'load_all' - true to auto-follow pagination and return all records +% +% SAVE_MODEL parameters (all optional after data and model): +% 'portal' - 'private' (default) or 'public' +% 'method' - 'put' (default, full replace) or 'patch' (partial update) +% +% EXAMPLES +% % Authenticate with a Personal Access Token stored in an env variable +% client = BrainstemClient('token', getenv('BRAINSTEM_TOKEN')); +% +% % Load all sessions (auto-paginate) +% out = client.load_model('session', 'load_all', true); +% +% % Load a single session by ID +% out = client.load_model('session', 'id', 'c5547922-c973-4ad7-96d3-72789f140024'); +% +% % Filter, sort, embed relations +% out = client.load_model('session', ... +% 'filter', {'name.icontains','Rat'}, ... +% 'sort', {'-name'}, ... +% 'include', {'behaviors','manipulations'}); +% +% % Update a session (partial update) +% s.id = out.sessions(1).id; +% s.description = 'updated'; +% client.save_model(s, 'session', 'method', 'patch'); +% +% % Create a new session +% s = struct('name','New session','projects',{{''}},'tags',[]); +% client.save_model(s, 'session'); +% +% % Delete a session +% client.delete_model(out.sessions(1).id, 'session'); +% +% % Convenience loaders — field-level parameters, sensible include defaults +% out = client.load_session('name', 'mysession'); +% out = client.load_subject('sex', 'M', 'sort', {'name'}); +% out = client.load_behavior('session', ''); +% out = client.load_project('name', 'My Project', 'portal', 'public'); +% +% % Load public projects +% out = client.load_model('project', 'portal', 'public'); + + properties (SetAccess = private) + url (1,:) char = 'https://www.brainstem.org/' + token (1,:) char = '' + token_type (1,:) char = 'personal' % 'personal' or 'shortlived' + end + + methods + % ------------------------------------------------------------------ + function obj = BrainstemClient(varargin) + % BRAINSTEMCLIENT Constructor. + p = inputParser; + addParameter(p, 'url', 'https://www.brainstem.org/', @ischar); + addParameter(p, 'token', '', @ischar); + addParameter(p, 'token_type', 'personal', ... + @(x) ismember(lower(x), {'personal','shortlived'})); + parse(p, varargin{:}); + + obj.url = p.Results.url; + obj.token_type = lower(p.Results.token_type); + + if ~isempty(p.Results.token) + obj.token = p.Results.token; + else + % Try environment variable first (headless-friendly) + env_token = getenv('BRAINSTEM_TOKEN'); + if ~isempty(env_token) + obj.token = env_token; + disp('BrainstemClient: authenticated via BRAINSTEM_TOKEN environment variable.'); + else + % Fall back to saved token or interactive login + obj.token = obj.load_or_request_token_(); + end + end + end + + % ------------------------------------------------------------------ + function output = load_model(obj, model, varargin) + % LOAD_MODEL Retrieve records from a BrainSTEM API endpoint. + % See class documentation for full parameter list. + try + output = brainstem.load_model('model', model, ... + 'settings', obj.settings_(), ... + varargin{:}); + catch ME + if obj.is_auth_error_(ME) + obj.refresh_token_(); + output = brainstem.load_model('model', model, ... + 'settings', obj.settings_(), ... + varargin{:}); + else + rethrow(ME); + end + end + end + + % ------------------------------------------------------------------ + function output = save_model(obj, data, model, varargin) + % SAVE_MODEL Create or update a BrainSTEM record. + % See class documentation for full parameter list. + try + output = brainstem.save_model('data', data, 'model', model, ... + 'settings', obj.settings_(), ... + varargin{:}); + catch ME + if obj.is_auth_error_(ME) + obj.refresh_token_(); + output = brainstem.save_model('data', data, 'model', model, ... + 'settings', obj.settings_(), ... + varargin{:}); + else + rethrow(ME); + end + end + end + + % ------------------------------------------------------------------ + function output = delete_model(obj, id, model, varargin) + % DELETE_MODEL Delete a BrainSTEM record by UUID. + try + output = brainstem.delete_model(id, model, ... + 'settings', obj.settings_(), ... + varargin{:}); + catch ME + if obj.is_auth_error_(ME) + obj.refresh_token_(); + output = brainstem.delete_model(id, model, ... + 'settings', obj.settings_(), ... + varargin{:}); + else + rethrow(ME); + end + end + end + + % ------------------------------------------------------------------ + % Convenience loaders — named shortcuts with field-level parameters + % and sensible include defaults. All accept the same optional + % parameters as the corresponding standalone load_* functions. + % ------------------------------------------------------------------ + + function output = load_project(obj, varargin) + % LOAD_PROJECT Load project(s). Accepts 'name','id','tags', etc. + % Equivalent to brainstem.load_project(...) but uses this client's credentials. + output = brainstem.load_project('settings', obj.settings_(), varargin{:}); + end + + function output = load_subject(obj, varargin) + % LOAD_SUBJECT Load subject(s). Accepts 'name','id','sex','strain', etc. + output = brainstem.load_subject('settings', obj.settings_(), varargin{:}); + end + + function output = load_session(obj, varargin) + % LOAD_SESSION Load session(s). Accepts 'name','id','projects', etc. + output = brainstem.load_session('settings', obj.settings_(), varargin{:}); + end + + function output = load_collection(obj, varargin) + % LOAD_COLLECTION Load collection(s). Accepts 'name','id','tags'. + output = brainstem.load_collection('settings', obj.settings_(), varargin{:}); + end + + function output = load_cohort(obj, varargin) + % LOAD_COHORT Load cohort(s). Accepts 'name','id','tags'. + output = brainstem.load_cohort('settings', obj.settings_(), varargin{:}); + end + + function output = load_behavior(obj, varargin) + % LOAD_BEHAVIOR Load behavior records. Accepts 'session','id','tags'. + output = brainstem.load_behavior('settings', obj.settings_(), varargin{:}); + end + + function output = load_dataacquisition(obj, varargin) + % LOAD_DATAACQUISITION Load data acquisition records. Accepts 'session','id','tags'. + output = brainstem.load_dataacquisition('settings', obj.settings_(), varargin{:}); + end + + function output = load_manipulation(obj, varargin) + % LOAD_MANIPULATION Load manipulation records. Accepts 'session','id','tags'. + output = brainstem.load_manipulation('settings', obj.settings_(), varargin{:}); + end + + function output = load_procedure(obj, varargin) + % LOAD_PROCEDURE Load procedure records. Accepts 'subject','id','tags'. + output = brainstem.load_procedure('settings', obj.settings_(), varargin{:}); + end + + function output = load_subjectlog(obj, varargin) + % LOAD_SUBJECTLOG Load subject log records. Accepts 'subject','type','description','id'. + output = brainstem.load_subjectlog('settings', obj.settings_(), varargin{:}); + end + + function output = load_procedurelog(obj, varargin) + % LOAD_PROCEDURELOG Load procedure log records. Accepts 'subject','id','tags'. + output = brainstem.load_procedurelog('settings', obj.settings_(), varargin{:}); + end + + function output = load_equipment(obj, varargin) + % LOAD_EQUIPMENT Load equipment records. Accepts 'name','session','id','tags'. + output = brainstem.load_equipment('settings', obj.settings_(), varargin{:}); + end + + function output = load_consumablestock(obj, varargin) + % LOAD_CONSUMABLESTOCK Load consumable stock records. Accepts 'subject','id','tags'. + output = brainstem.load_consumablestock('settings', obj.settings_(), varargin{:}); + end + + % ------------------------------------------------------------------ + function disp(obj) + % DISP Display a compact summary of the client state. + authenticated = ~isempty(obj.token); + fprintf(' BrainstemClient +'); + fprintf(' url : %s +', obj.url); + fprintf(' token_type : %s +', obj.token_type); + fprintf(' authenticated: %s +', mat2str(authenticated)); + if authenticated + n = min(8, numel(obj.token)); + fprintf(' token : %s... +', obj.token(1:n)); + end + end + end + + % ---------------------------------------------------------------------- + methods (Access = private) + + function s = settings_(obj) + % Build the settings struct expected by the underlying functions. + s.url = obj.url; + s.token = obj.token; + s.storage = {}; + end + + function token = load_or_request_token_(obj) + % Load a cached token for this URL, refreshing proactively when needed. + % Short-lived tokens are renewed silently via the refresh endpoint. + % Personal tokens warn when expiry is near (< 15 days remaining). + auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); + if exist(auth_path, 'file') + credentials = load(auth_path, 'authentication'); + auth_tbl = credentials.authentication; + idx = find(strcmp(obj.url, auth_tbl.urls)); + if ~isempty(idx) + has_expires = ismember('expires_at', auth_tbl.Properties.VariableNames); + has_saved = ismember('saved_at', auth_tbl.Properties.VariableNames); + has_type = ismember('token_type', auth_tbl.Properties.VariableNames); + has_refresh = ismember('refresh_tokens', auth_tbl.Properties.VariableNames); + + is_short = has_type && strcmp(auth_tbl.token_type{idx}, 'shortlived'); + + if has_expires + days_left = auth_tbl.expires_at{idx} - now; + elseif has_saved + days_left = (auth_tbl.saved_at{idx} + 365) - now; + is_short = false; + else + days_left = Inf; + end + + if days_left <= 0 + if is_short && has_refresh && ~isempty(auth_tbl.refresh_tokens{idx}) + try + token = brainstem.refresh_access_token( ... + obj.url, auth_tbl.refresh_tokens{idx}); + return + catch + warning('BrainstemClient:refreshFailed', ... + 'Automatic token refresh failed — re-authenticating.'); + end + end + warning('BrainstemClient:tokenExpired', ... + 'Saved token expired — re-authenticating.'); + token = brainstem.get_token(obj.url, '', '', obj.token_type); + elseif ~is_short && days_left < 15 + warning('BrainstemClient:tokenNearExpiry', ... + 'BrainSTEM token expires in ~%.0f days.', days_left); + token = auth_tbl.tokens{idx}; + else + token = auth_tbl.tokens{idx}; + end + return + end + end + % No cached token — run the interactive login + token = brainstem.get_token(obj.url, '', '', obj.token_type); + end + + function refresh_token_(obj) + % Re-authenticate and update the stored token. + % For short-lived tokens, the /api/auth/token/refresh/ endpoint is + % tried first; only falls back to interactive login if it fails or + % the refresh token has also expired. + auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); + if exist(auth_path, 'file') + credentials = load(auth_path, 'authentication'); + auth_tbl = credentials.authentication; + idx = find(strcmp(obj.url, auth_tbl.urls)); + if ~isempty(idx) && ... + ismember('token_type', auth_tbl.Properties.VariableNames) && ... + strcmp(auth_tbl.token_type{idx}, 'shortlived') && ... + ismember('refresh_tokens', auth_tbl.Properties.VariableNames) && ... + ~isempty(auth_tbl.refresh_tokens{idx}) + try + obj.token = brainstem.refresh_access_token( ... + obj.url, auth_tbl.refresh_tokens{idx}); + return + catch + % Refresh token also expired — fall through to interactive + end + end + end + warning('BrainstemClient:tokenExpired', ... + 'Token appears expired or invalid — re-authenticating.'); + obj.token = brainstem.get_token(obj.url, '', '', obj.token_type); + end + + function tf = is_auth_error_(~, ME) + % Return true if the error message indicates a 401/403 response. + tf = contains(ME.message, {'401','403','Unauthorized','Forbidden'}, ... + 'IgnoreCase', true); + end + end +end diff --git a/Contents.m b/Contents.m new file mode 100644 index 0000000..5182d19 --- /dev/null +++ b/Contents.m @@ -0,0 +1,37 @@ +% BrainSTEM MATLAB API Tools +% Version 2.0 (March 2026) +% https://github.com/brainstem-org/brainstem_matlab_api_tools +% +% Add the repo root to the MATLAB path: addpath('/path/to/brainstem_matlab_api_tools') +% Do NOT add +brainstem/ itself — MATLAB discovers it automatically. +% +% MAIN ENTRY POINT +% BrainstemClient - Client class (recommended) +% +% PACKAGE FUNCTIONS (call as brainstem. or via client methods) +% brainstem.load_model - Load records from any model +% brainstem.save_model - Create or update records (POST/PUT/PATCH) +% brainstem.delete_model - Delete a record by UUID +% brainstem.get_token - Acquire and cache an API token ('personal' or 'shortlived') +% brainstem.refresh_access_token - Silently renew a short-lived token pair +% brainstem.load_settings - Load settings struct from token cache +% brainstem.get_app_from_model - Map model name to API app prefix +% +% CONVENIENCE LOADERS (also available as client.() methods) +% brainstem.load_project - Projects (includes sessions, subjects) +% brainstem.load_subject - Subjects (includes procedures, logs) +% brainstem.load_session - Sessions (includes behaviors, manipulations) +% brainstem.load_collection - Collections +% brainstem.load_cohort - Cohorts +% brainstem.load_behavior - Behavior records +% brainstem.load_dataacquisition - Data acquisition records +% brainstem.load_manipulation - Manipulation records +% brainstem.load_procedure - Procedure records +% brainstem.load_subjectlog - Subject log records +% brainstem.load_procedurelog - Procedure log records +% brainstem.load_equipment - Equipment records +% brainstem.load_consumablestock - Consumable stock records +% +% TUTORIAL & TESTS +% brainstem_api_tutorial - Example script +% brainstem.BrainstemTests - Unit and integration test class (matlab.unittest) diff --git a/README.md b/README.md index 3240a63..0f2ac35 100644 --- a/README.md +++ b/README.md @@ -3,68 +3,145 @@ The `brainstem_matlab_api_tools` is a MATLAB toolset for interacting with the BrainSTEM API, designed for researchers and developers working with neuroscience data. ## Installation -Download the repository and add the folder to your MATLAB path. +Download the repository and add the **root folder** to your MATLAB path: + +```matlab +addpath('/path/to/brainstem_matlab_api_tools') +``` + +> Only the root needs to be added. MATLAB automatically discovers the `+brainstem` package folder and the `BrainstemClient` class inside it. Do **not** add `+brainstem/` itself to the path. ## Getting Started To get started, refer to the tutorial script `brainstem_api_tutorial.m` for example usage. The tutorial demonstrates how to: -- **Authenticate:** Authentication with your credentials. +- **Authenticate:** Using a Personal Access Token or interactive credentials. - **Loading Data:** Load sessions and filter data using flexible options. -- **Updating Entries:** Modify existing models and update them in the database. +- **Updating Entries:** Partially or fully update existing records. - **Creating Entries:** Submit new data entries with required fields. +- **Deleting Entries:** Remove records by UUID. - **Loading Public Data:** Access public projects and data using the public portal. +- **Pagination:** Load all records across multiple pages automatically. -### Setup Credentials/Token -Run the `get_token` command. You will be prompted to enter your email and password. The token will be saved in a `.mat` file (`brainstem_authentication.mat`) in the MATLAB API tool folder. +## Authentication -## Core Functions Overview -The main functions provided by the BrainSTEM MATLAB API tools are: - -| Function | Description | -|----------|-------------| -| `get_token` | Get and save authentication token | -| `load_model` | Load data from any model | -| `save_model` | Save data to any model | -| `load_settings` | Load local settings including API token, server URL, and local storage | -| `load_project` | Load project(s) with extra filters and relational data options | -| `load_subject` | Load subject(s) with extra filters and relational data options | -| `load_session` | Load session(s) with extra filters and relational data options | -| `brainstem_api_tutorial` | Tutorial script with example calls | +### Recommended: Personal Access Token (scripts, HPC, automation) +Create a token at [brainstem.org/private/users/tokens/](https://www.brainstem.org/private/users/tokens/). +Tokens are valid for 1 year and auto-refresh. -## Example Usage - -### Loading Sessions -You can load models using `load_model`. Example: ```matlab -output1 = load_model('model','session'); -session = output1.sessions(1); +% Option A: environment variable (set once per shell/session) +setenv('BRAINSTEM_TOKEN','') +client = BrainstemClient(); + +% Option B: pass directly +client = BrainstemClient('token',''); ``` -### Filtering and Sorting -You can filter and sort results: +### Interactive login (desktop MATLAB, GUI dialog) ```matlab -output1_1 = load_model('model','session','filter',{'name','yeah'}); -output1_2 = load_model('model','session','sort',{'-name'}); +client = BrainstemClient(); % opens a credential dialog ``` -### Including Related Models -You can load related models as well: +## BrainstemClient (recommended) + +Create the client once; it holds the token and base URL for all subsequent calls: + ```matlab -output1_3 = load_model('model','session','include',{'projects','dataacquisition','behaviors','manipulations'}); -dataacquisition = output1_3.dataacquisition; +client = BrainstemClient('token', getenv('BRAINSTEM_TOKEN')); + +% Load sessions +out = client.load_model('session'); + +% Partial update +patch_data.id = out.sessions(1).id; +patch_data.description = 'updated'; +client.save_model(patch_data, 'session', 'method', 'patch'); + +% Delete +client.delete_model(out.sessions(1).id, 'session'); ``` -### Using Convenience Functions -For easier access, the API provides convenience functions: +## Core Functions Overview + +| Function | Description | +|----------|-------------| +| `BrainstemClient` | Client class — authenticate once, call any endpoint | +| `get_token` | Interactively acquire and cache an API token | +| `load_model` | Load records from any BrainSTEM model | +| `save_model` | Create or update records (POST / PUT / PATCH) | +| `delete_model` | Delete a record by UUID | +| `load_settings` | Load settings struct (URL + token) from cache | +| `get_app_from_model` | Map a model name to its API app prefix | + +## Convenience Loaders + +| Function | Model | Default includes | +|----------|-------|-----------------| +| `load_project` | project | sessions, subjects, collections, cohorts | +| `load_subject` | subject | procedures, subjectlogs | +| `load_session` | session | dataacquisition, behaviors, manipulations, epochs | +| `load_collection` | collection | sessions | +| `load_cohort` | cohort | subjects | +| `load_behavior` | behavior (modules) | — | +| `load_dataacquisition` | dataacquisition (modules) | — | +| `load_manipulation` | manipulation (modules) | — | +| `load_procedure` | procedure (modules) | — | + +## Query Options + +All `load_model` calls (and the convenience loaders) support: + +| Parameter | Description | Example | +|-----------|-------------|---------| +| `filter` | `{field, value}` pairs | `{'name.icontains','Rat'}` | +| `sort` | field names; `-` prefix = descending | `{'-name'}` | +| `include` | relational fields to embed | `{'behaviors','subjects'}` | +| `id` | UUID → fetches single record | `'c5547922-...'` | +| `limit` | max records per page (max 100) | `50` | +| `offset` | records to skip | `20` | +| `load_all` | auto-follow pagination | `true` | +| `portal` | `'private'` or `'public'` | `'public'` | + +### Filter operators +`icontains`, `startswith`, `endswith`, `gt`, `gte`, `lt`, `lte` + +## Example Usage ```matlab -output = load_project('name','myproject'); -output = load_subject('name','mysubject'); -output = load_session('name','mysession'); +client = BrainstemClient('token', getenv('BRAINSTEM_TOKEN')); + +% Load ALL sessions (auto-paginate) +out = client.load_model('session', 'load_all', true); + +% Filter + sort + include +out = client.load_model('session', ... + 'filter', {'name.icontains', 'Rat'}, ... + 'sort', {'-name'}, ... + 'include', {'projects','behaviors'}); + +% Single record by UUID +out = client.load_model('session', 'id', 'c5547922-c973-4ad7-96d3-72789f140024'); + +% Convenience loader +sessions = load_session('name', 'mysession'); +behaviors = load_behavior('session', 'c5547922-c973-4ad7-96d3-72789f140024'); + +% Create +s.name = 'My new session'; s.projects = {''}; s.tags = []; +out = client.save_model(s, 'session'); + +% Partial update (PATCH) +patch.id = out.id; patch.description = 'updated'; +client.save_model(patch, 'session', 'method', 'patch'); + +% Delete +client.delete_model(out.id, 'session'); + +% Public data +public_projects = client.load_model('project', 'portal', 'public'); ``` -These functions are equivalent to detailed API calls using `load_model` with filters and included relational data. ## License This project is licensed under the MIT License. See the `LICENSE` file for details. diff --git a/brainstem_api_tutorial.m b/brainstem_api_tutorial.m index dd54b86..09935be 100644 --- a/brainstem_api_tutorial.m +++ b/brainstem_api_tutorial.m @@ -1,87 +1,115 @@ -% 0. Setup credentials/token. User email and password will be requested to generate the token. - -get_token - -% The token is saved to a mat file, brainstem_authentication.mat, in the Matlab API tool folder. +% BrainSTEM MATLAB API Tutorial +% +% This script demonstrates the recommended workflows using BrainstemClient. +% The client authenticates once and reuses the token for all subsequent calls. +% +% AUTHENTICATION OPTIONS (choose one): +% +% Option A - Personal Access Token (recommended for scripts / HPC / automation): +% Create your token at https://www.brainstem.org/private/users/tokens/ +% Then either: +% setenv('BRAINSTEM_TOKEN','') % set once per MATLAB session +% client = BrainstemClient(); % picks it up automatically +% Or pass it directly: +% client = BrainstemClient('token',''); +% +% Option B - Short-lived JWT tokens (access: 1 h, refresh: 30 days): +% Tokens renew automatically via the refresh endpoint when they expire. +% Useful when you cannot store a long-lived PAT. +% client = BrainstemClient('token_type', 'shortlived'); +% To renew manually: brainstem.refresh_access_token(url, refresh_token) +% +% Option C - Interactive login (GUI dialog, desktop MATLAB only): +% client = BrainstemClient(); % opens credential dialog + +client = BrainstemClient(); %% 1. Loading sessions +% Preferred: use named client methods (tab-completable, credentials automatic) -% load_model can be used to load any model: -output1 = load_model('model','session'); - -% We can fetch a single session entry from the loaded models. +% Load sessions using the convenience method (includes behaviors, manipulations by default) +output1 = client.load_session(); session = output1.sessions(1); -% We can also filter the models by providing cell array with paired filters -% In this example, it will just load sessions whose name is "yeah". -output1_1 = load_model('model','session','filter',{'name','Peters session 2'}); - -% Loaded models can be sorted by different criteria applying to their fields. -% In this example, sessions will be sorted in descending ording according to their name. -output1_2 = load_model('model','session','sort',{'-name'}); +% Load ALL sessions across all pages automatically +output1_all = client.load_session('load_all', true); -% In some cases models contain relations with other models, and they can be also loaded -% with the models if requested. In this example, all the projects, data acquisition, -% behaviors and manipulations related to each session will be included. -output1_3 = load_model('model','session','include',{'projects','dataacquisition','behaviors','manipulations'}); +% Filter by name +output1_1 = client.load_session('name', 'Peters session 2'); -% The list of related data acquisition can be retrived from the returned dictionary. -dataacquisition = output1_3.dataacquisition; +% Sort descending by name +output1_2 = client.load_session('sort', {'-name'}); -% Get all subjects with related procedures -output1_4 = load_model('model','subject','include',{'procedures'}); - -% Get all projects with related subjects and sessions -output1_5 = load_model('model','project','include',{'sessions','subjects'}); +% Fetch a single session by UUID +output1_id = client.load_model('session', 'id', 'c5547922-c973-4ad7-96d3-72789f140024'); -% All these options can be combined to suit the requirements of the users. For example, we can get only the session that -% contain the word "Rat" in their name, sorted in descending order by their name and including the related projects. -output1_6 = load_model('model','session', 'filter',{'name.icontains', 'Rat'}, 'sort',{'-name'}, 'include',{'projects'}); +% Combine filter + sort + include via the generic load_model +output1_6 = client.load_model('session', ... + 'filter', {'name.icontains', 'Rat'}, ... + 'sort', {'-name'}, ... + 'include', {'projects'}); +%% 2. Updating a session (partial update — only send changed fields) -%% 2. Updating a session - -% We can make changes to a model and update it in the database. In this case, we changed the description of -% one of the previously loaded sessions session = output1.sessions(1); -session.description = 'new description'; +patch_data.id = session.id; +patch_data.description = 'updated description'; +output2 = client.save_model(patch_data, 'session', 'method', 'patch'); -% Clearing empty fiels before submitting -fn = fieldnames(session); -tf = cellfun(@(c) isempty(session.(c)), fn); -session = rmfield(session, fn(tf)); -session.tags = []; % Tags is a required field +% Full replace (PUT) is still available: +% session.description = 'new description'; +% session.tags = []; % tags is required by the API +% output2_put = client.save_model(session, 'session'); -% Submitting changes to session -output2 = save_model('data',session,'model','session'); +%% 3. Creating a new session +new_session.name = 'New session 1236567576'; +new_session.description = 'new session description'; +new_session.projects = {'0ed470cf-4b48-49f8-b779-10980a8f9dd6'}; +new_session.tags = []; +output3 = client.save_model(new_session, 'session'); -%% 3. Creating a new session +%% 4. Deleting a record -% We can submit a new entry by defining a dictionary with the required fields. -session = {}; -session.name = 'New session 1236567576'; -session.description = 'new session description'; -session.projects = {'0ed470cf-4b48-49f8-b779-10980a8f9dd6'}; -session.tags = []; +% output_del = client.delete_model(output3.id, 'session'); -% Submitting session -output3 = save_model('data',session,'model','session'); +%% 5. Load public projects +output4 = client.load_model('project', 'portal', 'public'); -%% 4. Load public projects +%% 6. Convenience methods on the client (recommended) +% +% These are the preferred entry points — named, tab-completable, and +% automatically use the client's credentials. -% Request the public data by defining the portal to be public -output4 = load_model('model','project','portal','public'); +output5_1 = client.load_project('name', 'Peters NYU demo project'); +output5_2 = client.load_subject('name', 'Peters subject 2'); +output5_3 = client.load_session('name', 'mysession'); +output5_4 = client.load_behavior('session', 'c5547922-c973-4ad7-96d3-72789f140024'); +output5_5 = client.load_dataacquisition('session', 'c5547922-c973-4ad7-96d3-72789f140024'); +output5_6 = client.load_manipulation('session', 'c5547922-c973-4ad7-96d3-72789f140024'); +output5_7 = client.load_procedure('subject', '274469ce-ccd1-48b1-8631-0a347cee5728'); +output5_8 = client.load_collection('name', 'My Collection'); +output5_9 = client.load_cohort('name', 'My Cohort'); +output5_10 = client.load_subjectlog('subject', '274469ce-ccd1-48b1-8631-0a347cee5728'); +output5_11 = client.load_procedurelog('subject', '274469ce-ccd1-48b1-8631-0a347cee5728'); +output5_12 = client.load_equipment('session', 'c5547922-c973-4ad7-96d3-72789f140024'); +output5_13 = client.load_consumablestock('subject', '274469ce-ccd1-48b1-8631-0a347cee5728'); +% The package functions are also available directly when you need them: +output5_pkg = brainstem.load_session('name', 'mysession'); -%% 5. Convenience functions for projects, subjects, and sessions +%% 7. Using load_model directly (for models without a convenience method) -% Loading a project by its name -output5_1 = load_project('name','Peters NYU demo project'); +% Get all subjects with related procedures +output_subjects = client.load_subject('include', {'procedures'}); + +% Get all projects with related subjects and sessions +output_projects = client.load_project('include', {'sessions','subjects'}); -% Loading a subject by its name -output5_2 = load_subject('name','Peters subject 2'); +% Get consumable resources (no convenience loader — use load_model directly) +output_consumables = client.load_model('consumable', 'app', 'resources'); -% Loading a session by its name -output5_3 = load_session('name','mysession'); +% Paginate manually (first 50, then next 50) +output_page1 = client.load_session('limit', 50, 'offset', 0); +output_page2 = client.load_session('limit', 50, 'offset', 50); diff --git a/brainstem_local_storage.m b/brainstem_local_storage.m deleted file mode 100644 index 4279c37..0000000 --- a/brainstem_local_storage.m +++ /dev/null @@ -1,4 +0,0 @@ -function storage = brainstem_local_storage -% Path to data storage for BrainSTEM - -storage = {}; diff --git a/get_token.m b/get_token.m deleted file mode 100644 index aaa0c61..0000000 --- a/get_token.m +++ /dev/null @@ -1,52 +0,0 @@ -function token = get_token(url,username,password) -% Get token from BrainSTEM server -% A post request is sent to the token URL -% -% Inputs -% url: URL to server. Default: https://www.brainstem.org/ -% username: your username/email -% password: your password - -switch nargin - case 0 - url = 'https://www.brainstem.org/'; - username = ''; - password = ''; - case 1 - username = ''; - password = ''; - case 2 - password = ''; -end - -% Shows an input dialog if the username and password were not provided as inputs -if nargin < 3 - answer = passdlg(username); - if isempty(answer.User{1}) || isempty(answer.Pass{1}) - return - else - username = answer.User{1}; - password = answer.Pass{1}; - end -end - -% json-encoding the username and password -json_data = jsonencode(struct('username',username,'password',password)); - -% Setting options -options = weboptions('HeaderFields',{'Authorization',''},'MediaType','application/json','ContentType','json','ArrayFormat','json','RequestMethod','post'); - -% Sending request to the REST API to get the token -response = webwrite([url,'api/token/'],json_data,options); -token = response.token; - -tokens = {token}; -usernames = {username}; -urls = {url}; - -authentication = table(tokens,usernames,urls); - -% Saving the token -[path1,~,~] = fileparts(which('get_token.m')); -save(fullfile(path1,'brainstem_authentication.mat'),'authentication') -disp(['Tokens saved to ', path1, '/brainstem_authentication.mat']) diff --git a/load_model.m b/load_model.m deleted file mode 100644 index c3d3143..0000000 --- a/load_model.m +++ /dev/null @@ -1,72 +0,0 @@ -function output = load_model(varargin) -% Load model from BrainSTEM - -% Example calls: -% output = load_model('app','stem','model','session'); -% output = load_model('app','stem','model','project'); -% output = load_model('app','resources','model','consumable'); -% output = load_model('app','personal_attributes','model','setup') - -p = inputParser; -addParameter(p,'portal','private',@ischar); % private, public -addParameter(p,'app','',@ischar); % stem, modules, personal_attributes, resources, taxonomies, dissemination, users -addParameter(p,'model','session',@ischar); % project, subject, session, collection, ... -addParameter(p,'settings',load_settings,@isstruct); -addParameter(p,'filter',{},@iscell); % Filter parameters -addParameter(p,'sort',{},@iscell); % Sorting parameters -addParameter(p,'include',{},@iscell); % Embed relational fields -parse(p,varargin{:}) -parameters = p.Results; - -if isempty(parameters.app) - parameters.app = get_app_from_model(parameters.model); -end - -% Setting query parameters -query_parameters = ''; - -% Filter query parameters -if ~isempty(parameters.filter) - for i=1:2:numel(parameters.filter) - if isempty(query_parameters) - prefix = '?'; - else - prefix = '&'; - end - query_parameters = [query_parameters,prefix,'filter{',parameters.filter{i}, '}=',urlencode(parameters.filter{i+1})]; - end -end - -% Sort query parameters -if ~isempty(parameters.sort) - for i=1:numel(parameters.sort) - if isempty(query_parameters) - prefix = '?'; - else - prefix = '&'; - end - query_parameters = [query_parameters,prefix,'sort[]=',parameters.sort{i}]; - end -end - -% Embed relational fields? -if ~isempty(parameters.include) - for i=1:numel(parameters.include) - if isempty(query_parameters) - prefix = '?'; - else - prefix = '&'; - end - query_parameters = [query_parameters,prefix,'include[]=',parameters.include{i},'.*']; - end -end - -% Options -options = weboptions('HeaderFields',{'Authorization',['Bearer ' parameters.settings.token]},'ContentType','json','ArrayFormat','json','RequestMethod','get'); - -% Defining the endpoint url -url = [parameters.settings.url,'api/',parameters.portal,'/',parameters.app,'/',parameters.model,'/',query_parameters]; - -% Sending request to the REST API -output = webread(url,options); - diff --git a/load_project.m b/load_project.m deleted file mode 100644 index e0c6771..0000000 --- a/load_project.m +++ /dev/null @@ -1,52 +0,0 @@ -function output = load_project(varargin) -% Load project(s) from BrainSTEM - -% Example calls: -% output = load_project('id','ee57e766-fc0c-42e1-9277-7d40d6e9353a'); -% output = load_project('name','Peters Project'); -% output = load_project('filter',{'id','ee57e766-fc0c-42e1-9277-7d40d6e9353a'}); -% output = load_project('tags','1') - -p = inputParser; -addParameter(p,'portal','private',@ischar); % private, public, admin -addParameter(p,'app','stem',@ischar); % stem, modules, personal_attributes, resources, taxonomies, dissemination, users -addParameter(p,'model','project',@isstruct); % project, subject, session, collection, ... -addParameter(p,'settings',load_settings,@isstr); -addParameter(p,'filter',{},@iscell); % Filter parameters -addParameter(p,'sort',{},@iscell); % Sorting parameters -addParameter(p,'include',{'sessions','subjects','collections','cohorts'},@iscell); % Embed relational fields - -% Project fields (extra parameters) -addParameter(p,'id','',@ischar); % id of project -addParameter(p,'name','',@ischar); % name of project -addParameter(p,'description','',@ischar); % description of project -addParameter(p,'sessions','',@ischar); % sessions -addParameter(p,'subjects','',@ischar); % subjects -addParameter(p,'tags','',@ischar); % tags of project (id of tag) -addParameter(p,'is_public','',@islogical); % project is public - -parse(p,varargin{:}) -parameters = p.Results; - -% Filter query parameters -extra_parameters = {'id','name','description','sessions','subjects','tags','is_public'}; -for i = 1:length(extra_parameters) - if ~isempty(parameters.(extra_parameters{i})) - switch extra_parameters{i} - case 'id' - parameters.filter = [parameters.filter; {'id',parameters.(extra_parameters{i})}]; - case 'name' - parameters.filter = [parameters.filter; {'name.icontains',parameters.(extra_parameters{i})}]; - case 'sessions' - parameters.filter = [parameters.filter; {'sessions.id',parameters.(extra_parameters{i})}]; - case 'subjects' - parameters.filter = [parameters.filter; {'subjects.id',parameters.(extra_parameters{i})}]; - case 'tags' - parameters.filter = [parameters.filter; {'tags',parameters.(extra_parameters{i})}]; - otherwise - parameters.filter = [parameters.filter; {[extra_parameters{i},'.icontains'],parameters.(extra_parameters{i})}]; - end - end -end - -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model,'settings',parameters.settings,'sort',parameters.sort,'filter',parameters.filter,'include',parameters.include); diff --git a/load_session.m b/load_session.m deleted file mode 100644 index aa881e9..0000000 --- a/load_session.m +++ /dev/null @@ -1,51 +0,0 @@ -function output = load_session(varargin) -% Load session(s) from BrainSTEM - -% Example calls: -% output = load_session('id','c5547922-c973-4ad7-96d3-72789f140024'); -% output = load_session('name','New session'); -% output = load_session('filter',{'id','c5547922-c973-4ad7-96d3-72789f140024'}); -% output = load_session('tags','1') - -p = inputParser; -addParameter(p,'portal','private',@ischar); % private, public, admin -addParameter(p,'app','stem',@ischar); % stem, modules, personal_attributes, resources, taxonomies, dissemination, users -addParameter(p,'model','session',@isstruct); % project, subject, session, collection, ... -addParameter(p,'settings',load_settings,@isstr); -addParameter(p,'filter',{},@iscell); % Filter parameters -addParameter(p,'sort',{},@iscell); % Sorting parameters -addParameter(p,'include',{'dataacquisition','behaviors','manipulations','epochs'},@iscell); % Embed relational fields - -% Session fields (extra parameters) -addParameter(p,'id','',@ischar); % id of session -addParameter(p,'name','',@ischar); % name of session -addParameter(p,'description','',@ischar); % description of session -addParameter(p,'projects','',@ischar); % date and time of session -addParameter(p,'datastorage','',@ischar); % datastorage of session -addParameter(p,'tags','',@ischar); % tags of session (id of tag) - -parse(p,varargin{:}) -parameters = p.Results; - -% Filter query parameters -extra_parameters = {'id','name','description','projects','datastorage','tags'}; -for i = 1:length(extra_parameters) - if ~isempty(parameters.(extra_parameters{i})) - switch extra_parameters{i} - case 'id' - parameters.filter = [parameters.filter; {'id',parameters.(extra_parameters{i})}]; - case 'name' - parameters.filter = [parameters.filter; {'name.icontains',parameters.(extra_parameters{i})}]; - case 'projects' - parameters.filter = [parameters.filter; {'projects.id',parameters.(extra_parameters{i})}]; - case 'datastorage' - parameters.filter = [parameters.filter; {'datastorage.id',parameters.(extra_parameters{i})}]; - case 'tags' - parameters.filter = [parameters.filter; {'tags',parameters.(extra_parameters{i})}]; - otherwise - parameters.filter = [parameters.filter; {[extra_parameters{i},'.icontains'],parameters.(extra_parameters{i})}]; - end - end -end - -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model,'settings',parameters.settings,'sort',parameters.sort,'filter',parameters.filter,'include',parameters.include); diff --git a/load_settings.m b/load_settings.m deleted file mode 100644 index 05e8856..0000000 --- a/load_settings.m +++ /dev/null @@ -1,23 +0,0 @@ -function settings = load_settings -% function for loading local settings used for connecting to BrainSTEM - -% url to server -settings.url = 'https://www.brainstem.org/'; -% settings.url = 'https://brainstem-development.herokuapp.com/'; -% settings.url = 'http://127.0.0.1:8000/'; - -% Authentication info -if exist('brainstem_authentication.mat','file') - credentials1 = load('brainstem_authentication.mat','authentication'); - idx = find(ismember(settings.url,credentials1.authentication.urls)); - if isempty(idx) - settings.token = get_token(settings.url); - else - settings.token = credentials1.authentication.tokens{idx}; - end -else - settings.token = get_token(settings.url); -end - -% Local storage -settings.storage = brainstem_local_storage; diff --git a/save_model.m b/save_model.m deleted file mode 100644 index 95ed1d1..0000000 --- a/save_model.m +++ /dev/null @@ -1,41 +0,0 @@ -function output = save_model(varargin) -% Save database model to BrainSTEM - -% Example calls: -% output = save_model('data',data,'app','stem','model','session'); -% output = save_model('data',data,'app','stem','model','project'); -% output = save_model('data',data,'app','resources','model','consumable'); -% output = save_model('data',data,'app','personal_attributes','model','setup') - -p = inputParser; -addParameter(p,'portal','private',@ischar); % private, public -addParameter(p,'app','',@ischar); % stem, modules, personal_attributes, resources, taxonomies, dissemination, users -addParameter(p,'model','session',@ischar); % project, subject, session, collection, ... -addParameter(p,'settings',load_settings,@isstr); -addParameter(p,'data',{},@isstruct); % private, public -parse(p,varargin{:}) -parameters = p.Results; - -if isempty(parameters.app) - parameters.app = get_app_from_model(parameters.model); -end - -% Setting options -options = weboptions('HeaderFields',{'Authorization',['Bearer ' parameters.settings.token]},'MediaType','application/json','ContentType','json','ArrayFormat','json'); - -if isfield(parameters.data,'id') - % Setting RequestMethod - options.RequestMethod = 'put'; - - % Defining endpoint url: - brainstem_endpoint = [parameters.settings.url,'api/',parameters.portal,'/',parameters.app,'/',parameters.model,'/',parameters.data.id,'/']; -else - % Setting RequestMethod - options.RequestMethod = 'post'; - - % Defining endpoint url: - brainstem_endpoint = [parameters.settings.url,'api/',parameters.portal,'/',parameters.app,'/',parameters.model,'/']; -end - -% Sending request to the REST API -output = webwrite(brainstem_endpoint,parameters.data,options); diff --git a/set_basic_authorization.m b/set_basic_authorization.m deleted file mode 100644 index c60db4e..0000000 --- a/set_basic_authorization.m +++ /dev/null @@ -1,25 +0,0 @@ -function set_basic_authorization(username,password) -% Save creditials to a base64encoded char -switch nargin - case 0 - username = ''; - password = ''; - case 1 - password = ''; -end - -% Shows a input dialog if the username/email and password were not provided as inputs -if nargin<2 - answer = inputdlg({'Username/Email:','Password:'},'BrainSTEM credentials',[1 60],{username,password}); - username = answer{1}; - password = answer{2}; -end - -% Generating base64 encoded credentials -credentials = ['Basic ' matlab.net.base64encode([username ':' password])]; - -% Saving the credentials -[path1,~,~] = fileparts(which('brainstem_set_basic_authorization.m')); -save(fullfile(path1,'brainstem_credentials_encoded.mat'),'credentials') - -disp('Credentials saved to brainstem_credentials_encoded.mat') From de2e39a93c5fdf544c4238db5cbed21421080e64 Mon Sep 17 00:00:00 2001 From: "Peter C. Petersen" Date: Sun, 29 Mar 2026 17:21:05 +0200 Subject: [PATCH 2/6] Refactor API: rename load/delete/get_token Rename and refactor core BrainSTEM API helpers and update tests accordingly. load_model/delete_model/save_model were renamed to load/delete/save and callers updated to use brainstem.* namespaced functions. get_token was reworked to use the device authorization flow with a fallback manual PAT flow, persist tokens to prefdir, and simplified signature; token save/update logic was added. load* helpers now accept empty settings (load_settings is invoked lazily), expose limit/offset/load_all pagination args, omit Authorization header for public requests, and surface unified error IDs (e.g. 'BrainSTEM:load'/'BrainSTEM:delete'). Unit tests were updated to match API changes and client token_type handling (PAT-only flow). Miscellaneous: inputParser validation tweaks, minor I/O/text improvements, and function renames in the +brainstem package files. --- +brainstem/BrainstemTests.m | 212 +++++------ +brainstem/{delete_model.m => delete.m} | 19 +- +brainstem/get_token.m | 164 +++++---- +brainstem/{load_model.m => load.m} | 53 +-- +brainstem/load_behavior.m | 13 +- +brainstem/load_cohort.m | 13 +- +brainstem/load_collection.m | 13 +- +brainstem/load_consumablestock.m | 13 +- +brainstem/load_dataacquisition.m | 13 +- +brainstem/load_equipment.m | 13 +- +brainstem/load_manipulation.m | 13 +- +brainstem/load_procedure.m | 13 +- +brainstem/load_procedurelog.m | 13 +- +brainstem/load_project.m | 15 +- +brainstem/load_session.m | 15 +- +brainstem/load_settings.m | 30 +- +brainstem/load_subject.m | 15 +- +brainstem/load_subjectlog.m | 13 +- +brainstem/passdlg.m | 339 ----------------- +brainstem/passfield.m | 346 ------------------ .../private/brainstem_parse_api_error.m | 10 + +brainstem/refresh_access_token.m | 58 --- +brainstem/{save_model.m => save.m} | 29 +- BrainstemClient.m | 138 +++---- Contents.m | 6 +- README.md | 42 +-- brainstem_api_tutorial.m | 38 +- 27 files changed, 478 insertions(+), 1181 deletions(-) rename +brainstem/{delete_model.m => delete.m} (68%) rename +brainstem/{load_model.m => load.m} (64%) delete mode 100644 +brainstem/passdlg.m delete mode 100644 +brainstem/passfield.m delete mode 100644 +brainstem/refresh_access_token.m rename +brainstem/{save_model.m => save.m} (73%) diff --git a/+brainstem/BrainstemTests.m b/+brainstem/BrainstemTests.m index 50ea47c..49ffc0c 100644 --- a/+brainstem/BrainstemTests.m +++ b/+brainstem/BrainstemTests.m @@ -32,140 +32,140 @@ % get_app_from_model % ------------------------------------------------------------------ function testAppFromModelSession(tc) - tc.verifyEqual(get_app_from_model('session'), 'stem'); + tc.verifyEqual(brainstem.get_app_from_model('session'), 'stem'); end function testAppFromModelProject(tc) - tc.verifyEqual(get_app_from_model('project'), 'stem'); + tc.verifyEqual(brainstem.get_app_from_model('project'), 'stem'); end function testAppFromModelBreeding(tc) - tc.verifyEqual(get_app_from_model('breeding'), 'stem'); + tc.verifyEqual(brainstem.get_app_from_model('breeding'), 'stem'); end function testAppFromModelSubject(tc) - tc.verifyEqual(get_app_from_model('subject'), 'stem'); + tc.verifyEqual(brainstem.get_app_from_model('subject'), 'stem'); end function testAppFromModelBehavior(tc) - tc.verifyEqual(get_app_from_model('behavior'), 'modules'); + tc.verifyEqual(brainstem.get_app_from_model('behavior'), 'modules'); end function testAppFromModelDataAcquisition(tc) - tc.verifyEqual(get_app_from_model('dataacquisition'), 'modules'); + tc.verifyEqual(brainstem.get_app_from_model('dataacquisition'), 'modules'); end function testAppFromModelManipulation(tc) - tc.verifyEqual(get_app_from_model('manipulation'), 'modules'); + tc.verifyEqual(brainstem.get_app_from_model('manipulation'), 'modules'); end function testAppFromModelSetup(tc) - tc.verifyEqual(get_app_from_model('setup'), 'personal_attributes'); + tc.verifyEqual(brainstem.get_app_from_model('setup'), 'personal_attributes'); end function testAppFromModelConsumable(tc) - tc.verifyEqual(get_app_from_model('consumable'), 'resources'); + tc.verifyEqual(brainstem.get_app_from_model('consumable'), 'resources'); end function testAppFromModelSpecies(tc) - tc.verifyEqual(get_app_from_model('species'), 'taxonomies'); + tc.verifyEqual(brainstem.get_app_from_model('species'), 'taxonomies'); end function testAppFromModelPublication(tc) - tc.verifyEqual(get_app_from_model('publication'), 'dissemination'); + tc.verifyEqual(brainstem.get_app_from_model('publication'), 'dissemination'); end function testAppFromModelUser(tc) - tc.verifyEqual(get_app_from_model('user'), 'users'); + tc.verifyEqual(brainstem.get_app_from_model('user'), 'users'); end function testAppFromModelUnknown(tc) - tc.verifyEmpty(get_app_from_model('nonexistent_model_xyz')); + tc.verifyEmpty(brainstem.get_app_from_model('nonexistent_model_xyz')); end % stem (remaining) function testAppFromModelCollection(tc) - tc.verifyEqual(get_app_from_model('collection'), 'stem'); + tc.verifyEqual(brainstem.get_app_from_model('collection'), 'stem'); end function testAppFromModelCohort(tc) - tc.verifyEqual(get_app_from_model('cohort'), 'stem'); + tc.verifyEqual(brainstem.get_app_from_model('cohort'), 'stem'); end % modules (remaining) function testAppFromModelProcedure(tc) - tc.verifyEqual(get_app_from_model('procedure'), 'modules'); + tc.verifyEqual(brainstem.get_app_from_model('procedure'), 'modules'); end function testAppFromModelEquipment(tc) - tc.verifyEqual(get_app_from_model('equipment'), 'modules'); + tc.verifyEqual(brainstem.get_app_from_model('equipment'), 'modules'); end function testAppFromModelConsumableStock(tc) - tc.verifyEqual(get_app_from_model('consumablestock'), 'modules'); + tc.verifyEqual(brainstem.get_app_from_model('consumablestock'), 'modules'); end function testAppFromModelProcedureLog(tc) - tc.verifyEqual(get_app_from_model('procedurelog'), 'modules'); + tc.verifyEqual(brainstem.get_app_from_model('procedurelog'), 'modules'); end function testAppFromModelSubjectLog(tc) - tc.verifyEqual(get_app_from_model('subjectlog'), 'modules'); + tc.verifyEqual(brainstem.get_app_from_model('subjectlog'), 'modules'); end % personal_attributes function testAppFromModelBehavioralAssay(tc) - tc.verifyEqual(get_app_from_model('behavioralassay'), 'personal_attributes'); + tc.verifyEqual(brainstem.get_app_from_model('behavioralassay'), 'personal_attributes'); end function testAppFromModelDataStorage(tc) - tc.verifyEqual(get_app_from_model('datastorage'), 'personal_attributes'); + tc.verifyEqual(brainstem.get_app_from_model('datastorage'), 'personal_attributes'); end function testAppFromModelInventory(tc) - tc.verifyEqual(get_app_from_model('inventory'), 'personal_attributes'); + tc.verifyEqual(brainstem.get_app_from_model('inventory'), 'personal_attributes'); end function testAppFromModelProtocol(tc) - tc.verifyEqual(get_app_from_model('protocol'), 'personal_attributes'); + tc.verifyEqual(brainstem.get_app_from_model('protocol'), 'personal_attributes'); end % resources function testAppFromModelHardwareDevice(tc) - tc.verifyEqual(get_app_from_model('hardwaredevice'), 'resources'); + tc.verifyEqual(brainstem.get_app_from_model('hardwaredevice'), 'resources'); end function testAppFromModelSupplier(tc) - tc.verifyEqual(get_app_from_model('supplier'), 'resources'); + tc.verifyEqual(brainstem.get_app_from_model('supplier'), 'resources'); end % taxonomies function testAppFromModelStrain(tc) - tc.verifyEqual(get_app_from_model('strain'), 'taxonomies'); + tc.verifyEqual(brainstem.get_app_from_model('strain'), 'taxonomies'); end function testAppFromModelBrainRegion(tc) - tc.verifyEqual(get_app_from_model('brainregion'), 'taxonomies'); + tc.verifyEqual(brainstem.get_app_from_model('brainregion'), 'taxonomies'); end function testAppFromModelSetupType(tc) - tc.verifyEqual(get_app_from_model('setuptype'), 'taxonomies'); + tc.verifyEqual(brainstem.get_app_from_model('setuptype'), 'taxonomies'); end function testAppFromModelBehavioralParadigm(tc) - tc.verifyEqual(get_app_from_model('behavioralparadigm'), 'taxonomies'); + tc.verifyEqual(brainstem.get_app_from_model('behavioralparadigm'), 'taxonomies'); end function testAppFromModelRegulatoryAuthority(tc) - tc.verifyEqual(get_app_from_model('regulatoryauthority'), 'taxonomies'); + tc.verifyEqual(brainstem.get_app_from_model('regulatoryauthority'), 'taxonomies'); end % dissemination function testAppFromModelJournal(tc) - tc.verifyEqual(get_app_from_model('journal'), 'dissemination'); + tc.verifyEqual(brainstem.get_app_from_model('journal'), 'dissemination'); end % users function testAppFromModelLaboratory(tc) - tc.verifyEqual(get_app_from_model('laboratory'), 'users'); + tc.verifyEqual(brainstem.get_app_from_model('laboratory'), 'users'); end function testAppFromModelGroupMembershipInvitation(tc) - tc.verifyEqual(get_app_from_model('groupmembershipinvitation'), 'users'); + tc.verifyEqual(brainstem.get_app_from_model('groupmembershipinvitation'), 'users'); end function testAppFromModelGroupMembershipRequest(tc) - tc.verifyEqual(get_app_from_model('groupmembershiprequest'), 'users'); + tc.verifyEqual(brainstem.get_app_from_model('groupmembershiprequest'), 'users'); end % auth function testAppFromModelGroup(tc) - tc.verifyEqual(get_app_from_model('group'), 'auth'); + tc.verifyEqual(brainstem.get_app_from_model('group'), 'auth'); end % ------------------------------------------------------------------ @@ -189,7 +189,7 @@ function testBuildUrlPublicPortal(tc) function testBuildUrlTrailingSlashOnBase(tc) % Base URL without trailing slash should still produce valid URL got = brainstem_build_url('https://www.brainstem.org', 'private', 'stem', 'session', ''); - tc.verifyTrue(endsWith_(got, 'session/')); + tc.verifyTrue(tc.endsWith_(got, 'session/')); end % ------------------------------------------------------------------ @@ -238,7 +238,7 @@ function testQueryStringOffset(tc) function testQueryStringStartsWithQuestionMark(tc) qs = brainstem_build_query_string({'name','x'}, {}, {}, [], 0); - tc.verifyTrue(startsWith_(qs, '?'), qs); + tc.verifyTrue(tc.startsWith_(qs, '?'), qs); end % ------------------------------------------------------------------ @@ -287,16 +287,12 @@ function testFieldFiltersPreservesExistingFilter(tc) end function testFieldFiltersDefaultsToIcontains(tc) - % Fields not in filter_map get .icontains key + % Fields not in filter_map default to .icontains p.filter = {}; p.description = 'baseline'; result = brainstem_apply_field_filters(p, {'description'}, ... - containers.Map.empty); % empty map forces default - % Can't easily pass empty Map — test via a filter_map that - % does not include 'description': - result2 = brainstem_apply_field_filters(p, {'description'}, ... - {'name','name.icontains'}); - tc.verifyTrue(any(strcmp(result2(:,1), 'description.icontains'))); + {'name','name.icontains'}); % 'description' not in map + tc.verifyTrue(any(strcmp(result(:,1), 'description.icontains'))); end % ------------------------------------------------------------------ @@ -309,23 +305,17 @@ function testClientConstructorWithToken(tc) tc.verifyEqual(client.url, tc.BASE_URL); end - function testClientConstructorTokenTypeShortlived(tc) - client = BrainstemClient('token', 'tok', 'token_type', 'shortlived'); - tc.verifyEqual(client.token_type, 'shortlived'); - end - - function testClientConstructorTokenTypeCaseInsensitive(tc) - % 'Shortlived' and 'PERSONAL' both accepted - c1 = BrainstemClient('token', 'tok', 'token_type', 'Shortlived'); - tc.verifyEqual(c1.token_type, 'shortlived'); - c2 = BrainstemClient('token', 'tok', 'token_type', 'PERSONAL'); - tc.verifyEqual(c2.token_type, 'personal'); + function testClientConstructorTokenTypeIsPersonal(tc) + % token_type is always 'personal' (PAT-only flow) + client = BrainstemClient('token', 'tok'); + tc.verifyEqual(client.token_type, 'personal'); end - function testClientConstructorInvalidTokenTypeErrors(tc) + function testClientConstructorUnknownParamErrors(tc) + % Passing an unknown parameter should throw tc.verifyError( ... - @() BrainstemClient('token', 'tok', 'token_type', 'badtype'), ... - 'MATLAB:InputParser:ArgumentFailedValidation'); + @() BrainstemClient('token', 'tok', 'token_type', 'shortlived'), ... + 'MATLAB:InputParser:UnmatchedParameter'); end function testClientDispRunsWithoutError(tc) @@ -358,49 +348,36 @@ function testParseApiErrorNonJsonObject(tc) end % ------------------------------------------------------------------ - % get_token — input validation (no network call made) + % get_token — signature tests % ------------------------------------------------------------------ - function testGetTokenRejectsInvalidType(tc) - % Should throw before any network call since token_type is invalid + function testGetTokenTooManyArgsErrors(tc) + % get_token now only accepts one argument (url) tc.verifyError( ... - @() get_token(tc.BASE_URL, 'u@u.com', 'pass', 'badtype'), ... - 'BrainSTEM:getToken'); - end - - function testGetTokenAcceptsPersonal(tc) - % Valid token_type='personal' passes validation - % (will fail at network — that's expected, we just test the guard) - try - get_token(tc.BASE_URL, 'bad@user.com', 'wrongpass', 'personal'); - catch ME - tc.verifyNotEqual(ME.identifier, 'BrainSTEM:getToken', ... - 'Should not throw a getToken validation error for ''personal'''); - end + @() brainstem.get_token(tc.BASE_URL, 'extra_arg'), ... + 'MATLAB:TooManyInputs'); end - function testGetTokenAcceptsShortlived(tc) - % Valid token_type='shortlived' passes validation - try - get_token(tc.BASE_URL, 'bad@user.com', 'wrongpass', 'shortlived'); - catch ME - tc.verifyNotEqual(ME.identifier, 'BrainSTEM:getToken', ... - 'Should not throw a getToken validation error for ''shortlived'''); - end + function testGetTokenNoArgsUsesDefault(tc) + % get_token() with no args should not throw a signature error. + % We verify this by checking the function accepts 0 args via nargin, + % without actually invoking it (which would open a browser/dialog). + f = functions(str2func('brainstem.get_token')); + tc.verifyNotEmpty(f, 'brainstem.get_token should be resolvable'); end % ------------------------------------------------------------------ - % save_model validation (no network needed) + % save validation (no network needed) % ------------------------------------------------------------------ function testSaveModelPatchWithoutIdErrors(tc) % PATCH without id in data must throw immediately, before any % network call. settings = struct('url', tc.BASE_URL, 'token', 'fake', 'storage', {{}}); tc.verifyError( ... - @() save_model('data', struct('description', 'x'), ... + @() brainstem.save('data', struct('description', 'x'), ... 'model', 'session', ... 'method', 'patch', ... 'settings', settings), ... - 'BrainSTEM:saveModel'); + 'BrainSTEM:save'); end % ------------------------------------------------------------------ @@ -429,6 +406,19 @@ function testParseApiErrorFallsBackToRaw(tc) tc.verifyEqual(msg, raw); end + function testClientTokenTypeIsPersonal(tc) + client = BrainstemClient('token', 'tok'); + tc.verifyEqual(client.token_type, 'personal'); + end + + function testClientSavePatchGuardOffline(tc) + % PATCH without id must throw before any network round-trip + client = BrainstemClient('token', 'fake'); + tc.verifyError( ... + @() client.save(struct('description','x'), 'session', 'method','patch'), ... + 'BrainSTEM:save'); + end + end % offline tests % ====================================================================== @@ -438,7 +428,7 @@ function testParseApiErrorFallsBackToRaw(tc) function testLoadPublicProjects(tc) settings = struct('url', tc.BASE_URL, 'token', '', 'storage', {{}}); - out = load_model('model', 'project', 'portal', 'public', ... + out = brainstem.load('model', 'project', 'portal', 'public', ... 'settings', settings, 'limit', 5); tc.verifyTrue(isstruct(out)); tc.verifyTrue(isfield(out, 'projects') || isfield(out, 'count'), ... @@ -447,13 +437,14 @@ function testLoadPublicProjects(tc) function testLoadPublicProjectsStructure(tc) settings = struct('url', tc.BASE_URL, 'token', '', 'storage', {{}}); - out = load_model('model', 'project', 'portal', 'public', ... + out = brainstem.load('model', 'project', 'portal', 'public', ... 'settings', settings, 'limit', 1); if isfield(out, 'projects') && ~isempty(out.projects) - tc.verifyTrue(isfield(out.projects(1), 'id'), ... - 'Project record should have an id field'); - tc.verifyTrue(isfield(out.projects(1), 'name'), ... - 'Project record should have a name field'); + proj = out.projects(1); + tc.verifyTrue(isstruct(proj), ... + 'Each project record should be a struct'); + tc.verifyGreaterThan(numel(fieldnames(proj)), 0, ... + 'Project record should have at least one field'); end end @@ -468,7 +459,7 @@ function testLoadSessions(tc) tc.assumeNotEmpty(tc.TOKEN, ... 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); - out = load_model('model', 'session', 'settings', settings, 'limit', 5); + out = brainstem.load('model', 'session', 'settings', settings, 'limit', 5); tc.verifyTrue(isstruct(out)); tc.verifyTrue(isfield(out, 'sessions') || isfield(out, 'count')); end @@ -477,7 +468,7 @@ function testLoadSubjects(tc) tc.assumeNotEmpty(tc.TOKEN, ... 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); - out = load_model('model', 'subject', 'settings', settings, 'limit', 5); + out = brainstem.load('model', 'subject', 'settings', settings, 'limit', 5); tc.verifyTrue(isstruct(out)); end @@ -485,7 +476,7 @@ function testLoadProjects(tc) tc.assumeNotEmpty(tc.TOKEN, ... 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); - out = load_model('model', 'project', 'settings', settings, 'limit', 5); + out = brainstem.load('model', 'project', 'settings', settings, 'limit', 5); tc.verifyTrue(isstruct(out)); end @@ -494,10 +485,12 @@ function testLoadModelById(tc) 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); % First fetch a list to get a real id - out = load_model('model', 'project', 'settings', settings, 'limit', 1); + out = brainstem.load('model', 'project', 'settings', settings, 'limit', 1); if isfield(out, 'projects') && ~isempty(out.projects) - id = out.projects(1).id; - rec = load_model('model', 'project', 'settings', settings, 'id', id); + first = out.projects; + if iscell(first); first = first{1}; else; first = first(1); end + id = first.id; + rec = brainstem.load('model', 'project', 'settings', settings, 'id', id); tc.verifyTrue(isstruct(rec)); tc.verifyTrue(isfield(rec, 'id') || isfield(rec, 'project')); end @@ -507,7 +500,7 @@ function testLoadModelPagination(tc) tc.assumeNotEmpty(tc.TOKEN, ... 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); - out = load_model('model', 'session', 'settings', settings, ... + out = brainstem.load('model', 'session', 'settings', settings, ... 'limit', 2, 'offset', 0); tc.verifyTrue(isstruct(out)); end @@ -549,8 +542,8 @@ function testClientLoadAll(tc) 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); client = BrainstemClient('token', tc.TOKEN); % load_all should return at least as many records as a single page - out_page = client.load_model('project', 'limit', 1); - out_all = client.load_model('project', 'load_all', true); + out_page = client.load('project', 'limit', 1); + out_all = client.load('project', 'load_all', true); if isfield(out_page, 'count') && out_page.count > 1 data_key = setdiff(fieldnames(out_all), {'count','next','previous'}); if ~isempty(data_key) @@ -567,23 +560,6 @@ function testClientDispAuthenticated(tc) tc.verifyWarningFree(@() disp(client)); end - function testClientTokenTypeProperty(tc) - tc.assumeNotEmpty(tc.TOKEN, ... - 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); - client = BrainstemClient('token', tc.TOKEN); - tc.verifyEqual(client.token_type, 'personal'); - end - - function testClientSaveModelPatchGuard(tc) - tc.assumeNotEmpty(tc.TOKEN, ... - 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); - client = BrainstemClient('token', tc.TOKEN); - % PATCH without id must throw before any network round-trip - tc.verifyError( ... - @() client.save_model(struct('description','x'), 'session', 'method','patch'), ... - 'BrainSTEM:saveModel'); - end - end % authenticated tests % ====================================================================== diff --git a/+brainstem/delete_model.m b/+brainstem/delete.m similarity index 68% rename from +brainstem/delete_model.m rename to +brainstem/delete.m index dad5176..53c751d 100644 --- a/+brainstem/delete_model.m +++ b/+brainstem/delete.m @@ -1,8 +1,8 @@ -function output = delete_model(id, model, varargin) -% DELETE_MODEL Delete a record from a BrainSTEM API endpoint. +function output = delete(id, model, varargin) +% DELETE Delete a record from a BrainSTEM API endpoint. % -% output = delete_model(ID, MODEL) -% output = delete_model(ID, MODEL, 'portal', 'private', 'settings', settings) +% output = delete(ID, MODEL) +% output = delete(ID, MODEL, 'portal', 'private', 'settings', settings) % % Parameters: % id - UUID string of the record to delete (required) @@ -12,17 +12,20 @@ % settings - Settings struct from load_settings (loaded automatically if omitted) % % Example: -% delete_model('c5547922-c973-4ad7-96d3-72789f140024', 'session'); +% brainstem.delete('c5547922-c973-4ad7-96d3-72789f140024', 'session'); p = inputParser; addParameter(p,'portal', 'private', @ischar); addParameter(p,'app', '', @ischar); -addParameter(p,'settings',load_settings,@isstruct); +addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); parse(p, varargin{:}) parameters = p.Results; +if isempty(parameters.settings) + parameters.settings = brainstem.load_settings(); +end if isempty(parameters.app) - parameters.app = get_app_from_model(model); + parameters.app = brainstem.get_app_from_model(model); end options = weboptions( ... @@ -40,7 +43,7 @@ if contains(ME.message, '204') output = struct('status', 'deleted', 'id', id); else - error('BrainSTEM:deleteModel', 'API error deleting %s %s: %s', ... + error('BrainSTEM:delete', 'API error deleting %s %s: %s', ... model, id, brainstem_parse_api_error(ME)); end end diff --git a/+brainstem/get_token.m b/+brainstem/get_token.m index 60364c9..802efa8 100644 --- a/+brainstem/get_token.m +++ b/+brainstem/get_token.m @@ -1,77 +1,111 @@ -function token = get_token(url, username, password, token_type) -% GET_TOKEN Obtain an authentication token from the BrainSTEM server. +function token = get_token(url) +% GET_TOKEN Obtain a Personal Access Token from the BrainSTEM server. % -% BrainSTEM supports two token types: +% Uses the browser-based device authorization flow: +% 1. POST /api/auth/device/ → {state, verification_url, expires_in} +% 2. Opens verification_url in the default browser so the user can log +% in (including 2FA if enabled). +% 3. Polls GET /api/auth/device/token/?state= until the user +% completes the web login, then returns the issued token. % -% 'personal' (default) — Personal Access Token: long-lived, sliding -% 1-year window. Recommended for scripts and automation. -% POST /api/token/ → {access, token_id, message} -% -% 'shortlived' — Short-lived JWT pair: access token (1 hour) + refresh -% token (30 days). The access token is renewed silently -% via refresh_access_token when it expires; the refresh -% token rotates on each use. -% POST /api/auth/token/ → {access, refresh, expires_in} +% Fallback: if the server does not support the device flow (older +% deployments), the user is prompted to paste a Personal Access Token +% obtained from private/users/tokens/. % % Parameters: -% url - Server URL (default: https://www.brainstem.org/) -% username - Email / username (prompted via GUI if omitted) -% password - Password (prompted via GUI if omitted) -% token_type - 'personal' (default) or 'shortlived' - -if nargin < 1 || isempty(url), url = 'https://www.brainstem.org/'; end -if nargin < 2, username = ''; end -if nargin < 3, password = ''; end -if nargin < 4 || isempty(token_type), token_type = 'personal'; end - -token_type = lower(token_type); -if ~ismember(token_type, {'personal','shortlived'}) - error('BrainSTEM:getToken', ... - 'token_type must be ''personal'' or ''shortlived'', got ''%s''.', token_type); -end - -% Show GUI dialog if credentials are missing -if isempty(username) || isempty(password) - answer = passdlg(username); - if isempty(answer.User{1}) || isempty(answer.Pass{1}) - token = ''; - return - end - username = answer.User{1}; - password = answer.Pass{1}; +% 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', ... - 'ArrayFormat', 'json', ... 'RequestMethod','post'); +try + resp = webwrite([url 'api/auth/device/'], struct(), options_post); + token = device_flow_(url, resp); +catch + % Server does not support device flow — prompt for manual PAT entry + token = manual_pat_flow_(url); +end + +if ~isempty(token) + save_token_(url, token); +end +end + +% ------------------------------------------------------------------------- +function token = device_flow_(url, resp) +state = resp.state; +auth_url = resp.verification_url; +expires_in = 300; +if isfield(resp, 'expires_in'), expires_in = resp.expires_in; end -if strcmp(token_type, 'shortlived') - % Short-lived JWT: access (1 h) + refresh (30 days) - % Note: this endpoint uses 'email' as the field name - json_data = jsonencode(struct('email', username, 'password', password)); - response = webwrite([url, 'api/auth/token/'], json_data, options_post); - token = response.access; - refresh_token = response.refresh; - expires_in = 3600; - if isfield(response, 'expires_in') - expires_in = response.expires_in; +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(auth_url, '-browser'); +catch + % Headless or web() unavailable — user must open manually +end + +fprintf('Waiting for authentication (timeout: %d s) ...', expires_in); +options_get = weboptions('ContentType','json','RequestMethod','get'); +poll_url = [url 'api/auth/device/token/?state=' state]; +deadline = now + expires_in / 86400; +token = ''; +while now < deadline + pause(3); + fprintf('.'); + try + r = webread(poll_url, options_get); + catch + continue + end + if ~isfield(r,'status'), continue; end + switch r.status + case 'complete' + token = r.token; + fprintf('\nAuthenticated successfully.\n\n'); + return + case 'expired' + fprintf('\n'); + error('BrainSTEM:deviceAuthExpired', ... + 'Authentication request expired. Please call get_token() again.'); + % 'pending' → keep waiting end - expires_at = now + expires_in / 86400; +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 - % Personal / backward-compatible: sliding ~1-year PAT - json_data = jsonencode(struct('username', username, 'password', password)); - response = webwrite([url, 'api/token/'], json_data, options_post); - token = response.access; - refresh_token = ''; - expires_at = now + 365; + token = strtrim(answer{1}); +end end -% --- Persist credentials, preserving any other stored URLs --------------- -auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); -new_row = table({token}, {username}, {url}, {now}, {token_type}, ... - {refresh_token}, {expires_at}, ... +% ------------------------------------------------------------------------- +function save_token_(url, token) +auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); +expires_at = now + 365; % Personal Access Tokens are valid for ~1 year +new_row = table({token}, {''}, {url}, {now}, {'personal'}, {''}, {expires_at}, ... 'VariableNames', {'tokens','usernames','urls','saved_at', ... 'token_type','refresh_tokens','expires_at'}); @@ -94,22 +128,20 @@ end idx = find(strcmp(url, tbl.urls)); if ~isempty(idx) - % Update the existing entry for this URL tbl.tokens{idx} = token; - tbl.usernames{idx} = username; + tbl.usernames{idx} = ''; tbl.saved_at{idx} = now; - tbl.token_type{idx} = token_type; - tbl.refresh_tokens{idx} = refresh_token; + tbl.token_type{idx} = 'personal'; + tbl.refresh_tokens{idx} = ''; tbl.expires_at{idx} = expires_at; authentication = tbl; %#ok else - % New URL — append a row authentication = [tbl; new_row]; %#ok end else authentication = new_row; %#ok end -save(auth_path, 'authentication') -disp(['Token saved to ', auth_path]) +save(auth_path, 'authentication'); +fprintf('Token saved.\n'); end diff --git a/+brainstem/load_model.m b/+brainstem/load.m similarity index 64% rename from +brainstem/load_model.m rename to +brainstem/load.m index 5afa1fd..e53294c 100644 --- a/+brainstem/load_model.m +++ b/+brainstem/load.m @@ -1,7 +1,7 @@ -function output = load_model(varargin) -% LOAD_MODEL Retrieve records from a BrainSTEM API endpoint. +function output = load(varargin) +% LOAD Retrieve records from a BrainSTEM API endpoint. % -% output = load_model('model', MODEL) returns records for MODEL. +% output = load('model', MODEL) returns records for MODEL. % % Parameters: % model - Model name, e.g. 'session', 'project', 'subject' (default: 'session') @@ -17,38 +17,48 @@ % settings - Settings struct from load_settings (loaded automatically if omitted) % % Examples: -% output = load_model('model','session'); -% output = load_model('model','session','id','c5547922-c973-4ad7-96d3-72789f140024'); -% output = load_model('model','session','filter',{'name.icontains','Rat'},'sort',{'-name'}); -% output = load_model('model','session','include',{'behaviors','manipulations'}); -% output = load_model('model','session','load_all',true); -% output = load_model('model','project','portal','public'); +% output = brainstem.load('model','session'); +% output = brainstem.load('model','session','id','c5547922-c973-4ad7-96d3-72789f140024'); +% 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',load_settings, @isstruct); +addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); addParameter(p,'filter', {}, @iscell); addParameter(p,'sort', {}, @iscell); addParameter(p,'include', {}, @iscell); -addParameter(p,'limit', [], @(x) isnumeric(x) && isscalar(x)); +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.load_settings(); +end if isempty(parameters.app) - parameters.app = get_app_from_model(parameters.model); + parameters.app = brainstem.get_app_from_model(parameters.model); end -% Auth header -options = weboptions( ... - 'HeaderFields', {'Authorization', ['Bearer ' parameters.settings.token]}, ... - 'ContentType', 'json', ... - 'ArrayFormat', 'json', ... - 'RequestMethod','get'); +% Auth header — omit when token is empty (e.g. public portal requests) +if isempty(parameters.settings.token) + options = weboptions( ... + 'ContentType', 'json', ... + 'ArrayFormat', 'json', ... + 'RequestMethod','get'); +else + options = weboptions( ... + 'HeaderFields', {'Authorization', ['Bearer ' parameters.settings.token]}, ... + 'ContentType', 'json', ... + 'ArrayFormat', 'json', ... + 'RequestMethod','get'); +end % Single-record fetch by id if ~isempty(parameters.id) @@ -57,7 +67,7 @@ try output = webread(url, options); catch ME - error('BrainSTEM:loadModel', 'API error fetching %s: %s', url, brainstem_parse_api_error(ME)); + error('BrainSTEM:load', 'API error fetching %s: %s', url, brainstem_parse_api_error(ME)); end return end @@ -70,7 +80,7 @@ try output = webread(url, options); catch ME - error('BrainSTEM:loadModel', 'API error fetching %s: %s', url, brainstem_parse_api_error(ME)); + error('BrainSTEM:load', 'API error fetching %s: %s', url, brainstem_parse_api_error(ME)); end % Auto-paginate: keep fetching while there is a 'next' URL @@ -89,7 +99,7 @@ try next_page = webread(output.next, options); catch ME - error('BrainSTEM:loadModel', 'API error fetching next page: %s', brainstem_parse_api_error(ME)); + error('BrainSTEM:load', 'API error fetching next page: %s', brainstem_parse_api_error(ME)); end % Append records if ~isempty(model_key) && isfield(output, model_key) && isfield(next_page, model_key) @@ -102,4 +112,3 @@ end end end - diff --git a/+brainstem/load_behavior.m b/+brainstem/load_behavior.m index 93cf449..b8cd35c 100644 --- a/+brainstem/load_behavior.m +++ b/+brainstem/load_behavior.m @@ -10,20 +10,27 @@ addParameter(p,'portal', 'private', @ischar); addParameter(p,'app', 'modules', @ischar); addParameter(p,'model', 'behavior', @ischar); -addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); addParameter(p,'filter', {}, @iscell); addParameter(p,'sort', {}, @iscell); addParameter(p,'include', {}, @iscell); addParameter(p,'id', '', @ischar); addParameter(p,'session', '', @ischar); addParameter(p,'tags', '', @ischar); +addParameter(p,'limit', [], @(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.load_settings(); +end extra_fields = {'id','session','tags'}; filter_map = {'id','id'; 'session','session.id'; 'tags','tags'}; parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... +output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include); + 'filter',parameters.filter,'include',parameters.include, ... + 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); diff --git a/+brainstem/load_cohort.m b/+brainstem/load_cohort.m index 84452fe..95f5ea9 100644 --- a/+brainstem/load_cohort.m +++ b/+brainstem/load_cohort.m @@ -10,7 +10,7 @@ addParameter(p,'portal', 'private', @ischar); addParameter(p,'app', 'stem', @ischar); addParameter(p,'model', 'cohort', @ischar); -addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); addParameter(p,'filter', {}, @iscell); addParameter(p,'sort', {}, @iscell); addParameter(p,'include', {'subjects'}, @iscell); @@ -18,13 +18,20 @@ addParameter(p,'name', '', @ischar); addParameter(p,'description', '', @ischar); addParameter(p,'tags', '', @ischar); +addParameter(p,'limit', [], @(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.load_settings(); +end extra_fields = {'id','name','description','tags'}; filter_map = {'id','id'; 'name','name.icontains'; 'tags','tags'}; parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... +output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include); + 'filter',parameters.filter,'include',parameters.include, ... + 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); diff --git a/+brainstem/load_collection.m b/+brainstem/load_collection.m index 2e518b5..49ba980 100644 --- a/+brainstem/load_collection.m +++ b/+brainstem/load_collection.m @@ -10,7 +10,7 @@ addParameter(p,'portal', 'private', @ischar); addParameter(p,'app', 'stem', @ischar); addParameter(p,'model', 'collection', @ischar); -addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); addParameter(p,'filter', {}, @iscell); addParameter(p,'sort', {}, @iscell); addParameter(p,'include', {'sessions'}, @iscell); @@ -18,13 +18,20 @@ addParameter(p,'name', '', @ischar); addParameter(p,'description', '', @ischar); addParameter(p,'tags', '', @ischar); +addParameter(p,'limit', [], @(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.load_settings(); +end extra_fields = {'id','name','description','tags'}; filter_map = {'id','id'; 'name','name.icontains'; 'tags','tags'}; parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... +output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include); + 'filter',parameters.filter,'include',parameters.include, ... + 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); diff --git a/+brainstem/load_consumablestock.m b/+brainstem/load_consumablestock.m index 592c9e5..50eef4e 100644 --- a/+brainstem/load_consumablestock.m +++ b/+brainstem/load_consumablestock.m @@ -10,15 +10,21 @@ addParameter(p,'portal', 'private', @ischar); addParameter(p,'app', 'modules', @ischar); addParameter(p,'model', 'consumablestock', @ischar); -addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); addParameter(p,'filter', {}, @iscell); addParameter(p,'sort', {}, @iscell); addParameter(p,'include', {}, @iscell); addParameter(p,'id', '', @ischar); addParameter(p,'subject', '', @ischar); addParameter(p,'tags', '', @ischar); +addParameter(p,'limit', [], @(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.load_settings(); +end extra_fields = {'id','subject','tags'}; filter_map = {'id', 'id'; ... @@ -26,6 +32,7 @@ 'tags', 'tags'}; parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... +output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include); + 'filter',parameters.filter,'include',parameters.include, ... + 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); diff --git a/+brainstem/load_dataacquisition.m b/+brainstem/load_dataacquisition.m index 70ea27f..e484fbf 100644 --- a/+brainstem/load_dataacquisition.m +++ b/+brainstem/load_dataacquisition.m @@ -10,20 +10,27 @@ addParameter(p,'portal', 'private', @ischar); addParameter(p,'app', 'modules', @ischar); addParameter(p,'model', 'dataacquisition', @ischar); -addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); addParameter(p,'filter', {}, @iscell); addParameter(p,'sort', {}, @iscell); addParameter(p,'include', {}, @iscell); addParameter(p,'id', '', @ischar); addParameter(p,'session', '', @ischar); addParameter(p,'tags', '', @ischar); +addParameter(p,'limit', [], @(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.load_settings(); +end extra_fields = {'id','session','tags'}; filter_map = {'id','id'; 'session','session.id'; 'tags','tags'}; parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... +output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include); + 'filter',parameters.filter,'include',parameters.include, ... + 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); diff --git a/+brainstem/load_equipment.m b/+brainstem/load_equipment.m index 0fee259..61ab53f 100644 --- a/+brainstem/load_equipment.m +++ b/+brainstem/load_equipment.m @@ -11,7 +11,7 @@ addParameter(p,'portal', 'private', @ischar); addParameter(p,'app', 'modules', @ischar); addParameter(p,'model', 'equipment', @ischar); -addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); addParameter(p,'filter', {}, @iscell); addParameter(p,'sort', {}, @iscell); addParameter(p,'include', {}, @iscell); @@ -19,8 +19,14 @@ addParameter(p,'name', '', @ischar); addParameter(p,'session', '', @ischar); addParameter(p,'tags', '', @ischar); +addParameter(p,'limit', [], @(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.load_settings(); +end extra_fields = {'id','name','session','tags'}; filter_map = {'id', 'id'; ... @@ -29,6 +35,7 @@ 'tags', 'tags'}; parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... +output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include); + 'filter',parameters.filter,'include',parameters.include, ... + 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); diff --git a/+brainstem/load_manipulation.m b/+brainstem/load_manipulation.m index d80141c..0305f85 100644 --- a/+brainstem/load_manipulation.m +++ b/+brainstem/load_manipulation.m @@ -10,20 +10,27 @@ addParameter(p,'portal', 'private', @ischar); addParameter(p,'app', 'modules', @ischar); addParameter(p,'model', 'manipulation', @ischar); -addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); addParameter(p,'filter', {}, @iscell); addParameter(p,'sort', {}, @iscell); addParameter(p,'include', {}, @iscell); addParameter(p,'id', '', @ischar); addParameter(p,'session', '', @ischar); addParameter(p,'tags', '', @ischar); +addParameter(p,'limit', [], @(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.load_settings(); +end extra_fields = {'id','session','tags'}; filter_map = {'id','id'; 'session','session.id'; 'tags','tags'}; parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... +output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include); + 'filter',parameters.filter,'include',parameters.include, ... + 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); diff --git a/+brainstem/load_procedure.m b/+brainstem/load_procedure.m index 99296f2..47580e9 100644 --- a/+brainstem/load_procedure.m +++ b/+brainstem/load_procedure.m @@ -10,20 +10,27 @@ addParameter(p,'portal', 'private', @ischar); addParameter(p,'app', 'modules', @ischar); addParameter(p,'model', 'procedure', @ischar); -addParameter(p,'settings',load_settings, @isstruct); +addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); addParameter(p,'filter', {}, @iscell); addParameter(p,'sort', {}, @iscell); addParameter(p,'include', {}, @iscell); addParameter(p,'id', '', @ischar); addParameter(p,'subject', '', @ischar); addParameter(p,'tags', '', @ischar); +addParameter(p,'limit', [], @(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.load_settings(); +end extra_fields = {'id','subject','tags'}; filter_map = {'id','id'; 'subject','subject.id'; 'tags','tags'}; parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... +output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include); + 'filter',parameters.filter,'include',parameters.include, ... + 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); diff --git a/+brainstem/load_procedurelog.m b/+brainstem/load_procedurelog.m index 5382693..03b6a6f 100644 --- a/+brainstem/load_procedurelog.m +++ b/+brainstem/load_procedurelog.m @@ -10,15 +10,21 @@ addParameter(p,'portal', 'private', @ischar); addParameter(p,'app', 'modules', @ischar); addParameter(p,'model', 'procedurelog', @ischar); -addParameter(p,'settings', load_settings, @isstruct); +addParameter(p,'settings', [], @(x) isempty(x)||isstruct(x)); addParameter(p,'filter', {}, @iscell); addParameter(p,'sort', {}, @iscell); addParameter(p,'include', {}, @iscell); addParameter(p,'id', '', @ischar); addParameter(p,'subject', '', @ischar); addParameter(p,'tags', '', @ischar); +addParameter(p,'limit', [], @(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.load_settings(); +end extra_fields = {'id','subject','tags'}; filter_map = {'id', 'id'; ... @@ -26,6 +32,7 @@ 'tags', 'tags'}; parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... +output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include); + 'filter',parameters.filter,'include',parameters.include, ... + 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); diff --git a/+brainstem/load_project.m b/+brainstem/load_project.m index 0f56b4e..d2ff55f 100644 --- a/+brainstem/load_project.m +++ b/+brainstem/load_project.m @@ -11,7 +11,7 @@ addParameter(p,'portal','private',@ischar); % private, public, admin addParameter(p,'app','stem',@ischar); % stem, modules, personal_attributes, resources, taxonomies, dissemination, users addParameter(p,'model','project',@ischar); % project, subject, session, collection, ... -addParameter(p,'settings',load_settings,@isstruct); +addParameter(p,'settings',[],@(x) isempty(x)||isstruct(x)); addParameter(p,'filter',{},@iscell); % Filter parameters addParameter(p,'sort',{},@iscell); % Sorting parameters addParameter(p,'include',{'sessions','subjects','collections','cohorts'},@iscell); % Embed relational fields @@ -24,9 +24,13 @@ addParameter(p,'subjects','',@ischar); % subjects addParameter(p,'tags','',@ischar); % tags of project (id of tag) addParameter(p,'is_public','',@islogical); % project is public - -parse(p,varargin{:}) +addParameter(p,'limit', [], @(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.load_settings(); +end extra_fields = {'id','name','description','sessions','subjects','tags','is_public'}; filter_map = {'id', 'id'; ... @@ -36,6 +40,7 @@ 'tags', 'tags'}; parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... +output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include); + 'filter',parameters.filter,'include',parameters.include, ... + 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); diff --git a/+brainstem/load_session.m b/+brainstem/load_session.m index 0942513..e54d181 100644 --- a/+brainstem/load_session.m +++ b/+brainstem/load_session.m @@ -11,7 +11,7 @@ addParameter(p,'portal','private',@ischar); % private, public, admin addParameter(p,'app','stem',@ischar); % stem, modules, personal_attributes, resources, taxonomies, dissemination, users addParameter(p,'model','session',@ischar); % project, subject, session, collection, ... -addParameter(p,'settings',load_settings,@isstruct); +addParameter(p,'settings',[],@(x) isempty(x)||isstruct(x)); addParameter(p,'filter',{},@iscell); % Filter parameters addParameter(p,'sort',{},@iscell); % Sorting parameters addParameter(p,'include',{'dataacquisition','behaviors','manipulations','epochs'},@iscell); % Embed relational fields @@ -23,9 +23,13 @@ addParameter(p,'projects','',@ischar); % date and time of session addParameter(p,'datastorage','',@ischar); % datastorage of session addParameter(p,'tags','',@ischar); % tags of session (id of tag) - -parse(p,varargin{:}) +addParameter(p,'limit', [], @(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.load_settings(); +end extra_fields = {'id','name','description','projects','datastorage','tags'}; filter_map = {'id', 'id'; ... @@ -35,6 +39,7 @@ 'tags', 'tags'}; parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... +output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include); + 'filter',parameters.filter,'include',parameters.include, ... + 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); diff --git a/+brainstem/load_settings.m b/+brainstem/load_settings.m index aeb13c8..f8b47e7 100644 --- a/+brainstem/load_settings.m +++ b/+brainstem/load_settings.m @@ -14,18 +14,13 @@ % Use strcmp to get the correct index into a multi-URL table. idx = find(strcmp(settings.url, credentials1.authentication.urls)); if isempty(idx) - settings.token = get_token(settings.url); + settings.token = brainstem.get_token(settings.url); else auth_tbl = credentials1.authentication; % Determine remaining lifetime using expires_at (preferred) or % saved_at for backward-compatible old .mat files. has_expires = ismember('expires_at', auth_tbl.Properties.VariableNames); has_saved = ismember('saved_at', auth_tbl.Properties.VariableNames); - has_type = ismember('token_type', auth_tbl.Properties.VariableNames); - has_refresh = ismember('refresh_tokens', auth_tbl.Properties.VariableNames); - - is_short = has_type && strcmp(auth_tbl.token_type{idx}, 'shortlived'); - if has_expires days_left = auth_tbl.expires_at{idx} - now; elseif has_saved @@ -36,23 +31,10 @@ end if days_left <= 0 - if is_short && has_refresh && ~isempty(auth_tbl.refresh_tokens{idx}) - % Silently renew using the refresh token - try - settings.token = refresh_access_token(settings.url, ... - auth_tbl.refresh_tokens{idx}); - return - catch - warning('BrainSTEM:refreshFailed', ... - 'Automatic token refresh failed — re-authenticating.'); - end - settings.token = get_token(settings.url, '', '', 'shortlived'); - else - warning('BrainSTEM:tokenExpired', ... - 'Saved token has expired — re-authenticating.'); - settings.token = get_token(settings.url); - end - elseif ~is_short && days_left < 15 + warning('BrainSTEM:tokenExpired', ... + 'Saved token has expired — re-authenticating.'); + settings.token = brainstem.get_token(settings.url); + elseif days_left < 15 % Personal token approaching expiry — warn, but keep using it warning('BrainSTEM:tokenNearExpiry', ... ['BrainSTEM personal access token expires in ~%.0f days. ' ... @@ -63,7 +45,7 @@ end end else - settings.token = get_token(settings.url); + settings.token = brainstem.get_token(settings.url); end % Local storage (set to empty; configure manually if needed) diff --git a/+brainstem/load_subject.m b/+brainstem/load_subject.m index 027a76a..e8a03ec 100644 --- a/+brainstem/load_subject.m +++ b/+brainstem/load_subject.m @@ -13,7 +13,7 @@ addParameter(p,'portal','private',@ischar); % private, public addParameter(p,'app','stem',@ischar); % stem, modules, personal_attributes, resources, taxonomies, dissemination, users addParameter(p,'model','subject',@ischar); % project, subject, session, collection, ... -addParameter(p,'settings',load_settings,@isstruct); +addParameter(p,'settings',[],@(x) isempty(x)||isstruct(x)); addParameter(p,'filter',{},@iscell); % Filter parameters addParameter(p,'sort',{},@iscell); % Sorting parameters addParameter(p,'include',{'procedures','subjectlogs'},@iscell); % Embed relational fields @@ -26,9 +26,13 @@ addParameter(p,'strain','',@ischar); % strain of subject addParameter(p,'sex','',@ischar); % sec of subject addParameter(p,'tags','',@ischar); % tags of project (id of tag) - -parse(p,varargin{:}) +addParameter(p,'limit', [], @(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.load_settings(); +end extra_fields = {'id','name','description','projects','strain','sex','tags'}; filter_map = {'id', 'id'; ... @@ -39,6 +43,7 @@ 'tags', 'tags'}; parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... +output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include); + 'filter',parameters.filter,'include',parameters.include, ... + 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); diff --git a/+brainstem/load_subjectlog.m b/+brainstem/load_subjectlog.m index 0f66cd6..b5268f2 100644 --- a/+brainstem/load_subjectlog.m +++ b/+brainstem/load_subjectlog.m @@ -14,7 +14,7 @@ addParameter(p,'portal', 'private', @ischar); addParameter(p,'app', 'modules', @ischar); addParameter(p,'model', 'subjectlog', @ischar); -addParameter(p,'settings', load_settings, @isstruct); +addParameter(p,'settings', [], @(x) isempty(x)||isstruct(x)); addParameter(p,'filter', {}, @iscell); addParameter(p,'sort', {}, @iscell); addParameter(p,'include', {}, @iscell); @@ -22,8 +22,14 @@ addParameter(p,'subject', '', @ischar); addParameter(p,'type', '', @ischar); addParameter(p,'description', '', @ischar); +addParameter(p,'limit', [], @(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.load_settings(); +end extra_fields = {'id','subject','type','description'}; filter_map = {'id', 'id'; ... @@ -32,6 +38,7 @@ 'description', 'description.icontains'}; parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); -output = load_model('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... +output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include); + 'filter',parameters.filter,'include',parameters.include, ... + 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); diff --git a/+brainstem/passdlg.m b/+brainstem/passdlg.m deleted file mode 100644 index 5b41d77..0000000 --- a/+brainstem/passdlg.m +++ /dev/null @@ -1,339 +0,0 @@ -function answer = passdlg(username, varargin) -% PASSDLG Create and open password dialog box -% -% PASSDLG(UITYPE) It will always produce one masked password field plus the -% optional components: -% - 'u' or 'UsernameField' -% - 'c' or 'ConfirmPass' -% - 's' or 'ShowHideCheckBox' -% -% UITYPE can be a cell array with above named fields or a string with -% the initial letters 'u', 'c' and 's', e.g. passdlg('cs'). -% -% PASSDLG(..., Name, Value) Supports Name/Value pairs of figure properties. -% -% -% ANSWER = ... -% Returns a structure with cellstring fields (by default {''}) -% .User -% .Pass -% -% NOTE: pressing the button Cancel, Esc or closing the figure will -% return empty fields. -% -% -% Examples: -% % Simple password dialog -% out = passdlg(); -% -% % Add username and password confirmation field -% out = passdlg('uc'); % or passdlg({'UsernameField','ConfirmPass'}) -% -% See also: PASSFIELD, INPUTDLG - -% Author: Oleg Komarov (oleg.komarov@hotmail.it) -% Tested on R2014a Win7 64bit -% 2014 Sep 21 - Created - -uitype = 'us'; - -% Parse UI type -[hasUsernameField, hasConfirmPassword, hasShowCheckBox] = getUItype(uitype); -offset = (hasUsernameField + hasConfirmPassword)*40 + hasShowCheckBox*30 + ~hasShowCheckBox*10; - -% Figure -fh = figure('DockControls' , 'off',... - 'IntegerHandle' , 'off',... - 'InvertHardcopy', 'off',... - 'KeyPressFcn' , @kpf_figure, ... - 'MenuBar' , 'none',... - 'NumberTitle' , 'off',... - 'Resize' , 'on',... - 'UserData' , 'Cancel',... - 'Visible' , 'off',... - 'WindowStyle' , 'modal',... - 'Name' , 'BrainSTEM credentials',... - 'Position' , [0, 0, 175 83.6 + offset]); - -% Params for resize -h.FigMinWidth = 250; -h.FigHeight = 83.6 + offset; -h.OkFromRight = h.FigMinWidth - 59; -h.CancelFromRight = h.FigMinWidth - 117; - -% Axes (for text labels) -ah = axes('Parent',fh,'Position',[0 0 1 1],'Visible','off'); - -% Get some default properties -defaults = getDefaults; - -% Preallocate edit handles -h.edit = {}; - -% Username field and label -if hasUsernameField - h.edit{end+1} = uicontrol(fh, ... - defaults.EdInfo , ... - 'Max' ,1, ... - 'Position' ,[5, 36.6 + offset, 165, 23]); - h.labeluser = text('Parent',ah, ... - defaults.TextInfo , ... - 'Position' ,[5 59.6 + offset], ... - 'String' ,'Username' , ... - 'Interpreter','none'); - offset = offset - 40; -end - -% Password field -h.edit{end+1} = passfield('Parent',fh,'Position', [5, 36.6 + offset, 165, 23], 'BackgroundColor', [1,1,1]); - -% Password label -h.labelpass = text('Parent',ah,... - defaults.TextInfo,... - 'Position' ,[5 59.6 + offset],... - 'String' , 'Password',... - 'Interpreter','none'); - -% Confirm password -if hasConfirmPassword - offset = offset - 40; - h.edit{end+1} = passfield('Parent',fh,... - 'Position' , [5, 36.6 + offset, 165, 23],... - 'BackgroundColor', [1,1,1]); - h.labelpassconf = text('Parent',ah,... - defaults.TextInfo,... - 'Position' ,[5 59.6 + offset],... - 'String' , 'Confirm password',... - 'Interpreter','none'); -end - -% Show/hide password checkbox -if hasShowCheckBox - offset = offset - 28; - h.cbshow = uicontrol(fh, ... - defaults.CbInfo,... - 'Position',[5, 36.6 + offset, 165, 23],... - 'String' , 'Show password',... - 'Callback', {@clb_checkbox,h}); -end - -% OK button -h.button(1) = uicontrol(fh,... - defaults.BtnInfo , ... - 'Position' ,[59, 5, 53, 26.6] , ... - 'KeyPressFcn',@kpf_button, ... - 'String' ,'OK',... - 'UserData' ,'OK',... - 'Callback' ,@clb_button); - -% Cancel button -h.button(2) = uicontrol(fh,... - defaults.BtnInfo , ... - 'Position' ,[117 5 53 26.6],... - 'KeyPressFcn',@kpf_button,... - 'String' ,'Cancel',... - 'UserData' ,'Cancel',... - 'Callback' ,@clb_button); - -% fh.setDefaultButton(h.btnok); - -% Add password check on confirmation field -if hasConfirmPassword - set(h.edit{end}, 'KeyPressFcn', {@kpf_confirmpass,h}) -end - -% Add resize function -set(fh,'ResizeFcn', {@doResize, h}); - -% Make it visible and apply additional settings -movegui(fh,'center') -if nargin > 1 - set(fh, varargin{:}); -end -set(fh,'Visible','on'); -drawnow -h.edit{1}.String = username; -uicontrol(h.edit{1}) - -% Go into uiwait/modal -if ishghandle(fh), uiwait(fh); end - -% Parse outputs -if ishghandle(fh) - [answer.User, answer.Pass] = deal({''}); - if strcmp(get(fh,'UserData'),'OK'), - if hasUsernameField - answer.User = get(h.edit{1},{'String'}); - end - answer.Pass = get(h.edit{end},{'Password'}); - end - delete(fh); -else - [answer.User, answer.Pass] = deal({''}); -end -drawnow -end - -function kpf_figure(obj, evd) -switch(evd.Key) - case {'return','space'} - uiresume(gcbf); - case {'escape'} - delete(gcbf); -end -end - -function kpf_button(obj, evd) -switch(evd.Key) - case {'return'} - if ~strcmp(get(obj,'UserData'),'Cancel') - set(gcbf,'UserData','OK'); - uiresume(gcbf); - else - delete(gcbf) - end - case 'escape' - delete(gcbf) -end -end - -function kpf_confirmpass(obj,evd,varargin) -Data = varargin{1}; -passedit = Data.edit{end-1}; -if numel(obj.Password) >= numel(passedit.Password) || all(obj.BorderColor == [0 1 0]) - if strcmp(obj.Password, passedit.Password) - green = [0 1 0]; - obj.BorderColor = green; - passedit.BorderColor = green; - set(Data.button(1), 'Enable','on'); - else - red = [1 0 0]; - obj.BorderColor = red; - passedit.BorderColor = red; - set(Data.button(1), 'Enable','off'); - end -end -end - -function clb_button(obj, evd) -if ~strcmp(get(obj,'UserData'),'Cancel') - set(gcbf,'UserData','OK'); - uiresume(gcbf); -else - delete(gcbf) -end -end - -% Show/hide password -function clb_checkbox(obj,evd,varargin) -Data = varargin{1}; -isChecked = get(obj,'Value'); -for ii = 1:numel(Data.edit) - editbox = Data.edit{ii}; - if isa(editbox,'passfield') - if isChecked - editbox.show; - else - editbox.hide; - end - end -end -end - -% Horizontal resize -function doResize(fh, evd, varargin) -Data = varargin{1}; -resetPos = false; - -FigPos = get(fh,'Position'); -FigWidth = FigPos(3); -FigHeight = FigPos(4); - -% Keep min width -widthDiff = Data.FigMinWidth - FigWidth; -if widthDiff >= 1 - FigWidth = Data.FigMinWidth; - FigPos(3) = Data.FigMinWidth; - resetPos = true; -end - -% Resize edit fields -for ii = 1:length(Data.edit) - EditPos = get(Data.edit{ii},'Position'); - EditPos(3) = FigWidth - 10; - set(Data.edit{ii},'Position',EditPos); -end - -% Reposition buttons -ButtonPos = get(Data.button(1),'Position'); -ButtonPos(1) = FigWidth - Data.OkFromRight; -set(Data.button(1),'Position',ButtonPos); -ButtonPos = get(Data.button(2),'Position'); -ButtonPos(1) = FigWidth - Data.CancelFromRight; -set(Data.button(2),'Position',ButtonPos); - -% Keep height fixed -heightDiff = abs(FigHeight - Data.FigHeight); -if heightDiff >= 1 - FigPos(4) = Data.FigHeight; - resetPos = true; -end - -if resetPos, set(fh,'Position',FigPos); end -end - -% Parse arguments to main function -function [u, c, s] = getUItype(type) -ucs = false(1,3); -if iscellstr(type) - validoptions = {'UsernameField','ConfirmPass','ShowHideCheckBox'}; - for ii = 1:numel(type) - n = numel(type{ii}); - tmp = strncmpi(type{ii}, validoptions, n); - if ~any(tmp) - warning('passdlg:unrecognizeType','Unrecognized option ''%s''.',type{ii}) - continue - end - ucs = ucs | tmp; - if all(ucs), break, end - end -elseif ischar(type) && isrow(type) - ucs = any(bsxfun(@eq, 'ucs',type'),1); -end -u = ucs(1); -c = ucs(2); -s = ucs(3); -end - -% Retrieve hardcoded defaults -function s = getDefaults -s.FigColor = get(0,'DefaultFigureColor'); -s.TextInfo = struct('Units','pixels',... - 'FontSize' ,8,... - 'FontWeight' ,'normal',... - 'HorizontalAlignment' ,'left',... - 'HandleVisibility' ,'callback',... - 'VerticalAlignment' , 'bottom',... - 'BackgroundColor' ,s.FigColor); -s.BtnInfo = struct('Units', 'pixels',... - 'FontSize' ,8,... - 'FontWeight' ,'normal',... - 'HorizontalAlignment' ,'center',... - 'HandleVisibility' ,'callback',... - 'Style' ,'pushbutton',... - 'BackgroundColor' , s.FigColor); -s.EdInfo = struct('Units', 'pixels',... - 'FontSize' ,8,... - 'FontWeight' ,'normal',... - 'HorizontalAlignment' ,'left',... - 'HandleVisibility' ,'callback',... - 'Style' ,'edit',... - 'BackgroundColor' ,[1,1,1]); -s.CbInfo = struct('Units', 'pixels',... - 'FontSize' ,8,... - 'FontWeight' ,'normal',... - 'HorizontalAlignment' ,'left',... - 'HandleVisibility' ,'callback',... - 'Style' ,'checkbox',... - 'BackgroundColor' , s.FigColor); -end \ No newline at end of file diff --git a/+brainstem/passfield.m b/+brainstem/passfield.m deleted file mode 100644 index c47a037..0000000 --- a/+brainstem/passfield.m +++ /dev/null @@ -1,346 +0,0 @@ -classdef passfield < hgsetget -% PASSFIELD Create a password field -% -% PASSFIELD(Name, Value) Supports Name/Value pair syntax as in -% uicontrol(). -% Valid names of properties are: -% * 'EchoChar' - character masking the password -% * 'Password' - password in plain text -% * Some uicontrol properties -% -% Warning: -% This code heavily relies on undocumented and unsupported Matlab functionality. -% Use at your own risk! -% -% Additional features: -% - Submit/check issues on Github -% - Undocumented Matlab -% -% See also: PASSDLG, UICONTROL, JAVACOMPONENT - -% Author: Oleg Komarov (oleg.komarov@hotmail.it) -% Tested on R2014a Win7 64bit -% 2014 Jul 07 - created -% 2014 Sep 21 - added FontName property - - properties - BackgroundColor % Background color as short/long name or RGB triplet - BorderColor = [171,173,179] % Border color as short/long name or RGB triplet -% BusyAction = 'queue' -% ButtonDownFcn - Callback % Perform on action - EchoChar = char(9679); % Character displayed in the field -% Enable = 'on' - FontName % Font name for displaying string (affects size) - FontSize % Font size for displaying string - ForegroundColor % Text color as short/long name or RGB triplet -% HandleVisibility = 'on' -% HitTest = 'on' - HorizontalAlignment = 'left' % Alignment for password string - IsMasked = true; % Password is masked with EchoChar -% Interruptible = 'on' - KeyPressFcn % Key press callback function - Password % Password string in plain text - Position % Size and location of the password field -% Selected = 'off' - Tag % Identifier - TooltipString % Tooltip text - UIContextMenu % Context menu associated with the field - Units = 'pixels' % Units of measurement - UserData % Data associated with the field - Visible = 'on' % Visibility of the field - end - - properties(Hidden,SetAccess=private) - Parent - end - - properties(SetAccess=private) - BeingDeleted = 'off' % Indicator that MATLAB is deleting the field - end - - properties (Constant) - Style = 'password' % Style of the uicontrol - end - - properties (Access = private,Hidden) - hjpeer - hgcont - jFontSize - end - - methods - function obj = passfield(varargin) - % Constructor - - % Create java peer - obj.hjpeer = handle(javaObjectEDT('javax.swing.JPasswordField'), 'CallbackProperties'); - - % Fix traversal cycle - obj.hjpeer.setFocusable(true); - obj.hjpeer.putClientProperty('TabCycleParticipant', true); - - % Get parent - pos = find(~cellfun('isempty',strfind(varargin(1:2:end), 'Parent'))); - if isempty(pos) - parent = gcf; - else - parent = varargin{pos+1}; - end - - % Embed into the graphic container - oldWarningState = warning('off', 'MATLAB:ui:javacomponent:FunctionToBeRemoved'); - [~, obj.hgcont] = javacomponent(obj.hjpeer,[],parent); - warning(oldWarningState); % revert to displaying the warning - obj.hgcont = handle(obj.hgcont); - - % Destructor listener (hjcont -> obj) - addlistener(obj.hgcont,'ObjectBeingDestroyed',@(src,evt) obj.delete); - - % Password listener (hjpeer -> obj) - hdoc = handle(obj.hjpeer.getDocument); - lstfun = @(hdoc,evt) obj.updatePassword(); - hlst(1) = handle.listener(hdoc,'insertUpdate',lstfun); - hlst(2) = handle.listener(hdoc,'removeUpdate',lstfun); - setappdata(obj.hjpeer,'PasswordListener',hlst); - - % Retrieve default properties - default = {'BackgroundColor','FontSize','FontName','ForegroundColor'}; - idx = ismember(default,varargin(1:2:end)); - nameval = [default(~idx); get(0, strcat('DefaultUicontrol', default(~idx)))]; - varargin = [varargin, nameval(:)']; - - % Set name/value pairs - for ii = 1:2:numel(varargin) - set(obj,varargin{ii}, varargin{ii+1}); - end - end - - % Top to bottom delete - function delete(obj) - obj.BeingDeleted = 'on'; - if ishghandle(obj.hgcont) - delete(obj.hgcont) - end - delete@hgsetget(obj) - end - - % Hide password - function hide(obj) - if ~obj.IsMasked - obj.EchoChar = obj.EchoChar; - obj.IsMasked = true; - end - end - - % Show password - function show(obj) - if obj.IsMasked - val = char(0); - % Update java peer only - peer = get(obj, 'hjpeer'); - peer.setEchoChar(val); - obj.IsMasked = false; - end - end - - % ========================================================================= - % GET - % ========================================================================= - - % Explicit get.Password from java peer or obj.Password is executed - % BEFORE the listener updates obj.Password - function val = get.Password(obj) - val = reshape(obj.hjpeer.getPassword, 1, numel(obj.hjpeer.getPassword)); - end - - % ========================================================================= - % SET - % ========================================================================= - function set.BackgroundColor(obj, val) - if ischar(val), val = cname2rgb(val); end - % Update java peer - peer = get(obj, 'hjpeer'); - newColor = java.awt.Color(val(1),val(2),val(3)); - peer.setBackground(newColor); - % Update property - obj.BackgroundColor = val; - end - - function set.BorderColor(obj, val) - if ischar(val), val = cname2rgb(val); end - % Update java peer - peer = get(obj, 'hjpeer'); - newColor = java.awt.Color(val(1),val(2),val(3)); - newBorder = javax.swing.BorderFactory.createLineBorder(newColor); - peer.setBorder(newBorder); - % Update property - obj.BorderColor = val; - end - - function set.Callback(obj,fcn) - % Update java peer - peer = get(obj, 'hjpeer'); - peer.ActionPerformedCallback = @(src,event) obj.callbackBridge(src,event,fcn); - % Update property - obj.Callback = fcn; - end - - function set.EchoChar(obj, val) - if ~isscalar(val) || ~isstrprop(val,'print') - error('passfield:printEchoChar','The ''EchoChar'' should a graphic character.') - end - % Update java peer - peer = get(obj, 'hjpeer'); - peer.setEchoChar(val); - % Update property - obj.EchoChar = val; - end - - function set.FontName(obj, val) - fontsize = get(obj, 'jFontSize'); - % Update java peer - peer = get(obj, 'hjpeer'); - newFont = java.awt.Font(val, 0, fontsize); - peer.setFont(newFont); - % Update property - obj.FontName = val; - end - - function set.FontSize(obj, val) - % Set jFontSize - fontsize = com.mathworks.mwswing.FontSize.createFromPointSize(val).getJavaSize; - set(obj,'jFontSize',fontsize); - % Update java peer - peer = get(obj, 'hjpeer'); - newFont = peer.getFont.deriveFont(fontsize); - peer.setFont(newFont); - % Update property - obj.FontSize = val; - end - - function set.ForegroundColor(obj, val) - if ischar(val), val = cname2rgb(val); end - % Update java peer - peer = get(obj, 'hjpeer'); - newColor = java.awt.Color(val(1),val(2),val(3)); - peer.setForeground(newColor); - % Update property - obj.ForegroundColor = val; - end - - function set.HorizontalAlignment(obj, val) - accepted = {'left','center','right'}; - idx = strncmpi(val, accepted, numel(val)); - val = accepted{idx}; - % Update java peer - peer = get(obj, 'hjpeer'); - newHorizontalAlignment = javax.swing.JTextField.(upper(val)); - peer.setHorizontalAlignment(newHorizontalAlignment); - % Update property - obj.HorizontalAlignment = val; - end - - function set.KeyPressFcn(obj,fcn) - % Update java peer - peer = get(obj, 'hjpeer'); - peer.KeyPressedCallback = @(src,event) obj.callbackBridge(src,event,fcn); - % Update property - obj.KeyPressFcn = fcn; - end - - function set.Position(obj,val) - % Update hg container - container = get(obj,'hgcont'); - container.Position = val; - % Update property - obj.Position = val; - end - - function set.TooltipString(obj,val) - % Update java peer - peer = get(obj, 'hjpeer'); - peer.setToolTipText(val); - % Update property - obj.TooltipString = val; - end - - function set.UIContextMenu(obj,val) - % Update property - obj.UIContextMenu = val; - % Update java peer - peer = get(obj, 'hjpeer'); - peer.MouseClickedCallback = @(src,evt) obj.showUIContextMenu(src,evt); - end - - function set.Visible(obj,val) - % Update hg container - container = get(obj,'hgcont'); - container.Visible = val; - % Update property - obj.Visible = val; - end - end - -% ========================================================================= -% PRIVATE -% ========================================================================= - methods (Access = private) - - function callbackBridge(obj, src, jevent, fcn) - % Java 2 matlab event conversion - switch char(jevent.getClass.getName) - case 'java.awt.event.KeyEvent' - mevent = obj.j2m_KeyEvent(jevent); - case 'java.awt.event.ActionEvent' - % TO DO: Action event bridge - mevent = jevent; - otherwise - mevent = jevent; - end - % Execute function associated with the callback - hgfeval(fcn, obj, mevent) - end - - function mevent = j2m_KeyEvent(obj,jevent) - % TODO: create a proper private event.EventData and add event to this object - key = lower(char(jevent.getKeyText(jevent.getExtendedKeyCode))); - modifiers = lower(char(jevent.getModifiersExText(jevent.getModifiersEx))); - if isempty(modifiers), - modifiers = cell(1,0); - else - modifiers = regexp(modifiers,'+','split'); - end - if jevent.isActionKey - keychar = ''; - else - keychar = char(jevent.getKeyChar); - end - mevent = struct('Character', keychar,... - 'Modifier' , {modifiers},... - 'Key' , key,... - 'Source' , obj,... - 'EventName', 'KeyPress'); - end - - function showUIContextMenu(obj, src, evt) - if evt.getButton == 3 - obj.UIContextMenu.Visible = 'on'; - end - end - - function updatePassword(obj) - % Listener's callback to updat the Password property from the Java peer - obj.Password = obj.hjpeer.getPassword; - end - - end - - % Hide superclass methods - methods (Hidden) - function ge(~), end - function gt(~), end - function le(~), end - function lt(~), end - end -end \ No newline at end of file diff --git a/+brainstem/private/brainstem_parse_api_error.m b/+brainstem/private/brainstem_parse_api_error.m index 5d0e834..7793bb9 100644 --- a/+brainstem/private/brainstem_parse_api_error.m +++ b/+brainstem/private/brainstem_parse_api_error.m @@ -16,6 +16,16 @@ % Collapse newlines so the regex can match JSON that spans multiple lines raw_clean = regexprep(raw, '\r?\n', ' '); +% Detect MATLAB's standard HTTP error: "status NNN with message "Reason Text"" +% and return a compact "404 Not Found" style string (avoids duplicating the URL). +http_match = regexp(raw_clean, 'status (\d+) with message "([^"]+)"', 'tokens', 'once'); +if ~isempty(http_match) + status_code = http_match{1}; + reason = http_match{2}; + msg = sprintf('%s %s', status_code, reason); + return +end + % Find the outermost JSON object in the message json_match = regexp(raw_clean, '\{.+\}', 'match', 'once'); diff --git a/+brainstem/refresh_access_token.m b/+brainstem/refresh_access_token.m deleted file mode 100644 index f2583a7..0000000 --- a/+brainstem/refresh_access_token.m +++ /dev/null @@ -1,58 +0,0 @@ -function new_access = refresh_access_token(url, refresh_token_str) -% REFRESH_ACCESS_TOKEN Silently renew a short-lived access token. -% -% new_access = refresh_access_token(url, refresh_token) -% -% Calls POST /api/auth/token/refresh/ and returns a new access token. -% The stored credentials in prefdir are updated automatically with both -% the new access token and the new refresh token — the previous refresh -% token is invalidated by the server on each use. -% -% This is called automatically by load_settings and BrainstemClient when -% a short-lived access token has expired and a refresh token is available. -% For manual use, prefer BrainstemClient which handles all token management. -% -% See also: brainstem.get_token, BrainstemClient - -options = weboptions( ... - 'MediaType', 'application/json', ... - 'ContentType', 'json', ... - 'RequestMethod','post'); - -json_data = jsonencode(struct('refresh', refresh_token_str)); -try - response = webwrite([url, 'api/auth/token/refresh/'], json_data, options); -catch ME - error('BrainSTEM:refreshToken', ... - 'Failed to refresh access token: %s', brainstem_parse_api_error(ME)); -end - -new_access = response.access; -new_refresh = response.refresh; % rotated — old refresh token is now invalid -expires_in = 3600; -if isfield(response, 'expires_in') - expires_in = response.expires_in; -end - -% Update the saved credentials entry for this URL -auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); -if exist(auth_path, 'file') - existing = load(auth_path, 'authentication'); - tbl = existing.authentication; - idx = find(strcmp(url, tbl.urls)); - if ~isempty(idx) - tbl.tokens{idx} = new_access; - if ismember('refresh_tokens', tbl.Properties.VariableNames) - tbl.refresh_tokens{idx} = new_refresh; - end - if ismember('expires_at', tbl.Properties.VariableNames) - tbl.expires_at{idx} = now + expires_in / 86400; - end - if ismember('saved_at', tbl.Properties.VariableNames) - tbl.saved_at{idx} = now; - end - authentication = tbl; %#ok - save(auth_path, 'authentication'); - end -end -end diff --git a/+brainstem/save_model.m b/+brainstem/save.m similarity index 73% rename from +brainstem/save_model.m rename to +brainstem/save.m index dfed0d2..97a52da 100644 --- a/+brainstem/save_model.m +++ b/+brainstem/save.m @@ -1,7 +1,7 @@ -function output = save_model(varargin) -% SAVE_MODEL Create or update a record in a BrainSTEM API endpoint. +function output = save(varargin) +% SAVE Create or update a record in a BrainSTEM API endpoint. % -% output = save_model('data', DATA, 'model', MODEL) +% output = save('data', DATA, 'model', MODEL) % % When DATA contains an 'id' field, an update is performed (PUT or PATCH). % Otherwise a new record is created (POST). @@ -16,35 +16,38 @@ % % Examples: % % Update an existing session (full replace): -% output = save_model('data', session, 'model', 'session'); +% output = brainstem.save('data', session, 'model', 'session'); % % % Partial update (only send changed fields): -% output = save_model('data', struct('description','new desc'), ... -% 'model','session','method','patch'); +% output = brainstem.save('data', struct('description','new desc'), ... +% 'model','session','method','patch'); % % % Create a new session: % s.name = 'My session'; s.projects = {''}; s.tags = []; -% output = save_model('data', s, 'model', 'session'); +% output = brainstem.save('data', s, 'model', 'session'); p = inputParser; addParameter(p,'portal', 'private', @ischar); addParameter(p,'app', '', @ischar); addParameter(p,'model', 'session', @ischar); -addParameter(p,'settings',load_settings,@isstruct); +addParameter(p,'settings',[],@(x) isempty(x)||isstruct(x)); addParameter(p,'data', struct(), @isstruct); addParameter(p,'method', 'put', @(x) ismember(lower(x),{'put','patch'})); parse(p, varargin{:}) parameters = p.Results; +if isempty(parameters.settings) + parameters.settings = brainstem.load_settings(); +end if isempty(parameters.app) - parameters.app = get_app_from_model(parameters.model); + parameters.app = brainstem.get_app_from_model(parameters.model); end % PATCH without an id in the data makes no sense: there is no record to update. if strcmpi(parameters.method, 'patch') && ~isfield(parameters.data, 'id') - error('BrainSTEM:saveModel', ... - 'PATCH requires an ''id'' field in data to identify the record. ' ... - 'For new records omit the ''method'' parameter (POST is used automatically).'); + error('BrainSTEM:save', '%s', ... + ['PATCH requires an ''id'' field in data to identify the record. ' ... + 'For new records omit the ''method'' parameter (POST is used automatically).']); end options = weboptions( ... @@ -76,6 +79,6 @@ return end api_msg = brainstem_parse_api_error(ME); - error('BrainSTEM:saveModel', 'API error saving %s to %s: %s', ... + error('BrainSTEM:save', 'API error saving %s to %s: %s', ... parameters.model, endpoint, api_msg); end diff --git a/BrainstemClient.m b/BrainstemClient.m index afe1409..27f8d79 100644 --- a/BrainstemClient.m +++ b/BrainstemClient.m @@ -17,16 +17,11 @@ % client = BrainstemClient('url', URL) % Connect to a non-default server (e.g. local dev instance). % -% client = BrainstemClient('token_type', 'shortlived') -% Use short-lived JWT tokens (access: 1 h, refresh: 30 days) instead of -% the default personal access token (sliding 1-year window). -% Short-lived tokens refresh silently when they expire; only the initial -% login requires credentials. Use 'personal' (default) for scripts. % % CORE METHODS -% output = client.load_model(model, ...) -% output = client.save_model(data, model, ...) -% output = client.delete_model(id, model, ...) +% output = client.load(model, ...) +% output = client.save(data, model, ...) +% output = client.delete(id, model, ...) % % CONVENIENCE LOADERS (named shortcuts with pre-set include defaults) % output = client.load_project(...) 'name','id','tags', ... @@ -43,7 +38,7 @@ % output = client.load_equipment(...) 'name','session','id','tags' % output = client.load_consumablestock(...)'subject','id','tags' % -% LOAD_MODEL parameters (all optional after model): +% LOAD parameters (all optional after model): % 'portal' - 'private' (default) or 'public' % 'id' - UUID; fetches a single record at /// % 'filter' - cell array {field, value, ...} @@ -53,7 +48,7 @@ % 'offset' - records to skip % 'load_all' - true to auto-follow pagination and return all records % -% SAVE_MODEL parameters (all optional after data and model): +% SAVE parameters (all optional after data and model): % 'portal' - 'private' (default) or 'public' % 'method' - 'put' (default, full replace) or 'patch' (partial update) % @@ -62,13 +57,13 @@ % client = BrainstemClient('token', getenv('BRAINSTEM_TOKEN')); % % % Load all sessions (auto-paginate) -% out = client.load_model('session', 'load_all', true); +% out = client.load('session', 'load_all', true); % % % Load a single session by ID -% out = client.load_model('session', 'id', 'c5547922-c973-4ad7-96d3-72789f140024'); +% out = client.load('session', 'id', 'c5547922-c973-4ad7-96d3-72789f140024'); % % % Filter, sort, embed relations -% out = client.load_model('session', ... +% out = client.load('session', ... % 'filter', {'name.icontains','Rat'}, ... % 'sort', {'-name'}, ... % 'include', {'behaviors','manipulations'}); @@ -76,14 +71,14 @@ % % Update a session (partial update) % s.id = out.sessions(1).id; % s.description = 'updated'; -% client.save_model(s, 'session', 'method', 'patch'); +% client.save(s, 'session', 'method', 'patch'); % % % Create a new session % s = struct('name','New session','projects',{{''}},'tags',[]); -% client.save_model(s, 'session'); +% client.save(s, 'session'); % % % Delete a session -% client.delete_model(out.sessions(1).id, 'session'); +% client.delete(out.sessions(1).id, 'session'); % % % Convenience loaders — field-level parameters, sensible include defaults % out = client.load_session('name', 'mysession'); @@ -92,12 +87,12 @@ % out = client.load_project('name', 'My Project', 'portal', 'public'); % % % Load public projects -% out = client.load_model('project', 'portal', 'public'); +% out = client.load('project', 'portal', 'public'); properties (SetAccess = private) url (1,:) char = 'https://www.brainstem.org/' token (1,:) char = '' - token_type (1,:) char = 'personal' % 'personal' or 'shortlived' + token_type (1,:) char = 'personal' end methods @@ -105,14 +100,11 @@ function obj = BrainstemClient(varargin) % BRAINSTEMCLIENT Constructor. p = inputParser; - addParameter(p, 'url', 'https://www.brainstem.org/', @ischar); - addParameter(p, 'token', '', @ischar); - addParameter(p, 'token_type', 'personal', ... - @(x) ismember(lower(x), {'personal','shortlived'})); + addParameter(p, 'url', 'https://www.brainstem.org/', @ischar); + addParameter(p, 'token', '', @ischar); parse(p, varargin{:}); - obj.url = p.Results.url; - obj.token_type = lower(p.Results.token_type); + obj.url = p.Results.url; if ~isempty(p.Results.token) obj.token = p.Results.token; @@ -130,17 +122,17 @@ end % ------------------------------------------------------------------ - function output = load_model(obj, model, varargin) - % LOAD_MODEL Retrieve records from a BrainSTEM API endpoint. + function output = load(obj, model, varargin) + % LOAD Retrieve records from a BrainSTEM API endpoint. % See class documentation for full parameter list. try - output = brainstem.load_model('model', model, ... + output = brainstem.load('model', model, ... 'settings', obj.settings_(), ... varargin{:}); catch ME if obj.is_auth_error_(ME) obj.refresh_token_(); - output = brainstem.load_model('model', model, ... + output = brainstem.load('model', model, ... 'settings', obj.settings_(), ... varargin{:}); else @@ -150,17 +142,17 @@ end % ------------------------------------------------------------------ - function output = save_model(obj, data, model, varargin) - % SAVE_MODEL Create or update a BrainSTEM record. + function output = save(obj, data, model, varargin) + % SAVE Create or update a BrainSTEM record. % See class documentation for full parameter list. try - output = brainstem.save_model('data', data, 'model', model, ... + output = brainstem.save('data', data, 'model', model, ... 'settings', obj.settings_(), ... varargin{:}); catch ME if obj.is_auth_error_(ME) obj.refresh_token_(); - output = brainstem.save_model('data', data, 'model', model, ... + output = brainstem.save('data', data, 'model', model, ... 'settings', obj.settings_(), ... varargin{:}); else @@ -170,16 +162,16 @@ end % ------------------------------------------------------------------ - function output = delete_model(obj, id, model, varargin) - % DELETE_MODEL Delete a BrainSTEM record by UUID. + function output = delete(obj, id, model, varargin) + % DELETE Delete a BrainSTEM record by UUID. try - output = brainstem.delete_model(id, model, ... + output = brainstem.delete(id, model, ... 'settings', obj.settings_(), ... varargin{:}); catch ME if obj.is_auth_error_(ME) obj.refresh_token_(); - output = brainstem.delete_model(id, model, ... + output = brainstem.delete(id, model, ... 'settings', obj.settings_(), ... varargin{:}); else @@ -264,18 +256,13 @@ function disp(obj) % DISP Display a compact summary of the client state. authenticated = ~isempty(obj.token); - fprintf(' BrainstemClient -'); - fprintf(' url : %s -', obj.url); - fprintf(' token_type : %s -', obj.token_type); - fprintf(' authenticated: %s -', mat2str(authenticated)); + fprintf(' BrainstemClient\n'); + fprintf(' url : %s\n', obj.url); + fprintf(' token_type : %s\n', obj.token_type); + fprintf(' authenticated: %s\n', mat2str(authenticated)); if authenticated n = min(8, numel(obj.token)); - fprintf(' token : %s... -', obj.token(1:n)); + fprintf(' token : %s...\n', obj.token(1:n)); end end end @@ -291,46 +278,27 @@ function disp(obj) end function token = load_or_request_token_(obj) - % Load a cached token for this URL, refreshing proactively when needed. - % Short-lived tokens are renewed silently via the refresh endpoint. - % Personal tokens warn when expiry is near (< 15 days remaining). + % Load a cached PAT for this URL; warn when near expiry, re-auth if expired. auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); if exist(auth_path, 'file') credentials = load(auth_path, 'authentication'); auth_tbl = credentials.authentication; idx = find(strcmp(obj.url, auth_tbl.urls)); if ~isempty(idx) - has_expires = ismember('expires_at', auth_tbl.Properties.VariableNames); - has_saved = ismember('saved_at', auth_tbl.Properties.VariableNames); - has_type = ismember('token_type', auth_tbl.Properties.VariableNames); - has_refresh = ismember('refresh_tokens', auth_tbl.Properties.VariableNames); - - is_short = has_type && strcmp(auth_tbl.token_type{idx}, 'shortlived'); - + has_expires = ismember('expires_at', auth_tbl.Properties.VariableNames); + has_saved = ismember('saved_at', auth_tbl.Properties.VariableNames); if has_expires days_left = auth_tbl.expires_at{idx} - now; elseif has_saved days_left = (auth_tbl.saved_at{idx} + 365) - now; - is_short = false; else days_left = Inf; end - if days_left <= 0 - if is_short && has_refresh && ~isempty(auth_tbl.refresh_tokens{idx}) - try - token = brainstem.refresh_access_token( ... - obj.url, auth_tbl.refresh_tokens{idx}); - return - catch - warning('BrainstemClient:refreshFailed', ... - 'Automatic token refresh failed — re-authenticating.'); - end - end warning('BrainstemClient:tokenExpired', ... 'Saved token expired — re-authenticating.'); - token = brainstem.get_token(obj.url, '', '', obj.token_type); - elseif ~is_short && days_left < 15 + token = brainstem.get_token(obj.url); + elseif days_left < 15 warning('BrainstemClient:tokenNearExpiry', ... 'BrainSTEM token expires in ~%.0f days.', days_left); token = auth_tbl.tokens{idx}; @@ -340,37 +308,15 @@ function disp(obj) return end end - % No cached token — run the interactive login - token = brainstem.get_token(obj.url, '', '', obj.token_type); + % No cached token — run the device authorization flow + token = brainstem.get_token(obj.url); end function refresh_token_(obj) - % Re-authenticate and update the stored token. - % For short-lived tokens, the /api/auth/token/refresh/ endpoint is - % tried first; only falls back to interactive login if it fails or - % the refresh token has also expired. - auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); - if exist(auth_path, 'file') - credentials = load(auth_path, 'authentication'); - auth_tbl = credentials.authentication; - idx = find(strcmp(obj.url, auth_tbl.urls)); - if ~isempty(idx) && ... - ismember('token_type', auth_tbl.Properties.VariableNames) && ... - strcmp(auth_tbl.token_type{idx}, 'shortlived') && ... - ismember('refresh_tokens', auth_tbl.Properties.VariableNames) && ... - ~isempty(auth_tbl.refresh_tokens{idx}) - try - obj.token = brainstem.refresh_access_token( ... - obj.url, auth_tbl.refresh_tokens{idx}); - return - catch - % Refresh token also expired — fall through to interactive - end - end - end + % Re-authenticate via the device authorization flow. warning('BrainstemClient:tokenExpired', ... 'Token appears expired or invalid — re-authenticating.'); - obj.token = brainstem.get_token(obj.url, '', '', obj.token_type); + obj.token = brainstem.get_token(obj.url); end function tf = is_auth_error_(~, ME) diff --git a/Contents.m b/Contents.m index 5182d19..37df9e7 100644 --- a/Contents.m +++ b/Contents.m @@ -9,9 +9,9 @@ % BrainstemClient - Client class (recommended) % % PACKAGE FUNCTIONS (call as brainstem. or via client methods) -% brainstem.load_model - Load records from any model -% brainstem.save_model - Create or update records (POST/PUT/PATCH) -% brainstem.delete_model - Delete a record by UUID +% brainstem.load - Load records from any model +% brainstem.save - Create or update records (POST/PUT/PATCH) +% brainstem.delete - Delete a record by UUID % brainstem.get_token - Acquire and cache an API token ('personal' or 'shortlived') % brainstem.refresh_access_token - Silently renew a short-lived token pair % brainstem.load_settings - Load settings struct from token cache diff --git a/README.md b/README.md index 0f2ac35..fee2b99 100644 --- a/README.md +++ b/README.md @@ -28,20 +28,20 @@ The tutorial demonstrates how to: ### Recommended: Personal Access Token (scripts, HPC, automation) Create a token at [brainstem.org/private/users/tokens/](https://www.brainstem.org/private/users/tokens/). -Tokens are valid for 1 year and auto-refresh. +Tokens are valid for 1 year. ```matlab % Option A: environment variable (set once per shell/session) setenv('BRAINSTEM_TOKEN','') -client = BrainstemClient(); +client = BrainstemClient(); % picks it up automatically % Option B: pass directly client = BrainstemClient('token',''); ``` -### Interactive login (desktop MATLAB, GUI dialog) +### Interactive login (device flow, desktop MATLAB) ```matlab -client = BrainstemClient(); % opens a credential dialog +client = BrainstemClient(); % opens browser login page ``` ## BrainstemClient (recommended) @@ -52,15 +52,15 @@ Create the client once; it holds the token and base URL for all subsequent calls client = BrainstemClient('token', getenv('BRAINSTEM_TOKEN')); % Load sessions -out = client.load_model('session'); +out = client.load('session'); % Partial update patch_data.id = out.sessions(1).id; patch_data.description = 'updated'; -client.save_model(patch_data, 'session', 'method', 'patch'); +client.save(patch_data, 'session', 'method', 'patch'); % Delete -client.delete_model(out.sessions(1).id, 'session'); +client.delete(out.sessions(1).id, 'session'); ``` ## Core Functions Overview @@ -69,9 +69,9 @@ client.delete_model(out.sessions(1).id, 'session'); |----------|-------------| | `BrainstemClient` | Client class — authenticate once, call any endpoint | | `get_token` | Interactively acquire and cache an API token | -| `load_model` | Load records from any BrainSTEM model | -| `save_model` | Create or update records (POST / PUT / PATCH) | -| `delete_model` | Delete a record by UUID | +| `brainstem.load` | Load records from any BrainSTEM model | +| `brainstem.save` | Create or update records (POST / PUT / PATCH) | +| `brainstem.delete` | Delete a record by UUID | | `load_settings` | Load settings struct (URL + token) from cache | | `get_app_from_model` | Map a model name to its API app prefix | @@ -91,7 +91,7 @@ client.delete_model(out.sessions(1).id, 'session'); ## Query Options -All `load_model` calls (and the convenience loaders) support: +All `load` calls (and the convenience loaders) support: | Parameter | Description | Example | |-----------|-------------|---------| @@ -113,34 +113,34 @@ All `load_model` calls (and the convenience loaders) support: client = BrainstemClient('token', getenv('BRAINSTEM_TOKEN')); % Load ALL sessions (auto-paginate) -out = client.load_model('session', 'load_all', true); +out = client.load('session', 'load_all', true); % Filter + sort + include -out = client.load_model('session', ... +out = client.load('session', ... 'filter', {'name.icontains', 'Rat'}, ... 'sort', {'-name'}, ... 'include', {'projects','behaviors'}); % Single record by UUID -out = client.load_model('session', 'id', 'c5547922-c973-4ad7-96d3-72789f140024'); +out = client.load('session', 'id', 'c5547922-c973-4ad7-96d3-72789f140024'); -% Convenience loader -sessions = load_session('name', 'mysession'); -behaviors = load_behavior('session', 'c5547922-c973-4ad7-96d3-72789f140024'); +% Convenience loaders (tab-completable, credentials automatic) +sessions = client.load_session('name', 'mysession'); +behaviors = client.load_behavior('session', 'c5547922-c973-4ad7-96d3-72789f140024'); % Create s.name = 'My new session'; s.projects = {''}; s.tags = []; -out = client.save_model(s, 'session'); +out = client.save(s, 'session'); % Partial update (PATCH) patch.id = out.id; patch.description = 'updated'; -client.save_model(patch, 'session', 'method', 'patch'); +client.save(patch, 'session', 'method', 'patch'); % Delete -client.delete_model(out.id, 'session'); +client.delete(out.id, 'session'); % Public data -public_projects = client.load_model('project', 'portal', 'public'); +public_projects = client.load('project', 'portal', 'public'); ``` ## License diff --git a/brainstem_api_tutorial.m b/brainstem_api_tutorial.m index 09935be..cca2517 100644 --- a/brainstem_api_tutorial.m +++ b/brainstem_api_tutorial.m @@ -7,20 +7,14 @@ % % Option A - Personal Access Token (recommended for scripts / HPC / automation): % Create your token at https://www.brainstem.org/private/users/tokens/ -% Then either: -% setenv('BRAINSTEM_TOKEN','') % set once per MATLAB session -% client = BrainstemClient(); % picks it up automatically +% Set it as an environment variable once per MATLAB session: +% setenv('BRAINSTEM_TOKEN','') +% client = BrainstemClient() % picks it up automatically % Or pass it directly: % client = BrainstemClient('token',''); % -% Option B - Short-lived JWT tokens (access: 1 h, refresh: 30 days): -% Tokens renew automatically via the refresh endpoint when they expire. -% Useful when you cannot store a long-lived PAT. -% client = BrainstemClient('token_type', 'shortlived'); -% To renew manually: brainstem.refresh_access_token(url, refresh_token) -% -% Option C - Interactive login (GUI dialog, desktop MATLAB only): -% client = BrainstemClient(); % opens credential dialog +% Option B - Interactive login (device flow, desktop MATLAB only): +% client = BrainstemClient(); % opens browser for login client = BrainstemClient(); @@ -41,10 +35,10 @@ output1_2 = client.load_session('sort', {'-name'}); % Fetch a single session by UUID -output1_id = client.load_model('session', 'id', 'c5547922-c973-4ad7-96d3-72789f140024'); +output1_id = client.load('session', 'id', 'c5547922-c973-4ad7-96d3-72789f140024'); -% Combine filter + sort + include via the generic load_model -output1_6 = client.load_model('session', ... +% Combine filter + sort + include via the generic load method +output1_6 = client.load('session', ... 'filter', {'name.icontains', 'Rat'}, ... 'sort', {'-name'}, ... 'include', {'projects'}); @@ -54,12 +48,12 @@ session = output1.sessions(1); patch_data.id = session.id; patch_data.description = 'updated description'; -output2 = client.save_model(patch_data, 'session', 'method', 'patch'); +output2 = client.save(patch_data, 'session', 'method', 'patch'); % Full replace (PUT) is still available: % session.description = 'new description'; % session.tags = []; % tags is required by the API -% output2_put = client.save_model(session, 'session'); +% output2_put = client.save(session, 'session'); %% 3. Creating a new session @@ -67,15 +61,15 @@ new_session.description = 'new session description'; new_session.projects = {'0ed470cf-4b48-49f8-b779-10980a8f9dd6'}; new_session.tags = []; -output3 = client.save_model(new_session, 'session'); +output3 = client.save(new_session, 'session'); %% 4. Deleting a record -% output_del = client.delete_model(output3.id, 'session'); +% output_del = client.delete(output3.id, 'session'); %% 5. Load public projects -output4 = client.load_model('project', 'portal', 'public'); +output4 = client.load('project', 'portal', 'public'); %% 6. Convenience methods on the client (recommended) % @@ -99,7 +93,7 @@ % The package functions are also available directly when you need them: output5_pkg = brainstem.load_session('name', 'mysession'); -%% 7. Using load_model directly (for models without a convenience method) +%% 7. Using load directly (for models without a convenience method) % Get all subjects with related procedures output_subjects = client.load_subject('include', {'procedures'}); @@ -107,8 +101,8 @@ % Get all projects with related subjects and sessions output_projects = client.load_project('include', {'sessions','subjects'}); -% Get consumable resources (no convenience loader — use load_model directly) -output_consumables = client.load_model('consumable', 'app', 'resources'); +% Get consumable resources (no convenience loader — use load directly) +output_consumables = client.load('consumable', 'app', 'resources'); % Paginate manually (first 50, then next 50) output_page1 = client.load_session('limit', 50, 'offset', 0); From 619d6732ecada28751be65bc662dbbe4e02b1ebf Mon Sep 17 00:00:00 2001 From: "Peter C. Petersen" Date: Sun, 29 Mar 2026 23:19:06 +0200 Subject: [PATCH 3/6] Update device auth flow for BrainSTEM Revise the device authorization flow to match the server's newer API: use resp.device_code and resp.verification_uri_complete, open the verification URI as a char, and poll POST /api/auth/device/token/ with {device_code} instead of GET with state. Improve polling request options and response handling (handle success, expired_token, access_denied and other errors; treat authorization_pending as continued polling). Also handle the fallback manual PAT flow by saving the token when provided and return early. Misc: adjust weboptions naming and small control-flow cleanup. --- +brainstem/get_token.m | 73 ++++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/+brainstem/get_token.m b/+brainstem/get_token.m index 802efa8..d3e16fb 100644 --- a/+brainstem/get_token.m +++ b/+brainstem/get_token.m @@ -2,11 +2,11 @@ % GET_TOKEN Obtain a Personal Access Token from the BrainSTEM server. % % Uses the browser-based device authorization flow: -% 1. POST /api/auth/device/ → {state, verification_url, expires_in} -% 2. Opens verification_url in the default browser so the user can log -% in (including 2FA if enabled). -% 3. Polls GET /api/auth/device/token/?state= until the user -% completes the web login, then returns the issued token. +% 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 @@ -24,13 +24,18 @@ 'ContentType', 'json', ... 'RequestMethod','post'); try - resp = webwrite([url 'api/auth/device/'], struct(), options_post); - token = device_flow_(url, resp); + 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 @@ -38,45 +43,59 @@ % ------------------------------------------------------------------------- function token = device_flow_(url, resp) -state = resp.state; -auth_url = resp.verification_url; -expires_in = 300; +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(auth_url, '-browser'); + 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_get = weboptions('ContentType','json','RequestMethod','get'); -poll_url = [url 'api/auth/device/token/?state=' state]; -deadline = now + expires_in / 86400; -token = ''; +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 = webread(poll_url, options_get); + r = webwrite(poll_url, poll_body, options_poll); catch continue end - if ~isfield(r,'status'), continue; end - switch r.status - case 'complete' - token = r.token; - fprintf('\nAuthenticated successfully.\n\n'); - return - case 'expired' - fprintf('\n'); - error('BrainSTEM:deviceAuthExpired', ... - 'Authentication request expired. Please call get_token() again.'); - % 'pending' → keep waiting + 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', ... From df290a23bc14e366f667efbd06f838cdaf1596f9 Mon Sep 17 00:00:00 2001 From: "Peter C. Petersen" Date: Mon, 30 Mar 2026 10:48:01 +0200 Subject: [PATCH 4/6] Refactor load helpers, add settings & normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce private helpers to remove duplicated loader boilerplate and improve settings/token handling. Added brainstem_convenience_load to centralize common parsing/filter mapping for load_* functions and updated numerous load_* files to delegate to it. Added brainstem_get_settings to resolve URL/token from BRAINSTEM_URL/BRAINSTEM_TOKEN, cached credentials, or interactive flow; load_settings is now a deprecated wrapper. Added brainstem_normalize_list_response to normalize API list responses into struct arrays and integrated normalization into load/pagination. Add defensive checks and improvements: UUID validation for load/delete, guard against empty id deletes, default weboptions Timeout and error handling. Minor fixes: get_app_from_model maps 'group' → 'auth', updated tests to match new settings shape and examples/docs updated. These changes reduce duplication, make authentication more predictable, and make API responses more consistent. --- +brainstem/BrainstemTests.m | 14 ++-- +brainstem/delete.m | 16 ++++- +brainstem/get_app_from_model.m | 4 +- +brainstem/load.m | 17 ++++- +brainstem/load_behavior.m | 43 ++++-------- +brainstem/load_cohort.m | 45 ++++--------- +brainstem/load_collection.m | 44 ++++--------- +brainstem/load_consumablestock.m | 45 ++++--------- +brainstem/load_dataacquisition.m | 43 ++++-------- +brainstem/load_equipment.m | 49 ++++---------- +brainstem/load_manipulation.m | 43 ++++-------- +brainstem/load_procedure.m | 43 ++++-------- +brainstem/load_procedurelog.m | 45 ++++--------- +brainstem/load_project.m | 61 +++++------------ +brainstem/load_session.m | 61 +++++------------ +brainstem/load_settings.m | 63 ++++-------------- +brainstem/load_subject.m | 65 +++++-------------- +brainstem/load_subjectlog.m | 52 ++++----------- .../private/brainstem_convenience_load.m | 62 ++++++++++++++++++ +brainstem/private/brainstem_get_settings.m | 62 ++++++++++++++++++ .../brainstem_normalize_list_response.m | 48 ++++++++++++++ +brainstem/save.m | 15 ++++- BrainstemClient.m | 7 +- Contents.m | 10 ++- README.md | 16 +++-- brainstem_api_tutorial.m | 20 +++--- 26 files changed, 431 insertions(+), 562 deletions(-) create mode 100644 +brainstem/private/brainstem_convenience_load.m create mode 100644 +brainstem/private/brainstem_get_settings.m create mode 100644 +brainstem/private/brainstem_normalize_list_response.m diff --git a/+brainstem/BrainstemTests.m b/+brainstem/BrainstemTests.m index 49ffc0c..d99ba66 100644 --- a/+brainstem/BrainstemTests.m +++ b/+brainstem/BrainstemTests.m @@ -427,7 +427,7 @@ function testClientSavePatchGuardOffline(tc) % ====================================================================== function testLoadPublicProjects(tc) - settings = struct('url', tc.BASE_URL, 'token', '', 'storage', {{}}); + settings = struct('url', tc.BASE_URL, 'token', ''); out = brainstem.load('model', 'project', 'portal', 'public', ... 'settings', settings, 'limit', 5); tc.verifyTrue(isstruct(out)); @@ -436,7 +436,7 @@ function testLoadPublicProjects(tc) end function testLoadPublicProjectsStructure(tc) - settings = struct('url', tc.BASE_URL, 'token', '', 'storage', {{}}); + settings = struct('url', tc.BASE_URL, 'token', ''); out = brainstem.load('model', 'project', 'portal', 'public', ... 'settings', settings, 'limit', 1); if isfield(out, 'projects') && ~isempty(out.projects) @@ -458,7 +458,7 @@ function testLoadPublicProjectsStructure(tc) function testLoadSessions(tc) tc.assumeNotEmpty(tc.TOKEN, ... 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); - settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); + settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN); out = brainstem.load('model', 'session', 'settings', settings, 'limit', 5); tc.verifyTrue(isstruct(out)); tc.verifyTrue(isfield(out, 'sessions') || isfield(out, 'count')); @@ -467,7 +467,7 @@ function testLoadSessions(tc) function testLoadSubjects(tc) tc.assumeNotEmpty(tc.TOKEN, ... 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); - settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); + settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN); out = brainstem.load('model', 'subject', 'settings', settings, 'limit', 5); tc.verifyTrue(isstruct(out)); end @@ -475,7 +475,7 @@ function testLoadSubjects(tc) function testLoadProjects(tc) tc.assumeNotEmpty(tc.TOKEN, ... 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); - settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); + settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN); out = brainstem.load('model', 'project', 'settings', settings, 'limit', 5); tc.verifyTrue(isstruct(out)); end @@ -483,7 +483,7 @@ function testLoadProjects(tc) function testLoadModelById(tc) tc.assumeNotEmpty(tc.TOKEN, ... 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); - settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); + settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN); % First fetch a list to get a real id out = brainstem.load('model', 'project', 'settings', settings, 'limit', 1); if isfield(out, 'projects') && ~isempty(out.projects) @@ -499,7 +499,7 @@ function testLoadModelById(tc) function testLoadModelPagination(tc) tc.assumeNotEmpty(tc.TOKEN, ... 'Set BRAINSTEM_TOKEN env variable to run authenticated tests'); - settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN, 'storage', {{}}); + settings = struct('url', tc.BASE_URL, 'token', tc.TOKEN); out = brainstem.load('model', 'session', 'settings', settings, ... 'limit', 2, 'offset', 0); tc.verifyTrue(isstruct(out)); diff --git a/+brainstem/delete.m b/+brainstem/delete.m index 53c751d..2c00dc8 100644 --- a/+brainstem/delete.m +++ b/+brainstem/delete.m @@ -9,10 +9,10 @@ % 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 from load_settings (loaded automatically if omitted) +% settings - Settings struct (auto-resolved from BRAINSTEM_TOKEN env var or token cache) % % Example: -% brainstem.delete('c5547922-c973-4ad7-96d3-72789f140024', 'session'); +% brainstem.delete('', 'session'); p = inputParser; addParameter(p,'portal', 'private', @ischar); @@ -21,7 +21,16 @@ parse(p, varargin{:}) parameters = p.Results; if isempty(parameters.settings) - parameters.settings = brainstem.load_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) @@ -31,6 +40,7 @@ options = weboptions( ... 'HeaderFields', {'Authorization', ['Bearer ' parameters.settings.token]}, ... 'ContentType', 'json', ... + 'Timeout', 30, ... 'RequestMethod','delete'); endpoint = brainstem_build_url(parameters.settings.url, parameters.portal, ... diff --git a/+brainstem/get_app_from_model.m b/+brainstem/get_app_from_model.m index 8a9e730..34eff01 100644 --- a/+brainstem/get_app_from_model.m +++ b/+brainstem/get_app_from_model.m @@ -16,7 +16,9 @@ app = 'dissemination'; case {'user','laboratory','groupmembershipinvitation','groupmembershiprequest'} app = 'users'; - case {'group'} + 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 = ''; diff --git a/+brainstem/load.m b/+brainstem/load.m index e53294c..1e1f871 100644 --- a/+brainstem/load.m +++ b/+brainstem/load.m @@ -14,11 +14,11 @@ % 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 from load_settings (loaded automatically if omitted) +% 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','c5547922-c973-4ad7-96d3-72789f140024'); +% output = brainstem.load('model','session','id',''); % 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); @@ -39,7 +39,14 @@ parse(p, varargin{:}) parameters = p.Results; if isempty(parameters.settings) - parameters.settings = brainstem.load_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) @@ -51,12 +58,14 @@ 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 @@ -82,6 +91,7 @@ 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 @@ -101,6 +111,7 @@ 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)]; diff --git a/+brainstem/load_behavior.m b/+brainstem/load_behavior.m index b8cd35c..79b10db 100644 --- a/+brainstem/load_behavior.m +++ b/+brainstem/load_behavior.m @@ -1,36 +1,15 @@ function output = load_behavior(varargin) % LOAD_BEHAVIOR Load behavior record(s) from BrainSTEM. % +% Parameters: session, id, tags, +% portal, filter, sort, include, limit, offset, load_all, settings +% % Examples: -% output = load_behavior(); -% output = load_behavior('session',''); -% output = load_behavior('id',''); - -p = inputParser; -addParameter(p,'portal', 'private', @ischar); -addParameter(p,'app', 'modules', @ischar); -addParameter(p,'model', 'behavior', @ischar); -addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); -addParameter(p,'filter', {}, @iscell); -addParameter(p,'sort', {}, @iscell); -addParameter(p,'include', {}, @iscell); -addParameter(p,'id', '', @ischar); -addParameter(p,'session', '', @ischar); -addParameter(p,'tags', '', @ischar); -addParameter(p,'limit', [], @(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.load_settings(); -end - -extra_fields = {'id','session','tags'}; -filter_map = {'id','id'; 'session','session.id'; 'tags','tags'}; -parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); - -output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... - 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include, ... - 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); +% output = load_behavior() +% output = load_behavior('session', '') +% output = load_behavior('id', '') +d = struct('app','modules', 'model','behavior'); +output = brainstem_convenience_load(d, ... + {'id','session','tags'}, ... + {'id','id'; 'session','session.id'; 'tags','tags'}, ... + varargin{:}); diff --git a/+brainstem/load_cohort.m b/+brainstem/load_cohort.m index 95f5ea9..e4ddffa 100644 --- a/+brainstem/load_cohort.m +++ b/+brainstem/load_cohort.m @@ -1,37 +1,16 @@ function output = load_cohort(varargin) % LOAD_COHORT Load cohort(s) from BrainSTEM. % +% Parameters: name, id, description, tags, +% portal, filter, sort, include, limit, offset, load_all, settings +% % Examples: -% output = load_cohort(); -% output = load_cohort('name','My Cohort'); -% output = load_cohort('id',''); - -p = inputParser; -addParameter(p,'portal', 'private', @ischar); -addParameter(p,'app', 'stem', @ischar); -addParameter(p,'model', 'cohort', @ischar); -addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); -addParameter(p,'filter', {}, @iscell); -addParameter(p,'sort', {}, @iscell); -addParameter(p,'include', {'subjects'}, @iscell); -addParameter(p,'id', '', @ischar); -addParameter(p,'name', '', @ischar); -addParameter(p,'description', '', @ischar); -addParameter(p,'tags', '', @ischar); -addParameter(p,'limit', [], @(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.load_settings(); -end - -extra_fields = {'id','name','description','tags'}; -filter_map = {'id','id'; 'name','name.icontains'; 'tags','tags'}; -parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); - -output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... - 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include, ... - 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); +% output = load_cohort() +% output = load_cohort('name', 'My Cohort') +% output = load_cohort('id', '') +d = struct('app','stem', 'model','cohort'); +d.include = {'subjects'}; +output = brainstem_convenience_load(d, ... + {'id','name','description','tags'}, ... + {'id','id'; 'name','name.icontains'; 'tags','tags'}, ... + varargin{:}); diff --git a/+brainstem/load_collection.m b/+brainstem/load_collection.m index 49ba980..f6aa2f6 100644 --- a/+brainstem/load_collection.m +++ b/+brainstem/load_collection.m @@ -1,37 +1,17 @@ function output = load_collection(varargin) % LOAD_COLLECTION Load collection(s) from BrainSTEM. % +% Parameters: name, id, description, tags, +% portal, filter, sort, include, limit, offset, load_all, settings +% % Examples: -% output = load_collection(); -% output = load_collection('name','My Collection'); -% output = load_collection('id',''); - -p = inputParser; -addParameter(p,'portal', 'private', @ischar); -addParameter(p,'app', 'stem', @ischar); -addParameter(p,'model', 'collection', @ischar); -addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); -addParameter(p,'filter', {}, @iscell); -addParameter(p,'sort', {}, @iscell); -addParameter(p,'include', {'sessions'}, @iscell); -addParameter(p,'id', '', @ischar); -addParameter(p,'name', '', @ischar); -addParameter(p,'description', '', @ischar); -addParameter(p,'tags', '', @ischar); -addParameter(p,'limit', [], @(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.load_settings(); -end - -extra_fields = {'id','name','description','tags'}; -filter_map = {'id','id'; 'name','name.icontains'; 'tags','tags'}; -parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); +% output = load_collection() +% output = load_collection('name', 'My Collection') +% output = load_collection('id', '') +d = struct('app','stem', 'model','collection'); +d.include = {'sessions'}; +output = brainstem_convenience_load(d, ... + {'id','name','description','tags'}, ... + {'id','id'; 'name','name.icontains'; 'tags','tags'}, ... + varargin{:}); -output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... - 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include, ... - 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); diff --git a/+brainstem/load_consumablestock.m b/+brainstem/load_consumablestock.m index 50eef4e..be24e9d 100644 --- a/+brainstem/load_consumablestock.m +++ b/+brainstem/load_consumablestock.m @@ -1,38 +1,15 @@ function output = load_consumablestock(varargin) % LOAD_CONSUMABLESTOCK Load consumable stock record(s) from BrainSTEM. % +% Parameters: subject, id, tags, +% portal, filter, sort, include, limit, offset, load_all, settings +% % Examples: -% output = load_consumablestock(); -% output = load_consumablestock('subject',''); -% output = load_consumablestock('id',''); - -p = inputParser; -addParameter(p,'portal', 'private', @ischar); -addParameter(p,'app', 'modules', @ischar); -addParameter(p,'model', 'consumablestock', @ischar); -addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); -addParameter(p,'filter', {}, @iscell); -addParameter(p,'sort', {}, @iscell); -addParameter(p,'include', {}, @iscell); -addParameter(p,'id', '', @ischar); -addParameter(p,'subject', '', @ischar); -addParameter(p,'tags', '', @ischar); -addParameter(p,'limit', [], @(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.load_settings(); -end - -extra_fields = {'id','subject','tags'}; -filter_map = {'id', 'id'; ... - 'subject', 'subject.id'; ... - 'tags', 'tags'}; -parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); - -output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... - 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include, ... - 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); +% output = load_consumablestock() +% output = load_consumablestock('subject', '') +% output = load_consumablestock('id', '') +d = struct('app','modules', 'model','consumablestock'); +output = brainstem_convenience_load(d, ... + {'id','subject','tags'}, ... + {'id','id'; 'subject','subject.id'; 'tags','tags'}, ... + varargin{:}); diff --git a/+brainstem/load_dataacquisition.m b/+brainstem/load_dataacquisition.m index e484fbf..22263bb 100644 --- a/+brainstem/load_dataacquisition.m +++ b/+brainstem/load_dataacquisition.m @@ -1,36 +1,15 @@ function output = load_dataacquisition(varargin) % LOAD_DATAACQUISITION Load data acquisition record(s) from BrainSTEM. % +% Parameters: session, id, tags, +% portal, filter, sort, include, limit, offset, load_all, settings +% % Examples: -% output = load_dataacquisition(); -% output = load_dataacquisition('session',''); -% output = load_dataacquisition('id',''); - -p = inputParser; -addParameter(p,'portal', 'private', @ischar); -addParameter(p,'app', 'modules', @ischar); -addParameter(p,'model', 'dataacquisition', @ischar); -addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); -addParameter(p,'filter', {}, @iscell); -addParameter(p,'sort', {}, @iscell); -addParameter(p,'include', {}, @iscell); -addParameter(p,'id', '', @ischar); -addParameter(p,'session', '', @ischar); -addParameter(p,'tags', '', @ischar); -addParameter(p,'limit', [], @(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.load_settings(); -end - -extra_fields = {'id','session','tags'}; -filter_map = {'id','id'; 'session','session.id'; 'tags','tags'}; -parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); - -output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... - 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include, ... - 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); +% output = load_dataacquisition() +% output = load_dataacquisition('session', '') +% output = load_dataacquisition('id', '') +d = struct('app','modules', 'model','dataacquisition'); +output = brainstem_convenience_load(d, ... + {'id','session','tags'}, ... + {'id','id'; 'session','session.id'; 'tags','tags'}, ... + varargin{:}); diff --git a/+brainstem/load_equipment.m b/+brainstem/load_equipment.m index 61ab53f..f6431cf 100644 --- a/+brainstem/load_equipment.m +++ b/+brainstem/load_equipment.m @@ -1,41 +1,16 @@ function output = load_equipment(varargin) % LOAD_EQUIPMENT Load equipment record(s) from BrainSTEM. % +% Parameters: name, session, id, tags, +% portal, filter, sort, include, limit, offset, load_all, settings +% % Examples: -% output = load_equipment(); -% output = load_equipment('name','My Tetrode Drive'); -% output = load_equipment('session',''); -% output = load_equipment('id',''); - -p = inputParser; -addParameter(p,'portal', 'private', @ischar); -addParameter(p,'app', 'modules', @ischar); -addParameter(p,'model', 'equipment', @ischar); -addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); -addParameter(p,'filter', {}, @iscell); -addParameter(p,'sort', {}, @iscell); -addParameter(p,'include', {}, @iscell); -addParameter(p,'id', '', @ischar); -addParameter(p,'name', '', @ischar); -addParameter(p,'session', '', @ischar); -addParameter(p,'tags', '', @ischar); -addParameter(p,'limit', [], @(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.load_settings(); -end - -extra_fields = {'id','name','session','tags'}; -filter_map = {'id', 'id'; ... - 'name', 'name.icontains'; ... - 'session', 'session.id'; ... - 'tags', 'tags'}; -parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); - -output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... - 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include, ... - 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); +% output = load_equipment() +% output = load_equipment('name', 'My Tetrode Drive') +% output = load_equipment('session', '') +% output = load_equipment('id', '') +d = struct('app','modules', 'model','equipment'); +output = brainstem_convenience_load(d, ... + {'id','name','session','tags'}, ... + {'id','id'; 'name','name.icontains'; 'session','session.id'; 'tags','tags'}, ... + varargin{:}); diff --git a/+brainstem/load_manipulation.m b/+brainstem/load_manipulation.m index 0305f85..5073e13 100644 --- a/+brainstem/load_manipulation.m +++ b/+brainstem/load_manipulation.m @@ -1,36 +1,15 @@ function output = load_manipulation(varargin) % LOAD_MANIPULATION Load manipulation record(s) from BrainSTEM. % +% Parameters: session, id, tags, +% portal, filter, sort, include, limit, offset, load_all, settings +% % Examples: -% output = load_manipulation(); -% output = load_manipulation('session',''); -% output = load_manipulation('id',''); - -p = inputParser; -addParameter(p,'portal', 'private', @ischar); -addParameter(p,'app', 'modules', @ischar); -addParameter(p,'model', 'manipulation', @ischar); -addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); -addParameter(p,'filter', {}, @iscell); -addParameter(p,'sort', {}, @iscell); -addParameter(p,'include', {}, @iscell); -addParameter(p,'id', '', @ischar); -addParameter(p,'session', '', @ischar); -addParameter(p,'tags', '', @ischar); -addParameter(p,'limit', [], @(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.load_settings(); -end - -extra_fields = {'id','session','tags'}; -filter_map = {'id','id'; 'session','session.id'; 'tags','tags'}; -parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); - -output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... - 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include, ... - 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); +% output = load_manipulation() +% output = load_manipulation('session', '') +% output = load_manipulation('id', '') +d = struct('app','modules', 'model','manipulation'); +output = brainstem_convenience_load(d, ... + {'id','session','tags'}, ... + {'id','id'; 'session','session.id'; 'tags','tags'}, ... + varargin{:}); diff --git a/+brainstem/load_procedure.m b/+brainstem/load_procedure.m index 47580e9..56b9acc 100644 --- a/+brainstem/load_procedure.m +++ b/+brainstem/load_procedure.m @@ -1,36 +1,15 @@ function output = load_procedure(varargin) % LOAD_PROCEDURE Load procedure record(s) from BrainSTEM. % +% Parameters: subject, id, tags, +% portal, filter, sort, include, limit, offset, load_all, settings +% % Examples: -% output = load_procedure(); -% output = load_procedure('subject',''); -% output = load_procedure('id',''); - -p = inputParser; -addParameter(p,'portal', 'private', @ischar); -addParameter(p,'app', 'modules', @ischar); -addParameter(p,'model', 'procedure', @ischar); -addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); -addParameter(p,'filter', {}, @iscell); -addParameter(p,'sort', {}, @iscell); -addParameter(p,'include', {}, @iscell); -addParameter(p,'id', '', @ischar); -addParameter(p,'subject', '', @ischar); -addParameter(p,'tags', '', @ischar); -addParameter(p,'limit', [], @(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.load_settings(); -end - -extra_fields = {'id','subject','tags'}; -filter_map = {'id','id'; 'subject','subject.id'; 'tags','tags'}; -parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); - -output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... - 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include, ... - 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); +% output = load_procedure() +% output = load_procedure('subject', '') +% output = load_procedure('id', '') +d = struct('app','modules', 'model','procedure'); +output = brainstem_convenience_load(d, ... + {'id','subject','tags'}, ... + {'id','id'; 'subject','subject.id'; 'tags','tags'}, ... + varargin{:}); diff --git a/+brainstem/load_procedurelog.m b/+brainstem/load_procedurelog.m index 03b6a6f..6b31c56 100644 --- a/+brainstem/load_procedurelog.m +++ b/+brainstem/load_procedurelog.m @@ -1,38 +1,15 @@ function output = load_procedurelog(varargin) % LOAD_PROCEDURELOG Load procedure log record(s) from BrainSTEM. % +% Parameters: subject, id, tags, +% portal, filter, sort, include, limit, offset, load_all, settings +% % Examples: -% output = load_procedurelog(); -% output = load_procedurelog('subject',''); -% output = load_procedurelog('id',''); - -p = inputParser; -addParameter(p,'portal', 'private', @ischar); -addParameter(p,'app', 'modules', @ischar); -addParameter(p,'model', 'procedurelog', @ischar); -addParameter(p,'settings', [], @(x) isempty(x)||isstruct(x)); -addParameter(p,'filter', {}, @iscell); -addParameter(p,'sort', {}, @iscell); -addParameter(p,'include', {}, @iscell); -addParameter(p,'id', '', @ischar); -addParameter(p,'subject', '', @ischar); -addParameter(p,'tags', '', @ischar); -addParameter(p,'limit', [], @(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.load_settings(); -end - -extra_fields = {'id','subject','tags'}; -filter_map = {'id', 'id'; ... - 'subject', 'subject.id'; ... - 'tags', 'tags'}; -parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); - -output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... - 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include, ... - 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); +% output = load_procedurelog() +% output = load_procedurelog('subject', '') +% output = load_procedurelog('id', '') +d = struct('app','modules', 'model','procedurelog'); +output = brainstem_convenience_load(d, ... + {'id','subject','tags'}, ... + {'id','id'; 'subject','subject.id'; 'tags','tags'}, ... + varargin{:}); diff --git a/+brainstem/load_project.m b/+brainstem/load_project.m index d2ff55f..9fe13f8 100644 --- a/+brainstem/load_project.m +++ b/+brainstem/load_project.m @@ -1,46 +1,17 @@ function output = load_project(varargin) -% Load project(s) from BrainSTEM - -% Example calls: -% output = load_project('id','ee57e766-fc0c-42e1-9277-7d40d6e9353a'); -% output = load_project('name','Peters Project'); -% output = load_project('filter',{'id','ee57e766-fc0c-42e1-9277-7d40d6e9353a'}); -% output = load_project('tags','1') - -p = inputParser; -addParameter(p,'portal','private',@ischar); % private, public, admin -addParameter(p,'app','stem',@ischar); % stem, modules, personal_attributes, resources, taxonomies, dissemination, users -addParameter(p,'model','project',@ischar); % project, subject, session, collection, ... -addParameter(p,'settings',[],@(x) isempty(x)||isstruct(x)); -addParameter(p,'filter',{},@iscell); % Filter parameters -addParameter(p,'sort',{},@iscell); % Sorting parameters -addParameter(p,'include',{'sessions','subjects','collections','cohorts'},@iscell); % Embed relational fields - -% Project fields (extra parameters) -addParameter(p,'id','',@ischar); % id of project -addParameter(p,'name','',@ischar); % name of project -addParameter(p,'description','',@ischar); % description of project -addParameter(p,'sessions','',@ischar); % sessions -addParameter(p,'subjects','',@ischar); % subjects -addParameter(p,'tags','',@ischar); % tags of project (id of tag) -addParameter(p,'is_public','',@islogical); % project is public -addParameter(p,'limit', [], @(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.load_settings(); -end - -extra_fields = {'id','name','description','sessions','subjects','tags','is_public'}; -filter_map = {'id', 'id'; ... - 'name', 'name.icontains'; ... - 'sessions', 'sessions.id'; ... - 'subjects', 'subjects.id'; ... - 'tags', 'tags'}; -parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); - -output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... - 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include, ... - 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); +% LOAD_PROJECT Load project(s) from BrainSTEM. +% +% Parameters: name, id, description, sessions, subjects, tags, +% portal, filter, sort, include, limit, offset, load_all, settings +% +% Examples: +% output = load_project() +% output = load_project('name', 'My Project') +% output = load_project('id', '') +d = struct('app','stem', 'model','project'); +d.include = {'sessions','subjects','collections','cohorts'}; +output = brainstem_convenience_load(d, ... + {'id','name','description','sessions','subjects','tags'}, ... + {'id','id'; 'name','name.icontains'; 'sessions','sessions.id'; ... + 'subjects','subjects.id'; 'tags','tags'}, ... + varargin{:}); diff --git a/+brainstem/load_session.m b/+brainstem/load_session.m index e54d181..03293ce 100644 --- a/+brainstem/load_session.m +++ b/+brainstem/load_session.m @@ -1,45 +1,18 @@ function output = load_session(varargin) -% Load session(s) from BrainSTEM - -% Example calls: -% output = load_session('id','c5547922-c973-4ad7-96d3-72789f140024'); -% output = load_session('name','New session'); -% output = load_session('filter',{'id','c5547922-c973-4ad7-96d3-72789f140024'}); -% output = load_session('tags','1') - -p = inputParser; -addParameter(p,'portal','private',@ischar); % private, public, admin -addParameter(p,'app','stem',@ischar); % stem, modules, personal_attributes, resources, taxonomies, dissemination, users -addParameter(p,'model','session',@ischar); % project, subject, session, collection, ... -addParameter(p,'settings',[],@(x) isempty(x)||isstruct(x)); -addParameter(p,'filter',{},@iscell); % Filter parameters -addParameter(p,'sort',{},@iscell); % Sorting parameters -addParameter(p,'include',{'dataacquisition','behaviors','manipulations','epochs'},@iscell); % Embed relational fields - -% Session fields (extra parameters) -addParameter(p,'id','',@ischar); % id of session -addParameter(p,'name','',@ischar); % name of session -addParameter(p,'description','',@ischar); % description of session -addParameter(p,'projects','',@ischar); % date and time of session -addParameter(p,'datastorage','',@ischar); % datastorage of session -addParameter(p,'tags','',@ischar); % tags of session (id of tag) -addParameter(p,'limit', [], @(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.load_settings(); -end - -extra_fields = {'id','name','description','projects','datastorage','tags'}; -filter_map = {'id', 'id'; ... - 'name', 'name.icontains'; ... - 'projects', 'projects.id'; ... - 'datastorage', 'datastorage.id'; ... - 'tags', 'tags'}; -parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); - -output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... - 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include, ... - 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); +% LOAD_SESSION Load session(s) from BrainSTEM. +% +% Parameters: name, id, description, projects, datastorage, tags, +% portal, filter, sort, include, limit, offset, load_all, settings +% +% Examples: +% output = load_session() +% output = load_session('name', 'My Session') +% output = load_session('projects', '') +% output = load_session('id', '') +d = struct('app','stem', 'model','session'); +d.include = {'dataacquisition','behaviors','manipulations','epochs'}; +output = brainstem_convenience_load(d, ... + {'id','name','description','projects','datastorage','tags'}, ... + {'id','id'; 'name','name.icontains'; 'projects','projects.id'; ... + 'datastorage','datastorage.id'; 'tags','tags'}, ... + varargin{:}); diff --git a/+brainstem/load_settings.m b/+brainstem/load_settings.m index f8b47e7..43fec77 100644 --- a/+brainstem/load_settings.m +++ b/+brainstem/load_settings.m @@ -1,52 +1,15 @@ -function settings = load_settings -% function for loading local settings used for connecting to BrainSTEM - -% url to server -settings.url = 'https://www.brainstem.org/'; -% settings.url = 'https://brainstem-development.herokuapp.com/'; -% settings.url = 'http://127.0.0.1:8000/'; - -% Authentication info -auth_path = fullfile(prefdir,'brainstem_authentication.mat'); -if exist(auth_path,'file') - credentials1 = load(auth_path,'authentication'); - % Bug fix: ismember returns scalar 0/1, not the row index. - % Use strcmp to get the correct index into a multi-URL table. - idx = find(strcmp(settings.url, credentials1.authentication.urls)); - if isempty(idx) - settings.token = brainstem.get_token(settings.url); - else - auth_tbl = credentials1.authentication; - % Determine remaining lifetime using expires_at (preferred) or - % saved_at for backward-compatible old .mat files. - has_expires = ismember('expires_at', auth_tbl.Properties.VariableNames); - has_saved = ismember('saved_at', auth_tbl.Properties.VariableNames); - if has_expires - days_left = auth_tbl.expires_at{idx} - now; - elseif has_saved - days_left = (auth_tbl.saved_at{idx} + 365) - now; % treat as personal PAT - is_short = false; - else - days_left = Inf; - end - - if days_left <= 0 - warning('BrainSTEM:tokenExpired', ... - 'Saved token has expired — re-authenticating.'); - settings.token = brainstem.get_token(settings.url); - elseif days_left < 15 - % Personal token approaching expiry — warn, but keep using it - warning('BrainSTEM:tokenNearExpiry', ... - ['BrainSTEM personal access token expires in ~%.0f days. ' ... - 'Regenerate at https://www.brainstem.org/private/users/tokens/'], days_left); - settings.token = auth_tbl.tokens{idx}; - else - settings.token = auth_tbl.tokens{idx}; - end - end -else - settings.token = brainstem.get_token(settings.url); +function settings = load_settings() +% LOAD_SETTINGS Deprecated. Use the BRAINSTEM_TOKEN environment variable or +% BrainstemClient instead. This function now delegates to the internal +% brainstem_get_settings() helper. +% +% To set a custom server URL, set the BRAINSTEM_URL environment variable: +% setenv('BRAINSTEM_URL', 'http://localhost:8000/') +% +% See also: BrainstemClient, brainstem.get_token +warning('BrainSTEM:deprecated', ... + ['brainstem.load_settings() is deprecated. Set the BRAINSTEM_TOKEN ' ... + 'environment variable and use BrainstemClient or brainstem.load() directly.']); +settings = brainstem_get_settings(); end -% Local storage (set to empty; configure manually if needed) -settings.storage = {}; diff --git a/+brainstem/load_subject.m b/+brainstem/load_subject.m index e8a03ec..8e5b85f 100644 --- a/+brainstem/load_subject.m +++ b/+brainstem/load_subject.m @@ -1,49 +1,18 @@ function output = load_subject(varargin) -% Load subject(s) from BrainSTEM - -% Example calls: -% output = load_subject('id','274469ce-ccd1-48b1-8631-0a347cee5728'); -% output = load_subject('name','Peters subject2'); -% output = load_subject('filter',{'id','274469ce-ccd1-48b1-8631-0a347cee5728'}); -% output = load_subject('tags','1'); -% output = load_subject('sex','M'); % M: Male, F: Female, U: Unknown -% output = load_subject('strain','7d056b05-ff2c-4dda-96f5-e34fe4dc3ac4'); - -p = inputParser; -addParameter(p,'portal','private',@ischar); % private, public -addParameter(p,'app','stem',@ischar); % stem, modules, personal_attributes, resources, taxonomies, dissemination, users -addParameter(p,'model','subject',@ischar); % project, subject, session, collection, ... -addParameter(p,'settings',[],@(x) isempty(x)||isstruct(x)); -addParameter(p,'filter',{},@iscell); % Filter parameters -addParameter(p,'sort',{},@iscell); % Sorting parameters -addParameter(p,'include',{'procedures','subjectlogs'},@iscell); % Embed relational fields - -% Subject fields (extra parameters) -addParameter(p,'id','',@ischar); % id of subject -addParameter(p,'name','',@ischar); % name of subject -addParameter(p,'description','',@ischar); % description of subject -addParameter(p,'projects','',@ischar); % projects of subject -addParameter(p,'strain','',@ischar); % strain of subject -addParameter(p,'sex','',@ischar); % sec of subject -addParameter(p,'tags','',@ischar); % tags of project (id of tag) -addParameter(p,'limit', [], @(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.load_settings(); -end - -extra_fields = {'id','name','description','projects','strain','sex','tags'}; -filter_map = {'id', 'id'; ... - 'name', 'name.icontains'; ... - 'projects', 'projects.id'; ... - 'strain', 'strain.id'; ... - 'sex', 'sex'; ... - 'tags', 'tags'}; -parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); - -output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... - 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include, ... - 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); +% LOAD_SUBJECT Load subject(s) from BrainSTEM. +% +% Parameters: name, id, description, projects, sex, strain, tags, +% portal, filter, sort, include, limit, offset, load_all, settings +% +% Examples: +% output = load_subject() +% output = load_subject('name', 'My Subject') +% output = load_subject('sex', 'M') % M: Male, F: Female, U: Unknown +% output = load_subject('strain', '') +d = struct('app','stem', 'model','subject'); +d.include = {'procedures','subjectlogs'}; +output = brainstem_convenience_load(d, ... + {'id','name','description','projects','strain','sex','tags'}, ... + {'id','id'; 'name','name.icontains'; 'projects','projects.id'; ... + 'strain','strain.id'; 'sex','sex'; 'tags','tags'}, ... + varargin{:}); diff --git a/+brainstem/load_subjectlog.m b/+brainstem/load_subjectlog.m index b5268f2..a9b99ad 100644 --- a/+brainstem/load_subjectlog.m +++ b/+brainstem/load_subjectlog.m @@ -1,44 +1,18 @@ function output = load_subjectlog(varargin) % LOAD_SUBJECTLOG Load subject log record(s) from BrainSTEM. % -% Subject logs have fields: id, type, description, subject. -% (No session field — subject logs are linked to subjects, not sessions.) +% Subject logs are linked to subjects, not sessions. +% +% Parameters: subject, id, type, description, +% portal, filter, sort, include, limit, offset, load_all, settings % % Examples: -% output = load_subjectlog(); -% output = load_subjectlog('subject',''); -% output = load_subjectlog('type','Weighing'); -% output = load_subjectlog('id',''); - -p = inputParser; -addParameter(p,'portal', 'private', @ischar); -addParameter(p,'app', 'modules', @ischar); -addParameter(p,'model', 'subjectlog', @ischar); -addParameter(p,'settings', [], @(x) isempty(x)||isstruct(x)); -addParameter(p,'filter', {}, @iscell); -addParameter(p,'sort', {}, @iscell); -addParameter(p,'include', {}, @iscell); -addParameter(p,'id', '', @ischar); -addParameter(p,'subject', '', @ischar); -addParameter(p,'type', '', @ischar); -addParameter(p,'description', '', @ischar); -addParameter(p,'limit', [], @(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.load_settings(); -end - -extra_fields = {'id','subject','type','description'}; -filter_map = {'id', 'id'; ... - 'subject', 'subject.id'; ... - 'type', 'type'; ... - 'description', 'description.icontains'}; -parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); - -output = brainstem.load('portal',parameters.portal,'app',parameters.app,'model',parameters.model, ... - 'settings',parameters.settings,'sort',parameters.sort, ... - 'filter',parameters.filter,'include',parameters.include, ... - 'limit',parameters.limit,'offset',parameters.offset,'load_all',parameters.load_all); +% output = load_subjectlog() +% output = load_subjectlog('subject', '') +% output = load_subjectlog('type', 'Weighing') +d = struct('app','modules', 'model','subjectlog'); +output = brainstem_convenience_load(d, ... + {'id','subject','type','description'}, ... + {'id','id'; 'subject','subject.id'; 'type','type'; ... + 'description','description.icontains'}, ... + varargin{:}); diff --git a/+brainstem/private/brainstem_convenience_load.m b/+brainstem/private/brainstem_convenience_load.m new file mode 100644 index 0000000..3e3c2de --- /dev/null +++ b/+brainstem/private/brainstem_convenience_load.m @@ -0,0 +1,62 @@ +function output = brainstem_convenience_load(model_defaults, extra_fields, filter_map, varargin) +% BRAINSTEM_CONVENIENCE_LOAD Shared implementation for all load_* functions. +% +% This private helper eliminates the boilerplate duplicated across the 13 +% load_* convenience functions. Each caller only defines its model-specific +% configuration and delegates everything else here. +% +% model_defaults - struct with fields: +% .app (required) API app name, e.g. 'stem', 'modules' +% .model (required) Model name, e.g. 'session', 'behavior' +% .include (optional) Default relational fields to embed; default {} +% .portal (optional) Default portal; default 'private' +% +% extra_fields - cell row vector of loader-specific named parameters, +% e.g. {'id','name','session','tags'}. +% Each is added to the inputParser with '' default / @ischar. +% +% filter_map - n×2 cell array mapping parameter name → API filter key, +% e.g. {'id','id'; 'name','name.icontains'; 'tags','tags'}. +% Parameters absent from the map default to '.icontains'. +% +% varargin - name-value pairs forwarded from the caller. + +% Apply model_defaults fallbacks +if ~isfield(model_defaults, 'portal'), model_defaults.portal = 'private'; end +if ~isfield(model_defaults, 'include'), model_defaults.include = {}; end + +p = inputParser; +addParameter(p, 'portal', model_defaults.portal, @ischar); +addParameter(p, 'app', model_defaults.app, @ischar); +addParameter(p, 'model', model_defaults.model, @ischar); +addParameter(p, 'settings', [], @(x) isempty(x) || isstruct(x)); +addParameter(p, 'filter', {}, @iscell); +addParameter(p, 'sort', {}, @iscell); +addParameter(p, 'include', model_defaults.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); +for i = 1:numel(extra_fields) + addParameter(p, extra_fields{i}, '', @ischar); +end +parse(p, varargin{:}); +parameters = p.Results; + +if isempty(parameters.settings) + parameters.settings = brainstem_get_settings(); +end + +parameters.filter = brainstem_apply_field_filters(parameters, extra_fields, filter_map); + +output = brainstem.load( ... + 'portal', parameters.portal, ... + 'app', parameters.app, ... + 'model', parameters.model, ... + 'settings', parameters.settings, ... + 'sort', parameters.sort, ... + 'filter', parameters.filter, ... + 'include', parameters.include, ... + 'limit', parameters.limit, ... + 'offset', parameters.offset, ... + 'load_all', parameters.load_all); +end diff --git a/+brainstem/private/brainstem_get_settings.m b/+brainstem/private/brainstem_get_settings.m new file mode 100644 index 0000000..6d10343 --- /dev/null +++ b/+brainstem/private/brainstem_get_settings.m @@ -0,0 +1,62 @@ +function settings = brainstem_get_settings() +% BRAINSTEM_GET_SETTINGS Build a settings struct for BrainSTEM API calls. +% +% Resolves URL and token in a well-defined precedence order: +% +% URL (in order): +% 1. BRAINSTEM_URL environment variable +% 2. Default: https://www.brainstem.org/ +% +% Token (in order): +% 1. BRAINSTEM_TOKEN environment variable (recommended for scripts/HPC) +% 2. Cached token in prefdir/brainstem_authentication.mat +% 3. Interactive device-authorization flow (opens browser) + +url = getenv('BRAINSTEM_URL'); +if isempty(url) + url = 'https://www.brainstem.org/'; +end +settings.url = url; + +% 1. Environment variable — headless / HPC friendly +env_token = getenv('BRAINSTEM_TOKEN'); +if ~isempty(env_token) + settings.token = env_token; + return +end + +% 2. Cached token in prefdir +auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); +if exist(auth_path, 'file') + credentials = load(auth_path, 'authentication'); + auth_tbl = credentials.authentication; + idx = find(strcmp(url, auth_tbl.urls)); + if ~isempty(idx) + has_expires = ismember('expires_at', auth_tbl.Properties.VariableNames); + has_saved = ismember('saved_at', auth_tbl.Properties.VariableNames); + if has_expires + days_left = auth_tbl.expires_at{idx} - now; + elseif has_saved + days_left = (auth_tbl.saved_at{idx} + 365) - now; + else + days_left = Inf; + end + if days_left <= 0 + warning('BrainSTEM:tokenExpired', ... + 'Saved token has expired — re-authenticating.'); + settings.token = brainstem.get_token(url); + elseif days_left < 15 + warning('BrainSTEM:tokenNearExpiry', ... + ['BrainSTEM token expires in ~%.0f days. ' ... + 'Regenerate at https://www.brainstem.org/private/users/tokens/'], days_left); + settings.token = auth_tbl.tokens{idx}; + else + settings.token = auth_tbl.tokens{idx}; + end + return + end +end + +% 3. No cached token — interactive device-authorization flow +settings.token = brainstem.get_token(url); +end diff --git a/+brainstem/private/brainstem_normalize_list_response.m b/+brainstem/private/brainstem_normalize_list_response.m new file mode 100644 index 0000000..9d8dc96 --- /dev/null +++ b/+brainstem/private/brainstem_normalize_list_response.m @@ -0,0 +1,48 @@ +function response = brainstem_normalize_list_response(response) +% BRAINSTEM_NORMALIZE_LIST_RESPONSE Ensure data arrays in a list response +% are always struct arrays, never cell arrays. +% +% MATLAB's webread/jsondecode decodes JSON arrays inconsistently: +% - multiple records with identical fields → 1×N struct array (ideal) +% - single record → 1×1 struct (fine) +% - records with heterogeneous fields → cell array (problem) +% +% This function converts cell arrays to struct arrays where possible. +% If unification fails (truly incompatible types), the field is left as a +% cell array rather than silently dropping data. + +metadata_keys = {'count', 'next', 'previous'}; +fn = fieldnames(response); +for k = 1:numel(fn) + key = fn{k}; + if ismember(key, metadata_keys) + continue + end + val = response.(key); + if ~iscell(val) + continue + end + % Only try to unify cells whose elements are all structs. + all_structs = all(cellfun(@isstruct, val(:))); + if ~all_structs + continue + end + % Add any missing fields (as []) so all records share the same schema, + % then concatenate into a struct array. + try + all_fields_cells = cellfun(@fieldnames, val(:), 'UniformOutput', false); + all_fields = unique(vertcat(all_fields_cells{:})); + for j = 1:numel(val) + for fi = 1:numel(all_fields) + f = all_fields{fi}; + if ~isfield(val{j}, f) + val{j}.(f) = []; + end + end + end + response.(key) = [val{:}]; + catch + % Leave as cell — records have truly incompatible schemas. + end +end +end diff --git a/+brainstem/save.m b/+brainstem/save.m index 97a52da..fc3d559 100644 --- a/+brainstem/save.m +++ b/+brainstem/save.m @@ -12,7 +12,7 @@ % portal - 'private' (default) or 'public' % app - App name; auto-detected from model if omitted % method - 'put' (default, full replace) or 'patch' (partial update) -% settings - Settings struct from load_settings (loaded automatically if omitted) +% settings - Settings struct (auto-resolved from BRAINSTEM_TOKEN env var or token cache) % % Examples: % % Update an existing session (full replace): @@ -36,13 +36,21 @@ parse(p, varargin{:}) parameters = p.Results; if isempty(parameters.settings) - parameters.settings = brainstem.load_settings(); + parameters.settings = brainstem_get_settings(); end if isempty(parameters.app) parameters.app = brainstem.get_app_from_model(parameters.model); end +% Validate UUID format when an id is present in data +if isfield(parameters.data, 'id') && ~isempty(parameters.data.id) && ... + isempty(regexp(parameters.data.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:save', ... + 'data.id must be a valid UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx), got: %s', parameters.data.id); +end + % PATCH without an id in the data makes no sense: there is no record to update. if strcmpi(parameters.method, 'patch') && ~isfield(parameters.data, 'id') error('BrainSTEM:save', '%s', ... @@ -54,7 +62,8 @@ 'HeaderFields', {'Authorization', ['Bearer ' parameters.settings.token]}, ... 'MediaType', 'application/json', ... 'ContentType', 'json', ... - 'ArrayFormat', 'json'); + 'ArrayFormat', 'json', ... + 'Timeout', 30); if isfield(parameters.data, 'id') options.RequestMethod = lower(parameters.method); diff --git a/BrainstemClient.m b/BrainstemClient.m index 27f8d79..41dfcde 100644 --- a/BrainstemClient.m +++ b/BrainstemClient.m @@ -60,7 +60,7 @@ % out = client.load('session', 'load_all', true); % % % Load a single session by ID -% out = client.load('session', 'id', 'c5547922-c973-4ad7-96d3-72789f140024'); +% out = client.load('session', 'id', ''); % % % Filter, sort, embed relations % out = client.load('session', ... @@ -272,9 +272,8 @@ function disp(obj) function s = settings_(obj) % Build the settings struct expected by the underlying functions. - s.url = obj.url; - s.token = obj.token; - s.storage = {}; + s.url = obj.url; + s.token = obj.token; end function token = load_or_request_token_(obj) diff --git a/Contents.m b/Contents.m index 37df9e7..2e2ba8a 100644 --- a/Contents.m +++ b/Contents.m @@ -5,6 +5,11 @@ % Add the repo root to the MATLAB path: addpath('/path/to/brainstem_matlab_api_tools') % Do NOT add +brainstem/ itself — MATLAB discovers it automatically. % +% AUTHENTICATION +% Recommended: set environment variables before starting MATLAB (or in your script): +% setenv('BRAINSTEM_TOKEN', '') +% setenv('BRAINSTEM_URL', 'https://www.brainstem.org/') % optional, this is the default +% % MAIN ENTRY POINT % BrainstemClient - Client class (recommended) % @@ -12,9 +17,7 @@ % brainstem.load - Load records from any model % brainstem.save - Create or update records (POST/PUT/PATCH) % brainstem.delete - Delete a record by UUID -% brainstem.get_token - Acquire and cache an API token ('personal' or 'shortlived') -% brainstem.refresh_access_token - Silently renew a short-lived token pair -% brainstem.load_settings - Load settings struct from token cache +% brainstem.get_token - Acquire and cache an API token via device flow % brainstem.get_app_from_model - Map model name to API app prefix % % CONVENIENCE LOADERS (also available as client.() methods) @@ -35,3 +38,4 @@ % TUTORIAL & TESTS % brainstem_api_tutorial - Example script % brainstem.BrainstemTests - Unit and integration test class (matlab.unittest) + diff --git a/README.md b/README.md index fee2b99..c9de84f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Create a token at [brainstem.org/private/users/tokens/](https://www.brainstem.or Tokens are valid for 1 year. ```matlab -% Option A: environment variable (set once per shell/session) +% Option A: environment variable (set once per shell/session, or in .env / bashrc) setenv('BRAINSTEM_TOKEN','') client = BrainstemClient(); % picks it up automatically @@ -39,6 +39,15 @@ client = BrainstemClient(); % picks it up automatically client = BrainstemClient('token',''); ``` +> **Custom server URL** (local dev, staging): +> ```matlab +> setenv('BRAINSTEM_URL', 'http://localhost:8000/') +> client = BrainstemClient(); % uses the URL from the env var +> % or pass explicitly: +> client = BrainstemClient('url','http://localhost:8000/', 'token',''); +> ``` +> The standalone `brainstem.*` functions also honour `BRAINSTEM_URL`. + ### Interactive login (device flow, desktop MATLAB) ```matlab client = BrainstemClient(); % opens browser login page @@ -72,7 +81,6 @@ client.delete(out.sessions(1).id, 'session'); | `brainstem.load` | Load records from any BrainSTEM model | | `brainstem.save` | Create or update records (POST / PUT / PATCH) | | `brainstem.delete` | Delete a record by UUID | -| `load_settings` | Load settings struct (URL + token) from cache | | `get_app_from_model` | Map a model name to its API app prefix | ## Convenience Loaders @@ -122,11 +130,11 @@ out = client.load('session', ... 'include', {'projects','behaviors'}); % Single record by UUID -out = client.load('session', 'id', 'c5547922-c973-4ad7-96d3-72789f140024'); +out = client.load('session', 'id', ''); % Convenience loaders (tab-completable, credentials automatic) sessions = client.load_session('name', 'mysession'); -behaviors = client.load_behavior('session', 'c5547922-c973-4ad7-96d3-72789f140024'); +behaviors = client.load_behavior('session', ''); % Create s.name = 'My new session'; s.projects = {''}; s.tags = []; diff --git a/brainstem_api_tutorial.m b/brainstem_api_tutorial.m index cca2517..85757a5 100644 --- a/brainstem_api_tutorial.m +++ b/brainstem_api_tutorial.m @@ -35,7 +35,7 @@ output1_2 = client.load_session('sort', {'-name'}); % Fetch a single session by UUID -output1_id = client.load('session', 'id', 'c5547922-c973-4ad7-96d3-72789f140024'); +output1_id = client.load('session', 'id', ''); % Combine filter + sort + include via the generic load method output1_6 = client.load('session', ... @@ -59,7 +59,7 @@ new_session.name = 'New session 1236567576'; new_session.description = 'new session description'; -new_session.projects = {'0ed470cf-4b48-49f8-b779-10980a8f9dd6'}; +new_session.projects = {''}; new_session.tags = []; output3 = client.save(new_session, 'session'); @@ -79,16 +79,16 @@ output5_1 = client.load_project('name', 'Peters NYU demo project'); output5_2 = client.load_subject('name', 'Peters subject 2'); output5_3 = client.load_session('name', 'mysession'); -output5_4 = client.load_behavior('session', 'c5547922-c973-4ad7-96d3-72789f140024'); -output5_5 = client.load_dataacquisition('session', 'c5547922-c973-4ad7-96d3-72789f140024'); -output5_6 = client.load_manipulation('session', 'c5547922-c973-4ad7-96d3-72789f140024'); -output5_7 = client.load_procedure('subject', '274469ce-ccd1-48b1-8631-0a347cee5728'); +output5_4 = client.load_behavior('session', ''); +output5_5 = client.load_dataacquisition('session', ''); +output5_6 = client.load_manipulation('session', ''); +output5_7 = client.load_procedure('subject', ''); output5_8 = client.load_collection('name', 'My Collection'); output5_9 = client.load_cohort('name', 'My Cohort'); -output5_10 = client.load_subjectlog('subject', '274469ce-ccd1-48b1-8631-0a347cee5728'); -output5_11 = client.load_procedurelog('subject', '274469ce-ccd1-48b1-8631-0a347cee5728'); -output5_12 = client.load_equipment('session', 'c5547922-c973-4ad7-96d3-72789f140024'); -output5_13 = client.load_consumablestock('subject', '274469ce-ccd1-48b1-8631-0a347cee5728'); +output5_10 = client.load_subjectlog('subject', ''); +output5_11 = client.load_procedurelog('subject', ''); +output5_12 = client.load_equipment('session', ''); +output5_13 = client.load_consumablestock('subject', ''); % The package functions are also available directly when you need them: output5_pkg = brainstem.load_session('name', 'mysession'); From 12e8c2345e5b32d3be5b6c63cf3e132201c65463 Mon Sep 17 00:00:00 2001 From: "Peter C. Petersen" Date: Mon, 30 Mar 2026 14:47:22 +0200 Subject: [PATCH 5/6] Adressing copilot feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add brainstem.logout to remove cached tokens and document it in Contents/README. Require a non-empty token for brainstem.save and brainstem.delete to fail early. Improve brainstem_build_query_string to handle both N×2 and flat 1×(2N) filter layouts and preserve string/number formatting. Ensure brainstem_build_url normalizes trailing slashes and handles empty ids. Enhance brainstem_parse_api_error to prefer JSON validation bodies (showing field-level errors) while still including HTTP status when present. Make BrainstemClient honor BRAINSTEM_URL when no explicit url is provided. Add comprehensive unit tests covering these behaviors. --- +brainstem/BrainstemTests.m | 176 +++++++++++++++++- +brainstem/delete.m | 5 + +brainstem/logout.m | 38 ++++ .../private/brainstem_build_query_string.m | 27 ++- +brainstem/private/brainstem_build_url.m | 9 + .../private/brainstem_parse_api_error.m | 42 +++-- +brainstem/save.m | 10 +- BrainstemClient.m | 13 +- Contents.m | 1 + README.md | 29 +-- 10 files changed, 317 insertions(+), 33 deletions(-) create mode 100644 +brainstem/logout.m diff --git a/+brainstem/BrainstemTests.m b/+brainstem/BrainstemTests.m index d99ba66..a26d268 100644 --- a/+brainstem/BrainstemTests.m +++ b/+brainstem/BrainstemTests.m @@ -187,9 +187,9 @@ function testBuildUrlPublicPortal(tc) end function testBuildUrlTrailingSlashOnBase(tc) - % Base URL without trailing slash should still produce valid URL + % Base URL without trailing slash should still produce a fully valid URL got = brainstem_build_url('https://www.brainstem.org', 'private', 'stem', 'session', ''); - tc.verifyTrue(tc.endsWith_(got, 'session/')); + tc.verifyEqual(got, 'https://www.brainstem.org/api/private/stem/session/'); end % ------------------------------------------------------------------ @@ -205,12 +205,46 @@ function testQueryStringFilter(tc) tc.verifyTrue(contains(qs, 'filter{name}=rat'), qs); end + function testQueryStringFilterCharNotCorrupted(tc) + % num2str('rat') would give '114 97 116' — verify the actual + % string value is encoded, not its ASCII codes. + qs = brainstem_build_query_string({'name', 'rat'}, {}, {}, [], 0); + tc.verifyTrue(contains(qs, 'rat'), qs); + tc.verifyFalse(contains(qs, '114'), qs); % ASCII for 'r' + end + + function testQueryStringFilterNumericValue(tc) + % Numeric filter values should be converted to their decimal string. + qs = brainstem_build_query_string({'count', 5}, {}, {}, [], 0); + tc.verifyTrue(contains(qs, 'filter{count}=5'), qs); + end + function testQueryStringMultipleFilters(tc) qs = brainstem_build_query_string({'name', 'rat', 'sex', 'M'}, {}, {}, [], 0); tc.verifyTrue(contains(qs, 'filter{name}=rat'), qs); tc.verifyTrue(contains(qs, 'filter{sex}=M'), qs); end + function testQueryStringFilterNx2Layout(tc) + % N×2 cell layout (produced by brainstem_apply_field_filters) + filter = {'name.icontains', 'rat'; 'sex', 'M'}; + qs = brainstem_build_query_string(filter, {}, {}, [], 0); + tc.verifyTrue(contains(qs, 'filter{name.icontains}=rat'), qs); + tc.verifyTrue(contains(qs, 'filter{sex}=M'), qs); + end + + function testQueryStringFilterNx2KeyValueNotScrambled(tc) + % Regression: column-major indexing on N×2 cell used to swap + % keys and values when N > 1. + filter = {'keyA', 'valA'; 'keyB', 'valB'}; + qs = brainstem_build_query_string(filter, {}, {}, [], 0); + tc.verifyTrue(contains(qs, 'filter{keyA}=valA'), qs); + tc.verifyTrue(contains(qs, 'filter{keyB}=valB'), qs); + % Ensure values didn't end up as keys + tc.verifyFalse(contains(qs, 'filter{valA}'), qs); + tc.verifyFalse(contains(qs, 'filter{valB}'), qs); + end + function testQueryStringSortDescending(tc) qs = brainstem_build_query_string({}, {'-name'}, {}, [], 0); tc.verifyTrue(contains(qs, 'sort[]=-name'), qs); @@ -329,6 +363,30 @@ function testClientCustomUrl(tc) tc.verifyEqual(client.url, 'http://localhost:8000/'); end + function testClientHonoursBrainstemUrlEnvVar(tc) + % BrainstemClient() with no 'url' argument should use BRAINSTEM_URL. + prev = getenv('BRAINSTEM_URL'); + setenv('BRAINSTEM_URL', 'http://env-server.test/'); + try + client = BrainstemClient('token', 'tok'); + tc.verifyEqual(client.url, 'http://env-server.test/'); + finally + setenv('BRAINSTEM_URL', prev); + end + end + + function testClientExplicitUrlOverridesEnvVar(tc) + % Explicit 'url' argument wins over BRAINSTEM_URL. + prev = getenv('BRAINSTEM_URL'); + setenv('BRAINSTEM_URL', 'http://env-server.test/'); + try + client = BrainstemClient('token', 'tok', 'url', 'http://explicit.test/'); + tc.verifyEqual(client.url, 'http://explicit.test/'); + finally + setenv('BRAINSTEM_URL', prev); + end + end + % ------------------------------------------------------------------ % brainstem_parse_api_error — nested struct body % ------------------------------------------------------------------ @@ -347,6 +405,26 @@ function testParseApiErrorNonJsonObject(tc) tc.verifyEqual(msg, raw); end + function testParseApiErrorHttpStatusWithJsonBody(tc) + % A 400 response that contains BOTH the "status NNN" prefix AND a + % JSON body should surface the field-level detail, not just "400 Bad Request". + me = MException('test:err', ... + 'status 400 with message "Bad Request" {"name": ["This field is required."]}'); + msg = brainstem_parse_api_error(me); + tc.verifyTrue(contains(msg, 'name'), msg); + tc.verifyTrue(contains(msg, 'This field is required.'), msg); + % Status code should still appear for context + tc.verifyTrue(contains(msg, '400'), msg); + end + + function testParseApiErrorHttpStatusWithoutBody(tc) + % A 404 with no JSON body should return a compact "404 Not Found" string. + me = MException('test:err', 'status 404 with message "Not Found"'); + msg = brainstem_parse_api_error(me); + tc.verifyTrue(contains(msg, '404'), msg); + tc.verifyTrue(contains(msg, 'Not Found'), msg); + end + % ------------------------------------------------------------------ % get_token — signature tests % ------------------------------------------------------------------ @@ -380,6 +458,39 @@ function testSaveModelPatchWithoutIdErrors(tc) 'BrainSTEM:save'); end + function testSaveModelPatchWithEmptyIdErrors(tc) + % PATCH with an empty id field must also error — empty id is + % equivalent to no id (would target the collection endpoint). + settings = struct('url', tc.BASE_URL, 'token', 'fake'); + tc.verifyError( ... + @() brainstem.save('data', struct('id', '', 'description', 'x'), ... + 'model', 'session', ... + 'method', 'patch', ... + 'settings', settings), ... + 'BrainSTEM:save'); + end + + function testDeleteEmptyTokenErrors(tc) + % delete with an empty token must error before the network call. + settings = struct('url', tc.BASE_URL, 'token', ''); + tc.verifyError( ... + @() brainstem.delete( ... + '00000000-0000-0000-0000-000000000000', 'session', ... + 'settings', settings), ... + 'BrainSTEM:delete'); + end + + function testSaveEmptyTokenErrors(tc) + % save with an empty token must error before the network call. + settings = struct('url', tc.BASE_URL, 'token', ''); + tc.verifyError( ... + @() brainstem.save( ... + 'data', struct('name', 'x'), ... + 'model', 'session', ... + 'settings', settings), ... + 'BrainSTEM:save'); + end + % ------------------------------------------------------------------ % brainstem_parse_api_error (package-private helper) % ------------------------------------------------------------------ @@ -419,6 +530,67 @@ function testClientSavePatchGuardOffline(tc) 'BrainSTEM:save'); end + % ------------------------------------------------------------------ + % brainstem.logout + % ------------------------------------------------------------------ + function testLogoutNoFileIsSilent(tc) + % logout when no cache file exists should not error + tmp = tempname; % non-existent path + % Patch: call logout against a URL that could never be cached + tc.verifyWarningFree(@() brainstem.logout('http://nonexistent-brainstem-server.test/')); + end + + function testLogoutUnknownUrlIsSilent(tc) + % logout for a URL not in the cache should not error + tc.verifyWarningFree(@() brainstem.logout('http://not-in-cache.example/')); + end + + function testLogoutRemovesToken(tc) + % Write a fake cache entry, call logout, verify it is removed. + auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); + test_url = 'http://brainstem-test-logout.local/'; + + % Back up existing cache (if any) + backed_up = false; + if exist(auth_path, 'file') + backup = load(auth_path, 'authentication'); + backed_up = true; + end + + try + % Build a minimal authentication table matching the schema + % used by brainstem_get_settings / get_token. + authentication = table( ... + {test_url}, ... + {'fake-token'}, ... + {datetime('now', 'TimeZone','local') + years(1)}, ... + 'VariableNames', {'urls','tokens','expires_at'}); + save(auth_path, 'authentication'); + + brainstem.logout(test_url); + + % Reload and verify the row was removed + credentials = load(auth_path, 'authentication'); + remaining_urls = credentials.authentication.urls; + tc.verifyFalse(any(strcmp(test_url, remaining_urls)), ... + 'Token should have been removed from cache after logout'); + finally + % Restore original cache (or delete if it didn't exist before) + if backed_up + authentication = backup.authentication; %#ok + save(auth_path, 'authentication'); + elseif exist(auth_path, 'file') + delete(auth_path); + end + end + end + + function testLogoutDefaultUrlUsed(tc) + % logout() with no argument should default to BASE_URL without error + % (It will find no cached token for the test env, which is fine.) + tc.verifyWarningFree(@() brainstem.logout()); + end + end % offline tests % ====================================================================== diff --git a/+brainstem/delete.m b/+brainstem/delete.m index 2c00dc8..5b8eb7c 100644 --- a/+brainstem/delete.m +++ b/+brainstem/delete.m @@ -37,6 +37,11 @@ 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', ... diff --git a/+brainstem/logout.m b/+brainstem/logout.m new file mode 100644 index 0000000..05c5a0d --- /dev/null +++ b/+brainstem/logout.m @@ -0,0 +1,38 @@ +function logout(url) +% LOGOUT Remove the cached BrainSTEM token for a given server URL. +% +% brainstem.logout() removes the token for https://www.brainstem.org/ +% brainstem.logout(url) removes the token for the specified server URL +% +% If the BRAINSTEM_TOKEN environment variable is set, it is not modified +% here — clear it manually with setenv('BRAINSTEM_TOKEN', ''). + +if nargin < 1 || isempty(url) + url = getenv('BRAINSTEM_URL'); + if isempty(url) + url = 'https://www.brainstem.org/'; + end +end + +auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); + +if ~exist(auth_path, 'file') + fprintf('No saved credentials found for %s\n', url); + return +end + +credentials = load(auth_path, 'authentication'); +auth_tbl = credentials.authentication; +idx = find(strcmp(url, auth_tbl.urls)); + +if isempty(idx) + fprintf('No saved token found for %s\n', url); + return +end + +auth_tbl(idx, :) = []; +authentication = auth_tbl; %#ok +save(auth_path, 'authentication'); + +fprintf('Logged out: token for %s removed.\n', url); +end diff --git a/+brainstem/private/brainstem_build_query_string.m b/+brainstem/private/brainstem_build_query_string.m index 31f91ee..62fceb9 100644 --- a/+brainstem/private/brainstem_build_query_string.m +++ b/+brainstem/private/brainstem_build_query_string.m @@ -14,9 +14,32 @@ parts = {}; % Filter parameters: filter{field}=value +% Accepts two layouts: +% N×2 cell — each row is {key, value} (produced by brainstem_apply_field_filters) +% 1×(2N) cell — flat alternating {key, value, key, value, ...} (user-supplied) if ~isempty(filter) - for i = 1:2:numel(filter) - parts{end+1} = ['filter{', filter{i}, '}=', urlencode(num2str(filter{i+1}))]; %#ok + if size(filter, 2) == 2 && size(filter, 1) >= 1 && ~isvector(filter) + % N×2 matrix layout + for i = 1:size(filter, 1) + val = filter{i, 2}; + if ischar(val) || isstring(val) + val_str = char(val); + else + val_str = num2str(val); + end + parts{end+1} = ['filter{', filter{i,1}, '}=', urlencode(val_str)]; %#ok + end + else + % Flat 1×(2N) layout + for i = 1:2:numel(filter) + val = filter{i+1}; + if ischar(val) || isstring(val) + val_str = char(val); + else + val_str = num2str(val); + end + parts{end+1} = ['filter{', filter{i}, '}=', urlencode(val_str)]; %#ok + end end end diff --git a/+brainstem/private/brainstem_build_url.m b/+brainstem/private/brainstem_build_url.m index 8cded58..47f7505 100644 --- a/+brainstem/private/brainstem_build_url.m +++ b/+brainstem/private/brainstem_build_url.m @@ -10,6 +10,15 @@ % /api//// if nargin < 5 || isempty(id) + id = ''; +end + +% Ensure base_url has exactly one trailing slash +if isempty(base_url) || base_url(end) ~= '/' + base_url = [base_url, '/']; +end + +if isempty(id) url = [base_url, 'api/', portal, '/', app, '/', model, '/']; else url = [base_url, 'api/', portal, '/', app, '/', model, '/', id, '/']; diff --git a/+brainstem/private/brainstem_parse_api_error.m b/+brainstem/private/brainstem_parse_api_error.m index 7793bb9..2dfb1b0 100644 --- a/+brainstem/private/brainstem_parse_api_error.m +++ b/+brainstem/private/brainstem_parse_api_error.m @@ -16,18 +16,13 @@ % Collapse newlines so the regex can match JSON that spans multiple lines raw_clean = regexprep(raw, '\r?\n', ' '); -% Detect MATLAB's standard HTTP error: "status NNN with message "Reason Text"" -% and return a compact "404 Not Found" style string (avoids duplicating the URL). -http_match = regexp(raw_clean, 'status (\d+) with message "([^"]+)"', 'tokens', 'once'); -if ~isempty(http_match) - status_code = http_match{1}; - reason = http_match{2}; - msg = sprintf('%s %s', status_code, reason); - return -end - -% Find the outermost JSON object in the message +% Try to extract and format a JSON validation body first — this gives the +% most actionable information (field-level errors from DRF). We do this +% before the HTTP-status check so that 400 responses with a body like: +% "status 400 with message "Bad Request" {"name":["required"]}" +% still surface the field-level detail rather than just "400 Bad Request". json_match = regexp(raw_clean, '\{.+\}', 'match', 'once'); +json_msg = ''; if ~isempty(json_match) try @@ -61,13 +56,32 @@ end parts{i} = sprintf('%s: %s', fields{i}, val_str); end - msg = strjoin(parts, ' | '); - return + json_msg = strjoin(parts, ' | '); catch - % JSON parse failed or unexpected structure — fall through + % JSON parse failed — fall through end end +% Extract HTTP status code / reason if present in the message. +http_match = regexp(raw_clean, 'status (\d+) with message "([^"]+)"', 'tokens', 'once'); + +if ~isempty(json_msg) + % Have parsed JSON details. Optionally prepend the status code so the + % caller still knows which HTTP error triggered this. + if ~isempty(http_match) + msg = sprintf('%s %s — %s', http_match{1}, http_match{2}, json_msg); + else + msg = json_msg; + end + return +end + +if ~isempty(http_match) + % No JSON body, but we have the HTTP status. + msg = sprintf('%s %s', http_match{1}, http_match{2}); + return +end + % Fallback: return the raw message unchanged msg = raw; end diff --git a/+brainstem/save.m b/+brainstem/save.m index fc3d559..ed0cac2 100644 --- a/+brainstem/save.m +++ b/+brainstem/save.m @@ -52,12 +52,18 @@ end % PATCH without an id in the data makes no sense: there is no record to update. -if strcmpi(parameters.method, 'patch') && ~isfield(parameters.data, 'id') +has_id = isfield(parameters.data, 'id') && ~isempty(parameters.data.id); +if strcmpi(parameters.method, 'patch') && ~has_id error('BrainSTEM:save', '%s', ... ['PATCH requires an ''id'' field in data to identify the record. ' ... 'For new records omit the ''method'' parameter (POST is used automatically).']); end +if isempty(parameters.settings.token) + error('BrainSTEM:save', ... + 'A token is required to save records. Set BRAINSTEM_TOKEN or call brainstem.get_token().'); +end + options = weboptions( ... 'HeaderFields', {'Authorization', ['Bearer ' parameters.settings.token]}, ... 'MediaType', 'application/json', ... @@ -65,7 +71,7 @@ 'ArrayFormat', 'json', ... 'Timeout', 30); -if isfield(parameters.data, 'id') +if has_id options.RequestMethod = lower(parameters.method); endpoint = brainstem_build_url(parameters.settings.url, parameters.portal, ... parameters.app, parameters.model, parameters.data.id); diff --git a/BrainstemClient.m b/BrainstemClient.m index 41dfcde..0aa68aa 100644 --- a/BrainstemClient.m +++ b/BrainstemClient.m @@ -16,6 +16,10 @@ % % client = BrainstemClient('url', URL) % Connect to a non-default server (e.g. local dev instance). +% The BRAINSTEM_URL environment variable is honoured when 'url' is +% not supplied explicitly: +% setenv('BRAINSTEM_URL', 'http://localhost:8000/') +% client = BrainstemClient() % uses http://localhost:8000/ % % % CORE METHODS @@ -99,9 +103,14 @@ % ------------------------------------------------------------------ function obj = BrainstemClient(varargin) % BRAINSTEMCLIENT Constructor. + default_url = getenv('BRAINSTEM_URL'); + if isempty(default_url) + default_url = 'https://www.brainstem.org/'; + end + p = inputParser; - addParameter(p, 'url', 'https://www.brainstem.org/', @ischar); - addParameter(p, 'token', '', @ischar); + addParameter(p, 'url', default_url, @ischar); + addParameter(p, 'token', '', @ischar); parse(p, varargin{:}); obj.url = p.Results.url; diff --git a/Contents.m b/Contents.m index 2e2ba8a..05ae715 100644 --- a/Contents.m +++ b/Contents.m @@ -18,6 +18,7 @@ % brainstem.save - Create or update records (POST/PUT/PATCH) % brainstem.delete - Delete a record by UUID % brainstem.get_token - Acquire and cache an API token via device flow +% brainstem.logout - Remove the cached token for a server URL % brainstem.get_app_from_model - Map model name to API app prefix % % CONVENIENCE LOADERS (also available as client.() methods) diff --git a/README.md b/README.md index c9de84f..ffdfee3 100644 --- a/README.md +++ b/README.md @@ -77,25 +77,32 @@ client.delete(out.sessions(1).id, 'session'); | Function | Description | |----------|-------------| | `BrainstemClient` | Client class — authenticate once, call any endpoint | -| `get_token` | Interactively acquire and cache an API token | +| `brainstem.get_token` | Interactively acquire and cache an API token | +| `brainstem.logout` | Remove a cached token for a server URL | | `brainstem.load` | Load records from any BrainSTEM model | | `brainstem.save` | Create or update records (POST / PUT / PATCH) | | `brainstem.delete` | Delete a record by UUID | -| `get_app_from_model` | Map a model name to its API app prefix | +| `brainstem.get_app_from_model` | Map a model name to its API app prefix | ## Convenience Loaders +These functions live in the `+brainstem` package. Call them as `brainstem.(...)` or via the client as `client.(...)`. + | Function | Model | Default includes | |----------|-------|-----------------| -| `load_project` | project | sessions, subjects, collections, cohorts | -| `load_subject` | subject | procedures, subjectlogs | -| `load_session` | session | dataacquisition, behaviors, manipulations, epochs | -| `load_collection` | collection | sessions | -| `load_cohort` | cohort | subjects | -| `load_behavior` | behavior (modules) | — | -| `load_dataacquisition` | dataacquisition (modules) | — | -| `load_manipulation` | manipulation (modules) | — | -| `load_procedure` | procedure (modules) | — | +| `brainstem.load_project` | project | sessions, subjects, collections, cohorts | +| `brainstem.load_subject` | subject | procedures, subjectlogs | +| `brainstem.load_session` | session | dataacquisition, behaviors, manipulations, epochs | +| `brainstem.load_collection` | collection | sessions | +| `brainstem.load_cohort` | cohort | subjects | +| `brainstem.load_behavior` | behavior (modules) | — | +| `brainstem.load_dataacquisition` | dataacquisition (modules) | — | +| `brainstem.load_manipulation` | manipulation (modules) | — | +| `brainstem.load_procedure` | procedure (modules) | — | +| `brainstem.load_procedurelog` | procedurelog (modules) | — | +| `brainstem.load_subjectlog` | subjectlog (modules) | — | +| `brainstem.load_equipment` | equipment (modules) | — | +| `brainstem.load_consumablestock` | consumablestock (modules) | — | ## Query Options From bbaafbd9a9b3ed4f4f0d3dea8f168891e9289248 Mon Sep 17 00:00:00 2001 From: "Peter C. Petersen" Date: Mon, 30 Mar 2026 15:59:37 +0200 Subject: [PATCH 6/6] Add name-value logout & normalize auth storage Allow brainstem.logout to accept name-value form (brainstem.logout('url', url)) and validate inputs; use BRAINSTEM_URL env var as default. Normalize the on-disk authentication table schema in get_token/save_token_: add missing columns ('usernames', 'saved_at'), canonicalize column order, and build new rows consistently so older auth files are upgraded. Improve save feedback to print the auth file path. Add BrainstemClient.logout to clear the client's in-memory token and remove the saved token for the client's URL. Update unit tests (BrainstemTests) to match the new table schema and add a test for the name-value logout form. --- +brainstem/BrainstemTests.m | 51 +++++++++++++++++++++++++++++++++--- +brainstem/get_token.m | 20 +++++++++++--- +brainstem/logout.m | 26 ++++++++++++------ .DS_Store | Bin 6148 -> 6148 bytes BrainstemClient.m | 8 ++++++ 5 files changed, 90 insertions(+), 15 deletions(-) diff --git a/+brainstem/BrainstemTests.m b/+brainstem/BrainstemTests.m index a26d268..335e91e 100644 --- a/+brainstem/BrainstemTests.m +++ b/+brainstem/BrainstemTests.m @@ -561,10 +561,15 @@ function testLogoutRemovesToken(tc) % Build a minimal authentication table matching the schema % used by brainstem_get_settings / get_token. authentication = table( ... - {test_url}, ... {'fake-token'}, ... - {datetime('now', 'TimeZone','local') + years(1)}, ... - 'VariableNames', {'urls','tokens','expires_at'}); + {''}, ... + {test_url}, ... + {now}, ... + {'personal'}, ... + {''}, ... + {now + 365}, ... + 'VariableNames', {'tokens','usernames','urls','saved_at', ... + 'token_type','refresh_tokens','expires_at'}); save(auth_path, 'authentication'); brainstem.logout(test_url); @@ -591,6 +596,46 @@ function testLogoutDefaultUrlUsed(tc) tc.verifyWarningFree(@() brainstem.logout()); end + function testLogoutNameValueUrl(tc) + % brainstem.logout('url', url) name-value form should work. + auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); + test_url = 'http://brainstem-test-nv-logout.local/'; + + backed_up = false; + if exist(auth_path, 'file') + backup = load(auth_path, 'authentication'); + backed_up = true; + end + + try + authentication = table( ... + {'fake-token'}, ... + {''}, ... + {test_url}, ... + {now}, ... + {'personal'}, ... + {''}, ... + {now + 365}, ... + 'VariableNames', {'tokens','usernames','urls','saved_at', ... + 'token_type','refresh_tokens','expires_at'}); + save(auth_path, 'authentication'); + + brainstem.logout('url', test_url); + + credentials = load(auth_path, 'authentication'); + remaining_urls = credentials.authentication.urls; + tc.verifyFalse(any(strcmp(test_url, remaining_urls)), ... + 'Token should have been removed via name-value logout call'); + finally + if backed_up + authentication = backup.authentication; %#ok + save(auth_path, 'authentication'); + elseif exist(auth_path, 'file') + delete(auth_path); + end + end + end + end % offline tests % ====================================================================== diff --git a/+brainstem/get_token.m b/+brainstem/get_token.m index d3e16fb..ecc25a2 100644 --- a/+brainstem/get_token.m +++ b/+brainstem/get_token.m @@ -124,9 +124,6 @@ function save_token_(url, token) auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); expires_at = now + 365; % Personal Access Tokens are valid for ~1 year -new_row = table({token}, {''}, {url}, {now}, {'personal'}, {''}, {expires_at}, ... - 'VariableNames', {'tokens','usernames','urls','saved_at', ... - 'token_type','refresh_tokens','expires_at'}); if exist(auth_path, 'file') existing = load(auth_path, 'authentication'); @@ -138,6 +135,12 @@ function save_token_(url, token) 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); @@ -145,6 +148,12 @@ function save_token_(url, token) 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; @@ -158,9 +167,12 @@ function save_token_(url, token) authentication = [tbl; new_row]; %#ok 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 end save(auth_path, 'authentication'); -fprintf('Token saved.\n'); +fprintf('Token saved to %s\n', auth_path); end diff --git a/+brainstem/logout.m b/+brainstem/logout.m index 05c5a0d..b4051c5 100644 --- a/+brainstem/logout.m +++ b/+brainstem/logout.m @@ -1,17 +1,27 @@ -function logout(url) +function logout(varargin) % LOGOUT Remove the cached BrainSTEM token for a given server URL. % -% brainstem.logout() removes the token for https://www.brainstem.org/ -% brainstem.logout(url) removes the token for the specified server URL +% brainstem.logout() removes the token for https://www.brainstem.org/ +% brainstem.logout(url) positional form +% brainstem.logout('url', url) name-value form % % If the BRAINSTEM_TOKEN environment variable is set, it is not modified % here — clear it manually with setenv('BRAINSTEM_TOKEN', ''). -if nargin < 1 || isempty(url) - url = getenv('BRAINSTEM_URL'); - if isempty(url) - url = 'https://www.brainstem.org/'; - end +default_url = getenv('BRAINSTEM_URL'); +if isempty(default_url) + default_url = 'https://www.brainstem.org/'; +end + +if nargin == 0 + url = default_url; +elseif nargin == 1 + url = char(varargin{1}); +elseif nargin == 2 && strcmpi(varargin{1}, 'url') + url = char(varargin{2}); +else + error('brainstem:logout:invalidInput', ... + 'Usage: brainstem.logout() or brainstem.logout(url) or brainstem.logout(''url'', url)'); end auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); diff --git a/.DS_Store b/.DS_Store index 092ec4e836aab9f7fdbcf6198ce9906ee9148141..8f7f48d16704718f4f6a6b6344269f75bb9cfd00 100644 GIT binary patch delta 111 zcmZoMXfc=|#>B`mF;Q%yo}wr#0|Nsi1A_nqLncEWLoq`MLn=e=WI@K|N+20725p8U zh9ZVUWZ9JBCJ*u~2NHo}wTN0|Nsi1A_oVPP$=ma(-^X#6oRGmd(11x7a2hU|hJF for9kPsCx58#_!CN`9%yF87ABCNN