Simple and fast api client for ProFile.

Minibeak helps you:

  • Construct and perform HTTP requests against ProFile's json-api
  • Process responses from ProFile's json-api
  • Use ProFile's json-api from React

Differences from Kolibri resources:

  • Much faster
  • No immutablejs
  • Plain JS, supports circular resource links
  • No central resource store
  • No Redux
  • No action binding
  • Based on HTML5 fetch

Should you use minibeak instead of Kolibri resources?

  • Yes if you have performance issues because of processing thousands of resources.
  • Just decide something or ask your favorite source of authority.
  • May the spirit of the beak be with you.

Npm package:

npm package

Documentation build:

Netlify deploy status

Contribute on Bitbucket

React example code:

This example code lets you log in and out of profile, and select employments. It demonstrates some different ways to use minibeak.

import { Button, TextInputBlock, Well } from "@dossier/salvia-ui";
import { ReactNode } from "hoist-non-react-statics/node_modules/@types/react";
import React, {
  Component,
  ErrorInfo,
  createContext,
  useContext,
  useState,
} from "react";
import { FaKey, FaUser } from "react-icons/fa";
import {
  callProFile,
  defaultConfig,
  MinibeakHookResult,
  MinibeakResourceLookup,
  StrictProFileResource,
  useProFile,
  useProFileIncrementally,
} from "@dossier/minibeak";

// Initialization. Set default api url for requests:
defaultConfig.apiUrl = "/profile/jsonapi";

// Actions:

// These do the same as action creators in kolibri,
// except that they can be called directly. They do
// not need to be bound first.

interface SessionResource extends StrictProFileResource {
  activeDelegateEmploymentDelegations?: EmploymentDelegationResource[];
  loggedInUser?: ProFileUserResource;
  employment?: EmploymentResource;
  enabledEmployments?: EmploymentResource[];
}

interface EmploymentDelegationResource extends StrictProFileResource {
  delegatorEmployment?: EmploymentResource;
  delegateEmployment?: EmploymentResource;
}

interface EmploymentResource extends StrictProFileResource {
  person?: PersonResource;
  employmentInfo?: EmploymentInfoResource;
  departments?: DepartmentResource[];
}

interface EmploymentInfoResource extends StrictProFileResource {}

interface PersonResource extends StrictProFileResource {
  fullName: string;
}

interface ProFileUserResource extends StrictProFileResource {
  person?: PersonResource;
}

interface DepartmentResource extends StrictProFileResource {
  name: string;
}

interface ProFileResourceLookup extends MinibeakResourceLookup {
  sessions?: Record<string, SessionResource>;
  employments?: Record<string, EmploymentResource>;
  employmentDelegations?: Record<string, EmploymentDelegationResource>;
  employmentInfos?: Record<string, EmploymentInfoResource>;
  persons?: Record<string, PersonResource>;
  users?: Record<string, ProFileUserResource>;
}

function doLogin(
  username: string,
  password: string,
  organizationId: string,
  locale: string
) {
  return callProFile({
    ...defaultConfig,

    path: "/session",
    method: "post",
    body: {
      data: {
        attributes: {
          username,
          password,
          permanentLogin: false,
          organizationId,
          locale,
        },
      },
    },
  });
}

function doSelectEmployment(
  employmentId: string,
  delegatorEmploymentId?: string
) {
  return callProFile({
    ...defaultConfig,

    path: "/session/selectEmployment",
    method: "post",
    body: {
      data: {
        type: "session",
        attributes: {
          employmentId,
          delegatorEmploymentId,
        },
      },
    },
  });
}

function doLogout() {
  return callProFile({
    ...defaultConfig,

    path: "/session/logout",
    method: "post",
  });
}

// Components

const SessionContext = createContext<
  MinibeakHookResult<SessionResource, ProFileResourceLookup>
>(null);

