Skip to content

Commit

Permalink
Create controller and service for options/code lists (#13046)
Browse files Browse the repository at this point in the history
* Add GET endpoint for fetching an options list

* Add PUT endpoint for creating or overwriting an options list

* Implement 'Option' model

* Add DELETE endpoint

* Add GET endpoint for fetching list of options

* Add tests for all return codes

* Add tests for new methods in AltinnAppGitRepository
  • Loading branch information
ErlingHauan authored Jul 11, 2024
1 parent 99069a0 commit e069007
Show file tree
Hide file tree
Showing 14 changed files with 1,042 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ public ActionResult GetOptionListIds(string org, string app)
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
AltinnAppGitRepository altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, app, developer);
string[] optionListIds = altinnAppGitRepository.GetOptionListIds();
string[] optionListIds = altinnAppGitRepository.GetOptionsListIds();
return Ok(optionListIds);
}
catch (LibGit2Sharp.NotFoundException)
Expand Down
127 changes: 127 additions & 0 deletions backend/src/Designer/Controllers/OptionsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Altinn.Studio.Designer.Helpers;
using Altinn.Studio.Designer.Models;
using Altinn.Studio.Designer.Services.Interfaces;
using LibGit2Sharp;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace Altinn.Studio.Designer.Controllers;

/// <summary>
/// Controller containing actions related to options (code lists).
/// </summary>
[ApiController]
[Authorize]
[AutoValidateAntiforgeryToken]
[Route("designer/api/{org}/{repo:regex(^(?!datamodels$)[[a-z]][[a-z0-9-]]{{1,28}}[[a-z0-9]]$)}/options")]
public class OptionsController : ControllerBase
{
private readonly IOptionsService _optionsService;

/// <summary>
/// Initializes a new instance of the <see cref="OptionsController"/> class.
/// </summary>
/// <param name="optionsService">The options service.</param>
public OptionsController(IOptionsService optionsService)
{
_optionsService = optionsService;
}

/// <summary>
/// Fetches the IDs of the options lists belonging to the app.
/// </summary>
/// <param name="org">Unique identifier of the organisation responsible for the app.</param>
/// <param name="repo">Application identifier which is unique within an organisation.</param>
/// <returns>Array of options list's IDs. Empty array if none are found</returns>
[HttpGet]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<string[]> GetOptionsListIds(string org, string repo)
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);

string[] optionsListIds = _optionsService.GetOptionsListIds(org, repo, developer);

return Ok(optionsListIds);
}

/// <summary>
/// Fetches a specific option list.
/// </summary>
/// <param name="org">Unique identifier of the organisation responsible for the app.</param>
/// <param name="repo">Application identifier which is unique within an organisation.</param>
/// <param name="optionsListId">Name of the option list.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that observes if operation is cancelled.</param>
[HttpGet]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{optionsListId}")]
public async Task<ActionResult<List<Option>>> GetOptionsList(string org, string repo, [FromRoute] string optionsListId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);

try
{
List<Option> optionsList = await _optionsService.GetOptionsList(org, repo, developer, optionsListId, cancellationToken);
return Ok(optionsList);
}
catch (NotFoundException ex)
{
return NotFound(ex.Message);
}
}

/// <summary>
/// Creates or overwrites an options list.
/// </summary>
/// <param name="org">Unique identifier of the organisation responsible for the app.</param>
/// <param name="repo">Application identifier which is unique within an organisation.</param>
/// <param name="optionsListId">Name of the options list.</param>
/// <param name="payload">Contents of the options list.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that observes if operation is cancelled.</param>
[HttpPut]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[Route("{optionsListId}")]
public async Task<ActionResult> CreateOrOverwriteOptionsList(string org, string repo, [FromRoute] string optionsListId, [FromBody] List<Option> payload, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);

var newOptionsList = await _optionsService.CreateOrOverwriteOptionsList(org, repo, developer, optionsListId, payload, cancellationToken);

return Ok(newOptionsList);
}

/// <summary>
/// Deletes an option list.
/// </summary>
/// <param name="org">Unique identifier of the organisation responsible for the app.</param>
/// <param name="repo">Application identifier which is unique within an organisation.</param>
/// <param name="optionsListId">Name of the option list.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that observes if operation is cancelled.</param>
[HttpDelete]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Route("{optionsListId}")]
public async Task<ActionResult> DeleteOptionsList(string org, string repo, [FromRoute] string optionsListId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);

