Go quickstart

View as Markdown

In about 15 minutes you generate Go code from the M-Files protos, configure a connection and run a program that logs in to a vault, lists its 10 most recently modified documents and logs out. The program uses the official google.golang.org/grpc and google.golang.org/protobuf modules and mfilesclient, a small connection helper from the samples that you can copy into your own project.

Prerequisites

Cloud and on-premises and Connecting to the server explain the endpoints in detail.

Get the samples and the protos

Download the gRPC API bundle and unpack it. It contains:

An M-Files Server installation also has the API definition, in <installation folder>\Common\Web\GRPC\proto, as one combined legacy file without documentation. It describes the same wire contract, but it does not produce the Go package path these samples import. Prefer the bundle.

Generate the code

cd samples\go
.\generate.ps1          # PowerShell; in bash: ./generate.sh

The script runs protoc with protoc-gen-go and protoc-gen-go-grpc on every file of the bundle and writes the code into gen/. It finds the bundle at ../../proto; pass another folder with -ProtoDir (or as the first argument of generate.sh), or set MFILES_PROTO_DIR.

The bundle's go_package option puts all services, messages and enums into one package, github.com/M-Files/mfiles-grpc-api/go/mfiles. The script makes gen/ a module of its own with that path, and the samples' go.mod points to it:

// The generated code: a module of its own in ./gen, written by generate.ps1 or generate.sh.
replace github.com/M-Files/mfiles-grpc-api => ./gen

Source: go/go.mod

Your code imports the package as "github.com/M-Files/mfiles-grpc-api/go/mfiles". The package is large: the first build takes a while, later builds come from the cache.

To start a project of your own, copy gen/, the replace directive and the mfilesclient folder, and require google.golang.org/grpc and google.golang.org/protobuf in your go.mod.

Configure

The samples read their settings from environment variables:

Variable Value
MFILES_ADDRESS host:port, for example contoso.cloudvault.m-files.com:443 or mfserver:7766
MFILES_TLS true (default), or false for a server without TLS
MFILES_CA_CERT Optional: a PEM file of root certificates to trust, for a server whose certificate comes from a private CA
MFILES_VAULT The vault GUID, with braces
MFILES_USERNAME, MFILES_PASSWORD An M-Files user account (M-Files authentication)
MFILES_TOKEN, MFILES_AUTH_PLUGIN, MFILES_AUTH_SCOPE Instead of a password: a token from the vault's identity provider, the authentication plugin's name and its configuration scope

M-Files Cloud (PowerShell):

$env:MFILES_ADDRESS = 'contoso.cloudvault.m-files.com:443'
$env:MFILES_VAULT = '{7BBEA0BC-40D8-4BCB-A67D-8A3ACFBDB575}'
$env:MFILES_USERNAME = 'alice'
$env:MFILES_PASSWORD = Read-Host -Prompt 'Password'

On-premises without TLS (bash):

export MFILES_ADDRESS=mfserver:7766 MFILES_TLS=false
export MFILES_VAULT='{7BBEA0BC-40D8-4BCB-A67D-8A3ACFBDB575}'
export MFILES_USERNAME=alice
read -rs MFILES_PASSWORD && export MFILES_PASSWORD

Use MFILES_TLS=false only for a server that has no certificate, such as a lab installation.

Single sign-on. A vault that signs users in through M-Files Login Service, as is typical on M-Files Cloud, refuses a password login with "Authentication failed.". Set MFILES_TOKEN to an access token from the Login Service, and MFILES_AUTH_PLUGIN and MFILES_AUTH_SCOPE to the plugin and scope that the vault returns. M-Files Login Service shows how to get the token; the Python sample recipes/login_service.py does it end to end. Getting an SSO token is up to your application; see Authentication and sessions.

Run

go run ./quickstart

The output lists the documents, newest first. In the M-Files Sample Vault it starts like this:

Project Plan (template) (ID 445, version 1, modified 2012-06-07 12:01:57 UTC)
Project Tasks - CRM Development (ID 444, version 1, modified 2012-06-07 11:54:29 UTC)
Corporate Presentation (ID 443, version 1, modified 2012-06-07 11:40:14 UTC)
...

The code

// Connect and log in with the settings in the MFILES_* environment variables.
cfg, err := mfilesclient.ConfigFromEnv()
if err != nil {
	return err
}
cfg.ClientName = "M-Files gRPC quickstart"
client, err := mfilesclient.Connect(ctx, cfg)
if err != nil {
	return err
}
defer client.Close() // logs out and closes the channel