function SessionProvider(props: { children?: ReactNode }) {
  const session = useProFile<SessionResource, ProFileResourceLookup>({
    path: "/session/singleton",
    include: [
      "employment.departments",
      "loggedInUser.actualLoggedInUser.person",
      "loggedInUser.person",
      "translationValues",
      "enabledEmployments.employmentInfo.employmentPositionTypeNames",
      "enabledEmployments.departments",
      "user",
      "anonymousUser",
    ],
    minibeakCache: 10, // seconds
    // throwErrors: true,
  });

  // Session is an object that can have these fields:
  // { isReady, isFetching, data, error, resources, response, refresh }

  // Prevent rendering if session fetching crashed:

  if (session.error) {
    return (
      <Well
        variant="dark"
        intent="danger"
        style={{ margin: "2rem", maxWidth: "20rem" }}
      >
        <p>Session request failed. Is backend running? Maybe it has crashed?</p>
      </Well>
    );
  }

  // Pass the session down to children so they can know who is logged in:

  return (
    <SessionContext.Provider value={session}>
      {props.children}
    </SessionContext.Provider>
  );
}

function EmploymentSelector() {
  // Get the session that was passed down
  const session = useContext(SessionContext);

  const [
    isWatingForSelectEmployment,
    setIsWaitingForSelectEmployment,
  ] = useState(false);

  // We need to read the active delegates employment delegations,
  // but if we try to fetch them before we are logged in, we crash.
  // So we must first wait for the user to be logged in:

  const delegationsSession = useProFile<SessionResource, ProFileResourceLookup>(
    {
      // Wait for session. As long as path is falsey, the call will not happen
      path: !!session.data?.loggedInUser && "/session/singleton",
      include: [
        "activeDelegateEmploymentDelegations.delegatorEmployment.employmentInfo.employmentPositionTypeNames",
        "activeDelegateEmploymentDelegations.delegatorEmployment.employmentInfo.orgUnitNames",
        "activeDelegateEmploymentDelegations.delegatorEmployment.person.pictureUrl",
        "activeDelegateEmploymentDelegations.delegateEmployment.person.pictureUrl",
      ],
      minibeakCache: 10, // seconds
    }
  );

  // const orgUnits = useProFile({
  //   path: !!session.data?.loggedInUser && "/orgUnits",
  //   minibeakCache: true,
  // });

  // console.log(orgUnits

  // Handle loading and error states:
  if (!session.data || !delegationsSession.data) return <p>Loading...</p>;
  if (delegationsSession.error) return <p>Error fetching session!</p>;

  const { employment, enabledEmployments } = session.data;
  const { activeDelegateEmploymentDelegations } = delegationsSession.data;

  return (
    <>
      {enabledEmployments.map((enabledEmployment) => (
        <React.Fragment key={enabledEmployment.id}>
          <Button
            elementSize="large"
            intent={
              enabledEmployment.id === employment?.id ? "primary" : "neutral"
            }
            disabled={isWatingForSelectEmployment}
            onClick={async () => {
              setIsWaitingForSelectEmployment(true);
              await doSelectEmployment(enabledEmployment.id);
              await session.refresh(); // Refresh the session in SessionProvider
              setIsWaitingForSelectEmployment(false);
            }}
          >
            {enabledEmployment.person.fullName}
          </Button>

          {(activeDelegateEmploymentDelegations || [])
            .filter(
              (delegation) =>
                (delegation.$relationships.delegateEmployment.data as any)
                  .id === enabledEmployment.id
            )
            .map((delegation) => (
              <Button
                key={delegation.id}
                elementSize="large"
                intent={
                  delegation.delegatorEmployment.id === employment?.id
                    ? "primary"
                    : "neutral"
                }
                disabled={isWatingForSelectEmployment}
                onClick={async () => {
                  setIsWaitingForSelectEmployment(true);
                  await doSelectEmployment(
                    delegation.delegateEmployment.id,
                    delegation.delegatorEmployment.id
                  );
                  await session.refresh(); // Refresh the session in SessionProvider
                  setIsWaitingForSelectEmployment(false);
                }}
              >
                {delegation.delegatorEmployment.person.fullName}
              </Button>
            ))}
        </React.Fragment>
      ))}
    </>
  );
}

const errorMessages = { 401: "Invalid credentials." };