bool optionsListExists = await _optionsService.OptionsListExists(org, repo, developer, optionsListId, cancellationToken);
if (optionsListExists)
{
_optionsService.DeleteOptionsList(org, repo, developer, optionsListId);
}

return Ok($"The options file {optionsListId}.json has been deleted.");
}
}
4 changes: 2 additions & 2 deletions backend/src/Designer/Controllers/PreviewController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -854,7 +854,7 @@ public async Task<ActionResult<string>> GetOptions(string org, string app, strin
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
AltinnAppGitRepository altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, app, developer);
string options = await altinnAppGitRepository.GetOptions(optionListId, cancellationToken);
string options = await altinnAppGitRepository.GetOptionsList(optionListId, cancellationToken);
return Ok(options);
}
catch (NotFoundException)
Expand Down Expand Up @@ -883,7 +883,7 @@ public async Task<ActionResult<string>> GetOptionsForInstance(string org, string
// TODO: Need code to get dynamic options list based on language and source?
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
AltinnAppGitRepository altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, app, developer);
string options = await altinnAppGitRepository.GetOptions(optionListId, cancellationToken);
string options = await altinnAppGitRepository.GetOptionsList(optionListId, cancellationToken);
return Ok(options);
}
catch (NotFoundException)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -703,42 +703,79 @@ public async Task<string> GetAppFrontendCshtml(CancellationToken cancellationTok
}

/// <summary>
/// Gets the options list with the provided id.
/// <param name="optionsListId">The id of the options list to fetch.</param>
/// Gets a list of file names from the Options folder representing the available options lists.
/// </summary>
/// <returns>A list of option list names.</returns>
public string[] GetOptionsListIds()
{
string optionsFolder = Path.Combine(OptionsFolderPath);
if (!DirectoryExistsByRelativePath(optionsFolder))
{
throw new NotFoundException("Options folder not found.");
}

string[] fileNames = GetFilesByRelativeDirectory(optionsFolder);
List<string> optionsListIds = [];
foreach (string fileName in fileNames.Select(Path.GetFileNameWithoutExtension))
{
optionsListIds.Add(fileName);
}

return optionsListIds.ToArray();
}

/// <summary>
/// Gets a specific options list with the provided id.
/// </summary>
/// <param name="optionsListId">The name of the options list to fetch.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that observes if operation is cancelled.</param>
/// <returns>The options list as a string.</returns>
/// </summary>
public async Task<string> GetOptions(string optionsListId, CancellationToken cancellationToken = default)
public async Task<string> GetOptionsList(string optionsListId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();

string optionsFilePath = Path.Combine(OptionsFolderPath, $"{optionsListId}.json");
if (!FileExistsByRelativePath(optionsFilePath))
{
throw new NotFoundException("Options file not found.");
throw new NotFoundException($"Options file {optionsListId}.json was not found.");
}
string fileContent = await ReadTextByRelativePathAsync(optionsFilePath, cancellationToken);

return fileContent;
}

/// <summary>
/// Gets a list of file names from the Options folder representing the available options lists.
/// <returns>A list of option list names.</returns>
/// Creates or overwrites the options list with the provided id.
/// If the options list already exists, it will be overwritten.
/// </summary>
public string[] GetOptionListIds()
/// <param name="optionsListId">The name of the options list to create.</param>
/// <param name="payload">The contents of the new options list as a string</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that observes if operation is cancelled.</param>
/// <returns>The new options list as a string.</returns>
public async Task<string> CreateOrOverwriteOptionsList(string optionsListId, string payload, CancellationToken cancellationToken = default)
{
string optionsFolder = Path.Combine(OptionsFolderPath);
if (!DirectoryExistsByRelativePath(optionsFolder))
{
throw new NotFoundException("Options folder not found.");
}
string[] fileNames = GetFilesByRelativeDirectory(optionsFolder);
List<string> optionListIds = new();
foreach (string fileName in fileNames.Select(Path.GetFileNameWithoutExtension))
cancellationToken.ThrowIfCancellationRequested();

string optionsFilePath = Path.Combine(OptionsFolderPath, $"{optionsListId}.json");
await WriteTextByRelativePathAsync(optionsFilePath, payload, true, cancellationToken);
string fileContent = await ReadTextByRelativePathAsync(optionsFilePath, cancellationToken);

return fileContent;
}

