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
Show file tree
Hide file tree
Changes from 8 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
170 changes: 170 additions & 0 deletions backend/src/Designer/Controllers/OptionsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using Altinn.Studio.Designer.Helpers;
using Altinn.Studio.Designer.Models;
using Altinn.Studio.Designer.Services.Interfaces;
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>
[HttpGet]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[Route("{optionListId}")]
public async Task<ActionResult<List<Option>>> GetSingleOptionList(string org, string repo, [FromRoute] string optionListId)
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);

try
{
List<Option> optionList = await _optionsService.GetOptions(org, repo, developer, optionListId);
return Ok(optionList);
}
catch (IOException)
{
return NotFound($"The options file {optionListId}.json does not exist.");
}
catch (JsonException)
{
return new ObjectResult(new { errorMessage = $"The format of the file {optionListId}.json might be invalid." }) { StatusCode = 500 };
}
}

/// <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>
/// <returns>The created option list</returns>
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[Route("{optionListId}")]
public async Task<ActionResult> Post(string org, string repo, [FromRoute] string optionListId, [FromBody] List<Option> payload)
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);

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

try
{
var newOptionList = await _optionsService.UpdateOptions(org, repo, developer, optionListId, payload);
return CreatedAtAction("GetSingleOptionList", new { org, repo, optionListId }, newOptionList);
}
catch (IOException)
{
return new ObjectResult(new { errorMessage = $"An error occurred while saving the file {optionListId}.json." }) { StatusCode = 500 };
}
}

/// <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>
[HttpPut]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[Route("{optionListId}")]
public async Task<ActionResult> Put(string org, string repo, [FromRoute] string optionListId, [FromBody] List<Option> payload)
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);

try
{
var newOptionList = await _optionsService.UpdateOptions(org, repo, developer, optionListId, payload);
return Ok(newOptionList);
}
catch (IOException)
{
return new ObjectResult(new { errorMessage = $"An error occurred while saving the file {optionListId}.json." }) { StatusCode = 500 };
}
}

/// <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>
[HttpDelete]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Route("{optionListId}")]
public async Task<ActionResult> Delete(string org, string repo, [FromRoute] string optionListId)
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);

bool optionListExists = await _optionsService.OptionListExists(org, repo, developer, optionListId);
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
Expand Up @@ -702,28 +702,10 @@ public async Task<string> GetAppFrontendCshtml(CancellationToken cancellationTok
throw new FileNotFoundException("Index.cshtml was not found.");
}

/// <summary>
/// Gets the options list with the provided id.
/// <param name="optionsListId">The id 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)
{
string optionsFilePath = Path.Combine(OptionsFolderPath, $"{optionsListId}.json");
if (!FileExistsByRelativePath(optionsFilePath))
{
throw new NotFoundException("Options file 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>
/// </summary>
/// <returns>A list of option list names.</returns>
public string[] GetOptionListIds()
{
string optionsFolder = Path.Combine(OptionsFolderPath);
Expand All @@ -741,6 +723,56 @@ public string[] GetOptionListIds()
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>
public async Task<string> GetOptions(string optionListId, CancellationToken cancellationToken = default)
{
string optionsFilePath = Path.Combine(OptionsFolderPath, $"{optionListId}.json");
if (!FileExistsByRelativePath(optionsFilePath))
{
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>
/// 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>
/// <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 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))
{
throw new NotFoundException($"Options file {optionListId}.json was not found.");
standeren marked this conversation as resolved.
Show resolved Hide resolved
}

DeleteFileByRelativePath(optionsFilePath);
}

/// <summary>
/// Saves the process definition file on disk.
/// </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; }
}
86 changes: 86 additions & 0 deletions backend/src/Designer/Services/Implementation/OptionsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System.Collections.Generic;
using System.Text.Json;
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)
{
var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, repo, developer);

string optionListString = await altinnAppGitRepository.GetOptions(optionListId);
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)
{
var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, repo, developer);

string payloadString = JsonSerializer.Serialize(payload);
string updatedOptionsString = await altinnAppGitRepository.CreateOrOverwriteOptions(optionListId, payloadString);
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)
{
try
{
await GetOptions(org, repo, developer, optionListId);
return true;
}
catch (NotFoundException)
{
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
namespace Altinn.Studio.Designer.Services.Implementation
{
/// <summary>
/// Interface for dealing with texts in new format in an app repository.
/// Service for handling texts in new format in an app repository.
/// </summary>
public class TextsService : ITextsService
{
Expand Down
Loading
Loading