Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) 2025, Phoenix Contact GmbH & Co. KG
// Licensed under the Apache License, Version 2.0

using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Routing;

namespace Moryx.AbstractionLayer.Resources.Endpoints;

/// <summary>
/// The attribute controls the selection of an action method based on the existance of a query parameter <paramref name="paramName"/>.
/// </summary>
/// <param name="paramName">The query parameter to mark this method as invalid for selection</param>
public class InvalidQueryParameterAttribute(string paramName) : ActionMethodSelectorAttribute
{
/// <inheritdoc />
public override bool IsValidForRequest(RouteContext routeContext, ActionDescriptor action) =>
!routeContext.HttpContext.Request.Query.ContainsKey(paramName);
}
32 changes: 32 additions & 0 deletions src/Moryx.AbstractionLayer.Resources.Endpoints/PagedResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) 2025, Phoenix Contact GmbH & Co. KG
// Licensed under the Apache License, Version 2.0

namespace Moryx.AbstractionLayer.Resources.Endpoints;

/// <summary>
/// Generic paged result returned from endpoints providing the <see cref="Items"/>
/// with corresponding metadata from the request.
/// </summary>
/// <typeparam name="ItemType">Type of the items to be returned</typeparam>
public sealed class PagedResult<ItemType>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it be possible to extend a paged result with other meta data? For example errors instead of items?

Copy link
Member Author

@1nf0rmagician 1nf0rmagician Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For errors I would prefer to commit to using the RFC7807 standard conform ProblemDetails from now on, I found a nice blog post explaining it and I would definitely prefer it to any MORYX custom error API responses 🙂

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that! Regarding my comment: Erros might have been a bad example. I just want to make sure that there is absolutely no reason to derive from the PagedResult.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsealing the class is a lot easier than the other way around, thats why i sealed it for now 😅