/// <summary>
/// Deletes the option list with the provided id.
/// </summary>
/// <param name="optionsListId">The name of the option list to create.</param>
public void DeleteOptionsList(string optionsListId)
{
string optionsFilePath = Path.Combine(OptionsFolderPath, $"{optionsListId}.json");
if (!FileExistsByRelativePath(optionsFilePath))
{
optionListIds.Add(fileName);
throw new NotFoundException($"Options file {optionsListId}.json was not found.");
}

return optionListIds.ToArray();
DeleteFileByRelativePath(optionsFilePath);
}

/// <summary>
Expand Down
1 change: 1 addition & 0 deletions backend/src/Designer/Infrastructure/ServiceRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public static IServiceCollection RegisterServiceImplementations(this IServiceCol
services.AddTransient<ISigningCredentialsResolver, SigningCredentialsResolver>();
services.AddTransient<ILanguagesService, LanguagesService>();
services.AddTransient<ITextsService, TextsService>();
services.AddTransient<IOptionsService, OptionsService>();
services.AddTransient<IEnvironmentsService, EnvironmentsService>();
services.AddHttpClient<IOrgService, OrgService>();
services.AddTransient<IAppDevelopmentService, AppDevelopmentService>();
Expand Down
37 changes: 37 additions & 0 deletions backend/src/Designer/Models/Option.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Microsoft.CodeAnalysis;

namespace Altinn.Studio.Designer.Models;

/// <summary>
/// Options used in checkboxes, radio buttons and dropdowns.
/// </summary>
public class Option
{
/// <summary>
/// Value that connects the option to the data model.
/// </summary>
[Required]
[JsonPropertyName("value")]
public string Value { get; set; }

/// <summary>
/// Label to present to the user.
/// </summary>
[Required]
[JsonPropertyName("label")]
public string Label { get; set; }

/// <summary>
/// Description, typically displayed below the label.
/// </summary>
[JsonPropertyName("description")]
public Optional<string> Description { get; set; }

/// <summary>
/// Help text, typically wrapped inside a popover.
/// </summary>
[JsonPropertyName("helpText")]
public Optional<string> HelpText { get; set; }
}
93 changes: 93 additions & 0 deletions backend/src/Designer/Services/Implementation/OptionsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Altinn.Studio.Designer.Models;
using Altinn.Studio.Designer.Services.Interfaces;
using LibGit2Sharp;

namespace Altinn.Studio.Designer.Services.Implementation;

/// <summary>
/// Service for handling options lists (code lists).
/// </summary>
public class OptionsService : IOptionsService
{
private readonly IAltinnGitRepositoryFactory _altinnGitRepositoryFactory;

/// <summary>
/// Constructor
/// </summary>
/// <param name="altinnGitRepositoryFactory">IAltinnGitRepository</param>
public OptionsService(IAltinnGitRepositoryFactory altinnGitRepositoryFactory)
{
_altinnGitRepositoryFactory = altinnGitRepositoryFactory;
}

/// <inheritdoc />
public string[] GetOptionsListIds(string org, string repo, string developer)
{
var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, repo, developer);

try
{
string[] optionsLists = altinnAppGitRepository.GetOptionsListIds();
return optionsLists;
}
catch (NotFoundException) // Is raised if the Options folder does not exist
{
return [];
}
}

/// <inheritdoc />
public async Task<List<Option>> GetOptionsList(string org, string repo, string developer, string optionsListId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, repo, developer);

string optionsListString = await altinnAppGitRepository.GetOptionsList(optionsListId, cancellationToken);
var optionsList = JsonSerializer.Deserialize<List<Option>>(optionsListString);

return optionsList;
}

/// <inheritdoc />
public async Task<List<Option>> CreateOrOverwriteOptionsList(string org, string repo, string developer, string optionsListId, List<Option> payload, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, repo, developer);

var jsonOptions = new JsonSerializerOptions { WriteIndented = true };
string payloadString = JsonSerializer.Serialize(payload, jsonOptions);

string updatedOptionsString = await altinnAppGitRepository.CreateOrOverwriteOptionsList(optionsListId, payloadString, cancellationToken);
var updatedOptions = JsonSerializer.Deserialize<List<Option>>(updatedOptionsString);

return updatedOptions;
}

/// <inheritdoc />
public void DeleteOptionsList(string org, string repo, string developer, string optionsListId)
{
var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, repo, developer);

altinnAppGitRepository.DeleteOptionsList(optionsListId);
}

/// <inheritdoc />
public async Task<bool> OptionsListExists(string org, string repo, string developer, string optionsListId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();

try
{
await GetOptionsList(org, repo, developer, optionsListId, cancellationToken);
return true;
}
catch (NotFoundException)
{
return false;
}
}
}
Loading

0 comments on commit e069007

Please sign in to comment.