diff --git a/+brainstem/BrainstemTests.m b/+brainstem/BrainstemTests.m new file mode 100644 index 0000000..335e91e --- /dev/null +++ b/+brainstem/BrainstemTests.m @@ -0,0 +1,798 @@ +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(brainstem.get_app_from_model('session'), 'stem'); + end + + function testAppFromModelProject(tc) + tc.verifyEqual(brainstem.get_app_from_model('project'), 'stem'); + end + + function testAppFromModelBreeding(tc) + tc.verifyEqual(brainstem.get_app_from_model('breeding'), 'stem'); + end + + function testAppFromModelSubject(tc) + tc.verifyEqual(brainstem.get_app_from_model('subject'), 'stem'); + end + + function testAppFromModelBehavior(tc) + tc.verifyEqual(brainstem.get_app_from_model('behavior'), 'modules'); + end + + function testAppFromModelDataAcquisition(tc) + tc.verifyEqual(brainstem.get_app_from_model('dataacquisition'), 'modules'); + end + + function testAppFromModelManipulation(tc) + tc.verifyEqual(brainstem.get_app_from_model('manipulation'), 'modules'); + end + + function testAppFromModelSetup(tc) + tc.verifyEqual(brainstem.get_app_from_model('setup'), 'personal_attributes'); + end + + function testAppFromModelConsumable(tc) + tc.verifyEqual(brainstem.get_app_from_model('consumable'), 'resources'); + end + + function testAppFromModelSpecies(tc) + tc.verifyEqual(brainstem.get_app_from_model('species'), 'taxonomies'); + end + + function testAppFromModelPublication(tc) + tc.verifyEqual(brainstem.get_app_from_model('publication'), 'dissemination'); + end + + function testAppFromModelUser(tc) + tc.verifyEqual(brainstem.get_app_from_model('user'), 'users'); + end + + function testAppFromModelUnknown(tc) + tc.verifyEmpty(brainstem.get_app_from_model('nonexistent_model_xyz')); + end + + % stem (remaining) + function testAppFromModelCollection(tc) + tc.verifyEqual(brainstem.get_app_from_model('collection'), 'stem'); + end + function testAppFromModelCohort(tc) + tc.verifyEqual(brainstem.get_app_from_model('cohort'), 'stem'); + end + + % modules (remaining) + function testAppFromModelProcedure(tc) + tc.verifyEqual(brainstem.get_app_from_model('procedure'), 'modules'); + end + function testAppFromModelEquipment(tc) + tc.verifyEqual(brainstem.get_app_from_model('equipment'), 'modules'); + end + function testAppFromModelConsumableStock(tc) + tc.verifyEqual(brainstem.get_app_from_model('consumablestock'), 'modules'); + end + function testAppFromModelProcedureLog(tc) + tc.verifyEqual(brainstem.get_app_from_model('procedurelog'), 'modules'); + end + function testAppFromModelSubjectLog(tc) + tc.verifyEqual(brainstem.get_app_from_model('subjectlog'), 'modules'); + end + + % personal_attributes + function testAppFromModelBehavioralAssay(tc) + tc.verifyEqual(brainstem.get_app_from_model('behavioralassay'), 'personal_attributes'); + end + function testAppFromModelDataStorage(tc) + tc.verifyEqual(brainstem.get_app_from_model('datastorage'), 'personal_attributes'); + end + function testAppFromModelInventory(tc) + tc.verifyEqual(brainstem.get_app_from_model('inventory'), 'personal_attributes'); + end + function testAppFromModelProtocol(tc) + tc.verifyEqual(brainstem.get_app_from_model('protocol'), 'personal_attributes'); + end + + % resources + function testAppFromModelHardwareDevice(tc) + tc.verifyEqual(brainstem.get_app_from_model('hardwaredevice'), 'resources'); + end + function testAppFromModelSupplier(tc) + tc.verifyEqual(brainstem.get_app_from_model('supplier'), 'resources'); + end + + % taxonomies + function testAppFromModelStrain(tc) + tc.verifyEqual(brainstem.get_app_from_model('strain'), 'taxonomies'); + end + function testAppFromModelBrainRegion(tc) + tc.verifyEqual(brainstem.get_app_from_model('brainregion'), 'taxonomies'); + end + function testAppFromModelSetupType(tc) + tc.verifyEqual(brainstem.get_app_from_model('setuptype'), 'taxonomies'); + end + function testAppFromModelBehavioralParadigm(tc) + tc.verifyEqual(brainstem.get_app_from_model('behavioralparadigm'), 'taxonomies'); + end + function testAppFromModelRegulatoryAuthority(tc) + tc.verifyEqual(brainstem.get_app_from_model('regulatoryauthority'), 'taxonomies'); + end + + % dissemination + function testAppFromModelJournal(tc) + tc.verifyEqual(brainstem.get_app_from_model('journal'), 'dissemination'); + end + + % users + function testAppFromModelLaboratory(tc) + tc.verifyEqual(brainstem.get_app_from_model('laboratory'), 'users'); + end + function testAppFromModelGroupMembershipInvitation(tc) + tc.verifyEqual(brainstem.get_app_from_model('groupmembershipinvitation'), 'users'); + end + function testAppFromModelGroupMembershipRequest(tc) + tc.verifyEqual(brainstem.get_app_from_model('groupmembershiprequest'), 'users'); + end + + % auth + function testAppFromModelGroup(tc) + tc.verifyEqual(brainstem.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 a fully valid URL + got = brainstem_build_url('https://www.brainstem.org', 'private', 'stem', 'session', ''); + tc.verifyEqual(got, 'https://www.brainstem.org/api/private/stem/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 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); + 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(tc.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 default to .icontains + p.filter = {}; + p.description = 'baseline'; + result = brainstem_apply_field_filters(p, {'description'}, ... + {'name','name.icontains'}); % 'description' not in map + tc.verifyTrue(any(strcmp(result(:,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 testClientConstructorTokenTypeIsPersonal(tc) + % token_type is always 'personal' (PAT-only flow) + client = BrainstemClient('token', 'tok'); + tc.verifyEqual(client.token_type, 'personal'); + end + + function testClientConstructorUnknownParamErrors(tc) + % Passing an unknown parameter should throw + tc.verifyError( ... + @() BrainstemClient('token', 'tok', 'token_type', 'shortlived'), ... + 'MATLAB:InputParser:UnmatchedParameter'); + 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 + + 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 + % ------------------------------------------------------------------ + 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 + + 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 + % ------------------------------------------------------------------ + function testGetTokenTooManyArgsErrors(tc) + % get_token now only accepts one argument (url) + tc.verifyError( ... + @() brainstem.get_token(tc.BASE_URL, 'extra_arg'), ... + 'MATLAB:TooManyInputs'); + 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 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( ... + @() brainstem.save('data', struct('description', 'x'), ... + 'model', 'session', ... + 'method', 'patch', ... + 'settings', settings), ... + '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) + % ------------------------------------------------------------------ + 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 + + 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 + + % ------------------------------------------------------------------ + % 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( ... + {'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(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 + + 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 + + % ====================================================================== + methods (Test, TestTags = {'network'}) + % Requires internet access but no authentication token. + % ====================================================================== + + function testLoadPublicProjects(tc) + settings = struct('url', tc.BASE_URL, 'token', ''); + out = brainstem.load('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', ''); + out = brainstem.load('model', 'project', 'portal', 'public', ... + 'settings', settings, 'limit', 1); + if isfield(out, 'projects') && ~isempty(out.projects) + 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 + + 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); + out = brainstem.load('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); + out = brainstem.load('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); + out = brainstem.load('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); + % 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) + 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 + 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); + out = brainstem.load('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('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) + 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 + + 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.m b/+brainstem/delete.m new file mode 100644 index 0000000..5b8eb7c --- /dev/null +++ b/+brainstem/delete.m @@ -0,0 +1,64 @@ +function output = delete(id, model, varargin) +% DELETE Delete a record from a BrainSTEM API endpoint. +% +% output = delete(ID, MODEL) +% output = delete(ID, MODEL, 'portal', 'private', 'settings', settings) +% +% Parameters: +% id - UUID string of the record to delete (required) +% model - Model name, e.g. 'session', 'project', 'subject' (required) +% portal - 'private' (default) or 'public' +% app - App name; auto-detected from model if omitted +% settings - Settings struct (auto-resolved from BRAINSTEM_TOKEN env var or token cache) +% +% Example: +% brainstem.delete('', 'session'); + +p = inputParser; +addParameter(p,'portal', 'private', @ischar); +addParameter(p,'app', '', @ischar); +addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); +parse(p, varargin{:}) +parameters = p.Results; +if isempty(parameters.settings) + parameters.settings = brainstem_get_settings(); +end + +% Guard against deleting the collection endpoint by accident +if isempty(id) + error('BrainSTEM:delete', 'id must be a non-empty UUID string.'); +end +if isempty(regexp(id, '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', 'once')) + error('BrainSTEM:delete', ... + 'id must be a valid UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx), got: %s', id); +end + +if isempty(parameters.app) + parameters.app = brainstem.get_app_from_model(model); +end + +if isempty(parameters.settings.token) + error('BrainSTEM:delete', ... + 'A token is required to delete records. Set BRAINSTEM_TOKEN or call brainstem.get_token().'); +end + +options = weboptions( ... + 'HeaderFields', {'Authorization', ['Bearer ' parameters.settings.token]}, ... + 'ContentType', 'json', ... + 'Timeout', 30, ... + 'RequestMethod','delete'); + +endpoint = brainstem_build_url(parameters.settings.url, parameters.portal, ... + parameters.app, model, id); +try + output = webread(endpoint, options); +catch ME + % A 204 No Content response is a successful delete; webread raises an + % error for non-200 responses, so tolerate the expected 204 case. + if contains(ME.message, '204') + output = struct('status', 'deleted', 'id', id); + else + error('BrainSTEM:delete', 'API error deleting %s %s: %s', ... + model, id, brainstem_parse_api_error(ME)); + end +end diff --git a/+brainstem/get_app_from_model.m b/+brainstem/get_app_from_model.m new file mode 100644 index 0000000..34eff01 --- /dev/null +++ b/+brainstem/get_app_from_model.m @@ -0,0 +1,25 @@ +function app = get_app_from_model(modelname) + +switch modelname + case {'project','subject','session','collection','cohort', ... + 'breeding','projectmembershipinvitation','projectgroupmembershipinvitation'} + app = 'stem'; + case {'procedure','equipment','consumablestock','behavior','dataacquisition','manipulation','procedurelog','subjectlog'} + app = 'modules'; + case {'behavioralassay','datastorage','setup','inventory','license','protocol'} + app = 'personal_attributes'; + case {'consumable','hardwaredevice','supplier'} + app = 'resources'; + case {'brainregion','setuptype','species','strain','behavioralcategory','behavioralparadigm','regulatoryauthority'} + app = 'taxonomies'; + case {'journal','publication'} + app = 'dissemination'; + case {'user','laboratory','groupmembershipinvitation','groupmembershiprequest'} + app = 'users'; + case {'group'} + % Groups live under the 'auth' app namespace in the API URL, even + % though they are documented under the Users section of the API docs. + app = 'auth'; + otherwise + app = ''; +end diff --git a/+brainstem/get_token.m b/+brainstem/get_token.m new file mode 100644 index 0000000..ecc25a2 --- /dev/null +++ b/+brainstem/get_token.m @@ -0,0 +1,178 @@ +function token = get_token(url) +% GET_TOKEN Obtain a Personal Access Token from the BrainSTEM server. +% +% Uses the browser-based device authorization flow: +% 1. POST /api/auth/device/ → {session_id, device_code, verification_uri_complete, expires_in} +% 2. Opens verification_uri_complete in the default browser so the user +% can log in (including 2FA if enabled). +% 3. Polls POST /api/auth/device/token/ with {device_code} until the +% user completes the web login, then returns the issued token. +% +% Fallback: if the server does not support the device flow (older +% deployments), the user is prompted to paste a Personal Access Token +% obtained from private/users/tokens/. +% +% Parameters: +% url - Server base URL (default: https://www.brainstem.org/) +if nargin < 1 || isempty(url) + url = 'https://www.brainstem.org/'; +end + +% Attempt device authorization flow +options_post = weboptions( ... + 'MediaType', 'application/json', ... + 'ContentType', 'json', ... + 'RequestMethod','post'); +try + resp = webwrite([url 'api/auth/device/'], struct(), options_post); +catch + % Server does not support device flow — prompt for manual PAT entry + token = manual_pat_flow_(url); + if ~isempty(token) + save_token_(url, token); + end + return +end + +token = device_flow_(url, resp); + +if ~isempty(token) + save_token_(url, token); +end +end + +% ------------------------------------------------------------------------- +function token = device_flow_(url, resp) +device_code = resp.device_code; +auth_url = resp.verification_uri_complete; +expires_in = 300; +if isfield(resp, 'expires_in'), expires_in = resp.expires_in; end + +fprintf('\nAuthenticating with BrainSTEM...\n'); +fprintf('Opening login page in your browser.\n'); +fprintf('If the browser does not open automatically, visit:\n %s\n\n', auth_url); +try + web(char(auth_url), '-browser'); +catch + % Headless or web() unavailable — user must open manually +end + +fprintf('Waiting for authentication (timeout: %d s) ...', expires_in); +options_poll = weboptions( ... + 'MediaType', 'application/json', ... + 'ContentType', 'json', ... + 'RequestMethod','post'); +poll_url = [url 'api/auth/device/token/']; +poll_body = struct('device_code', device_code); +deadline = now + expires_in / 86400; +token = ''; +while now < deadline + pause(3); + fprintf('.'); + try + r = webwrite(poll_url, poll_body, options_poll); + catch + continue + end + if isfield(r, 'status') && strcmp(r.status, 'success') + token = r.token; + fprintf('\nAuthenticated successfully.\n\n'); + return + end + if isfield(r, 'error') + switch r.error + case 'expired_token' + fprintf('\n'); + error('BrainSTEM:deviceAuthExpired', ... + 'Authentication request expired. Please call get_token() again.'); + case 'access_denied' + fprintf('\n'); + error('BrainSTEM:deviceAuthDenied', ... + 'Access denied. Please try again.'); + otherwise + fprintf('\n'); + error('BrainSTEM:deviceAuthError', ... + 'Unexpected error: %s', r.error); + end + end + % authorization_pending → keep waiting +end +fprintf('\n'); +error('BrainSTEM:deviceAuthTimeout', ... + 'Authentication timed out after %d seconds. Please try again.', expires_in); +end + +% ------------------------------------------------------------------------- +function token = manual_pat_flow_(url) +tokens_url = [url 'private/users/tokens/']; +fprintf(['\nThis server does not support the device authorization flow.\n' ... + 'To authenticate:\n' ... + ' 1. Open: %s\n' ... + ' 2. Create a Personal Access Token\n' ... + ' 3. Copy the token and paste it below\n\n'], tokens_url); +answer = inputdlg( ... + {sprintf('Personal Access Token\n(from %s)', tokens_url)}, ... + 'BrainSTEM Authentication', [1 72]); +if isempty(answer) || isempty(strtrim(answer{1})) + token = ''; +else + token = strtrim(answer{1}); +end +end + +% ------------------------------------------------------------------------- +function save_token_(url, token) +auth_path = fullfile(prefdir, 'brainstem_authentication.mat'); +expires_at = now + 365; % Personal Access Tokens are valid for ~1 year + +if exist(auth_path, 'file') + existing = load(auth_path, 'authentication'); + tbl = existing.authentication; + % Upgrade old table schemas that are missing new columns + if ~ismember('token_type', tbl.Properties.VariableNames) + tbl.token_type = repmat({'personal'}, height(tbl), 1); + end + if ~ismember('refresh_tokens', tbl.Properties.VariableNames) + tbl.refresh_tokens = repmat({''}, height(tbl), 1); + end + if ~ismember('usernames', tbl.Properties.VariableNames) + tbl.usernames = repmat({''}, height(tbl), 1); + end + if ~ismember('saved_at', tbl.Properties.VariableNames) + tbl.saved_at = repmat({now}, height(tbl), 1); + end + if ~ismember('expires_at', tbl.Properties.VariableNames) + if ismember('saved_at', tbl.Properties.VariableNames) + tbl.expires_at = cellfun(@(t) t + 365, tbl.saved_at, 'UniformOutput', false); + else + tbl.expires_at = repmat({now + 365}, height(tbl), 1); + end + end + % Reorder tbl to canonical schema, then build new_row explicitly + canonical_vars = {'tokens','usernames','urls','saved_at','token_type','refresh_tokens','expires_at'}; + tbl = tbl(:, canonical_vars); + new_row = table({token}, {''}, {url}, {now}, {'personal'}, {''}, {expires_at}, ... + 'VariableNames', canonical_vars); + + idx = find(strcmp(url, tbl.urls)); + if ~isempty(idx) + tbl.tokens{idx} = token; + tbl.usernames{idx} = ''; + tbl.saved_at{idx} = now; + tbl.token_type{idx} = 'personal'; + tbl.refresh_tokens{idx} = ''; + tbl.expires_at{idx} = expires_at; + authentication = tbl; %#ok + else + 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 to %s\n', auth_path); +end diff --git a/+brainstem/load.m b/+brainstem/load.m new file mode 100644 index 0000000..1e1f871 --- /dev/null +++ b/+brainstem/load.m @@ -0,0 +1,125 @@ +function output = load(varargin) +% LOAD Retrieve records from a BrainSTEM API endpoint. +% +% output = load('model', MODEL) returns records for MODEL. +% +% Parameters: +% model - Model name, e.g. 'session', 'project', 'subject' (default: 'session') +% portal - 'private' (default) or 'public' +% app - App name; auto-detected from model if omitted +% id - UUID string; fetches a single record at /// +% filter - Cell array of {field, value} pairs, e.g. {'name.icontains','Rat'} +% sort - Cell array of field names; prefix '-' for descending +% include - Cell array of relational fields to embed +% limit - Max records per page (API default: 20, max: 100) +% offset - Records to skip (for manual paging) +% load_all - true to auto-follow pagination and return all records (default: false) +% settings - Settings struct (auto-resolved from BRAINSTEM_TOKEN env var or token cache) +% +% Examples: +% output = brainstem.load('model','session'); +% output = brainstem.load('model','session','id',''); +% output = brainstem.load('model','session','filter',{'name.icontains','Rat'},'sort',{'-name'}); +% output = brainstem.load('model','session','include',{'behaviors','manipulations'}); +% output = brainstem.load('model','session','load_all',true); +% output = brainstem.load('model','project','portal','public'); + +p = inputParser; +addParameter(p,'portal', 'private', @ischar); +addParameter(p,'app', '', @ischar); +addParameter(p,'model', 'session', @ischar); +addParameter(p,'id', '', @ischar); +addParameter(p,'settings',[], @(x) isempty(x)||isstruct(x)); +addParameter(p,'filter', {}, @iscell); +addParameter(p,'sort', {}, @iscell); +addParameter(p,'include', {}, @iscell); +addParameter(p,'limit', [], @(x) isempty(x) || (isnumeric(x) && isscalar(x))); +addParameter(p,'offset', 0, @(x) isnumeric(x) && isscalar(x)); +addParameter(p,'load_all',false, @islogical); +parse(p, varargin{:}) +parameters = p.Results; +if isempty(parameters.settings) + parameters.settings = brainstem_get_settings(); +end + +% Validate UUID format when an id is supplied +if ~isempty(parameters.id) && isempty(regexp(parameters.id, ... + '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', 'once')) + error('BrainSTEM:load', ... + 'id must be a valid UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx), got: %s', parameters.id); +end + +if isempty(parameters.app) + parameters.app = brainstem.get_app_from_model(parameters.model); +end + +% Auth header — omit when token is empty (e.g. public portal requests) +if isempty(parameters.settings.token) + options = weboptions( ... + 'ContentType', 'json', ... + 'ArrayFormat', 'json', ... + 'Timeout', 30, ... + 'RequestMethod','get'); +else + options = weboptions( ... + 'HeaderFields', {'Authorization', ['Bearer ' parameters.settings.token]}, ... + 'ContentType', 'json', ... + 'ArrayFormat', 'json', ... + 'Timeout', 30, ... + 'RequestMethod','get'); +end + +% Single-record fetch by id +if ~isempty(parameters.id) + url = brainstem_build_url(parameters.settings.url, parameters.portal, ... + parameters.app, parameters.model, parameters.id); + try + output = webread(url, options); + catch ME + error('BrainSTEM:load', 'API error fetching %s: %s', url, brainstem_parse_api_error(ME)); + end + return +end + +% Collection fetch (with optional pagination) +qs = brainstem_build_query_string(parameters.filter, parameters.sort, ... + parameters.include, parameters.limit, parameters.offset); +url = [brainstem_build_url(parameters.settings.url, parameters.portal, ... + parameters.app, parameters.model), qs]; +try + output = webread(url, options); +catch ME + error('BrainSTEM:load', 'API error fetching %s: %s', url, brainstem_parse_api_error(ME)); +end +output = brainstem_normalize_list_response(output); + +% Auto-paginate: keep fetching while there is a 'next' URL +if parameters.load_all + % Detect the data key dynamically: it is the response field whose + % value is a struct array (i.e. not the scalar metadata fields). + metadata_keys = {'count','next','previous'}; + all_keys = fieldnames(output); + data_keys = all_keys(~ismember(all_keys, metadata_keys)); + if ~isempty(data_keys) + model_key = data_keys{1}; % e.g. 'sessions', 'dataacquisitions' + else + model_key = ''; + end + while isfield(output, 'next') && ~isempty(output.next) + try + next_page = webread(output.next, options); + catch ME + error('BrainSTEM:load', 'API error fetching next page: %s', brainstem_parse_api_error(ME)); + end + next_page = brainstem_normalize_list_response(next_page); + % Append records + if ~isempty(model_key) && isfield(output, model_key) && isfield(next_page, model_key) + output.(model_key) = [output.(model_key), next_page.(model_key)]; + end + if isfield(next_page, 'next') + output.next = next_page.next; + else + output.next = []; + end + end +end diff --git a/+brainstem/load_behavior.m b/+brainstem/load_behavior.m new file mode 100644 index 0000000..79b10db --- /dev/null +++ b/+brainstem/load_behavior.m @@ -0,0 +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', '') +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 new file mode 100644 index 0000000..e4ddffa --- /dev/null +++ b/+brainstem/load_cohort.m @@ -0,0 +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', '') +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 new file mode 100644 index 0000000..f6aa2f6 --- /dev/null +++ b/+brainstem/load_collection.m @@ -0,0 +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', '') +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{:}); + diff --git a/+brainstem/load_consumablestock.m b/+brainstem/load_consumablestock.m new file mode 100644 index 0000000..be24e9d --- /dev/null +++ b/+brainstem/load_consumablestock.m @@ -0,0 +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', '') +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 new file mode 100644 index 0000000..22263bb --- /dev/null +++ b/+brainstem/load_dataacquisition.m @@ -0,0 +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', '') +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 new file mode 100644 index 0000000..f6431cf --- /dev/null +++ b/+brainstem/load_equipment.m @@ -0,0 +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', '') +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 new file mode 100644 index 0000000..5073e13 --- /dev/null +++ b/+brainstem/load_manipulation.m @@ -0,0 +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', '') +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 new file mode 100644 index 0000000..56b9acc --- /dev/null +++ b/+brainstem/load_procedure.m @@ -0,0 +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', '') +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 new file mode 100644 index 0000000..6b31c56 --- /dev/null +++ b/+brainstem/load_procedurelog.m @@ -0,0 +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', '') +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 new file mode 100644 index 0000000..9fe13f8 --- /dev/null +++ b/+brainstem/load_project.m @@ -0,0 +1,17 @@ +function output = load_project(varargin) +% 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 new file mode 100644 index 0000000..03293ce --- /dev/null +++ b/+brainstem/load_session.m @@ -0,0 +1,18 @@ +function output = load_session(varargin) +% 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 new file mode 100644 index 0000000..43fec77 --- /dev/null +++ b/+brainstem/load_settings.m @@ -0,0 +1,15 @@ +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 + diff --git a/+brainstem/load_subject.m b/+brainstem/load_subject.m new file mode 100644 index 0000000..8e5b85f --- /dev/null +++ b/+brainstem/load_subject.m @@ -0,0 +1,18 @@ +function output = load_subject(varargin) +% 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 new file mode 100644 index 0000000..a9b99ad --- /dev/null +++ b/+brainstem/load_subjectlog.m @@ -0,0 +1,18 @@ +function output = load_subjectlog(varargin) +% LOAD_SUBJECTLOG Load subject log record(s) from BrainSTEM. +% +% 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') +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/logout.m b/+brainstem/logout.m new file mode 100644 index 0000000..b4051c5 --- /dev/null +++ b/+brainstem/logout.m @@ -0,0 +1,48 @@ +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) 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', ''). + +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'); + +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_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..62fceb9 --- /dev/null +++ b/+brainstem/private/brainstem_build_query_string.m @@ -0,0 +1,72 @@ +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 +% 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) + 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 + +% 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..47f7505 --- /dev/null +++ b/+brainstem/private/brainstem_build_url.m @@ -0,0 +1,25 @@ +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) + 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, '/']; +end 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/private/brainstem_parse_api_error.m b/+brainstem/private/brainstem_parse_api_error.m new file mode 100644 index 0000000..2dfb1b0 --- /dev/null +++ b/+brainstem/private/brainstem_parse_api_error.m @@ -0,0 +1,87 @@ +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', ' '); + +% 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 + 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 + json_msg = strjoin(parts, ' | '); + catch + % 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 new file mode 100644 index 0000000..ed0cac2 --- /dev/null +++ b/+brainstem/save.m @@ -0,0 +1,99 @@ +function output = save(varargin) +% SAVE Create or update a record in a BrainSTEM API endpoint. +% +% 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). +% +% 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 (auto-resolved from BRAINSTEM_TOKEN env var or token cache) +% +% Examples: +% % Update an existing session (full replace): +% output = brainstem.save('data', session, 'model', 'session'); +% +% % Partial update (only send changed fields): +% 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 = 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',[],@(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_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. +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', ... + 'ContentType', 'json', ... + 'ArrayFormat', 'json', ... + 'Timeout', 30); + +if has_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:save', 'API error saving %s to %s: %s', ... + parameters.model, endpoint, api_msg); +end diff --git a/.DS_Store b/.DS_Store index 092ec4e..8f7f48d 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/BrainstemClient.m b/BrainstemClient.m new file mode 100644 index 0000000..9fe4f95 --- /dev/null +++ b/BrainstemClient.m @@ -0,0 +1,344 @@ +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). +% 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 +% 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', ... +% 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 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 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('session', 'load_all', true); +% +% % Load a single session by ID +% out = client.load('session', 'id', ''); +% +% % Filter, sort, embed relations +% out = client.load('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(s, 'session', 'method', 'patch'); +% +% % Create a new session +% s = struct('name','New session','projects',{{''}},'tags',[]); +% client.save(s, 'session'); +% +% % Delete a session +% client.delete(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('project', 'portal', 'public'); + + properties (SetAccess = private) + url (1,:) char = 'https://www.brainstem.org/' + token (1,:) char = '' + token_type (1,:) char = 'personal' + end + + methods + % ------------------------------------------------------------------ + 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', default_url, @ischar); + addParameter(p, 'token', '', @ischar); + parse(p, varargin{:}); + + obj.url = p.Results.url; + + 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(obj, model, varargin) + % LOAD Retrieve records from a BrainSTEM API endpoint. + % See class documentation for full parameter list. + try + 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, ... + 'settings', obj.settings_(), ... + varargin{:}); + else + rethrow(ME); + end + end + end + + % ------------------------------------------------------------------ + 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('data', data, 'model', model, ... + 'settings', obj.settings_(), ... + varargin{:}); + catch ME + if obj.is_auth_error_(ME) + obj.refresh_token_(); + output = brainstem.save('data', data, 'model', model, ... + 'settings', obj.settings_(), ... + varargin{:}); + else + rethrow(ME); + end + end + end + + % ------------------------------------------------------------------ + function output = delete(obj, id, model, varargin) + % DELETE Delete a BrainSTEM record by UUID. + try + output = brainstem.delete(id, model, ... + 'settings', obj.settings_(), ... + varargin{:}); + catch ME + if obj.is_auth_error_(ME) + obj.refresh_token_(); + output = brainstem.delete(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 logout(obj) + % LOGOUT Remove the cached token for this client's URL and clear the + % in-memory token so subsequent calls will re-authenticate. + brainstem.logout(obj.url); + obj.token = ''; + end + + % ------------------------------------------------------------------ + function disp(obj) + % DISP Display a compact summary of the client state. + authenticated = ~isempty(obj.token); + 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...\n', 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; + end + + function token = load_or_request_token_(obj) + % 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); + 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('BrainstemClient:tokenExpired', ... + 'Saved token expired — re-authenticating.'); + 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}; + else + token = auth_tbl.tokens{idx}; + end + return + end + end + % No cached token — run the device authorization flow + token = brainstem.get_token(obj.url); + end + + function refresh_token_(obj) + % Re-authenticate via the device authorization flow. + warning('BrainstemClient:tokenExpired', ... + 'Token appears expired or invalid — re-authenticating.'); + obj.token = brainstem.get_token(obj.url); + 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..05ae715 --- /dev/null +++ b/Contents.m @@ -0,0 +1,42 @@ +% 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. +% +% 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) +% +% PACKAGE FUNCTIONS (call as brainstem. or via client methods) +% 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 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) +% 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..ffdfee3 100644 --- a/README.md +++ b/README.md @@ -3,68 +3,160 @@ 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. - -## 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 | +## Authentication -## Example Usage +### 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. -### 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, or in .env / bashrc) +setenv('BRAINSTEM_TOKEN','') +client = BrainstemClient(); % picks it up automatically + +% Option B: pass directly +client = BrainstemClient('token',''); ``` -### Filtering and Sorting -You can filter and sort results: +> **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 -output1_1 = load_model('model','session','filter',{'name','yeah'}); -output1_2 = load_model('model','session','sort',{'-name'}); +client = BrainstemClient(); % opens browser login page ``` -### 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('session'); + +% Partial update +patch_data.id = out.sessions(1).id; +patch_data.description = 'updated'; +client.save(patch_data, 'session', 'method', 'patch'); + +% Delete +client.delete(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 | +| `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 | +| `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 | +|----------|-------|-----------------| +| `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 + +All `load` 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('session', 'load_all', true); + +% Filter + sort + include +out = client.load('session', ... + 'filter', {'name.icontains', 'Rat'}, ... + 'sort', {'-name'}, ... + 'include', {'projects','behaviors'}); + +% Single record by UUID +out = client.load('session', 'id', ''); + +% Convenience loaders (tab-completable, credentials automatic) +sessions = client.load_session('name', 'mysession'); +behaviors = client.load_behavior('session', ''); + +% Create +s.name = 'My new session'; s.projects = {''}; s.tags = []; +out = client.save(s, 'session'); + +% Partial update (PATCH) +patch.id = out.id; patch.description = 'updated'; +client.save(patch, 'session', 'method', 'patch'); + +% Delete +client.delete(out.id, 'session'); + +% Public data +public_projects = client.load('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..85757a5 100644 --- a/brainstem_api_tutorial.m +++ b/brainstem_api_tutorial.m @@ -1,87 +1,109 @@ -% 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/ +% 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 - Interactive login (device flow, desktop MATLAB only): +% client = BrainstemClient(); % opens browser for login + +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('session', 'id', ''); -% 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 method +output1_6 = client.load('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(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(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 = {''}; +new_session.tags = []; +output3 = client.save(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(output3.id, 'session'); -% Submitting session -output3 = save_model('data',session,'model','session'); +%% 5. Load public projects +output4 = client.load('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', ''); +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', ''); +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'); -%% 5. Convenience functions for projects, subjects, and sessions +%% 7. Using load 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 directly) +output_consumables = client.load('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_app_from_model.m b/get_app_from_model.m deleted file mode 100644 index 65cc421..0000000 --- a/get_app_from_model.m +++ /dev/null @@ -1,22 +0,0 @@ -function app = get_app_from_model(modelname) - -switch modelname - case {'project','subject','session','collection','cohort'} - app = 'stem'; - case {'procedure','equipment','consumablestock','behavior','dataacquisition','manipulation','procedurelog','subjectlog'} - app = 'modules'; - case {'behavioralparadigm','datastorage','setup','inventory'} - app = 'personal_attributes'; - case {'consumable','hardwaredevice','supplier'} - app = 'resources'; - case {'brainregion','setuptype','species','strain'} - app = 'taxonomies'; - case {'journal','publication'} - app = 'dissemination'; - case {'user','laboratory'} - app = 'users'; - case {'group'} - app = 'auth'; - otherwise - app = ''; -end 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/load_subject.m b/load_subject.m deleted file mode 100644 index 5a607c0..0000000 --- a/load_subject.m +++ /dev/null @@ -1,54 +0,0 @@ -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',@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',{'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) - -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 - -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/passdlg.m deleted file mode 100644 index 5b41d77..0000000 --- a/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/passfield.m b/passfield.m deleted file mode 100644 index c47a037..0000000 --- a/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/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')