diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 923bc255..1e4c0547 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -35,9 +35,11 @@ jobs: - name: Test run: ./test.sh env: + TEST_HOST_WRITE: "https://greenfield.cognitedata.com" TEST_TENANT_ID_WRITE: ${{ secrets.TEST_TENANT_ID_WRITE }} TEST_CLIENT_ID_WRITE: ${{ secrets.TEST_CLIENT_ID_WRITE }} TEST_CLIENT_SECRET_WRITE: ${{ secrets.TEST_CLIENT_SECRET_WRITE }} + TEST_HOST_READ: "https://api.cognitedata.com" TEST_TENANT_ID_READ: ${{ secrets.TEST_TENANT_ID_READ }} TEST_CLIENT_ID_READ: ${{ secrets.TEST_CLIENT_ID_READ }} TEST_CLIENT_SECRET_READ: ${{ secrets.TEST_CLIENT_SECRET_READ }} diff --git a/CogniteSdk.Types/Alpha/Simulators/SimulatorConverters.cs b/CogniteSdk.Types/Alpha/Simulators/SimulatorConverters.cs index d65ce41e..ab073eac 100644 --- a/CogniteSdk.Types/Alpha/Simulators/SimulatorConverters.cs +++ b/CogniteSdk.Types/Alpha/Simulators/SimulatorConverters.cs @@ -45,7 +45,8 @@ public override SimulatorValue Read(ref Utf8JsonReader reader, Type typeToConver if (listStr.Count > 0 && listDbl.Count > 0) { throw new JsonException("Unable to parse value of type: mixed array"); - } else if (listStr.Count > 0) + } + else if (listStr.Count > 0) { return SimulatorValue.Create(listStr); } diff --git a/CogniteSdk.Types/Beta/StreamRecords/Stream.cs b/CogniteSdk.Types/Beta/StreamRecords/Stream.cs new file mode 100644 index 00000000..183535ad --- /dev/null +++ b/CogniteSdk.Types/Beta/StreamRecords/Stream.cs @@ -0,0 +1,30 @@ +// Copyright 2024 Cognite AS +// SPDX-License-Identifier: Apache-2.0 + +namespace CogniteSdk.Beta +{ + /// + /// Request to create a stream. + /// + public class StreamWrite + { + /// + /// Stream external ID. Must be unique within the project and a valid stream identifier. + /// Stream identifiers can only consist of alphanumeric characters, hyphens, and underscores. + /// It must not start with cdf_ or cognite_, as those are reserved for future use. + /// Stream id cannot be "logs" or "records". Max length is 100 characters. + /// + public string ExternalId { get; set; } + } + + /// + /// A stream. + /// + public class Stream : StreamWrite + { + /// + /// Time the stream was created, in milliseconds since epoch. + /// + public long CreatedTime { get; set; } + } +} \ No newline at end of file diff --git a/CogniteSdk.Types/Beta/StreamRecords/StreamRecord.cs b/CogniteSdk.Types/Beta/StreamRecords/StreamRecord.cs new file mode 100644 index 00000000..9ab05996 --- /dev/null +++ b/CogniteSdk.Types/Beta/StreamRecords/StreamRecord.cs @@ -0,0 +1,29 @@ +// Copyright 2024 Cognite AS +// SPDX-License-Identifier: Apache-2.0 + +using CogniteSdk.DataModels; + +namespace CogniteSdk.Beta +{ + /// + /// A retrieved stream record. + /// + /// Type of the properties bag. + public class StreamRecord + { + /// + /// Id of the space the record belongs to. + /// + public string Space { get; set; } + /// + /// The number of milliseconds since 00:00:00 Thursday 1 January 1970, Coordinated + /// Unversal Time (UTC) minus leap seconds. + /// + public long CreatedTime { get; set; } + /// + /// Spaces to containers to properties and their values for the requested containers. + /// You can use as a fallback for generic results here. + /// + public T Properties { get; set; } + } +} \ No newline at end of file diff --git a/CogniteSdk.Types/Beta/StreamRecords/StreamRecordIngest.cs b/CogniteSdk.Types/Beta/StreamRecords/StreamRecordIngest.cs new file mode 100644 index 00000000..555dbb33 --- /dev/null +++ b/CogniteSdk.Types/Beta/StreamRecords/StreamRecordIngest.cs @@ -0,0 +1,35 @@ +// Copyright 2024 Cognite AS +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using CogniteSdk.DataModels; + +namespace CogniteSdk.Beta +{ + /// + /// Stream record to ingest. + /// + public class StreamRecordWrite + { + /// + /// Id of the space the record belongs to. + /// + public string Space { get; set; } + /// + /// List of source properties to write. The properties are from the container(s) making up this record. + /// Note that `InstanceData` is abstract, you should generally use `InstanceData[T]` + /// to assign types to the record item, but since record sources currently only write to + /// containers, it is usually impossible to assign only a single type to the records. + /// + /// As a fallback, you can use . + /// + public IEnumerable Sources { get; set; } + } + + /// + /// Insertion request for records. + /// + public class StreamRecordIngest : ItemsWithoutCursor + { + } +} \ No newline at end of file diff --git a/CogniteSdk.Types/Beta/StreamRecords/StreamRecordRetrieve.cs b/CogniteSdk.Types/Beta/StreamRecords/StreamRecordRetrieve.cs new file mode 100644 index 00000000..0e9b8120 --- /dev/null +++ b/CogniteSdk.Types/Beta/StreamRecords/StreamRecordRetrieve.cs @@ -0,0 +1,165 @@ +// Copyright 2024 Cognite AS +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using CogniteSdk.DataModels; + +namespace CogniteSdk.Beta +{ + /// + /// Sources to retrieve stream record data from. + /// + public class StreamRecordSource + { + /// + /// Container reference. + /// + public ContainerIdentifier Source { get; set; } + /// + /// List of properties to retrieve. + /// + public IEnumerable Properties { get; set; } + } + + /// + /// Optional attribute to extend the filter with full text search capabilities for + /// a single query in the list of record properties with OR logic. + /// + public class StreamRecordSearch + { + /// + /// Query string that will be parsed and used for search. + /// + public string Query { get; set; } + /// + /// Array of property identifiers to search through. + /// + public IEnumerable> Properties { get; set; } + } + + /// + /// Filter on records created within the provided range. + /// + public class CreatedTimeFilter + { + /// + /// Value must be greater than this + /// + [JsonPropertyName("gt")] + public IDMSValue GreaterThan { get; set; } + + /// + /// Value must be greater than or equal to this + /// + [JsonPropertyName("gte")] + public IDMSValue GreaterThanEqual { get; set; } + + /// + /// Value must be less than this + /// + [JsonPropertyName("lt")] + public IDMSValue LessThan { get; set; } + + /// + /// Value must be less than or equal to this + /// + [JsonPropertyName("lte")] + public IDMSValue LessThanEqual { get; set; } + } + + /// + /// Specification for sorting retrieved records. + /// + public class StreamRecordsSort + { + /// + /// Property you want to sort on. + /// + public IEnumerable Property { get; set; } + /// + /// Sort direction. + /// + public SortDirection? Direction { get; set; } + } + + /// + /// Retrieve stream records. + /// + public class StreamRecordsRetrieve + { + /// + /// Name of the stream where records are located, required. + /// + public string Stream { get; set; } + /// + /// List of containers and the properties that should be selected. + /// + /// Optional, if this is left out all properties are returned. + /// + public IEnumerable Sources { get; set; } + /// + /// A filter Domain Specific Language (DSL) used to create advanced filter queries. + /// + /// Note that some filter types are not supported, see API docs. + /// + public IDMSFilter Filter { get; set; } + /// + /// Matches records with created time within the provided range. + /// + public CreatedTimeFilter CreatedTime { get; set; } + /// + /// Maximum number of results to return. Default 10, max 10000. + /// + public int? Limit { get; set; } + /// + /// Ordered list of sorting specifications. + /// + public IEnumerable Sort { get; set; } + } + + + /// + /// Request for syncing records. + /// + public class StreamRecordsSync + { + /// + /// List of containers and the properties that should be selected. + /// + /// Optional, if this is left out all properties are returned. + /// + public IEnumerable Sources { get; set; } + /// + /// A filter Domain Specific Language (DSL) used to create advanced filter queries. + /// + /// Note that some filter types are not supported, see API docs. + /// + public IDMSFilter Filter { get; set; } + /// + /// A cursor returned from the previous sync request. + /// + public string Cursor { get; set; } + /// + /// Maximum number of results to return. + /// + public int? Limit { get; set; } + /// + /// Initialize cursor. Required if `Cursor` is not set. + /// + public bool InitializeCursor { get; set; } + } + + /// + /// Response from a sync request. + /// + /// Type of properties in returned records. + public class StreamRecordsSyncResponse : ItemsWithCursor> + { + /// + /// The attribute indiciates if there are more records to read in storage, + /// or if the cursor points to the last item. + /// + public bool HasNext { get; set; } + } +} \ No newline at end of file diff --git a/CogniteSdk.Types/Common/Converters.cs b/CogniteSdk.Types/Common/Converters.cs index 1c28b766..739ee1af 100644 --- a/CogniteSdk.Types/Common/Converters.cs +++ b/CogniteSdk.Types/Common/Converters.cs @@ -142,7 +142,7 @@ private string ReadValue(ref Utf8JsonReader reader, JsonSerializerOptions option return number.ToString(); } return reader.GetDouble().ToString(); - default: + default: throw new JsonException($"'{reader.TokenType}' is not supported"); } } diff --git a/CogniteSdk/src/Resources/Beta.cs b/CogniteSdk/src/Resources/Beta.cs index b07bb0a7..a1d9d8f6 100644 --- a/CogniteSdk/src/Resources/Beta.cs +++ b/CogniteSdk/src/Resources/Beta.cs @@ -25,6 +25,11 @@ public class BetaResource : Resource /// public SubscriptionsResource Subscriptions { get; } + /// + /// Resource for Stream Records. + /// + public StreamRecordsResource StreamRecords { get; } + /// /// Will only be instantiated by the client. /// @@ -34,6 +39,7 @@ internal BetaResource(Func> authHandler, FSharpF { Templates = new TemplatesResource(authHandler, ctx); Subscriptions = new SubscriptionsResource(authHandler, ctx); + StreamRecords = new StreamRecordsResource(authHandler, ctx); } } } diff --git a/CogniteSdk/src/Resources/Beta/StreamRecords.cs b/CogniteSdk/src/Resources/Beta/StreamRecords.cs new file mode 100644 index 00000000..4ff61ee3 --- /dev/null +++ b/CogniteSdk/src/Resources/Beta/StreamRecords.cs @@ -0,0 +1,141 @@ +// Copyright 2024 Cognite AS +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using CogniteSdk.Beta; +using Microsoft.FSharp.Core; +using Oryx; + +namespace CogniteSdk.Resources.Beta +{ + /// + /// Contains methods for stream records. + /// + public class StreamRecordsResource : Resource + { + internal StreamRecordsResource(Func> authHandler, FSharpFunc, Task> ctx) : base(authHandler, ctx) + { + } + + /// + /// Creates a list of records in the provided stream. + /// + /// Stream to ingest records into. + /// Records to ingest. + /// Optional cancellation token + public async Task IngestAsync(string stream, IEnumerable records, CancellationToken token = default) + { + if (stream is null) + { + throw new ArgumentNullException(nameof(stream)); + } + + var req = Oryx.Cognite.Beta.StreamRecords.ingest(stream, new StreamRecordIngest + { + Items = records, + }, GetContext(token)); + await RunAsync(req).ConfigureAwait(false); + } + + /// + /// Retrieve a list of records. + /// + /// Type of properties in the retrieved records. + /// Stream to ingest records into. + /// record retrieval request. + /// Optional cancellation token. + /// Retrieved records. + public async Task>> RetrieveAsync(string stream, StreamRecordsRetrieve request, CancellationToken token = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var req = Oryx.Cognite.Beta.StreamRecords.retrieve(stream, request, GetContext(token)); + return await RunAsync(req).ConfigureAwait(false); + } + + /// + /// Synchronizes updates to records. This endpoint will always return a cursor. + /// + /// Type of properties in the retrieved records. + /// Stream to ingest records into. + /// record retreival request. + /// Optional cancellation token. + /// Sync response. + public async Task> SyncAsync(string stream, StreamRecordsSync request, CancellationToken token = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var req = Oryx.Cognite.Beta.StreamRecords.sync(stream, request, GetContext(token)); + return await RunAsync(req).ConfigureAwait(false); + } + + /// + /// Create a new stream. A stream is a target for high volume data ingestion, + /// with data shaped by the Data Modeling concepts. + /// For beta we allow each project to create up to 10 streams. + /// + /// Stream to create + /// Optional cancellation token + /// Created stream + public async Task CreateStreamAsync(StreamWrite stream, CancellationToken token = default) + { + if (stream is null) + { + throw new ArgumentNullException(nameof(stream)); + } + + var req = Oryx.Cognite.Beta.StreamRecords.createStream(stream, GetContext(token)); + return await RunAsync(req).ConfigureAwait(false); + } + + /* Unimplemented + /// + /// Delete a stream by its identifier. + /// + /// Stream to delete + /// Optional cancellation token + public async Task DeleteStreamAsync(string stream, CancellationToken token = default) + { + if (stream is null) + { + throw new ArgumentNullException(nameof(stream)); + } + + var req = Oryx.Cognite.Beta.StreamRecords.deleteStream(stream, GetContext(token)); + await RunAsync(req).ConfigureAwait(false); + } + */ + + /// + /// List all streams in the project. + /// + /// Optional cancellation token + /// Listed streams. + public async Task> ListStreamsAsync(CancellationToken token = default) + { + var req = Oryx.Cognite.Beta.StreamRecords.listStreams(GetContext(token)); + return await RunAsync(req).ConfigureAwait(false); + } + + /// + /// Retrieve a stream by its identifier. + /// + /// Stream to retrieve + /// Optional cancellation token + /// Retrieved stream + public async Task RetrieveStreamAsync(string stream, CancellationToken token = default) + { + var req = Oryx.Cognite.Beta.StreamRecords.retrieveStream(stream, GetContext(token)); + return await RunAsync(req).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/CogniteSdk/test/csharp/StreamRecords.cs b/CogniteSdk/test/csharp/StreamRecords.cs new file mode 100644 index 00000000..ffe11dbc --- /dev/null +++ b/CogniteSdk/test/csharp/StreamRecords.cs @@ -0,0 +1,176 @@ +// Copyright 2024 Cognite AS +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CogniteSdk; +using CogniteSdk.Beta; +using CogniteSdk.DataModels; +using Xunit; +using Xunit.Abstractions; + +namespace Test.CSharp.Integration +{ + public class StreamRecordsFixture : TestFixture, IAsyncLifetime + { + public Client Write => WriteClient; + + public string TestSpace { get; private set; } + public string TestStream { get; private set; } + + public ContainerIdentifier TestContainer { get; private set; } + + public override async Task InitializeAsync() + { + // Share space cross-tests because space existence is eventually consistent in + // stream records... + TestSpace = "SdkTestStreamRecordsSpace"; + + var testSpace = new SpaceCreate { Space = TestSpace }; + + await Write.DataModels.UpsertSpaces(new[] { testSpace }); + + var testContainer = new ContainerCreate + { + ExternalId = "TestContainer", + Space = TestSpace, + Name = "Test", + UsedFor = UsedFor.all, + Properties = new Dictionary + { + { "prop", new ContainerPropertyDefinition + { + Type = BasePropertyType.Text(), + Nullable = true, + } }, + { "intProp", new ContainerPropertyDefinition + { + Type = BasePropertyType.Create(PropertyTypeVariant.int64), + Nullable = true, + } }, + { "boolArrayProp", new ContainerPropertyDefinition + { + Type = BasePropertyType.Create(PropertyTypeVariant.boolean, true), + Nullable = true, + }} + } + }; + var testContainerIdt = new ContainerIdentifier(TestSpace, "TestContainer"); + + await Write.DataModels.UpsertContainers(new[] { testContainer }); + TestContainer = testContainerIdt; + + TestStream = "dotnet-sdk-test-stream"; + + // Try to create the stream, ignore errors. Streams cannot currently + // be deleted, and there is a limit on the count per project, so we can't create + // one per test run. + try + { + await Write.Beta.StreamRecords.CreateStreamAsync(new StreamWrite + { + ExternalId = TestStream + }); + } + catch (ResponseException ex) when (ex.Code == 409) { } + } + } + + public class StreamRecordsTest : IClassFixture + { + private readonly StreamRecordsFixture tester; + private readonly int perTestUniqueInt; + + private static int testCounter = 0; + public StreamRecordsTest(StreamRecordsFixture tester) + { + this.tester = tester; + perTestUniqueInt = Interlocked.Increment(ref testCounter); + } + + [Fact] + public async Task TestListRetrieveStreams() + { + var streams = await tester.Write.Beta.StreamRecords.ListStreamsAsync(); + Assert.Contains(streams, s => s.ExternalId == tester.TestStream); + + var retrieved = await tester.Write.Beta.StreamRecords.RetrieveStreamAsync(tester.TestStream); + Assert.Equal(tester.TestStream, retrieved.ExternalId); + } + + [Fact] + public async Task TestIngestRecords() + { + // Create some records + var req = new[] { + new StreamRecordWrite { + Space = tester.TestSpace, + Sources = new[] { + new InstanceData + { + Source = tester.TestContainer, + Properties = new StandardInstanceWriteData + { + { "prop", new RawPropertyValue("Test value") }, + { "intProp", new RawPropertyValue(perTestUniqueInt) }, + { "boolArrayProp", new RawPropertyValue(new[] { + true, false, true + })} + } + } + } + }, + new StreamRecordWrite { + Space = tester.TestSpace, + Sources = new[] { + new InstanceData + { + Source = tester.TestContainer, + Properties = new StandardInstanceWriteData + { + { "prop", new RawPropertyValue("Test value 2") }, + { "intProp", new RawPropertyValue(perTestUniqueInt) }, + { "boolArrayProp", new RawPropertyValue(new[] { + false, true, false + })} + } + } + } + } + }; + + await tester.Write.Beta.StreamRecords.IngestAsync(tester.TestStream, req); + } + + [Fact] + public async Task TestRetrieveRecords() + { + // The stream records API is so eventually consistent that this test would take + // way too long to run. Just test that we can send a request without failing. + var start = DateTimeOffset.UtcNow.AddMinutes(-1).ToUnixTimeMilliseconds(); + await tester.Write.Beta.StreamRecords.RetrieveAsync( + tester.TestStream, + new StreamRecordsRetrieve + { + CreatedTime = new CreatedTimeFilter + { + GreaterThan = new RawPropertyValue(start), + LessThan = new RawPropertyValue(start + 1000 * 60 * 5), + }, + Filter = new EqualsFilter + { + Property = new[] { tester.TestSpace, tester.TestContainer.ExternalId, "intProp" }, + Value = new RawPropertyValue(perTestUniqueInt), + }, + Limit = 123, + Sort = new[] { new StreamRecordsSort { + Property = new[] { tester.TestSpace, tester.TestContainer.ExternalId, "intProp"} + }} + } + ); + } + } +} \ No newline at end of file diff --git a/Oryx.Cognite/src/Beta/StreamRecords.fs b/Oryx.Cognite/src/Beta/StreamRecords.fs new file mode 100644 index 00000000..bc64c211 --- /dev/null +++ b/Oryx.Cognite/src/Beta/StreamRecords.fs @@ -0,0 +1,90 @@ +// Copyright 2024 Cognite AS +// SPDX-License-Identifier: Apache-2.0 + +namespace Oryx.Cognite.Beta + +open Oryx +open Oryx.Cognite +open Oryx.Cognite.Beta + +open CogniteSdk.Beta + +[] +module StreamRecords = + [] + let Url = "/streams" + + let ingest + (stream: string) + (items: StreamRecordIngest) + (source: HttpHandler) + : HttpHandler = + source + |> withLogMessage "streamrecords:ingest" + |> withAlphaHeader + |> postV10 items (Url +/ stream +/ "records") + + let retrieve<'T> + (stream: string) + (request: StreamRecordsRetrieve) + (source: HttpHandler) + : HttpHandler seq> = + http { + let! ret = + source + |> withLogMessage "streamrecords:retrieve" + |> withAlphaHeader + |> withCompletion System.Net.Http.HttpCompletionOption.ResponseHeadersRead + |> postV10<_, CogniteSdk.ItemsWithoutCursor<_>> request (Url +/ stream +/ "records/filter") + + return ret.Items + } + + let sync<'T> + (stream: string) + (request: StreamRecordsSync) + (source: HttpHandler) + : HttpHandler> = + source + |> withLogMessage "streamrecords:sync" + |> withAlphaHeader + |> withCompletion System.Net.Http.HttpCompletionOption.ResponseHeadersRead + |> postV10 request (Url +/ stream +/ "records/sync") + + let createStream (stream: StreamWrite) (source: HttpHandler) : HttpHandler = + http { + let request = CogniteSdk.ItemsWithoutCursor(Items = [ stream ]) + + let! ret = + source + |> withLogMessage "streamrecords:createstream" + |> withAlphaHeader + |> postV10<_, CogniteSdk.ItemsWithoutCursor<_>> request Url + + return Seq.exactlyOne (ret.Items) + } + + // Unimplemented + // let deleteStream (stream: string) (source: HttpHandler) : HttpHandler = + // source + // |> withLogMessage "streamrecords:deletestream" + // |> withAlphaHeader + // |> deleteV10 (Url +/ stream) + + let listStreams (source: HttpHandler) : HttpHandler = + http { + let! ret = + source + |> withLogMessage "streamrecords:liststreams" + |> withAlphaHeader + |> withCompletion System.Net.Http.HttpCompletionOption.ResponseHeadersRead + |> getV10> Url + + return ret.Items + } + + let retrieveStream (stream: string) (source: HttpHandler) : HttpHandler = + source + |> withLogMessage "streamrecords:retrievestream" + |> withAlphaHeader + |> getV10 (Url +/ stream) diff --git a/Oryx.Cognite/src/Handler.fs b/Oryx.Cognite/src/Handler.fs index 4ec56f44..cdbf18ec 100644 --- a/Oryx.Cognite/src/Handler.fs +++ b/Oryx.Cognite/src/Handler.fs @@ -217,6 +217,16 @@ module HttpHandler = let getV10<'TResult> (url: string) source = source |> withVersion V10 |> get<'TResult> url + let deleteV10<'TResult> (url: string) (source: HttpHandler) = + source + |> withVersion V10 + |> DELETE + |> withResource url + |> fetch + |> withError decodeError + |> json<'TResult> jsonOptions + |> log + let getV10Options<'TResult> (url: string) (options: JsonSerializerOptions) diff --git a/Oryx.Cognite/src/Oryx.Cognite.fsproj b/Oryx.Cognite/src/Oryx.Cognite.fsproj index d4f83b67..bf6d063a 100644 --- a/Oryx.Cognite/src/Oryx.Cognite.fsproj +++ b/Oryx.Cognite/src/Oryx.Cognite.fsproj @@ -37,6 +37,7 @@ + diff --git a/test_auth.sh b/test_auth.sh index b359d0e3..4df35138 100644 --- a/test_auth.sh +++ b/test_auth.sh @@ -5,14 +5,14 @@ TOKEN_WRITE=$(curl -sX POST \ -F client_id="$TEST_CLIENT_ID_WRITE" \ -F client_secret="$TEST_CLIENT_SECRET_WRITE" \ -F grant_type='client_credentials' \ --F scope='https://greenfield.cognitedata.com/.default' "https://login.microsoftonline.com/$TEST_TENANT_ID_WRITE/oauth2/v2.0/token" | jq -j '.access_token') +-F scope="$TEST_HOST_WRITE/.default" "https://login.microsoftonline.com/$TEST_TENANT_ID_WRITE/oauth2/v2.0/token" | jq -j '.access_token') TOKEN_READ=$(curl -sX POST \ --fail \ -F client_id="$TEST_CLIENT_ID_READ" \ -F client_secret="$TEST_CLIENT_SECRET_READ" \ -F grant_type='client_credentials' \ --F scope='https://api.cognitedata.com/.default' "https://login.microsoftonline.com/$TEST_TENANT_ID_READ/oauth2/v2.0/token" | jq -j '.access_token') +-F scope="$TEST_HOST_READ/.default" "https://login.microsoftonline.com/$TEST_TENANT_ID_READ/oauth2/v2.0/token" | jq -j '.access_token') if [[ -z "$TOKEN_WRITE" ]] then