// Documents (object type 0) that are not deleted, sorted by "Last modified" (property 21),
// newest first. The server sorts before it applies the limit.
search := mfiles.NewIRPCSearchClient(client.Conn)
response, err := search.SearchObjects(ctx, &mfiles.SearchObjectsRequest{
	Conditions: mfilesclient.AllOf(mfilesclient.IsObjectType(0), mfilesclient.IsNotDeleted()),
	Sorting:    mfilesclient.SortBy(21, true),
	Limit:      10,
})
if err != nil {
	return err
}

for _, item := range response.GetResults() {
	object := item.GetObject()
	version := object.GetVersionInfo()
	fmt.Printf("%s (ID %d, version %d, modified %s UTC)\n",
		version.GetTitle(),
		object.GetObjectInfo().GetObjId().GetItemId().GetInternalId(),
		version.GetVersion().GetInternalVersion(),
		version.GetLastModifiedAtUtc().AsTime().Format(time.DateTime))
}

Source: go/quickstart/main.go

IsObjectType and IsNotDeleted are small builders from the helper. They show the message shapes of the two conditions:


// StatusCondition compares a status value of the object, such as its object type or whether
// it is deleted.
func StatusCondition(status mfiles.StatusType, condition mfiles.ConditionType,
	value *mfiles.TypedValue) *mfiles.SearchCondition {
	return &mfiles.SearchCondition{
		Expression: &mfiles.Expression{
			Type: mfiles.ExpressionType_EXPRESSION_TYPE_STATUS_VALUE,
			Data: &mfiles.ExpressionUnion{Data: &mfiles.ExpressionUnion_StatusValue{
				StatusValue: &mfiles.ExpressionUnion_StatusValueData{Type: status},
			}},
		},
		Type:  condition,
		Value: value,
	}
}

// PropertyCondition compares a property value of the object, such as its class (property 100)
// or "Last modified" (property 21).
func PropertyCondition(propertyDef int32, condition mfiles.ConditionType,
	value *mfiles.TypedValue) *mfiles.SearchCondition {
	return &mfiles.SearchCondition{
		Expression: &mfiles.Expression{
			Type: mfiles.ExpressionType_EXPRESSION_TYPE_PROPERTY_VALUE,
			Data: &mfiles.ExpressionUnion{Data: &mfiles.ExpressionUnion_PropertyValue{
				PropertyValue: &mfiles.ExpressionUnion_PropertyValueData{PropertyDef: propertyDef},
			}},
		},
		Type:  condition,
		Value: value,
	}
}

// IsObjectType matches objects of one object type (0 = document). Value list -11 lists the
// object types.
func IsObjectType(objectType int32) *mfiles.SearchCondition {
	return StatusCondition(mfiles.StatusType_STATUS_TYPE_OBJECT_TYPE,
		mfiles.ConditionType_CONDITION_TYPE_EQUAL, Lookup(-11, objectType))
}

// IsNotDeleted matches objects that are not deleted.
func IsNotDeleted() *mfiles.SearchCondition {
	return StatusCondition(mfiles.StatusType_STATUS_TYPE_IS_DELETED,
		mfiles.ConditionType_CONDITION_TYPE_EQUAL, Boolean(false))
}

Source: go/mfilesclient/search.go

Go and the generated code

The helper has constructors for the common values:


// Text is a single-line text value.
func Text(value string) *mfiles.TypedValue {
	return &mfiles.TypedValue{
		Type: mfiles.Datatype_DATATYPE_TEXT,
		Data: &mfiles.TypedValueUnion{Data: &mfiles.TypedValueUnion_Text{Text: value}},
	}
}

// Integer is an integer value.
func Integer(value int32) *mfiles.TypedValue {
	return &mfiles.TypedValue{
		Type: mfiles.Datatype_DATATYPE_INTEGER,
		Data: &mfiles.TypedValueUnion{Data: &mfiles.TypedValueUnion_Integer{Integer: value}},
	}
}

// Boolean is a yes/no value.
func Boolean(value bool) *mfiles.TypedValue {
	return &mfiles.TypedValue{
		Type: mfiles.Datatype_DATATYPE_BOOLEAN,
		Data: &mfiles.TypedValueUnion{Data: &mfiles.TypedValueUnion_Boolean{Boolean: value}},
	}
}