{
/// <summary>
/// The requested page from the total set of items
/// </summary>
public int PageNumber { get; set; }

/// <summary>
/// The maximum page size provided for the request
/// </summary>
public int PageSize { get; set; }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be Count or ItemsCount (looking at TotalCount). I'd propose something like

  • Page(Number)
  • Page(s)Count
  • (Items)Count *of this set
  • TotalCount

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like PageSize as it is very telling in the context of pagination, while I think that

  • Page(Number) -> to close to PageNumber which already exists
  • Page(s)Count -> without the apostrophe in page's count think it is misleading
  • (Items)Count *of this set -> is to long
  • TotalCount -> already exists for the total number of items

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh sorry, I messed this up... This is what my idea was::

PagedResult {
    long Page(Number); // Could be Page but I don't have a strong opinion on changing this
    long Page(s)Count; // i.e. Pages/PageCount/PagesCount
    long (Items)Count; // of this set (instead of PageSize)
    long TotalCount; // Keeping it
    [] Items; 
}

That way, everything countable could be *Count. The 'pagination' could read sth like Page x of n Pages


/// <summary>
/// The total number of items available
/// </summary>
public long TotalCount { get; set; }

/// <summary>
/// The items returned for the request
/// </summary>
public ItemType[] Items { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) 2025, Phoenix Contact GmbH & Co. KG
// Licensed under the Apache License, Version 2.0

namespace Moryx.AbstractionLayer.Resources.Endpoints;

/// <summary>
/// Extension methods on the <see cref="PagedResult{ItemType}"/> for fluent instance creation
/// </summary>
public static class PagedResultExtensions
{
extension<ItemType>(PagedResult<ItemType> pagedResult)
{
/// <summary>
/// Adds the <paramref name="pagination"/> meta informtaion to this <paramref name="pagedResult"/>
/// </summary>
/// <returns></returns>
public PagedResult<ItemType> With(PaginationParameters pagination)
{
pagedResult.PageNumber = pagination.PageNumber;
pagedResult.PageSize = pagination.PageSize;
// ToDo: Verify that already added results match the metadata
return pagedResult;
}

/// <summary>
/// Populates this <paramref name="pagedResult"/> with the <paramref name="fullSet"/>
/// of items taking already configured <see cref="PagedResult{ItemType}.PageNumber"/>
/// and <see cref="PagedResult{ItemType}.PageSize"/> into account
/// </summary>
/// <returns></returns>
public PagedResult<ItemType> Of(IEnumerable<ItemType> fullSet)
{
// ToDo: Account for unset metadata
pagedResult.TotalCount = fullSet.Count();
var skip = (pagedResult.PageNumber - 1) * pagedResult.PageSize;
pagedResult.Items = [.. fullSet.Skip(skip).Take(pagedResult.PageSize)];
return pagedResult;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) 2025, Phoenix Contact GmbH & Co. KG
// Licensed under the Apache License, Version 2.0

namespace Moryx.AbstractionLayer.Resources.Endpoints;

/// <summary>
/// Parameters used for paging requests.
/// </summary>
public sealed class PaginationParameters
{
/// <summary>
/// The page number for a query returning a list of items; default to 1
/// </summary>
public int PageNumber { get; set => field = Math.Max(value, 1); } = 1;

/// <summary>
/// The number of items provided on the page; defaults to 20 with a minimum of 0 and a maximum of 100
/// </summary>
public int PageSize { get; set => field = Math.Min(Math.Max(value, 0), 100); } = 20;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about numbers. I think common values are 25, 50, 100, 200 (or 250?). While the max value depends on the use case, I'd tend to go for 200 and would also use max as a default.
Min should be 1.

Copy link
Member Author

@1nf0rmagician 1nf0rmagician Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, should be 1 and 200 (or something reasonable that also works in the tree structures we have) 👍

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) 2025, Phoenix Contact GmbH & Co. KG
// Licensed under the Apache License, Version 2.0

namespace Moryx.AbstractionLayer.Resources.Endpoints;

/// <summary>
/// Extension methods for handling pagination
/// </summary>
public static class PaginationParametersExtensions
{
extension(PaginationParameters pagination)
{
/// <summary>
/// Calculates the number of items to be skipped with the request
/// </summary>
public int Skip() => (pagination.PageNumber - 1) * pagination.PageSize;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) 2025, Phoenix Contact GmbH & Co. KG
// Licensed under the Apache License, Version 2.0

using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Routing;

namespace Moryx.AbstractionLayer.Resources.Endpoints;

/// <summary>
/// The attribute controls the selection of an action method based on the existance of a query parameter <paramref name="paramName"/>.
/// </summary>
/// <param name="paramName">The required query parameter to mark this method as valid for selection</param>
public class RequiresQueryParameterAttribute(string paramName) : ActionMethodSelectorAttribute
{
/// <inheritdoc />
public override bool IsValidForRequest(RouteContext routeContext, ActionDescriptor action) =>
routeContext.HttpContext.Request.Query.ContainsKey(paramName);
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
// Copyright (c) 2025, Phoenix Contact GmbH & Co. KG
// Licensed under the Apache License, Version 2.0

using System.ComponentModel.DataAnnotations;
using System.Net;
using System.Reflection;
using System.Runtime.Serialization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Moryx.AbstractionLayer.Activities;
using Moryx.AbstractionLayer.Resources.Endpoints.Properties;
using Moryx.Asp.Extensions;
using Moryx.Configuration;
using Moryx.Runtime.Modules;
using Moryx.Serialization;
using Moryx.Tools;
using Moryx.Runtime.Modules;
using Moryx.Configuration;
using System.Runtime.Serialization;
using System.ComponentModel.DataAnnotations;
using Moryx.AbstractionLayer.Resources.Endpoints.Properties;

namespace Moryx.AbstractionLayer.Resources.Endpoints
{
Expand Down Expand Up @@ -68,11 +69,14 @@ public ActionResult<ResourceModel[]> GetDetailsBatch([FromQuery] long[] ids)
}

[HttpPost]
[InvalidQueryParameter(nameof(PaginationParameters.PageNumber))]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status417ExpectationFailed)]
[Route("query")]
[Authorize(Policy = ResourcePermissions.CanViewTree)]
public ActionResult<ResourceModel[]> GetResources(ResourceQuery query)
public ActionResult<ResourceModel[]> GetResources(ResourceQuery query) => QueryResourceModels(query);

private ResourceModel[] QueryResourceModels(ResourceQuery query)
{
var filter = new ResourceQueryFilter(query, _resourceTypeTree);
var resourceProxies = _resourceManagement.GetAllResources<IResource>(r => filter.Match(r as Resource)).ToArray();
Expand All @@ -82,6 +86,17 @@ public ActionResult<ResourceModel[]> GetResources(ResourceQuery query)
return values;
}

[HttpPost("query")]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we put the query parameters to the query instead of the request body and make these GET requests?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not completely sure, but as far as I understand no, because the body is a deep object 🤔

[RequiresQueryParameter(nameof(PaginationParameters.PageNumber))]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to require a page number? Isn't 1 is a legit default?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, could go for page size. We just need a parameter here for the minor conform change. I would drop the duplicate action (and the corresponding attributes) in the next major then 🙂

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some Ideas:

  • Could we introduce attributes, that define things like for example maximum page size to bring it to the api spec/doc?
  • Should we already think about sorting?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good ideas:

  • Yes at least swagger respects the usual DataAnnotation attributes like Range, so we should make use of them
  • Somewhat, the parameter class makes it extendable to also include sorting parameters, so I would make it two separate PRs

[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[Authorize(Policy = ResourcePermissions.CanViewTree)]
public ActionResult<PagedResult<ResourceModel>> GetResources([FromBody] ResourceQuery query, [FromQuery] PaginationParameters parameters)
{
var resourceModels = QueryResourceModels(query);
return new PagedResult<ResourceModel>().With(parameters).Of(resourceModels);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parameters should be updated depending on the actual data, instead of just passing them back.

Ok, I see this MR is right now rather defining the API and doesn't have any further logic yet.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes exactly, up till now I just wanted to prove that we can make it Minor conform. Nevertheless, I agree the metadata should be updated according to the actual content 🙂

}

[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { filter, map } from 'rxjs/operators';
import { StrictHttpResponse } from '../../strict-http-response';
import { RequestBuilder } from '../../request-builder';

import { ResourceModel as MoryxAbstractionLayerResourcesEndpointsResourceModel } from '../../models/Moryx/AbstractionLayer/Resources/Endpoints/resource-model';
import { ResourceModel } from '../../models/resource-model';
import { Entry } from '@moryx/ngx-web-framework/entry-editor/src/models/entry';


Expand All @@ -18,7 +18,7 @@ export interface ConstructWithParameters$Params {
body?: Entry
}

export function constructWithParameters(http: HttpClient, rootUrl: string, params: ConstructWithParameters$Params, context?: HttpContext): Observable<StrictHttpResponse<MoryxAbstractionLayerResourcesEndpointsResourceModel>> {
export function constructWithParameters(http: HttpClient, rootUrl: string, params: ConstructWithParameters$Params, context?: HttpContext): Observable<StrictHttpResponse<ResourceModel>> {
const rb = new RequestBuilder(rootUrl, constructWithParameters.PATH, 'post');
if (params) {
rb.path('type', params.type, {});
Expand All @@ -31,7 +31,7 @@ export function constructWithParameters(http: HttpClient, rootUrl: string, param
).pipe(
filter((r: any): r is HttpResponse<any> => r instanceof HttpResponse),
map((r: HttpResponse<any>) => {
return r as StrictHttpResponse<MoryxAbstractionLayerResourcesEndpointsResourceModel>;
return r as StrictHttpResponse<ResourceModel>;
})
);
}
Expand Down
Loading
Loading