function LoginUi() {
  const session = useContext(SessionContext);

  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [isWaitingForLogin, setIsWaitingForLogin] = useState(false);
  const [loginError, setLoginError] = useState(null);

  if (
    !["localhost", "127.0.0.1"].find(
      (host) => window.location.hostname === host
    )
  ) {
    return (
      <p>
        Unfortunately, because of CORS, you can only try minibeak against
        profile by running a local dev server. No worries, just{" "}
        <a href="https://bitbucket.org/dossiersolutions/minibeak/src/master/src/">
          clone the repo
        </a>
        .
      </p>
    );
  }

  if (!session.data) return <p>Authenticating...</p>;

  if (!session.data.loggedInUser)
    return (
      <form>
        {!session.data.loggedInUser && (
          <>
            {loginError && (
              <Well intent="danger">
                {errorMessages[loginError.response?.status] ||
                  "Error while logging in."}
              </Well>
            )}

            <TextInputBlock
              elementSize="large"
              placeholder="User..."
              rightContent={<FaUser />}
              value={username}
              onChange={(event) => setUsername(event.target.value)}
            />
            <TextInputBlock
              elementSize="large"
              placeholder="Password..."
              type="password"
              rightContent={<FaKey />}
              value={password}
              onChange={(event) => setPassword(event.target.value)}
            />
            <Button
              elementSize="large"
              intent="primary"
              disabled={isWaitingForLogin}
              onClick={async (event) => {
                event.preventDefault();
                setIsWaitingForLogin(true);
                try {
                  await doLogin(username, password, "109106", "nb-NO");
                  await session.refresh(); // Refresh the session in SessionProvider
                  setLoginError(null); // clear old login errors
                } catch (error) {
                  console.log(error);
                  setLoginError(error);
                }
                setIsWaitingForLogin(false);
              }}
            >
              {isWaitingForLogin ? "Logging in..." : "Sign in"}
            </Button>
          </>
        )}
      </form>
    );

  if (!session.data.employment) return <EmploymentSelector />;

  return (
    <>
      <p>
        Logged in as {session.data.loggedInUser?.person?.fullName} (
        {(session.data.employment.departments || [])
          .map((department) => department.name)
          .join(", ")}
        ).
      </p>

      <EmploymentSelector />

      <Button
        elementSize="large"
        onClick={async () => {
          await doLogout();
          await session.refresh(); // Refresh the session in SessionProvider
        }}
      >
        Sign out
      </Button>

      <pre>
        <code>{JSON.stringify(session.data.employment.person, null, 2)}</code>
      </pre>
    </>
  );
}

function PaginationUi() {
  const {
    results,
    loadNextPage,
    totalResults,
    fetchedResults,
    remainingResults,
  } = useProFileIncrementally({
    path: `/coworkerModuleStatusTableRows`,
    queryParams: {
      mode: "COWORKER_DATA",
      max: 20,
      // sort: convertFieldSortSpecToOrderForQuery(fieldSortSpec),
      // filter: generateFilterStringFromMap(debouncedFieldFilterSpec),
      includeSubOrgUnits: false,
    },
    include: [
      "employment.person",
      "employment.employmentInfo.employmentPositionTypeNames",
      "cells.moduleStatus",
    ],
  });

  return (
    <div>
      {results.map((result, i) =>
        result.isFetching ? (
          <p key={i}>Loading...</p>
        ) : (
          <Well key={i}>
            <Button intent="primary" onClick={result.refresh}>
              Refresh
            </Button>
            {result?.data?.map((resource) => {
              return (
                <p key={resource.id}>
                  {resource?.employment?.person?.firstName}
                </p>
              );
            })}
          </Well>
        )
      )}

      {remainingResults && (
        <Button
          onClick={() => {
            loadNextPage();
          }}
        >
          Load more (showing {fetchedResults} of {totalResults})
        </Button>
      )}
    </div>
  );
}

export default function ReactExample() {
  return (
    <ErrorBoundary>
      <SessionProvider>
        <LoginUi />
        <PaginationUi />
      </SessionProvider>
    </ErrorBoundary>
  );
}