// Timestamp is a date and time value, such as "Last modified" (property 21).
func Timestamp(value time.Time) *mfiles.TypedValue {
	return &mfiles.TypedValue{
		Type: mfiles.Datatype_DATATYPE_TIMESTAMP,
		Data: &mfiles.TypedValueUnion{Data: &mfiles.TypedValueUnion_Timestamp{
			Timestamp: timestamppb.New(value),
		}},
	}
}

// Lookup is a single-select lookup value: the item with the given ID in a value list (or an
// object of the object type with that ID).
func Lookup(valueList, item int32) *mfiles.TypedValue {
	return &mfiles.TypedValue{
		Type: mfiles.Datatype_DATATYPE_LOOKUP,
		Data: &mfiles.TypedValueUnion{Data: &mfiles.TypedValueUnion_Lookup{
			Lookup: lookupItem(valueList, item),
		}},
	}
}

// MultiSelectLookup is a multi-select lookup value.
func MultiSelectLookup(valueList int32, items ...int32) *mfiles.TypedValue {
	lookups := make([]*mfiles.Lookup, 0, len(items))
	for _, item := range items {
		lookups = append(lookups, lookupItem(valueList, item))
	}
	return &mfiles.TypedValue{
		Type: mfiles.Datatype_DATATYPE_MULTI_SELECT_LOOKUP,
		Data: &mfiles.TypedValueUnion{Data: &mfiles.TypedValueUnion_MultiSelectLookup{
			MultiSelectLookup: &mfiles.MultiSelectLookup{Values: lookups},
		}},
	}
}

func lookupItem(valueList, item int32) *mfiles.Lookup {
	return &mfiles.Lookup{
		ValueListItemInfo: &mfiles.ItemInfo{ObjId: ObjID(valueList, item)},
		Version: &mfiles.ObjVerVersion{
			Type:            mfiles.ObjVerVersionType_OBJ_VER_VERSION_TYPE_LATEST,
			InternalVersion: -1,
		},
	}
}

Source: go/mfilesclient/values.go

What the helper does

gRPC alone is not enough to talk to M-Files: part of the contract travels in call metadata, and errors arrive in trailers. mfilesclient takes care of that.

The channel. TLS with the system's root certificates plus an optional PEM file, or no TLS at all, and the message size limits that M-Files' own client uses: responses of any size instead of gRPC's default 4 MiB, requests up to 48 MiB (the server's default limit). Two interceptors handle every call:


// Dial creates the channel to cfg.Address. It does not log in and does not contact the server
// yet: the first call does.
func Dial(cfg Config) (*Client, error) {
	transport, err := transportCredentials(cfg)
	if err != nil {
		return nil, err
	}
	client := &Client{host: cfg.host()}
	client.Conn, err = grpc.NewClient(cfg.Address,
		grpc.WithTransportCredentials(transport),
		grpc.WithDefaultCallOptions(
			grpc.MaxCallRecvMsgSize(MaxReceiveMessageSize),
			grpc.MaxCallSendMsgSize(MaxSendMessageSize)),
		// Every call carries the M-Files metadata; failed calls return *Error.
		grpc.WithChainUnaryInterceptor(client.unaryInterceptor),
		grpc.WithChainStreamInterceptor(client.streamInterceptor))
	if err != nil {
		return nil, err
	}
	return client, nil
}

// transportCredentials is TLS with the system's root certificates, plus cfg.CACertFile when set;
// or no TLS at all when cfg.TLS is false.
func transportCredentials(cfg Config) (credentials.TransportCredentials, error) {
	if !cfg.TLS {
		return insecure.NewCredentials(), nil
	}
	roots, err := x509.SystemCertPool()
	if err != nil {
		roots = x509.NewCertPool()
	}
	if cfg.CACertFile != "" {
		pem, err := os.ReadFile(cfg.CACertFile)
		if err != nil {
			return nil, err
		}
		if !roots.AppendCertsFromPEM(pem) {
			return nil, fmt.Errorf("%s: no PEM certificates found", cfg.CACertFile)
		}
	}
	return credentials.NewTLS(&tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}), nil
}

Source: go/mfilesclient/client.go

Call metadata. Every call carries mfiles-is-remote-call: true and a new random 16-byte mfiles-activity-id-bin; after login also the session ID in mfiles-session-id-bin. In Go the value of a -bin key is the raw bytes in a string; gRPC base64-encodes it on the wire. M-Files Cloud sets the cookie mfilesmsm to keep a client on one server instance; gRPC does not keep cookies, so the interceptor stores set-cookie values and sends them back in a cookie header (see Call metadata):


