Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create controller and service for options/code lists #13046

Merged
merged 19 commits into from
Jul 11, 2024
Merged
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions backend/src/Designer/Controllers/OptionsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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 a list of the static option 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 option lists. Empty array if none are found</returns>
[HttpGet]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
standeren marked this conversation as resolved.
Show resolved Hide resolved
public ActionResult<string[]> GetOptionListIds(string org, string repo)
standeren marked this conversation as resolved.
Show resolved Hide resolved
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);

string[] optionLists = _optionsService.GetOptionListIds(org, repo, developer);

return Ok(optionLists);
}

/// <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="optionListId">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("{optionListId}")]
public async Task<ActionResult<List<Option>>> GetSingleOptionList(string org, string repo, [FromRoute] string optionListId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);

try
{
List<Option> optionList = await _optionsService.GetOptions(org, repo, developer, optionListId, cancellationToken);
return Ok(optionList);
}
catch (NotFoundException)
{
return NotFound($"The options file {optionListId}.json does not exist.");
}
}

/// <summary>
/// Create 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="optionListId">Name of the new option list</param>
/// <param name="payload">The option list contents</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that observes if operation is cancelled.</param>
/// <returns>The created option list</returns>
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[Route("{optionListId}")]
public async Task<ActionResult> Post(string org, string repo, [FromRoute] string optionListId, [FromBody] List<Option> payload, CancellationToken cancellationToken = default)
standeren marked this conversation as resolved.
Show resolved Hide resolved
{
cancellationToken.ThrowIfCancellationRequested();
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);

bool optionListAlreadyExists = await _optionsService.OptionListExists(org, repo, developer, optionListId, cancellationToken);
if (optionListAlreadyExists)
{
return Conflict("The option list already exists.");
}

var newOptionList = await _optionsService.UpdateOptions(org, repo, developer, optionListId, payload, cancellationToken);
return CreatedAtAction("GetSingleOptionList", new { org, repo, optionListId }, newOptionList);
}

/// <summary>
/// Creates or overwrites an option list.
standeren marked this conversation as resolved.
Show resolved Hide resolved
/// </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="optionListId">Name of the option list.</param>
/// <param name="payload">Contents of the option 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("{optionListId}")]
public async Task<ActionResult> Put(string org, string repo, [FromRoute] string optionListId, [FromBody] List<Option> payload, CancellationToken cancellationToken = default)
standeren marked this conversation as resolved.
Show resolved Hide resolved
{
cancellationToken.ThrowIfCancellationRequested();
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);

var newOptionList = await _optionsService.UpdateOptions(org, repo, developer, optionListId, payload, cancellationToken);

return Ok(newOptionList);
standeren marked this conversation as resolved.
Show resolved Hide resolved
}

/// <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="optionListId">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)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{optionListId}")]
public async Task<ActionResult> Delete(string org, string repo, [FromRoute] string optionListId, CancellationToken cancellationToken = default)
standeren marked this conversation as resolved.
Show resolved Hide resolved
{
cancellationToken.ThrowIfCancellationRequested();
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);

bool optionListExists = await _optionsService.OptionListExists(org, repo, developer, optionListId, cancellationToken);
if (!optionListExists)
{
return NotFound($"The options file {optionListId}.json does not exist.");
standeren marked this conversation as resolved.
Show resolved Hide resolved
}

_optionsService.DeleteOptions(org, repo, developer, optionListId);
return Ok($"The options file {optionListId}.json was successfully deleted.");
}
}
Original file line number Diff line number Diff line change
@@ -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[] GetOptionListIds()
{
string optionsFolder = Path.Combine(OptionsFolderPath);
if (!DirectoryExistsByRelativePath(optionsFolder))
{
throw new NotFoundException("Options folder not found.");
}

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

return optionListIds.ToArray();
}

/// <summary>
/// Gets a specific options list with the provided id.
/// </summary>
/// <param name="optionListId">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> GetOptions(string optionListId, CancellationToken cancellationToken = default)
{
string optionsFilePath = Path.Combine(OptionsFolderPath, $"{optionsListId}.json");
cancellationToken.ThrowIfCancellationRequested();

string optionsFilePath = Path.Combine(OptionsFolderPath, $"{optionListId}.json");
if (!FileExistsByRelativePath(optionsFilePath))
{
throw new NotFoundException("Options file not found.");
throw new NotFoundException($"Options file {optionListId}.json was not found.");
standeren marked this conversation as resolved.
Show resolved Hide resolved
}
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 a new option list with the provided id.
standeren marked this conversation as resolved.
Show resolved Hide resolved
/// If the option list already exists, it will be overwritten.
standeren marked this conversation as resolved.
Show resolved Hide resolved
/// </summary>
public string[] GetOptionListIds()
/// <param name="optionListId">The name of the option list to create.</param>
/// <param name="payload">The contents of the new option 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> CreateOrOverwriteOptions(string optionListId, 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, $"{optionListId}.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="optionListId">The name of the option list to create.</param>
public void DeleteOptions(string optionListId)
{
string optionsFilePath = Path.Combine(OptionsFolderPath, $"{optionListId}.json");
if (!FileExistsByRelativePath(optionsFilePath))
{
optionListIds.Add(fileName);
throw new NotFoundException($"Options file {optionListId}.json was not found.");
standeren marked this conversation as resolved.
Show resolved Hide resolved
}

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

/// <summary>
1 change: 1 addition & 0 deletions backend/src/Designer/Infrastructure/ServiceRegistration.cs
Original file line number Diff line number Diff line change
@@ -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>();
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 (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[] GetOptionListIds(string org, string repo, string developer)
{
var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, repo, developer);

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

/// <inheritdoc />
public async Task<List<Option>> GetOptions(string org, string repo, string developer, string optionListId, CancellationToken cancellationToken = default)
standeren marked this conversation as resolved.
Show resolved Hide resolved
{
cancellationToken.ThrowIfCancellationRequested();
var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, repo, developer);

string optionListString = await altinnAppGitRepository.GetOptions(optionListId, cancellationToken);
var optionList = JsonSerializer.Deserialize<List<Option>>(optionListString);

return optionList;
}

/// <inheritdoc />
public async Task<List<Option>> UpdateOptions(string org, string repo, string developer, string optionListId, 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.CreateOrOverwriteOptions(optionListId, payloadString, cancellationToken);
var updatedOptions = JsonSerializer.Deserialize<List<Option>>(updatedOptionsString);

return updatedOptions;
}

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

altinnAppGitRepository.DeleteOptions(optionListId);
}

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

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