interface Props {
  children?: ReactNode;
}

interface State {
  hasError: boolean;
}

class ErrorBoundary extends Component<Props, State> {
  public state: State = {
    hasError: false,
  };

  public static getDerivedStateFromError(_: Error): State {
    // Update state so the next render will show the fallback UI.
    return { hasError: true };
  }

  public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error("Uncaught error:", error, errorInfo);
  }

  public render() {
    if (this.state.hasError) {
      return <h1>Error boundary triggered!</h1>;
    }

    return this.props.children;
  }
}

API

defaultConfig

Holds default request options
defaultConfig: MinibeakConfig

Example initialization:

import {defaultConfig} from "@dossier/minibeak";

defaultConfig.apiUrl = "http://localhost:8080/profile/jsonapi";

Let's you set default configuration that will be merged into the parameters passed to useProFile.

Does not affect callProFile– you must pass configuration manually there.

Implements MinibeakConfig.

useProFile

Fetches data from ProFile
useProFile(config: MinibeakConfig): MinibeakHookResult

React hook to fetch data from ProFile's json-api. Refreshes automatically when config changes. Must be refreshed manually when resources are modified, by calling .refresh() on the returned object.

Returns a MinibeakHookResult.

useProFile is only intended to be used to GET resources. If you need to create, update, delete or do some other action, use callProFile instead.

useProFile uses callProFile and processProFileResult internally.

Example code:

// Inside your functional component:

const achievementTypes = useProFile<AchievementTypeResource[]>({
  path: "/achievementTypes/editable",
  queryParams: {
    max,
    offset,
    sort: orderByField + " " + (orderReverse ? "DESC" : "ASC"),
    filter: debouncedFilterString
  },
  include: [
    "shares",
    "drafts",
    "usedInPlanTypesCount",
    "category",
    "orgUnit"
  ]
});

if (!achievmentTypes.isReady) {
  return <LoadingComponent/>;
}

// For pagination:
const totalCount = achievementTypes.response?.links.total;

console.log("Result of call, with linked resources:", achievmentTypes.data);
console.log("All included resources by type and id:", achievmentTypes.resources);

// to refresh, call achievementTypes.refresh()


callProFile

Performs calls against ProFile
callProFile(config: MinibeakConfig): Promise<ProFileRawResponse|Response>

Performs a call against json-api based on the given configuration.

Returns a promise resolving to the response returned by the api. When the response Content-Type is json, the parsed json value is returned. Otherwise the fetch response is returned directly.

Fails with ResponseNotOkError when the response does not have an http status in the 200-series.

import { useProFileApi, callProFile, defaultConfig } from "@dossier/minibeak";


async function doSelectEmployment(employmentId, delegatorEmploymentId) {
  return callProFile({
    ...defaultConfig,

    path: "/session/selectEmployment",
    method: "post",
    body: {
      data: {
        type: "session",
        attributes: {
          employmentId,
          delegatorEmploymentId,
          permanentLogin: false,
        },
      },
    },
  });
}

// You don't need to bind this, you can just use it.
// For example:

function MyFunctionalComponent(props) {
  const session = useProFile(/* ... */);

  async function handleSelectEmployment(employmentId, delegatorEmploymentId) {
    // Usually you don't use the result of actions.
    // Instead you just refresh affected resources:

    await doSelectEmployment(employmentId, delegatorEmploymentId)
    await session.refresh();
  }


  // If you want to use the result, you can do this:

  async function handleSelectEmploymentAndUseResult(employmentId, delegatorEmploymentId) {
    const rawResult = await doSelectEmployment(employmentId, delegatorEmploymentId)

    const selectEmploymentResult = processProFileResult(rawResult);

    console.log("Result of call, with linked resources:", selectEmploymentResult.data);
    console.log("All included resources by type and id:", selectEmploymentResult.resources);

    await session.refresh();
  }


    /* ... */
  }

processProFileResult

Crates links between returned resources
processProFileResult(result: ProFileRawResponse): MinibeakResult

Processes a callProFile result.

Returns a MinibeakResult with proper resource links, easier access to attributes etc.