// withMetadata adds the M-Files call metadata to an outgoing call: the remote-call flag, a new
// activity ID, the session ID once logged in, and the cookies the server has set.
func (c *Client) withMetadata(ctx context.Context) context.Context {
	activityID := make([]byte, 16)
	_, _ = rand.Read(activityID)
	pairs := []string{headerRemoteCall, "true", headerActivityID, string(activityID)}
	if session := c.sessionID(); len(session) > 0 {
		pairs = append(pairs, headerSessionID, string(session))
	}
	if cookies := c.cookieHeader(); cookies != "" {
		pairs = append(pairs, headerCookie, cookies)
	}
	return metadata.AppendToOutgoingContext(ctx, pairs...)
}

// unaryInterceptor adds the metadata to every unary call, keeps the cookies and the metadata
// version the server returns, and turns a failure into *Error.
func (c *Client) unaryInterceptor(ctx context.Context, method string, req, reply any,
	cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
	var header, trailer metadata.MD
	opts = append(opts, grpc.Header(&header), grpc.Trailer(&trailer))
	err := invoker(c.withMetadata(ctx), method, req, reply, cc, opts...)
	c.readResponseMetadata(header, trailer)
	return decodeError(err, trailer)
}

Source: go/mfilesclient/metadata.go

Login. LogIn binds the session to the vault in login_data.vault_guid. Its response's session_data.session_id (48 bytes) goes into mfiles-session-id-bin from then on:


// LogInWith logs in to a vault. From then on every call carries the session.
func (c *Client) LogInWith(ctx context.Context, vault string, auth *mfiles.AuthDataClient,
	clientName string) (*mfiles.SessionData, error) {
	if clientName == "" {
		clientName = filepath.Base(os.Args[0])
	}
	response, err := mfiles.NewIRPCLoginClient(c.Conn).LogIn(ctx, &mfiles.LogInRequest{
		EnvironmentData: &mfiles.EnvironmentData{ClientName: clientName, HostPlatform: "Go"},
		ClientData: &mfiles.ClientData{
			Type:     mfiles.ClientType_CLIENT_TYPE_SERVER_API,
			Language: "en",
			// A plugin (token) login uses the 32-byte form of the attempt identifier.
			Capabilities: &mfiles.ClientCapabilities{LongAuthenticationAttemptIdentifier: true},
		},
		LoginData:          &mfiles.LoginData{ServerHostname: c.host, VaultGuid: vault},
		AuthenticationData: auth,
	})
	if err != nil {
		return nil, err
	}
	session := response.GetSessionData()
	if len(session.GetSessionId()) == 0 {
		// A plugin that needs more than one round trip answers without a session.
		return nil, errors.New("LogIn returned no session: multi-step authentication is not supported")
	}
	// From now on the interceptor sends session_id in mfiles-session-id-bin on every call.
	c.mu.Lock()
	c.session = session
	c.mu.Unlock()
	return session, nil
}

Source: go/mfilesclient/client.go

With a user name and password, authentication_data is:


// PasswordAuth is the authentication data of an M-Files user name and password.
func PasswordAuth(username, password string) *mfiles.AuthDataClient {
	return &mfiles.AuthDataClient{
		Type: mfiles.AuthDataType_AUTH_DATA_TYPE_CREDENTIALS,
		Data: &mfiles.AuthDataClientUnion{Data: &mfiles.AuthDataClientUnion_Credentials{
			Credentials: &mfiles.Credentials{
				Type:     mfiles.CredentialsType_CREDENTIALS_TYPE_MFILES,
				Username: username,
				Password: password,
			},
		}},
	}
}

Source: go/mfilesclient/client.go

Errors. A failed call returns *mfilesclient.Error, decoded from the mfiles-error-serialized-bin and mfiles-error-text-bin trailers: the gRPC status (Status), the message, the M-Files error code of the top-level error (HResult, such as 0x8004000B "Not found."; the last entry of the error stack that is not the generic 0x80040001), every code of the stack (Codes) and Has(code). It keeps the gRPC status, so status.Code(err) still works:


// Error is a failed M-Files call. Test for it with errors.As; status.Code(err) still gives the
// gRPC status code.
type Error struct {
	Status  *status.Status         // the gRPC status: Unknown for M-Files errors
	HResult uint32                 // the M-Files error code, e.g. 0x8004000B; 0 when unknown
	Codes   []uint32               // the codes of the error stack, the original error first
	Message string                 // the error message (the gRPC status message)
	Details string                 // the long error message with the server's error stack
	Stack   []*mfiles.RPCErrorInfo // the server's error stack, the original error first
}