When using React, you will usually not be interested in processing the result, because you will be refreshing instances of useProFile instead.

clearCallCache

Clears the call cache
type CacheClearable = string | MinibeakConfig;

clearCallCache(configsOrTags?: boolean | CacheClearable | Array<CacheClearable>)

When called without parameters, or with false: Clears the entire call cache.

When called with a string: Clears all caches with this cache tag.

When called with a MinibeakConfig: clears the cache for this specific call.

Can also be called with an array of tags or configs.

Types

MinibeakConfig

Specifies ProFile call parameters
FieldTypeDefaultDescription
apiPathstring/profile/jsonapiPath to the api server
pathstringRelative path to the specific endpoint with leading slash, for example "/orgUnits"
queryParamsMap of keys to valuesJavaScript object that will be serialized into the URL as query params
includearray of stringsList of linked resources to include
minibeakCacheboolean or cache tag string or number of timeout seconds, or array of some combinationfalseEnables request caching
bodyjson-like dataBody of the request (for POST etc). For example: {data: {type: "session", attributes: {employmentId, delegatorEmploymentId}}
Also supports any other fields supported by the HTTP fetch request API, including:
methodstring"GET"Which HTTP method to use
shouldExecutebooleantrueIf true, the request will be executed. If false, it will be delayed until it is set to true.

MinibeakResult

Contains the result of a processProFileResult call
FieldTypeDescription
dataResource or array of resourcesThe resource or resources returned directly by the call.
resourcesMap of resource types to maps of resource ids to resourcesAll of the resources returned by the call, including included resources.

MinibeakHookResult

Contains the result and state of a useProFile call
FieldTypeDescription
isReadybooleanWhether the requested resource is available yet. This is always true after the initial fetch.
isFetchingbooleanWhether this resource is currently being fetched or re-fetched.
dataResource or array of resourcesThe resource or resources returned directly by the call.
resourcesMap of resource types to maps of resource ids to resourcesAll of the resources returned by the call, including included resources.
responseJson response objectThe unprocessed json returned from ProFile. Useful for pagination.
errorErrorAny errors that happened during the request. If the HTTP status was not in the 200-range, this will be a ResponseNotOkError, with a .response field containing the response itself.
refresh() => voidA function that can be called to run the fetch again, to refresh the data. This can be called manually when a resource is changed somewhere else.

TypesScript and casting

How to type the returned resources

You're going to want proper typings for the resources that you fetch. Since these types are not known at compile time, we have to cast the response to the expected type for each call. Because the ProFile json api types will change over time, those types are not included in minibeak itself. However, minibeak contains generics and inheritable interfaces that help with the casting.

Start by implementing resource types that inherit from ProFileResource, for example an AchievementResource that extends ProFileResource. Now you can use the generic versions of useProFile and processProFileResult:

interface AchievementResource extends ProFileResource {
  name: string;
  // ...
}

// Single resource
useProFile<AchievementResource>({...});
// Collection
useProFile<AchievementResource[]>({...});

// Same for processProFileResult:
processProFileResult<AchievementResource>(response);
// Or for a collection:
processProFileResult<AchievementResource[]>(response);


The ProFileResource interface will let you read fields without an explicit typing as the any type. If you want stricter typechecking on resources, you can inherit from StrictProFileResource instead. This will only let you read fields that are defined in the type.

Typing the resource lookup

You can also implement a ProFileResourceLookup type for the returned resource lookup (the field called resources on MinibeakResult). This type should be the same for all backend calls. For example:

interface ProFileResourceLookup extends MinibeakResourceLookup {
  achievementTypes?: Record<string, AchievementTypeResource>;
  achievements?: Record<string, AchievementResource>;
  // and all of the other types...
}

useProFile<AchievementResource, ProFileResourceLookup>({...})
useProFile<AchievementResource[], ProFileResourceLookup>({...})
processProFileResult<AchievementResource, ProFileResourceLookup>(response)
processProFileResult<AchievementResource[], ProFileResourceLookup>(response)

Typing callProFile

Raw responses from callProFile can be cast to anything, but you can use RawProFileResource or RawProFileResource[] together with ProFileRawResponse to get the basic fields like id, type, etc:

callProFile<ProFileRawResponse<RawProFileResource>>({...});
// Or for a collection:
callProFile<ProFileRawResponse<RawProFileResource[]>>({...});

Note that RawProFileResource is different from ProFileResource. The first is returned from callProFile (it is the raw json from backend). The second is a processed resource returned from useProFile or processProFileResult. All of your normal resource type definitions for useProFile and processProFileResult should inherit from ProFileResource.

Errors

ResponseNotOkError

Response status code not in 200-series

Thrown when the http response has a status code not in the 200-series. For example, when authentication failed, access was denied, or the resource did not exist.

This error has a .response field containing the response.

Mini-Guides

These mini-guides explain how to do different things with minibeak. Most of them are also covered by the example code above.

Fetching resources

You can use the useProFile hook to fetch resources from ProFile. For example, look at how it is used in the example code above. For non-react usage, see Without React.

Waiting for data to load

useProFile returns a MinibeakHookResult that contains a bunch of different useful stuff. You can check the value of .isReady or .isFetching to see if the requested resource is available, and if it is being fetched. Or you can just check if .data or .error is present.

Delaying a call with useProFile

Some times you don't have all the data needed to make a call with useProFile. For example, maybe you need to wait for another call to finish first. You can prevent useProFile from actually making any call by passing path: undefined or shouldExecute: false to MinibeakConfig until the data become available.

Updating, creating and deleting resources

You can make calls to ProFile by calling callProFile directly. There is no need to make action creators and bind them, like in Kolibri. See how callProFile is used in the example code above.

Error handling

Backend calls may fail for several reasons. The request itself may fail, or it may have the wrong status code (for example, if authentication failed or access was denied).

useProFile:

useProFile returns a MinibeakHookResult that has a field called .error when there are any errors during the request. You can inspect this error to find out what failed. ResponseNotOkError provides the .request property. Errors shuold be responded to in some way. See the example code above.

callProFile:

callProFile returns a promise. When there is an error, this fails with the given error. You can see examples of this in the try-catch blocks in the event handlers in the example code.

Refreshing stale resources

Minibeak does not have a central resource store. This means that when a call changes some resource that was populated elsewhere, that useProFile result must be manually refreshed. You can see examples of this in the event handlers in the example code.

useProFile refreshes automatically when its configuration changes.

Custom HTTP parameters

useProFile and callProFile support a lot of different configuration parameters. See MinibeakConfig for more.

It also includes all of the options supported by the HTTP fetch API.

For example, to set a custom header, you can just set the headers field on the request config to an array of the headers you want.

Caching

Experimental Minibeak has built-in backend call caching. This can be used to cache the results of calls based on their config (until the browser is refreshed). This is useful when users are expected to keep revisiting expensive fetches.

Caching can be enabled simply by setting minibeakCache to true in the MinibeakConfig.

minibeakCache can also be set to a number of seconds, after which the cache will time out.

It can also be set to a string cache tag, that can later be used with clearCallCache to clear all caches with that tag.

It can also be set to an array combining several cache tags and a timeout.

Without React

callProFile and processProFileResult can be used without React:

import {defaultConfig, callProFile, processProFileResult} from "@dossier/minibeak";

async function getStatusTableRows() {
  const requestResult = await callProFile({
    ...defaultConfig,
    path:   "/coworkerModuleStatusTableRows",
    include: [
      "employment.person",
      "employment.person.pictureUrl",
      "employment.employmentInfos.employmentPositionTypeNames",
    ],
    queryParams: {
      mode: "MANAGED_COWORKER_DATA",
      offset: 0,
      max: 20,
      includeSubOrgUnits: false,
    },
  });

  const processedResult = processProFileResult(requestResult);

  return processedResult;
}

getStatusTableRows().then((result) => {
  if (result.error) {
    console.log("Error:", result.error)
  }
  else {
    console.log("Data:", result.data);
    console.log("All resources:", result.resources);
    console.log("Response object:", result.response);
  }
})