func (e *Error) Error() string {
	if len(e.Codes) == 0 {
		return fmt.Sprintf("gRPC error %s: %s", e.Status.Code(), e.Message)
	}
	return fmt.Sprintf("M-Files error 0x%08X (%s): %s", e.HResult, e.Status.Code(), e.Message)
}

// SignedHResult is the error code as a signed 32-bit integer, e.g. -2147221493.
func (e *Error) SignedHResult() int32 { return int32(e.HResult) }

// GRPCStatus lets status.Code and status.FromError see through the *Error.
func (e *Error) GRPCStatus() *status.Status { return e.Status }

// Has reports whether code is anywhere in the error stack.
func (e *Error) Has(code uint32) bool {
	for _, c := range e.Codes {
		if c == code {
			return true
		}
	}
	return false
}

// AsError returns the *Error in err's chain, if there is one.
func AsError(err error) (*Error, bool) {
	var mfilesErr *Error
	ok := errors.As(err, &mfilesErr)
	return mfilesErr, ok
}

// decodeError turns a failed call into *Error, using the error trailers when present.
func decodeError(err error, trailer metadata.MD) error {
	if err == nil {
		return nil
	}
	st, ok := status.FromError(err)
	if !ok || st.Code() == codes.OK {
		return err
	}
	result := &Error{Status: st, Message: st.Message()}
	if values := trailer.Get(headerErrorText); len(values) > 0 {
		result.Details = values[0]
	}
	if values := trailer.Get(headerErrorStack); len(values) > 0 {
		stack := &mfiles.RPCErrorInfoArray{}
		if proto.Unmarshal([]byte(values[0]), stack) == nil {
			result.Stack = stack.GetValue()
		}
	}
	for _, info := range result.Stack {
		result.Codes = append(result.Codes, uint32(info.GetErrorCode()))
	}
	result.HResult = errorCode(result.Codes)
	return result
}

// errorPassThrough is the generic code of an error that is only passed up the stack; it does
// not say what went wrong.
const errorPassThrough = 0x80040001

// errorCode is the code of the top-level error, the last entry of the stack: the error the
// status message describes, such as 0x800407E4 for "Access denied." Entries with the generic
// code 0x80040001 are skipped.
func errorCode(stack []uint32) uint32 {
	for i := len(stack) - 1; i >= 0; i-- {
		if stack[i] != errorPassThrough {
			return stack[i]
		}
	}
	if len(stack) > 0 {
		return stack[len(stack)-1]
	}
	return 0
}

Source: go/mfilesclient/errors.go

Handle it like this:

_, err := objects.GetObjectInfoAndProperties(ctx, request)
if mfilesErr, ok := mfilesclient.AsError(err); ok && mfilesErr.Has(mfilesclient.CodeNotFound) {
	fmt.Printf("0x%08X: %s\n", mfilesErr.HResult, mfilesErr.Message)
}

Errors lists the common codes and what to do about each.

The metadata structure version. Calls that report the vault's metadata structure version return it in the mfiles-metadata-version-bin trailer; client.MetadataStructureVersion() has the latest one. See Metadata structure version and caching.

Troubleshooting

go run ./recipes/connect calls QueryVersion before it logs in, so it tells connection problems from login problems.

Symptom Cause
generate.ps1: "No M-Files proto bundle found" Pass -ProtoDir or set MFILES_PROTO_DIR to the folder that contains mfiles/structs.proto.
Unavailable, "tls: first record does not look like a TLS handshake" TLS against a server without TLS: set MFILES_TLS=false, only for a lab server on a trusted network.
Unimplemented, "unexpected HTTP status code received from server: 404" A call without TLS to a TLS endpoint, such as M-Files Cloud: remove MFILES_TLS=false.
Unavailable, "connection error" ... "Error while dialing" Wrong host or port, or a firewall in between.
"M-Files error 0x8004001A (Unknown): Authentication failed." Wrong user name or password, or the vault uses single sign-on (see the configuration step).
"M-Files error 0x80040020 (Unknown): The specified document vault does not exist." Wrong MFILES_VAULT.
"gRPC error Unauthenticated: The current session with M-Files Server is no longer valid." No session or an expired one: log in again.

Next steps